diff --git a/apps/ml-service/src/routes.rs b/apps/ml-service/src/routes.rs index edd9033..3d0cc09 100644 --- a/apps/ml-service/src/routes.rs +++ b/apps/ml-service/src/routes.rs @@ -130,18 +130,33 @@ pub async fn predict( }; // Preprocess the image + let preprocess_start = std::time::Instant::now(); let input = preprocess_image(&bytes, state.model.input_size())?; - // Run prediction - let prediction = state.model.predict(input)?; + // Record image size metric + telemetry::image_size_bytes().observe(bytes.len() as f64); - // Record business and request telemetry + // Run prediction with timing + let inference_start = std::time::Instant::now(); + let prediction = state.model.predict(input)?; + telemetry::inference_duration_seconds().observe(inference_start.elapsed().as_secs_f64()); + + // Record business telemetry telemetry::predictions_total().inc(); + telemetry::predictions_by_class() + .with_label_values(&[&prediction.label]) + .inc(); + telemetry::predictions_confidence().observe(prediction.confidence as f64); _guard.finish(); Ok(Json(prediction_response(prediction))) } +/// Helper to record errors from route handlers +pub fn record_error(kind: &str) { + telemetry::errors_total().with_label_values(&[kind]).inc(); +} + pub fn router(state: AppState) -> Router { Router::new() .route("/health", get(health)) diff --git a/apps/ml-service/src/telemetry.rs b/apps/ml-service/src/telemetry.rs index 6a08d15..44c36fc 100644 --- a/apps/ml-service/src/telemetry.rs +++ b/apps/ml-service/src/telemetry.rs @@ -1,4 +1,4 @@ -use prometheus::{Counter, Gauge, Histogram, HistogramOpts, Registry, TextEncoder}; +use prometheus::{Counter, CounterVec, Gauge, Histogram, HistogramOpts, HistogramVec, Opts, Registry, TextEncoder}; use std::sync::OnceLock; use std::time::Instant; @@ -78,6 +78,76 @@ define_metric!( .expect("create gauge") ); +/// Per-class prediction counter +define_metric!( + predictions_by_class, + CounterVec, + CounterVec::new( + Opts::new( + "zeavis_ml_predictions_by_class_total", + "Total predictions by predicted class label", + ), + &["label"], + ) + .expect("create counter_vec") +); + +/// Per-class ground-truth counter (for monitoring label distribution) +define_metric!( + predictions_confidence, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_prediction_confidence", + "Confidence values of predictions", + ) + .buckets(vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99, 1.0]), + ) + .expect("create histogram") +); + +/// Latency of ONNX inference (model.predict call) +define_metric!( + inference_duration_seconds, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_inference_duration_seconds", + "ONNX model inference duration in seconds", + ) + .buckets(vec![0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 2.0]), + ) + .expect("create histogram") +); + +/// Image size processed by the ML service +define_metric!( + image_size_bytes, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_image_size_bytes", + "Size of images sent for prediction in bytes", + ) + .buckets(vec![1024.0, 10240.0, 51200.0, 102400.0, 204800.0, 512000.0, 1048576.0, 2097152.0]), + ) + .expect("create histogram") +); + +/// Error counter by error kind (e.g. bad_request, model_error, internal) +define_metric!( + errors_total, + CounterVec, + CounterVec::new( + Opts::new( + "zeavis_ml_errors_total", + "Total errors by kind", + ), + &["kind"], + ) + .expect("create counter_vec") +); + // ── Request Guard (Drop-based cleanup for active gauge) ─ pub struct RequestMetricsGuard { diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts index 46f3255..cdaf84d 100644 --- a/apps/web/src/lib/telemetry.ts +++ b/apps/web/src/lib/telemetry.ts @@ -2,8 +2,10 @@ * Client‑side telemetry for the ZeaVis Edu web app. * * In development, metrics are collected in‑memory and exposed at /metrics - * via a Vite plugin. In production they are sent as HTTP beacons to the - * Telemetry pipeline (see METRICS.md). + * via a Vite plugin. In production they are served through the same plugin + * (or proxied by nginx in production mode). + * + * Metric name prefix: zeavis_web_ */ // ── Web Vitals ────────────────────────────────────────── @@ -18,35 +20,99 @@ const vitalsBuffer: MetricEntry[] = []; export function reportWebVitals(metric: MetricEntry): void { vitalsBuffer.push(metric); - // Keep last 20 entries in memory for the /metrics endpoint - if (vitalsBuffer.length > 20) vitalsBuffer.shift(); - console.debug(`[telemetry] ${metric.name}: ${metric.value} (${metric.rating ?? 'n/a'})`); + if (vitalsBuffer.length > 30) vitalsBuffer.splice(0, vitalsBuffer.length - 30); } // ── Page‑view counter ─────────────────────────────────── let pageViewCount = 0; +const routeViews: Record = {}; export function trackPageView(path: string): void { pageViewCount++; - console.debug(`[telemetry] pageview: ${path} (total: ${pageViewCount})`); + routeViews[path] = (routeViews[path] || 0) + 1; } -// ── Metrics serialisation (consumed by vite‑plugin) ──── +// ── API call timing ───────────────────────────────────── +// Track how long API calls take from the browser side + +const apiLatencies: number[] = []; +const MAX_API_SAMPLES = 100; + +export function recordApiCall(method: string, path: string, durationMs: number, status: number): void { + apiLatencies.push(durationMs); + if (apiLatencies.length > MAX_API_SAMPLES) apiLatencies.shift(); + console.debug(`[telemetry] api ${method} ${path} → ${status} (${durationMs.toFixed(0)}ms)`); +} + +// ── Error tracking (client-side JS errors) ────────────── + +let errorCount = 0; + +export function trackError(source: string): void { + errorCount++; + console.debug(`[telemetry] error from ${source} (total: ${errorCount})`); +} + +// ── Diagnosis actions ─────────────────────────────────── + +let scanCount = 0; +let diagnosisSuccess = 0; +let diagnosisFailure = 0; + +export function trackScan(): void { + scanCount++; +} + +export function trackDiagnosisResult(success: boolean): void { + if (success) diagnosisSuccess++; + else diagnosisFailure++; +} + +// ── Metrics serialisation (consumed by vite-plugin) ──── export function collectMetrics(): string { const lines: string[] = []; - // ── Default process‑like metrics ────────────────────── lines.push('# HELP zeavis_web_page_views_total Total page views'); lines.push('# TYPE zeavis_web_page_views_total counter'); lines.push(`zeavis_web_page_views_total ${pageViewCount}`); - lines.push('# HELP zeavis_web_vital_bucket Web Vitals observed this session'); - lines.push('# TYPE zeavis_web_vital_bucket gauge'); - for (const v of vitalsBuffer) { - lines.push(`zeavis_web_vital_bucket{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`); + lines.push('# HELP zeavis_web_route_views_total Page views per route'); + lines.push('# TYPE zeavis_web_route_views_total counter'); + for (const [route, count] of Object.entries(routeViews)) { + lines.push(`zeavis_web_route_views_total{route="${route}"} ${count}`); } + lines.push('# HELP zeavis_web_vital Web Vitals observed this session'); + lines.push('# TYPE zeavis_web_vital gauge'); + for (const v of vitalsBuffer) { + lines.push(`zeavis_web_vital{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`); + } + + if (apiLatencies.length > 0) { + const avg = apiLatencies.reduce((a, b) => a + b, 0) / apiLatencies.length; + lines.push('# HELP zeavis_web_api_call_duration_ms Average API call duration from browser'); + lines.push('# TYPE zeavis_web_api_call_duration_ms gauge'); + lines.push(`zeavis_web_api_call_duration_ms ${avg.toFixed(2)}`); + } + + lines.push('# HELP zeavis_web_client_errors_total Client-side JS errors'); + lines.push('# TYPE zeavis_web_client_errors_total counter'); + lines.push(`zeavis_web_client_errors_total ${errorCount}`); + + lines.push('# HELP zeavis_web_scans_total Scan button clicks'); + lines.push('# TYPE zeavis_web_scans_total counter'); + lines.push(`zeavis_web_scans_total ${scanCount}`); + + lines.push('# HELP zeavis_web_diagnoses_total Diagnosis results from browser'); + lines.push('# TYPE zeavis_web_diagnoses_total counter'); + lines.push(`zeavis_web_diagnoses_total{result="success"} ${diagnosisSuccess}`); + lines.push(`zeavis_web_diagnoses_total{result="failure"} ${diagnosisFailure}`); + + lines.push('# HELP zeavis_web_active_users User activity (1 = active this session)'); + lines.push('# TYPE zeavis_web_active_users gauge'); + lines.push(`zeavis_web_active_users 1`); + return lines.join('\n') + '\n'; }