Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,25 @@ use rocket::http::HeaderMap;
/// Channel label used when no other source information is present.
pub const CHANNEL_UNKNOWN: &str = "unknown";

/// Channels pre-seeded at value 0 on startup so dashboards see the full
/// label set from the first scrape, rather than each channel popping into
/// existence the first time a request from it lands. Without this, PromQL
/// `increase()` over a window can read `0` for a channel whose first
/// observed sample is already non-zero — see #102 follow-up discussion.
pub const KNOWN_CHANNELS: &[&str] = &[
"website",
"staging-website",
"outlook",
"thunderbird",
"api",
CHANNEL_UNKNOWN,
];

/// Header clients can set to identify themselves (`outlook`, `thunderbird`,
/// `api`, ...). Leading whitespace is trimmed and the value is lowercased
/// and restricted to `[a-z0-9_-]` so it cannot inject Prometheus syntax.
pub const SOURCE_HEADER: &str = "X-Cryptify-Source";

#[derive(Default)]
pub struct Metrics {
uploads: Mutex<BTreeMap<String, u64>>,
upload_bytes: Mutex<BTreeMap<String, u64>>,
Expand All @@ -34,9 +47,32 @@ pub struct Metrics {
expired_files: AtomicU64,
}

// `Default` is implemented manually (not derived) so it goes through
// `Metrics::new()` and pre-seeds `KNOWN_CHANNELS`. A derived `Default`
// would silently produce an empty-channel object, which diverges from
// `new()` and re-introduces the missing-baseline problem this module
// exists to solve.
impl Default for Metrics {
fn default() -> Self {
Self::new()
}
}

impl Metrics {
pub fn new() -> Self {
Self::default()
let mut uploads = BTreeMap::new();
let mut bytes = BTreeMap::new();
for c in KNOWN_CHANNELS {
uploads.insert((*c).to_string(), 0u64);
bytes.insert((*c).to_string(), 0u64);
}
Self {
uploads: Mutex::new(uploads),
upload_bytes: Mutex::new(bytes),
storage_bytes: AtomicI64::new(0),
active_files: AtomicI64::new(0),
expired_files: AtomicU64::new(0),
}
}

/// Record a successfully finalized upload.
Expand Down Expand Up @@ -325,11 +361,19 @@ mod tests {
}

#[test]
fn render_emits_zero_counters_when_empty() {
fn render_preseeds_known_channels_at_zero() {
let m = Metrics::new();
let text = m.render();
assert!(text.contains("cryptify_uploads_total{channel=\"unknown\"} 0"));
assert!(text.contains("cryptify_upload_bytes_total{channel=\"unknown\"} 0"));
for c in KNOWN_CHANNELS {
assert!(
text.contains(&format!("cryptify_uploads_total{{channel=\"{c}\"}} 0")),
"missing zero-seed for uploads channel={c} in:\n{text}"
);
assert!(
text.contains(&format!("cryptify_upload_bytes_total{{channel=\"{c}\"}} 0")),
"missing zero-seed for upload_bytes channel={c} in:\n{text}"
);
}
assert!(text.contains("cryptify_storage_bytes 0"));
assert!(text.contains("cryptify_active_files 0"));
assert!(text.contains("cryptify_expired_files_total 0"));
Expand Down