max / makenotwork
3 files changed,
+46 insertions,
-0 deletions
| @@ -309,6 +309,35 @@ pub fn record_clamav_degraded_hold() { | |||
| 309 | 309 | counter!("clamav_degraded_hold_total").increment(1); | |
| 310 | 310 | } | |
| 311 | 311 | ||
| 312 | + | /// Record the terminal verdict of a completed file scan, keyed by verdict | |
| 313 | + | /// (`clean`, `quarantined`, `held_for_review`, `error`). Before Run 20 the scan | |
| 314 | + | /// pipeline exposed only the degraded-hold counter, so quarantine/hold rates and | |
| 315 | + | /// scanner error rates were invisible to dashboards despite being security- | |
| 316 | + | /// critical signals. | |
| 317 | + | pub fn record_scan_verdict(verdict: &'static str) { | |
| 318 | + | counter!("scan_verdicts_total", "verdict" => verdict).increment(1); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// Record wall-clock duration of a completed file scan (seconds), so a slow | |
| 322 | + | /// scanner (clamd backpressure, large-archive walk) is graphable rather than | |
| 323 | + | /// only inferable from queue depth. | |
| 324 | + | pub fn record_scan_duration(seconds: f64) { | |
| 325 | + | histogram!("scan_duration_seconds").record(seconds); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// Sample the scan-job queue depth (pending / running / stuck) as gauges. | |
| 329 | + | /// Called from the periodic monitor loop alongside the other stat collectors so | |
| 330 | + | /// a stalled worker or a scan backlog is observable at `/metrics`, not just via | |
| 331 | + | /// the unauthenticated health JSON. | |
| 332 | + | pub async fn record_scan_queue_stats(pool: &sqlx::PgPool) { | |
| 333 | + | let pending = crate::db::scan_jobs::queued_count(pool).await.unwrap_or(0); | |
| 334 | + | let running = crate::db::scan_jobs::running_count(pool).await.unwrap_or(0); | |
| 335 | + | let stuck = crate::db::scan_jobs::stuck_count(pool, 300).await.unwrap_or(0); | |
| 336 | + | gauge!("scan_queue_jobs", "state" => "pending").set(pending as f64); | |
| 337 | + | gauge!("scan_queue_jobs", "state" => "running").set(running as f64); | |
| 338 | + | gauge!("scan_queue_jobs", "state" => "stuck").set(stuck as f64); | |
| 339 | + | } | |
| 340 | + | ||
| 312 | 341 | /// Axum middleware that implements idempotency keys for POST endpoints. | |
| 313 | 342 | /// | |
| 314 | 343 | /// If the request includes an `Idempotency-Key` header and the user is |
| @@ -191,6 +191,7 @@ async fn run_monitor_loop(state: AppState, mut shutdown_rx: watch::Receiver<()>) | |||
| 191 | 191 | if should_refresh { | |
| 192 | 192 | crate::metrics::record_storage_fill_stats(&state.db).await; | |
| 193 | 193 | crate::metrics::record_custom_pages_stats(&state.db).await; | |
| 194 | + | crate::metrics::record_scan_queue_stats(&state.db).await; | |
| 194 | 195 | } | |
| 195 | 196 | ||
| 196 | 197 | // Log status changes. Skip the bootstrap None->Operational transition |
| @@ -205,6 +205,7 @@ async fn process_job(ctx: &WorkerContext, job: ScanJob) -> Result<(), Box<dyn st | |||
| 205 | 205 | let kind = job.typed_kind().ok_or_else(|| format!("unknown target_kind: {}", job.target_kind))?; | |
| 206 | 206 | let file_type = job.typed_file_type().ok_or_else(|| format!("unknown file_type: {}", job.file_type))?; | |
| 207 | 207 | let target_id = job.target_id; | |
| 208 | + | let started = std::time::Instant::now(); | |
| 208 | 209 | ||
| 209 | 210 | // Mark target as Scanning while the worker is running (only entities with | |
| 210 | 211 | // a scan_status column). This is a visible signal in the admin dashboard | |
| @@ -218,11 +219,26 @@ async fn process_job(ctx: &WorkerContext, job: ScanJob) -> Result<(), Box<dyn st | |||
| 218 | 219 | // HeldForReview so admins see it on the dashboard and decide | |
| 219 | 220 | // whether to delete the orphan record. | |
| 220 | 221 | update_entity_status(&ctx.db, kind, target_id, FileScanStatus::HeldForReview).await.ok(); | |
| 222 | + | crate::metrics::record_scan_verdict("error"); | |
| 223 | + | crate::metrics::record_scan_duration(started.elapsed().as_secs_f64()); | |
| 221 | 224 | return Err(e); | |
| 222 | 225 | } | |
| 223 | 226 | }; | |
| 224 | 227 | update_entity_status(&ctx.db, kind, target_id, entity_status).await?; | |
| 225 | 228 | ||
| 229 | + | // Verdict + duration metrics (Run 20 Observability): quarantine/hold/error | |
| 230 | + | // rates and scan latency are now graphable at /metrics. | |
| 231 | + | let verdict_label = match entity_status { | |
| 232 | + | FileScanStatus::Clean => "clean", | |
| 233 | + | FileScanStatus::Quarantined => "quarantined", | |
| 234 | + | FileScanStatus::HeldForReview => "held_for_review", | |
| 235 | + | FileScanStatus::Pending => "pending", | |
| 236 | + | FileScanStatus::Scanning => "scanning", | |
| 237 | + | FileScanStatus::Error => "error", | |
| 238 | + | }; | |
| 239 | + | crate::metrics::record_scan_verdict(verdict_label); | |
| 240 | + | crate::metrics::record_scan_duration(started.elapsed().as_secs_f64()); | |
| 241 | + | ||
| 226 | 242 | db::scan_jobs::mark_done(&ctx.db, job_id).await?; | |
| 227 | 243 | Ok(()) | |
| 228 | 244 | } |