Skip to main content

mockforge_http/
metrics_middleware.rs

1//! HTTP metrics collection middleware
2//!
3//! Collects Prometheus metrics for all HTTP requests including:
4//! - Request counts by method and status
5//! - Request duration histograms
6//! - In-flight request tracking
7//! - Error counts
8//! - Pillar dimension for usage tracking
9
10use axum::{
11    extract::{MatchedPath, Request},
12    middleware::Next,
13    response::Response,
14};
15use mockforge_observability::get_global_registry;
16use std::time::Instant;
17use tracing::debug;
18
19/// Determine pillar from endpoint path
20///
21/// Analyzes the request path to determine which pillar(s) the request belongs to.
22/// This enables pillar-based usage tracking in telemetry.
23fn determine_pillar_from_path(path: &str) -> &'static str {
24    let path_lower = path.to_lowercase();
25
26    // Reality pillar patterns
27    if path_lower.contains("/reality")
28        || path_lower.contains("/personas")
29        || path_lower.contains("/chaos")
30        || path_lower.contains("/fidelity")
31        || path_lower.contains("/continuum")
32    {
33        return "reality";
34    }
35
36    // Contracts pillar patterns
37    if path_lower.contains("/contracts")
38        || path_lower.contains("/validation")
39        || path_lower.contains("/drift")
40        || path_lower.contains("/schema")
41        || path_lower.contains("/sync")
42    {
43        return "contracts";
44    }
45
46    // DevX pillar patterns
47    if path_lower.contains("/sdk")
48        || path_lower.contains("/playground")
49        || path_lower.contains("/plugins")
50        || path_lower.contains("/cli")
51        || path_lower.contains("/generator")
52    {
53        return "devx";
54    }
55
56    // Cloud pillar patterns
57    if path_lower.contains("/registry")
58        || path_lower.contains("/workspace")
59        || path_lower.contains("/org")
60        || path_lower.contains("/marketplace")
61        || path_lower.contains("/collab")
62    {
63        return "cloud";
64    }
65
66    // AI pillar patterns
67    if path_lower.contains("/ai")
68        || path_lower.contains("/mockai")
69        || path_lower.contains("/voice")
70        || path_lower.contains("/llm")
71        || path_lower.contains("/studio")
72    {
73        return "ai";
74    }
75
76    // Default to unknown if no pattern matches
77    "unknown"
78}
79
80/// Metrics collection middleware for HTTP requests
81///
82/// This middleware should be applied to all HTTP routes to collect comprehensive
83/// metrics for Prometheus. It tracks:
84/// - Total request counts (by method and status code)
85/// - Request duration (as histograms for percentile calculations)
86/// - In-flight requests
87/// - Error rates
88pub async fn collect_http_metrics(
89    matched_path: Option<MatchedPath>,
90    req: Request,
91    next: Next,
92) -> Response {
93    let start_time = Instant::now();
94    let method = req.method().to_string();
95    let uri_path = req.uri().path().to_string();
96    let path = matched_path.as_ref().map(|mp| mp.as_str().to_string()).unwrap_or(uri_path);
97
98    // Get metrics registry
99    let registry = get_global_registry();
100
101    // Track in-flight requests
102    registry.increment_in_flight("http");
103    debug!(
104        method = %method,
105        path = %path,
106        "Starting HTTP request metrics collection"
107    );
108
109    // Process the request
110    let response = next.run(req).await;
111
112    // Decrement in-flight requests
113    registry.decrement_in_flight("http");
114
115    // Calculate metrics
116    let duration = start_time.elapsed();
117    let duration_seconds = duration.as_secs_f64();
118    let status_code = response.status().as_u16();
119
120    // Determine pillar from path
121    let pillar = determine_pillar_from_path(&path);
122
123    // Record metrics with pillar information
124    registry.record_http_request_with_pillar(&method, status_code, duration_seconds, pillar);
125
126    // #677 — feed the EndpointCoverage MockOps dashboard. This stays a
127    // no-op when no analytics database has been installed via
128    // `mockforge_analytics::set_global_db`, so OSS quick-start doesn't
129    // implicitly create a sqlite file. We use the raw path rather than a
130    // route-template because the analytics DB upserts by (endpoint, method,
131    // protocol) and the dashboard already groups by that triple.
132    mockforge_analytics::record_endpoint_coverage_async(
133        path.clone(),
134        Some(method.clone()),
135        "http".to_string(),
136        None, // workspace_id — see drift_tracking note about plumbing tenant ID
137        None,
138    );
139
140    // Bump TPS / RPS counters for the dashboard rate sampler.
141    mockforge_foundation::rate_counters::record_response(status_code);
142
143    // Record errors separately with pillar
144    if status_code >= 400 {
145        let error_type = if status_code >= 500 {
146            "server_error"
147        } else {
148            "client_error"
149        };
150        registry.record_error_with_pillar("http", error_type, pillar);
151    }
152
153    debug!(
154        method = %method,
155        path = %path,
156        status = status_code,
157        duration_ms = duration.as_millis(),
158        pillar = pillar,
159        "HTTP request metrics recorded with pillar dimension"
160    );
161
162    response
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use axum::{
169        body::Body,
170        http::{Request, StatusCode},
171        middleware,
172        response::IntoResponse,
173        Router,
174    };
175    use tower::ServiceExt;
176
177    async fn test_handler() -> impl IntoResponse {
178        (StatusCode::OK, "test response")
179    }
180
181    // ==================== Pillar Detection Tests - Reality ====================
182
183    #[test]
184    fn test_pillar_reality_path() {
185        assert_eq!(determine_pillar_from_path("/api/reality/test"), "reality");
186    }
187
188    #[test]
189    fn test_pillar_personas_path() {
190        assert_eq!(determine_pillar_from_path("/api/personas/user-1"), "reality");
191    }
192
193    #[test]
194    fn test_pillar_chaos_path() {
195        assert_eq!(determine_pillar_from_path("/chaos/scenarios"), "reality");
196    }
197
198    #[test]
199    fn test_pillar_fidelity_path() {
200        assert_eq!(determine_pillar_from_path("/fidelity/config"), "reality");
201    }
202
203    #[test]
204    fn test_pillar_continuum_path() {
205        assert_eq!(determine_pillar_from_path("/api/continuum/timeline"), "reality");
206    }
207
208    // ==================== Pillar Detection Tests - Contracts ====================
209
210    #[test]
211    fn test_pillar_contracts_path() {
212        assert_eq!(determine_pillar_from_path("/api/contracts/v1"), "contracts");
213    }
214
215    #[test]
216    fn test_pillar_validation_path() {
217        assert_eq!(determine_pillar_from_path("/validation/schema"), "contracts");
218    }
219
220    #[test]
221    fn test_pillar_drift_path() {
222        assert_eq!(determine_pillar_from_path("/api/drift/analysis"), "contracts");
223    }
224
225    #[test]
226    fn test_pillar_schema_path() {
227        assert_eq!(determine_pillar_from_path("/schema/openapi"), "contracts");
228    }
229
230    #[test]
231    fn test_pillar_sync_path() {
232        assert_eq!(determine_pillar_from_path("/sync/status"), "contracts");
233    }
234
235    // ==================== Pillar Detection Tests - DevX ====================
236
237    #[test]
238    fn test_pillar_sdk_path() {
239        assert_eq!(determine_pillar_from_path("/sdk/download"), "devx");
240    }
241
242    #[test]
243    fn test_pillar_playground_path() {
244        assert_eq!(determine_pillar_from_path("/playground/execute"), "devx");
245    }
246
247    #[test]
248    fn test_pillar_plugins_path() {
249        assert_eq!(determine_pillar_from_path("/api/plugins/list"), "devx");
250    }
251
252    #[test]
253    fn test_pillar_cli_path() {
254        assert_eq!(determine_pillar_from_path("/cli/config"), "devx");
255    }
256
257    #[test]
258    fn test_pillar_generator_path() {
259        assert_eq!(determine_pillar_from_path("/generator/create"), "devx");
260    }
261
262    // ==================== Pillar Detection Tests - Cloud ====================
263
264    #[test]
265    fn test_pillar_registry_path() {
266        assert_eq!(determine_pillar_from_path("/registry/packages"), "cloud");
267    }
268
269    #[test]
270    fn test_pillar_workspace_path() {
271        assert_eq!(determine_pillar_from_path("/api/workspace/list"), "cloud");
272    }
273
274    #[test]
275    fn test_pillar_org_path() {
276        assert_eq!(determine_pillar_from_path("/org/settings"), "cloud");
277    }
278
279    #[test]
280    fn test_pillar_marketplace_path() {
281        assert_eq!(determine_pillar_from_path("/marketplace/browse"), "cloud");
282    }
283
284    #[test]
285    fn test_pillar_collab_path() {
286        assert_eq!(determine_pillar_from_path("/collab/sessions"), "cloud");
287    }
288
289    // ==================== Pillar Detection Tests - AI ====================
290
291    #[test]
292    fn test_pillar_ai_path() {
293        assert_eq!(determine_pillar_from_path("/api/ai/generate"), "ai");
294    }
295
296    #[test]
297    fn test_pillar_mockai_path() {
298        assert_eq!(determine_pillar_from_path("/mockai/responses"), "ai");
299    }
300
301    #[test]
302    fn test_pillar_voice_path() {
303        assert_eq!(determine_pillar_from_path("/voice/recognize"), "ai");
304    }
305
306    #[test]
307    fn test_pillar_llm_path() {
308        assert_eq!(determine_pillar_from_path("/llm/completion"), "ai");
309    }
310
311    #[test]
312    fn test_pillar_studio_path() {
313        assert_eq!(determine_pillar_from_path("/studio/projects"), "ai");
314    }
315
316    // ==================== Pillar Detection Tests - Unknown ====================
317
318    #[test]
319    fn test_pillar_unknown_path() {
320        assert_eq!(determine_pillar_from_path("/api/users/123"), "unknown");
321    }
322
323    #[test]
324    fn test_pillar_root_path() {
325        assert_eq!(determine_pillar_from_path("/"), "unknown");
326    }
327
328    #[test]
329    fn test_pillar_health_path() {
330        assert_eq!(determine_pillar_from_path("/health"), "unknown");
331    }
332
333    #[test]
334    fn test_pillar_empty_path() {
335        assert_eq!(determine_pillar_from_path(""), "unknown");
336    }
337
338    // ==================== Pillar Detection - Case Insensitivity ====================
339
340    #[test]
341    fn test_pillar_uppercase_reality() {
342        assert_eq!(determine_pillar_from_path("/API/REALITY/test"), "reality");
343    }
344
345    #[test]
346    fn test_pillar_mixed_case_contracts() {
347        assert_eq!(determine_pillar_from_path("/Api/Contracts/V1"), "contracts");
348    }
349
350    #[test]
351    fn test_pillar_mixed_case_ai() {
352        assert_eq!(determine_pillar_from_path("/API/Ai/Generate"), "ai");
353    }
354
355    // ==================== Middleware Integration Tests ====================
356
357    #[tokio::test]
358    async fn test_metrics_middleware_records_success() {
359        use axum::Router;
360        let app = Router::new()
361            .route("/test", axum::routing::get(test_handler))
362            .layer(middleware::from_fn(collect_http_metrics));
363
364        let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
365
366        let response = app.oneshot(request).await.unwrap();
367        assert_eq!(response.status(), StatusCode::OK);
368    }
369
370    #[tokio::test]
371    async fn test_metrics_middleware_records_errors() {
372        async fn error_handler() -> impl IntoResponse {
373            (StatusCode::INTERNAL_SERVER_ERROR, "error")
374        }
375
376        use axum::Router;
377        let app = Router::new()
378            .route("/error", axum::routing::get(error_handler))
379            .layer(middleware::from_fn(collect_http_metrics));
380
381        let request = Request::builder().uri("/error").body(Body::empty()).unwrap();
382
383        let response = app.oneshot(request).await.unwrap();
384        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
385    }
386
387    #[tokio::test]
388    async fn test_metrics_middleware_records_client_errors() {
389        async fn not_found_handler() -> impl IntoResponse {
390            (StatusCode::NOT_FOUND, "not found")
391        }
392
393        let app = Router::new()
394            .route("/notfound", axum::routing::get(not_found_handler))
395            .layer(middleware::from_fn(collect_http_metrics));
396
397        let request = Request::builder().uri("/notfound").body(Body::empty()).unwrap();
398
399        let response = app.oneshot(request).await.unwrap();
400        assert_eq!(response.status(), StatusCode::NOT_FOUND);
401    }
402
403    #[tokio::test]
404    async fn test_metrics_middleware_records_bad_request() {
405        async fn bad_request_handler() -> impl IntoResponse {
406            (StatusCode::BAD_REQUEST, "bad request")
407        }
408
409        let app = Router::new()
410            .route("/bad", axum::routing::get(bad_request_handler))
411            .layer(middleware::from_fn(collect_http_metrics));
412
413        let request = Request::builder().uri("/bad").body(Body::empty()).unwrap();
414
415        let response = app.oneshot(request).await.unwrap();
416        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
417    }
418
419    #[tokio::test]
420    async fn test_metrics_middleware_with_reality_pillar() {
421        let app = Router::new()
422            .route("/api/reality/test", axum::routing::get(test_handler))
423            .layer(middleware::from_fn(collect_http_metrics));
424
425        let request = Request::builder().uri("/api/reality/test").body(Body::empty()).unwrap();
426
427        let response = app.oneshot(request).await.unwrap();
428        assert_eq!(response.status(), StatusCode::OK);
429    }
430
431    #[tokio::test]
432    async fn test_metrics_middleware_with_contracts_pillar() {
433        let app = Router::new()
434            .route("/api/contracts/validate", axum::routing::get(test_handler))
435            .layer(middleware::from_fn(collect_http_metrics));
436
437        let request =
438            Request::builder().uri("/api/contracts/validate").body(Body::empty()).unwrap();
439
440        let response = app.oneshot(request).await.unwrap();
441        assert_eq!(response.status(), StatusCode::OK);
442    }
443
444    #[tokio::test]
445    async fn test_metrics_middleware_post_request() {
446        async fn post_handler() -> impl IntoResponse {
447            (StatusCode::CREATED, "created")
448        }
449
450        let app = Router::new()
451            .route("/api/create", axum::routing::post(post_handler))
452            .layer(middleware::from_fn(collect_http_metrics));
453
454        let request = Request::builder()
455            .method("POST")
456            .uri("/api/create")
457            .body(Body::empty())
458            .unwrap();
459
460        let response = app.oneshot(request).await.unwrap();
461        assert_eq!(response.status(), StatusCode::CREATED);
462    }
463
464    #[tokio::test]
465    async fn test_metrics_middleware_delete_request() {
466        async fn delete_handler() -> impl IntoResponse {
467            (StatusCode::NO_CONTENT, "")
468        }
469
470        let app = Router::new()
471            .route("/api/delete", axum::routing::delete(delete_handler))
472            .layer(middleware::from_fn(collect_http_metrics));
473
474        let request = Request::builder()
475            .method("DELETE")
476            .uri("/api/delete")
477            .body(Body::empty())
478            .unwrap();
479
480        let response = app.oneshot(request).await.unwrap();
481        assert_eq!(response.status(), StatusCode::NO_CONTENT);
482    }
483
484    /// Issue #79 regression — the middleware must actually advance the
485    /// `mockforge_foundation::rate_counters` snapshot so the dashboard's
486    /// TPS / RPS200 sampler can compute non-zero rates. Earlier landings
487    /// of TPS/RPS/CPS asserted only response status, which let a quiet
488    /// regression slip through where the layer wasn't wired onto the
489    /// production router. This test pins the actual counter delta.
490    #[tokio::test]
491    async fn middleware_advances_rate_counters_on_2xx() {
492        use mockforge_foundation::rate_counters;
493
494        let app = Router::new()
495            .route("/ok", axum::routing::get(test_handler))
496            .layer(middleware::from_fn(collect_http_metrics));
497
498        let before = rate_counters::snapshot();
499        let request = Request::builder().uri("/ok").body(Body::empty()).unwrap();
500        let response = app.oneshot(request).await.unwrap();
501        assert_eq!(response.status(), StatusCode::OK);
502        let after = rate_counters::snapshot();
503
504        assert!(
505            after.successful > before.successful,
506            "200 OK must bump SUCCESSFUL_RESPONSES_TOTAL: before={} after={}",
507            before.successful,
508            after.successful
509        );
510        assert!(
511            after.ok > before.ok,
512            "200 OK must bump OK_RESPONSES_TOTAL: before={} after={}",
513            before.ok,
514            after.ok
515        );
516    }
517}