Skip to content

Commit 2af3ba0

Browse files
authored
feat(metrics): pre-seed known channels at zero on startup (#165)
* feat(metrics): pre-seed known channels at zero on startup Without this, Prometheus only creates a series for cryptify_uploads_total {channel="X"} when the first upload from channel X lands. PromQL increase(...[1h]) over that series returns nothing because the earliest sample is already non-zero — dashboards under-report low-volume channels for an hour after each pod restart. Pre-seeds website, staging-website, outlook, thunderbird, api, unknown to 0 in Metrics::new(), so the full label set is visible from the first scrape and increase() can compute deltas honestly. The previous render-time "if empty, emit unknown=0" fallback is now effectively dead (the map is never empty after new()), but left in place defensively against any future code path that constructs Metrics via Default rather than new(). * refactor(metrics): route Default through new() so they can't diverge Address dobby's non-blocking observation on #165: with #[derive(Default)] still in place, Metrics::default() produced an empty-channels object while Metrics::new() pre-seeded — a footgun for a future contributor who reaches for ::default() in a generic context or test helper. Drops the derive, refactors new() to build the maps directly (no internal lock dance), and adds a manual `impl Default for Metrics` that just calls Self::new(). The two construction paths now produce identical objects. All 13 metrics tests still pass.
1 parent ffeedb6 commit 2af3ba0

1 file changed

Lines changed: 49 additions & 5 deletions

File tree

src/metrics.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,25 @@ use rocket::http::HeaderMap;
2020
/// Channel label used when no other source information is present.
2121
pub const CHANNEL_UNKNOWN: &str = "unknown";
2222

23+
/// Channels pre-seeded at value 0 on startup so dashboards see the full
24+
/// label set from the first scrape, rather than each channel popping into
25+
/// existence the first time a request from it lands. Without this, PromQL
26+
/// `increase()` over a window can read `0` for a channel whose first
27+
/// observed sample is already non-zero — see #102 follow-up discussion.
28+
pub const KNOWN_CHANNELS: &[&str] = &[
29+
"website",
30+
"staging-website",
31+
"outlook",
32+
"thunderbird",
33+
"api",
34+
CHANNEL_UNKNOWN,
35+
];
36+
2337
/// Header clients can set to identify themselves (`outlook`, `thunderbird`,
2438
/// `api`, ...). Leading whitespace is trimmed and the value is lowercased
2539
/// and restricted to `[a-z0-9_-]` so it cannot inject Prometheus syntax.
2640
pub const SOURCE_HEADER: &str = "X-Cryptify-Source";
2741

28-
#[derive(Default)]
2942
pub struct Metrics {
3043
uploads: Mutex<BTreeMap<String, u64>>,
3144
upload_bytes: Mutex<BTreeMap<String, u64>>,
@@ -34,9 +47,32 @@ pub struct Metrics {
3447
expired_files: AtomicU64,
3548
}
3649

50+
// `Default` is implemented manually (not derived) so it goes through
51+
// `Metrics::new()` and pre-seeds `KNOWN_CHANNELS`. A derived `Default`
52+
// would silently produce an empty-channel object, which diverges from
53+
// `new()` and re-introduces the missing-baseline problem this module
54+
// exists to solve.
55+
impl Default for Metrics {
56+
fn default() -> Self {
57+
Self::new()
58+
}
59+
}
60+
3761
impl Metrics {
3862
pub fn new() -> Self {
39-
Self::default()
63+
let mut uploads = BTreeMap::new();
64+
let mut bytes = BTreeMap::new();
65+
for c in KNOWN_CHANNELS {
66+
uploads.insert((*c).to_string(), 0u64);
67+
bytes.insert((*c).to_string(), 0u64);
68+
}
69+
Self {
70+
uploads: Mutex::new(uploads),
71+
upload_bytes: Mutex::new(bytes),
72+
storage_bytes: AtomicI64::new(0),
73+
active_files: AtomicI64::new(0),
74+
expired_files: AtomicU64::new(0),
75+
}
4076
}
4177

4278
/// Record a successfully finalized upload.
@@ -325,11 +361,19 @@ mod tests {
325361
}
326362

327363
#[test]
328-
fn render_emits_zero_counters_when_empty() {
364+
fn render_preseeds_known_channels_at_zero() {
329365
let m = Metrics::new();
330366
let text = m.render();
331-
assert!(text.contains("cryptify_uploads_total{channel=\"unknown\"} 0"));
332-
assert!(text.contains("cryptify_upload_bytes_total{channel=\"unknown\"} 0"));
367+
for c in KNOWN_CHANNELS {
368+
assert!(
369+
text.contains(&format!("cryptify_uploads_total{{channel=\"{c}\"}} 0")),
370+
"missing zero-seed for uploads channel={c} in:\n{text}"
371+
);
372+
assert!(
373+
text.contains(&format!("cryptify_upload_bytes_total{{channel=\"{c}\"}} 0")),
374+
"missing zero-seed for upload_bytes channel={c} in:\n{text}"
375+
);
376+
}
333377
assert!(text.contains("cryptify_storage_bytes 0"));
334378
assert!(text.contains("cryptify_active_files 0"));
335379
assert!(text.contains("cryptify_expired_files_total 0"));

0 commit comments

Comments
 (0)