mockforge_http/
metrics_middleware.rs1use 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}