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