diff --git a/api-description.yaml b/api-description.yaml index 6093071..e17185c 100644 --- a/api-description.yaml +++ b/api-description.yaml @@ -9,6 +9,8 @@ servers: tags: - name: "Health" description: "Health check" +- name: "Metrics" + description: "Prometheus scrape endpoint" - name: "File upload" description: "Upload files" - name: "File download" @@ -30,6 +32,37 @@ paths: schema: type: "string" example: "OK" + /metrics: + get: + tags: + - "Metrics" + summary: "Prometheus text-format metrics" + description: | + Returns usage counters and gauges suitable for Prometheus scraping + by the Grafana instance on Scaleway. Intended to be reachable only + from the internal monitoring network; firewall or reverse-proxy + allow-list in front of Cryptify. + + Exposed metrics: + * `cryptify_uploads_total{channel}` — counter of finalized uploads. + * `cryptify_upload_bytes_total{channel}` — counter of bytes. + * `cryptify_storage_bytes` — gauge, current disk usage. + * `cryptify_active_files` — gauge, current file count. + * `cryptify_expired_files_total` — counter of uploads purged + before finalization. + + The `channel` label is derived from the `X-Cryptify-Source` header, + falling back to `Authorization`/`X-Api-Key` (→ `api`), then the + `Origin` header (`website` / `staging-website`), then `User-Agent` + (`outlook` / `thunderbird`), then `unknown`. + operationId: "metrics" + responses: + "200": + description: "Prometheus text exposition format" + content: + text/plain: + schema: + type: "string" /fileupload/init: post: tags: diff --git a/src/config.rs b/src/config.rs index b20e4fe..7773c41 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct RawCryptifyConfig { smtp_tls: Option, allowed_origins: String, pkg_url: String, + metrics_scan_interval_secs: Option, chunk_size: Option, session_ttl_secs: Option, staging_mode: Option, @@ -30,6 +31,7 @@ pub struct CryptifyConfig { smtp_tls: bool, allowed_origins: String, pkg_url: String, + metrics_scan_interval_secs: u64, chunk_size: u64, session_ttl_secs: u64, staging_mode: bool, @@ -51,6 +53,7 @@ impl From for CryptifyConfig { smtp_tls: config.smtp_tls.unwrap_or(true), allowed_origins: config.allowed_origins, pkg_url: config.pkg_url, + metrics_scan_interval_secs: config.metrics_scan_interval_secs.unwrap_or(60), chunk_size: config.chunk_size.unwrap_or(5_000_000), session_ttl_secs: config.session_ttl_secs.unwrap_or(3600), staging_mode: config.staging_mode.unwrap_or(false), @@ -99,6 +102,10 @@ impl CryptifyConfig { &self.pkg_url } + pub fn metrics_scan_interval_secs(&self) -> u64 { + self.metrics_scan_interval_secs + } + pub fn chunk_size(&self) -> u64 { self.chunk_size } @@ -124,6 +131,7 @@ impl CryptifyConfig { smtp_tls: false, allowed_origins: String::new(), pkg_url: String::new(), + metrics_scan_interval_secs: 60, chunk_size: 5_000_000, session_ttl_secs: 3600, staging_mode, diff --git a/src/email.rs b/src/email.rs index 3a951cb..1cf2988 100644 --- a/src/email.rs +++ b/src/email.rs @@ -419,6 +419,7 @@ mod tests { ("phone".to_owned(), "+31123".to_owned()), ], confirm: true, + source_channel: String::new(), notify_recipients: true, api_key_tenant: None, api_key_validation_failed: false, diff --git a/src/main.rs b/src/main.rs index 27e8e3a..4f14e57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,16 @@ mod config; mod email; mod error; +mod metrics; mod store; +use std::sync::Arc; +use std::time::Duration; + use crate::config::CryptifyConfig; use crate::email::send_email; use crate::error::{Error, PayloadTooLargeBody}; +use crate::metrics::{detect_channel, storage_sampler, Metrics}; use crate::store::{ API_KEY_PER_UPLOAD_LIMIT, API_KEY_ROLLING_LIMIT, PER_UPLOAD_LIMIT, ROLLING_LIMIT, ROLLING_WINDOW_SECS, @@ -38,7 +43,6 @@ use rocket::http::Method; use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions}; use serde::{Deserialize, Serialize}; -use std::time::Duration; use store::{FileState, LastChunkRecord, Store}; #[derive(Serialize, Deserialize)] @@ -89,11 +93,35 @@ struct InitResponder { cryptify_token: CryptifyToken, } +/// Request guard that derives the traffic source channel from the request +/// headers for metrics labelling. +struct ClientHeaders { + channel: String, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ClientHeaders { + type Error = std::convert::Infallible; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(ClientHeaders { + channel: detect_channel(request.headers()), + }) + } +} + #[get("/health")] fn health() -> &'static str { "OK" } +#[get("/metrics")] +fn metrics_endpoint(metrics: &State>) -> rocket::response::content::RawText { + rocket::response::content::RawText(metrics.render()) +} + /// Extract a `PG-…` bearer token from an Authorization header value, or /// `None` for any other shape (missing, wrong scheme, non-PG prefix). Kept /// as a pure helper so the parsing rules are unit-testable without HTTP. @@ -258,6 +286,7 @@ async fn upload_init( store: &State, api_key: ApiKey, request: Json, + client_headers: ClientHeaders, ) -> Result { let current_time = chrono::offset::Utc::now().timestamp(); @@ -288,6 +317,7 @@ async fn upload_init( sender: None, sender_attributes: Vec::new(), confirm: request.confirm, + source_channel: client_headers.channel, notify_recipients: request.notify_recipients, api_key_tenant: api_key.tenant, api_key_validation_failed: api_key.validation_failed, @@ -694,6 +724,7 @@ async fn upload_finalize( config: &State, store: &State, vk: &State>, + metrics: &State>, headers: FinalizeHeaders, uuid: &str, ) -> Result<(), Error> { @@ -792,6 +823,8 @@ async fn upload_finalize( Error::InternalServerError(Some("could not send email".to_owned())) })?; + metrics.record_upload(&state.source_channel, state.uploaded); + if let Some(key) = accounting_key { store.record_upload(key, state.uploaded, now_secs); } @@ -1141,6 +1174,13 @@ pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket) -> Rocket) -> Rocket()) - .manage(Store::with_idle_ttl(std::time::Duration::from_secs( - config.session_ttl_secs(), - ))) + .manage(Store::with_idle_ttl( + std::time::Duration::from_secs(config.session_ttl_secs()), + metrics.clone(), + )) .manage(vk) .manage(pkg_client) + .manage(metrics) } #[launch] @@ -1361,7 +1404,7 @@ mod tests { let rocket = rocket::custom(figment) .mount("/", routes![upload_init]) .attach(AdHoc::config::()) - .manage(Store::new()); + .manage(Store::new(Arc::new(Metrics::new()))); Client::tracked(rocket).await.expect("valid rocket") } @@ -1450,7 +1493,7 @@ mod tests { let rocket = rocket::custom(figment) .mount("/", routes![upload_init, upload_status]) .attach(AdHoc::config::()) - .manage(Store::new()); + .manage(Store::new(Arc::new(Metrics::new()))); Client::tracked(rocket).await.expect("valid rocket") } @@ -1497,7 +1540,7 @@ mod tests { .attach(cors) .mount("/", routes![upload_init, upload_status]) .attach(AdHoc::config::()) - .manage(Store::new()); + .manage(Store::new(Arc::new(Metrics::new()))); Client::tracked(rocket).await.expect("valid rocket") } @@ -1787,6 +1830,7 @@ mod tests { sender: None, sender_attributes: Vec::new(), confirm: false, + source_channel: String::new(), notify_recipients: true, api_key_tenant: None, api_key_validation_failed: false, diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..ed4b57f --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,374 @@ +//! Usage metrics for Grafana scraping. +//! +//! Exposes a Prometheus text-format `/metrics` endpoint covering: +//! - uploads completed, split by traffic source ("channel") +//! - bytes uploaded, split by channel +//! - current on-disk storage bytes and active file count (sampled +//! periodically by a background task) +//! +//! See `docs/grafana/` for the reference dashboard JSON. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::Path; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rocket::http::HeaderMap; + +/// Channel label used when no other source information is present. +pub const CHANNEL_UNKNOWN: &str = "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>, + upload_bytes: Mutex>, + storage_bytes: AtomicI64, + active_files: AtomicI64, + expired_files: AtomicU64, +} + +impl Metrics { + pub fn new() -> Self { + Self::default() + } + + /// Record a successfully finalized upload. + pub fn record_upload(&self, channel: &str, bytes: u64) { + let channel = sanitize_label(channel); + let mut uploads = self.uploads.lock().unwrap(); + *uploads.entry(channel.clone()).or_insert(0) += 1; + let mut bytes_map = self.upload_bytes.lock().unwrap(); + *bytes_map.entry(channel).or_insert(0) += bytes; + } + + /// Record an upload that expired / was purged without finalizing. + pub fn record_expired(&self) { + self.expired_files.fetch_add(1, Ordering::Relaxed); + } + + /// Update the current on-disk storage sample. + pub fn set_storage(&self, bytes: i64, active_files: i64) { + self.storage_bytes.store(bytes, Ordering::Relaxed); + self.active_files.store(active_files, Ordering::Relaxed); + } + + /// Render all metrics in Prometheus text-exposition format. + pub fn render(&self) -> String { + let mut out = String::new(); + + let _ = writeln!( + out, + "# HELP cryptify_uploads_total Total finalized uploads per channel." + ); + let _ = writeln!(out, "# TYPE cryptify_uploads_total counter"); + let uploads = self.uploads.lock().unwrap(); + if uploads.is_empty() { + let _ = writeln!( + out, + "cryptify_uploads_total{{channel=\"{}\"}} 0", + CHANNEL_UNKNOWN + ); + } else { + for (channel, count) in uploads.iter() { + let _ = writeln!( + out, + "cryptify_uploads_total{{channel=\"{}\"}} {}", + channel, count + ); + } + } + drop(uploads); + + let _ = writeln!( + out, + "# HELP cryptify_upload_bytes_total Total bytes uploaded per channel." + ); + let _ = writeln!(out, "# TYPE cryptify_upload_bytes_total counter"); + let bytes = self.upload_bytes.lock().unwrap(); + if bytes.is_empty() { + let _ = writeln!( + out, + "cryptify_upload_bytes_total{{channel=\"{}\"}} 0", + CHANNEL_UNKNOWN + ); + } else { + for (channel, b) in bytes.iter() { + let _ = writeln!( + out, + "cryptify_upload_bytes_total{{channel=\"{}\"}} {}", + channel, b + ); + } + } + drop(bytes); + + let _ = writeln!( + out, + "# HELP cryptify_storage_bytes Current bytes of uploads held on disk." + ); + let _ = writeln!(out, "# TYPE cryptify_storage_bytes gauge"); + let _ = writeln!( + out, + "cryptify_storage_bytes {}", + self.storage_bytes.load(Ordering::Relaxed) + ); + + let _ = writeln!( + out, + "# HELP cryptify_active_files Number of upload files currently on disk." + ); + let _ = writeln!(out, "# TYPE cryptify_active_files gauge"); + let _ = writeln!( + out, + "cryptify_active_files {}", + self.active_files.load(Ordering::Relaxed) + ); + + let _ = writeln!( + out, + "# HELP cryptify_expired_files_total Uploads that expired before being finalized." + ); + let _ = writeln!(out, "# TYPE cryptify_expired_files_total counter"); + let _ = writeln!( + out, + "cryptify_expired_files_total {}", + self.expired_files.load(Ordering::Relaxed) + ); + + out + } +} + +/// Derive the channel label for a request from its headers. +/// +/// Priority: +/// 1. `X-Cryptify-Source` explicit header. +/// 2. API auth (`Authorization: Bearer …` or `X-Api-Key`) → `api`. +/// 3. `Origin` → `staging-website` / `website`. +/// 4. `User-Agent` substring for Outlook / Thunderbird. +/// 5. `unknown`. +pub fn detect_channel(headers: &HeaderMap<'_>) -> String { + if let Some(raw) = headers.get_one(SOURCE_HEADER) { + let cleaned = sanitize_label(raw); + if !cleaned.is_empty() && cleaned != CHANNEL_UNKNOWN { + return cleaned; + } + } + if headers.get_one("X-Api-Key").is_some() + || headers + .get_one("Authorization") + .map(|v| v.trim_start().to_ascii_lowercase().starts_with("bearer ")) + .unwrap_or(false) + { + return "api".to_string(); + } + if let Some(origin) = headers.get_one("Origin") { + let o = origin.to_ascii_lowercase(); + if o.contains("staging.postguard") || o.contains("staging-postguard") { + return "staging-website".to_string(); + } + if o.contains("postguard.") { + return "website".to_string(); + } + } + if let Some(ua) = headers.get_one("User-Agent") { + let ua = ua.to_ascii_lowercase(); + if ua.contains("outlook") { + return "outlook".to_string(); + } + if ua.contains("thunderbird") { + return "thunderbird".to_string(); + } + } + CHANNEL_UNKNOWN.to_string() +} + +/// Reduce an arbitrary string to a safe Prometheus label value: +/// lower-case, `[a-z0-9_-]`, max 32 chars, non-empty (falls back to +/// `unknown`). This prevents clients from injecting label syntax or +/// exploding cardinality with arbitrary inputs. +fn sanitize_label(raw: &str) -> String { + let cleaned: String = raw + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' => c, + _ => '-', + }) + .take(32) + .collect(); + let trimmed = cleaned.trim_matches('-').to_string(); + if trimmed.is_empty() { + CHANNEL_UNKNOWN.to_string() + } else { + trimmed + } +} + +/// Walk `data_dir` once and return `(total_bytes, file_count)`. Symlinks +/// and subdirectories are ignored — the upload directory is a flat +/// directory of files named by UUID. +pub fn sample_storage(data_dir: &Path) -> std::io::Result<(i64, i64)> { + let mut total: i64 = 0; + let mut count: i64 = 0; + match std::fs::read_dir(data_dir) { + Ok(rd) => { + for entry in rd.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + total = total.saturating_add(meta.len() as i64); + count += 1; + } + } + } + Ok((total, count)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((0, 0)), + Err(e) => Err(e), + } +} + +/// Periodically sample `data_dir` and push the numbers onto `metrics`. +pub async fn storage_sampler( + metrics: std::sync::Arc, + data_dir: std::path::PathBuf, + interval: Duration, +) { + loop { + match sample_storage(&data_dir) { + Ok((bytes, count)) => metrics.set_storage(bytes, count), + Err(e) => log::warn!("metrics: storage sampling failed for {:?}: {}", data_dir, e), + } + rocket::tokio::time::sleep(interval).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::http::Header; + + fn headers(pairs: &[(&'static str, &'static str)]) -> rocket::http::HeaderMap<'static> { + let mut h = rocket::http::HeaderMap::new(); + for (k, v) in pairs { + h.add(Header::new(*k, *v)); + } + h + } + + #[test] + fn channel_explicit_header_wins() { + let h = headers(&[ + ("X-Cryptify-Source", "OUTLOOK"), + ("Origin", "https://postguard.eu"), + ]); + assert_eq!(detect_channel(&h), "outlook"); + } + + #[test] + fn channel_bearer_is_api() { + let h = headers(&[("Authorization", "Bearer abc123")]); + assert_eq!(detect_channel(&h), "api"); + } + + #[test] + fn channel_api_key_is_api() { + let h = headers(&[("X-Api-Key", "s3cret")]); + assert_eq!(detect_channel(&h), "api"); + } + + #[test] + fn channel_origin_staging() { + let h = headers(&[("Origin", "https://staging.postguard.eu")]); + assert_eq!(detect_channel(&h), "staging-website"); + } + + #[test] + fn channel_origin_production() { + let h = headers(&[("Origin", "https://postguard.eu")]); + assert_eq!(detect_channel(&h), "website"); + } + + #[test] + fn channel_user_agent_outlook() { + let h = headers(&[("User-Agent", "Mozilla Outlook/16.0")]); + assert_eq!(detect_channel(&h), "outlook"); + } + + #[test] + fn channel_user_agent_thunderbird() { + let h = headers(&[("User-Agent", "Thunderbird/115.0")]); + assert_eq!(detect_channel(&h), "thunderbird"); + } + + #[test] + fn channel_defaults_to_unknown() { + let h = headers(&[]); + assert_eq!(detect_channel(&h), "unknown"); + } + + #[test] + fn sanitize_strips_unsafe_chars_and_caps_length() { + assert_eq!(sanitize_label("Outlook\n\"}"), "outlook"); + assert_eq!(sanitize_label(""), "unknown"); + assert_eq!(sanitize_label(" "), "unknown"); + let long = "a".repeat(100); + assert_eq!(sanitize_label(&long).len(), 32); + } + + #[test] + fn render_emits_zero_counters_when_empty() { + 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")); + assert!(text.contains("cryptify_storage_bytes 0")); + assert!(text.contains("cryptify_active_files 0")); + assert!(text.contains("cryptify_expired_files_total 0")); + } + + #[test] + fn render_aggregates_by_channel() { + let m = Metrics::new(); + m.record_upload("website", 1_000); + m.record_upload("website", 500); + m.record_upload("outlook", 250); + m.record_expired(); + m.set_storage(9_999, 3); + let text = m.render(); + assert!(text.contains("cryptify_uploads_total{channel=\"website\"} 2")); + assert!(text.contains("cryptify_uploads_total{channel=\"outlook\"} 1")); + assert!(text.contains("cryptify_upload_bytes_total{channel=\"website\"} 1500")); + assert!(text.contains("cryptify_upload_bytes_total{channel=\"outlook\"} 250")); + assert!(text.contains("cryptify_storage_bytes 9999")); + assert!(text.contains("cryptify_active_files 3")); + assert!(text.contains("cryptify_expired_files_total 1")); + } + + #[test] + fn sample_storage_missing_dir_is_zero() { + let tmp = std::env::temp_dir().join("cryptify-metrics-missing-xyz"); + let (bytes, count) = sample_storage(&tmp).unwrap(); + assert_eq!((bytes, count), (0, 0)); + } + + #[test] + fn sample_storage_counts_files() { + let tmp = std::env::temp_dir().join(format!("cryptify-metrics-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("a"), b"hello").unwrap(); + std::fs::write(tmp.join("b"), b"world!").unwrap(); + let (bytes, count) = sample_storage(&tmp).unwrap(); + assert_eq!(count, 2); + assert_eq!(bytes, 11); + std::fs::remove_dir_all(&tmp).unwrap(); + } +} diff --git a/src/store.rs b/src/store.rs index e920633..0a7001e 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,4 +1,5 @@ use crate::email; +use crate::metrics::Metrics; use std::{ collections::{BTreeMap, HashMap, VecDeque}, @@ -31,6 +32,9 @@ pub struct FileState { pub sender: Option, pub sender_attributes: Vec<(String, String)>, pub confirm: bool, + /// Traffic source this upload originated from ("website", "outlook", + /// "thunderbird", "api", ...). Used only for metrics labelling. + pub source_channel: String, /// When false, the recipient notification email is suppressed (the /// recipients still appear in the parsed list, but the SMTP delivery /// loop in `send_email` is skipped). The sender confirmation, if @@ -110,6 +114,7 @@ struct SharedState { state: std::sync::Mutex, notify: Notify, idle_ttl: Duration, + metrics: Arc, } pub struct Store { @@ -118,13 +123,14 @@ pub struct Store { impl Store { #[cfg(test)] - pub fn new() -> Self { - Self::with_idle_ttl(Duration::from_secs( - DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS, - )) + pub fn new(metrics: Arc) -> Self { + Self::with_idle_ttl( + Duration::from_secs(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS), + metrics, + ) } - pub fn with_idle_ttl(idle_ttl: Duration) -> Self { + pub fn with_idle_ttl(idle_ttl: Duration, metrics: Arc) -> Self { let result = Store { shared: Arc::new(SharedState { state: std::sync::Mutex::new(StoreState { @@ -137,6 +143,7 @@ impl Store { }), notify: Notify::new(), idle_ttl, + metrics, }), }; @@ -277,8 +284,20 @@ impl SharedState { return Some(when); } - state.files.remove(id); - state.expiration_keys.remove(id); + let id = id.clone(); + if let Some(entry) = state.files.remove(&id) { + // An entry that still had no `sender` set was never finalized. + // (`sender` is populated by `upload_finalize` once the file has + // been unsealed.) + let was_unfinalized = entry + .try_lock() + .map(|g| g.sender.is_none()) + .unwrap_or(false); + if was_unfinalized { + self.metrics.record_expired(); + } + } + state.expiration_keys.remove(&id); state.expirations.remove(&(when, removal_id)); } @@ -309,7 +328,7 @@ mod tests { #[rocket::async_test] async fn usage_is_zero_for_unknown_email() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); assert_eq!( store.get_usage("unknown@example.com", 1_000_000).used_bytes, 0 @@ -318,7 +337,7 @@ mod tests { #[rocket::async_test] async fn usage_sums_records_in_window() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); let now: i64 = 2_000_000; store.record_upload("a@example.com".into(), 1_000_000_000, now - 3600); store.record_upload("a@example.com".into(), 2_000_000_000, now - 60); @@ -332,7 +351,7 @@ mod tests { #[rocket::async_test] async fn usage_excludes_records_outside_window() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); let now: i64 = 2_000_000; store.record_upload( "b@example.com".into(), @@ -348,7 +367,7 @@ mod tests { #[rocket::async_test] async fn usage_is_isolated_per_email() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); let now: i64 = 2_000_000; store.record_upload("a@example.com".into(), 1_000, now); store.record_upload("b@example.com".into(), 2_000, now); @@ -367,6 +386,7 @@ mod tests { sender: None, sender_attributes: Vec::new(), confirm: false, + source_channel: String::new(), notify_recipients: true, api_key_tenant: None, api_key_validation_failed: false, @@ -377,7 +397,7 @@ mod tests { #[rocket::async_test] async fn touch_extends_eviction_deadline() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); store.create("u1".into(), dummy_filestate()); let original = { @@ -408,7 +428,7 @@ mod tests { #[rocket::async_test] async fn touch_on_unknown_id_is_noop() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); store.touch("nope"); let s = store.shared.state.lock().unwrap(); assert!(s.expirations.is_empty()); @@ -417,7 +437,7 @@ mod tests { #[rocket::async_test] async fn remove_cleans_up_expirations() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); store.create("u2".into(), dummy_filestate()); store.remove("u2"); let s = store.shared.state.lock().unwrap(); @@ -428,7 +448,7 @@ mod tests { #[rocket::async_test] async fn pruning_removes_only_expired_records() { - let store = Store::new(); + let store = Store::new(Arc::new(Metrics::new())); let now: i64 = 2_000_000; store.record_upload( "c@example.com".into(),