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    // Record errors separately with pillar
127    if status_code >= 400 {
128        let error_type = if status_code >= 500 {
129            "server_error"
130        } else {
131            "client_error"
132        };
133        registry.record_error_with_pillar("http", error_type, pillar);
134    }
135
136    debug!(
137        method = %method,
138        path = %path,
139        status = status_code,
140        duration_ms = duration.as_millis(),
141        pillar = pillar,
142        "HTTP request metrics recorded with pillar dimension"
143    );
144
145    response
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use axum::{
152        body::Body,
153        http::{Request, StatusCode},
154        middleware,
155        response::IntoResponse,
156        Router,
157    };
158    use tower::ServiceExt;
159
160    async fn test_handler() -> impl IntoResponse {
161        (StatusCode::OK, "test response")
162    }
163
164    // ==================== Pillar Detection Tests - Reality ====================
165
166    #[test]
167    fn test_pillar_reality_path() {
168        assert_eq!(determine_pillar_from_path("/api/reality/test"), "reality");
169    }
170
171    #[test]
172    fn test_pillar_personas_path() {
173        assert_eq!(determine_pillar_from_path("/api/personas/user-1"), "reality");
174    }
175
176    #[test]
177    fn test_pillar_chaos_path() {
178        assert_eq!(determine_pillar_from_path("/chaos/scenarios"), "reality");
179    }
180
181    #[test]
182    fn test_pillar_fidelity_path() {
183        assert_eq!(determine_pillar_from_path("/fidelity/config"), "reality");
184    }
185
186    #[test]
187    fn test_pillar_continuum_path() {
188        assert_eq!(determine_pillar_from_path("/api/continuum/timeline"), "reality");
189    }
190
191    // ==================== Pillar Detection Tests - Contracts ====================
192
193    #[test]
194    fn test_pillar_contracts_path() {
195        assert_eq!(determine_pillar_from_path("/api/contracts/v1"), "contracts");
196    }
197
198    #[test]
199    fn test_pillar_validation_path() {
200        assert_eq!(determine_pillar_from_path("/validation/schema"), "contracts");
201    }
202
203    #[test]
204    fn test_pillar_drift_path() {
205        assert_eq!(determine_pillar_from_path("/api/drift/analysis"), "contracts");
206    }
207
208    #[test]
209    fn test_pillar_schema_path() {
210        assert_eq!(determine_pillar_from_path("/schema/openapi"), "contracts");
211    }
212
213    #[test]
214    fn test_pillar_sync_path() {
215        assert_eq!(determine_pillar_from_path("/sync/status"), "contracts");
216    }
217
218    // ==================== Pillar Detection Tests - DevX ====================
219
220    #[test]
221    fn test_pillar_sdk_path() {
222        assert_eq!(determine_pillar_from_path("/sdk/download"), "devx");
223    }
224
225    #[test]
226    fn test_pillar_playground_path() {
227        assert_eq!(determine_pillar_from_path("/playground/execute"), "devx");
228    }
229
230    #[test]
231    fn test_pillar_plugins_path() {
232        assert_eq!(determine_pillar_from_path("/api/plugins/list"), "devx");
233    }
234
235    #[test]
236    fn test_pillar_cli_path() {
237        assert_eq!(determine_pillar_from_path("/cli/config"), "devx");
238    }
239
240    #[test]
241    fn test_pillar_generator_path() {
242        assert_eq!(determine_pillar_from_path("/generator/create"), "devx");
243    }
244
245    // ==================== Pillar Detection Tests - Cloud ====================
246
247    #[test]
248    fn test_pillar_registry_path() {
249        assert_eq!(determine_pillar_from_path("/registry/packages"), "cloud");
250    }
251
252    #[test]
253    fn test_pillar_workspace_path() {
254        assert_eq!(determine_pillar_from_path("/api/workspace/list"), "cloud");
255    }
256
257    #[test]
258    fn test_pillar_org_path() {
259        assert_eq!(determine_pillar_from_path("/org/settings"), "cloud");
260    }
261
262    #[test]
263    fn test_pillar_marketplace_path() {
264        assert_eq!(determine_pillar_from_path("/marketplace/browse"), "cloud");
265    }
266
267    #[test]
268    fn test_pillar_collab_path() {
269        assert_eq!(determine_pillar_from_path("/collab/sessions"), "cloud");
270    }
271
272    // ==================== Pillar Detection Tests - AI ====================
273
274    #[test]
275    fn test_pillar_ai_path() {
276        assert_eq!(determine_pillar_from_path("/api/ai/generate"), "ai");
277    }
278
279    #[test]
280    fn test_pillar_mockai_path() {
281        assert_eq!(determine_pillar_from_path("/mockai/responses"), "ai");
282    }
283
284    #[test]
285    fn test_pillar_voice_path() {
286        assert_eq!(determine_pillar_from_path("/voice/recognize"), "ai");
287    }
288
289    #[test]
290    fn test_pillar_llm_path() {
291        assert_eq!(determine_pillar_from_path("/llm/completion"), "ai");
292    }
293
294    #[test]
295    fn test_pillar_studio_path() {
296        assert_eq!(determine_pillar_from_path("/studio/projects"), "ai");
297    }
298
299    // ==================== Pillar Detection Tests - Unknown ====================
300
301    #[test]
302    fn test_pillar_unknown_path() {
303        assert_eq!(determine_pillar_from_path("/api/users/123"), "unknown");
304    }
305
306    #[test]
307    fn test_pillar_root_path() {
308        assert_eq!(determine_pillar_from_path("/"), "unknown");
309    }
310
311    #[test]
312    fn test_pillar_health_path() {
313        assert_eq!(determine_pillar_from_path("/health"), "unknown");
314    }
315
316    #[test]
317    fn test_pillar_empty_path() {
318        assert_eq!(determine_pillar_from_path(""), "unknown");
319    }
320
321    // ==================== Pillar Detection - Case Insensitivity ====================
322
323    #[test]
324    fn test_pillar_uppercase_reality() {
325        assert_eq!(determine_pillar_from_path("/API/REALITY/test"), "reality");
326    }
327
328    #[test]
329    fn test_pillar_mixed_case_contracts() {
330        assert_eq!(determine_pillar_from_path("/Api/Contracts/V1"), "contracts");
331    }
332
333    #[test]
334    fn test_pillar_mixed_case_ai() {
335        assert_eq!(determine_pillar_from_path("/API/Ai/Generate"), "ai");
336    }
337
338    // ==================== Middleware Integration Tests ====================
339
340    #[tokio::test]
341    async fn test_metrics_middleware_records_success() {
342        use axum::Router;
343        let app = Router::new()
344            .route("/test", axum::routing::get(test_handler))
345            .layer(middleware::from_fn(collect_http_metrics));
346
347        let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
348
349        let response = app.oneshot(request).await.unwrap();
350        assert_eq!(response.status(), StatusCode::OK);
351    }
352
353    #[tokio::test]
354    async fn test_metrics_middleware_records_errors() {
355        async fn error_handler() -> impl IntoResponse {
356            (StatusCode::INTERNAL_SERVER_ERROR, "error")
357        }
358
359        use axum::Router;
360        let app = Router::new()
361            .route("/error", axum::routing::get(error_handler))
362            .layer(middleware::from_fn(collect_http_metrics));
363
364        let request = Request::builder().uri("/error").body(Body::empty()).unwrap();
365
366        let response = app.oneshot(request).await.unwrap();
367        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
368    }
369
370    #[tokio::test]
371    async fn test_metrics_middleware_records_client_errors() {
372        async fn not_found_handler() -> impl IntoResponse {
373            (StatusCode::NOT_FOUND, "not found")
374        }
375
376        let app = Router::new()
377            .route("/notfound", axum::routing::get(not_found_handler))
378            .layer(middleware::from_fn(collect_http_metrics));
379
380        let request = Request::builder().uri("/notfound").body(Body::empty()).unwrap();
381
382        let response = app.oneshot(request).await.unwrap();
383        assert_eq!(response.status(), StatusCode::NOT_FOUND);
384    }
385
386    #[tokio::test]
387    async fn test_metrics_middleware_records_bad_request() {
388        async fn bad_request_handler() -> impl IntoResponse {
389            (StatusCode::BAD_REQUEST, "bad request")
390        }
391
392        let app = Router::new()
393            .route("/bad", axum::routing::get(bad_request_handler))
394            .layer(middleware::from_fn(collect_http_metrics));
395
396        let request = Request::builder().uri("/bad").body(Body::empty()).unwrap();
397
398        let response = app.oneshot(request).await.unwrap();
399        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
400    }
401
402    #[tokio::test]
403    async fn test_metrics_middleware_with_reality_pillar() {
404        let app = Router::new()
405            .route("/api/reality/test", axum::routing::get(test_handler))
406            .layer(middleware::from_fn(collect_http_metrics));
407
408        let request = Request::builder().uri("/api/reality/test").body(Body::empty()).unwrap();
409
410        let response = app.oneshot(request).await.unwrap();
411        assert_eq!(response.status(), StatusCode::OK);
412    }
413
414    #[tokio::test]
415    async fn test_metrics_middleware_with_contracts_pillar() {
416        let app = Router::new()
417            .route("/api/contracts/validate", axum::routing::get(test_handler))
418            .layer(middleware::from_fn(collect_http_metrics));
419
420        let request =
421            Request::builder().uri("/api/contracts/validate").body(Body::empty()).unwrap();
422
423        let response = app.oneshot(request).await.unwrap();
424        assert_eq!(response.status(), StatusCode::OK);
425    }
426
427    #[tokio::test]
428    async fn test_metrics_middleware_post_request() {
429        async fn post_handler() -> impl IntoResponse {
430            (StatusCode::CREATED, "created")
431        }
432
433        let app = Router::new()
434            .route("/api/create", axum::routing::post(post_handler))
435            .layer(middleware::from_fn(collect_http_metrics));
436
437        let request = Request::builder()
438            .method("POST")
439            .uri("/api/create")
440            .body(Body::empty())
441            .unwrap();
442
443        let response = app.oneshot(request).await.unwrap();
444        assert_eq!(response.status(), StatusCode::CREATED);
445    }
446
447    #[tokio::test]
448    async fn test_metrics_middleware_delete_request() {
449        async fn delete_handler() -> impl IntoResponse {
450            (StatusCode::NO_CONTENT, "")
451        }
452
453        let app = Router::new()
454            .route("/api/delete", axum::routing::delete(delete_handler))
455            .layer(middleware::from_fn(collect_http_metrics));
456
457        let request = Request::builder()
458            .method("DELETE")
459            .uri("/api/delete")
460            .body(Body::empty())
461            .unwrap();
462
463        let response = app.oneshot(request).await.unwrap();
464        assert_eq!(response.status(), StatusCode::NO_CONTENT);
465    }
466}