1use 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
19fn 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
37fn determine_pillar_from_path(path: &str) -> &'static str {
42 let path_lower = path.to_lowercase();
43
44 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 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 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 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 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 "unknown"
96}
97
98pub 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 let registry = get_global_registry();
118
119 let (declared_persona, ci_run_id) = detect_persona_headers(req.headers());
122
123 registry.increment_in_flight("http");
125 debug!(
126 method = %method,
127 path = %path,
128 "Starting HTTP request metrics collection"
129 );
130
131 let response = next.run(req).await;
133
134 registry.decrement_in_flight("http");
136
137 let duration = start_time.elapsed();
139 let duration_seconds = duration.as_secs_f64();
140 let status_code = response.status().as_u16();
141
142 let pillar = determine_pillar_from_path(&path);
144
145 registry.record_http_request_with_pillar(&method, status_code, duration_seconds, pillar);
147
148 mockforge_analytics::record_endpoint_coverage_async(
155 path.clone(),
156 Some(method.clone()),
157 "http".to_string(),
158 None, None,
160 );
161
162 if let Some(persona_id) = declared_persona {
165 mockforge_analytics::record_persona_ci_hit_async(
166 persona_id, None, None, ci_run_id,
168 );
169 }
170
171 mockforge_foundation::rate_counters::record_response(status_code);
173
174 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}