use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use axum::{ Router, body::{Body, to_bytes}, http::{Request, StatusCode, header}, middleware, response::IntoResponse, routing::get, }; use crank_observability::{ MetricsConfig, ObservabilityConfig, RedactionLimits, ServiceIdentity, record_http_request, }; use tower::ServiceExt; #[tokio::test] async fn http_metrics_use_matched_routes_and_closed_labels() { let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "test").expect("valid identity"); let lifecycle = crank_observability::init(ObservabilityConfig::new( identity, "off", RedactionLimits::default(), )) .expect("observability lifecycle"); let config = MetricsConfig::new( true, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9464), None, ) .expect("loopback metrics"); let metrics = lifecycle.metrics_surface(config).router(); let app = Router::new() .route( "/api/admin/workspaces/{workspace_id}/operations/{operation_id}", get(|| async { let mut response = StatusCode::NO_CONTENT.into_response(); response.extensions_mut().insert( crank_metrics::ExemplarTraceId::parse("0123456789abcdef0123456789abcdef") .unwrap(), ); response }), ) .layer(middleware::from_fn(record_http_request)); let sensitive_path_segment = "customer-secret-operation-id"; for index in 0..100 { let response = app .clone() .oneshot( Request::get(format!( "/api/admin/workspaces/customer-secret-workspace-{index}/operations/{sensitive_path_segment}-{index}" )) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } let response = app .oneshot( Request::get("/unknown/customer-controlled-path") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = metrics .clone() .oneshot( Request::get("/metrics") .body(Body::empty()) .expect("request"), ) .await .expect("metrics response"); let body = to_bytes(response.into_body(), 1024 * 1024) .await .expect("bounded metrics body"); let body = String::from_utf8(body.to_vec()).expect("utf-8 metrics"); assert!(body.contains("crank_http_requests_total")); assert!( body.contains("route=\"/api/admin/workspaces/{workspace_id}/operations/{operation_id}\"") ); assert!(body.contains("method=\"GET\"")); assert!(body.contains("status_class=\"2xx\"")); assert!(body.contains("crank_http_request_duration_seconds_bucket")); assert!(!body.contains("trace_id")); assert!(!body.contains(sensitive_path_segment)); assert_eq!( body.lines() .filter(|line| { line.starts_with("crank_http_requests_total{") && line.contains( "route=\"/api/admin/workspaces/{workspace_id}/operations/{operation_id}\"", ) }) .count(), 1, "different entity ids must not create additional series" ); let openmetrics = metrics .oneshot( Request::get("/metrics") .header( header::ACCEPT, "application/openmetrics-text; version=1.0.0", ) .body(Body::empty()) .expect("request"), ) .await .expect("OpenMetrics response"); assert_eq!(openmetrics.status(), StatusCode::OK); assert!( openmetrics.headers()[header::CONTENT_TYPE] .to_str() .unwrap() .starts_with("application/openmetrics-text") ); let body = to_bytes(openmetrics.into_body(), 1024 * 1024) .await .unwrap(); let body = String::from_utf8(body.to_vec()).unwrap(); assert!(body.contains("# {trace_id=\"0123456789abcdef0123456789abcdef\"}")); assert!(body.ends_with("# EOF\n")); }