feat(ml-service): add detailed ML observability metrics for inference, predictions, and errors
This commit is contained in:
@@ -130,18 +130,33 @@ pub async fn predict(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Preprocess the image
|
// Preprocess the image
|
||||||
|
let preprocess_start = std::time::Instant::now();
|
||||||
let input = preprocess_image(&bytes, state.model.input_size())?;
|
let input = preprocess_image(&bytes, state.model.input_size())?;
|
||||||
|
|
||||||
// Run prediction
|
// Record image size metric
|
||||||
let prediction = state.model.predict(input)?;
|
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_total().inc();
|
||||||
|
telemetry::predictions_by_class()
|
||||||
|
.with_label_values(&[&prediction.label])
|
||||||
|
.inc();
|
||||||
|
telemetry::predictions_confidence().observe(prediction.confidence as f64);
|
||||||
_guard.finish();
|
_guard.finish();
|
||||||
|
|
||||||
Ok(Json(prediction_response(prediction)))
|
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 {
|
pub fn router(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
|
|||||||
@@ -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::sync::OnceLock;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -78,6 +78,76 @@ define_metric!(
|
|||||||
.expect("create gauge")
|
.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) ─
|
// ── Request Guard (Drop-based cleanup for active gauge) ─
|
||||||
|
|
||||||
pub struct RequestMetricsGuard {
|
pub struct RequestMetricsGuard {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* Client‑side telemetry for the ZeaVis Edu web app.
|
* Client‑side telemetry for the ZeaVis Edu web app.
|
||||||
*
|
*
|
||||||
* In development, metrics are collected in‑memory and exposed at /metrics
|
* 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
|
* via a Vite plugin. In production they are served through the same plugin
|
||||||
* Telemetry pipeline (see METRICS.md).
|
* (or proxied by nginx in production mode).
|
||||||
|
*
|
||||||
|
* Metric name prefix: zeavis_web_
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Web Vitals ──────────────────────────────────────────
|
// ── Web Vitals ──────────────────────────────────────────
|
||||||
@@ -18,35 +20,99 @@ const vitalsBuffer: MetricEntry[] = [];
|
|||||||
|
|
||||||
export function reportWebVitals(metric: MetricEntry): void {
|
export function reportWebVitals(metric: MetricEntry): void {
|
||||||
vitalsBuffer.push(metric);
|
vitalsBuffer.push(metric);
|
||||||
// Keep last 20 entries in memory for the /metrics endpoint
|
if (vitalsBuffer.length > 30) vitalsBuffer.splice(0, vitalsBuffer.length - 30);
|
||||||
if (vitalsBuffer.length > 20) vitalsBuffer.shift();
|
|
||||||
console.debug(`[telemetry] ${metric.name}: ${metric.value} (${metric.rating ?? 'n/a'})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page‑view counter ───────────────────────────────────
|
// ── Page‑view counter ───────────────────────────────────
|
||||||
|
|
||||||
let pageViewCount = 0;
|
let pageViewCount = 0;
|
||||||
|
const routeViews: Record<string, number> = {};
|
||||||
|
|
||||||
export function trackPageView(path: string): void {
|
export function trackPageView(path: string): void {
|
||||||
pageViewCount++;
|
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 {
|
export function collectMetrics(): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
// ── Default process‑like metrics ──────────────────────
|
|
||||||
lines.push('# HELP zeavis_web_page_views_total Total page views');
|
lines.push('# HELP zeavis_web_page_views_total Total page views');
|
||||||
lines.push('# TYPE zeavis_web_page_views_total counter');
|
lines.push('# TYPE zeavis_web_page_views_total counter');
|
||||||
lines.push(`zeavis_web_page_views_total ${pageViewCount}`);
|
lines.push(`zeavis_web_page_views_total ${pageViewCount}`);
|
||||||
|
|
||||||
lines.push('# HELP zeavis_web_vital_bucket Web Vitals observed this session');
|
lines.push('# HELP zeavis_web_route_views_total Page views per route');
|
||||||
lines.push('# TYPE zeavis_web_vital_bucket gauge');
|
lines.push('# TYPE zeavis_web_route_views_total counter');
|
||||||
for (const v of vitalsBuffer) {
|
for (const [route, count] of Object.entries(routeViews)) {
|
||||||
lines.push(`zeavis_web_vital_bucket{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`);
|
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';
|
return lines.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user