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