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 determine_pillar_from_path(path: &str) -> &'static str {
24 let path_lower = path.to_lowercase();
25
26 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 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 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 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 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 "unknown"
78}
79
80pub 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 let registry = get_global_registry();
100
101 registry.increment_in_flight("http");
103 debug!(
104 method = %method,
105 path = %path,
106 "Starting HTTP request metrics collection"
107 );
108
109 let response = next.run(req).await;
111
112 registry.decrement_in_flight("http");
114
115 let duration = start_time.elapsed();
117 let duration_seconds = duration.as_secs_f64();
118 let status_code = response.status().as_u16();
119
120 let pillar = determine_pillar_from_path(&path);
122
123 registry.record_http_request_with_pillar(&method, status_code, duration_seconds, pillar);
125
126 mockforge_analytics::record_endpoint_coverage_async(
133 path.clone(),
134 Some(method.clone()),
135 "http".to_string(),
136 None, None,
138 );
139
140 mockforge_foundation::rate_counters::record_response(status_code);
142
143 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}