@@ -614,9 +614,19 @@ mod tests {
614614
615615 use super :: * ;
616616 use crate :: db:: FilesDb ;
617+ use crate :: metrics:: HTTP_REQUESTS_TOTAL ;
617618 use crate :: ohttp_relay:: SentinelTag ;
618619
619620 async fn test_service ( v1 : Option < V1 > ) -> Service < FilesDb > {
621+ test_service_with_metrics ( v1, MetricsService :: new ( None ) ) . await
622+ }
623+
624+ /// Like [`test_service`] but injects a caller-owned [`MetricsService`] so
625+ /// tests can assert on recorded labels via its `SdkMeterProvider`.
626+ async fn test_service_with_metrics (
627+ v1 : Option < V1 > ,
628+ metrics : MetricsService ,
629+ ) -> Service < FilesDb > {
620630 let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
621631 let db = FilesDb :: init (
622632 Duration :: from_millis ( 100 ) ,
@@ -627,7 +637,7 @@ mod tests {
627637 . expect ( "db init" ) ;
628638 let ohttp: ohttp:: Server =
629639 crate :: key_config:: gen_ohttp_server_config ( ) . expect ( "ohttp config" ) . into ( ) ;
630- Service :: new ( db, ohttp, SentinelTag :: new ( [ 0u8 ; 32 ] ) , v1, MetricsService :: new ( None ) )
640+ Service :: new ( db, ohttp, SentinelTag :: new ( [ 0u8 ; 32 ] ) , v1, metrics )
631641 }
632642
633643 /// A valid ShortId encoded as bech32 for use in URL paths.
@@ -964,4 +974,201 @@ mod tests {
964974 other => panic ! ( "expected U64 Gauge, got {other:?}" ) ,
965975 }
966976 }
977+
978+ // V2 metrics: handle_decapsulated_request records http_requests_total
979+
980+ /// Finds an `http_requests_total` data point whose labels exactly match the
981+ /// given `layer`, `endpoint`, `method`, and `status_code`. Returns its
982+ /// counter value (0 if no matching data point exists).
983+ fn count_http_request_record (
984+ exporter : & opentelemetry_sdk:: metrics:: InMemoryMetricExporter ,
985+ layer : & str ,
986+ endpoint : & str ,
987+ method : & str ,
988+ status_code : u16 ,
989+ ) -> u64 {
990+ use opentelemetry_sdk:: metrics:: data:: { AggregatedMetrics , MetricData } ;
991+
992+ let finished = exporter. get_finished_metrics ( ) . expect ( "metrics" ) ;
993+ finished
994+ . iter ( )
995+ . flat_map ( |rm| rm. scope_metrics ( ) )
996+ . flat_map ( |sm| sm. metrics ( ) )
997+ . filter ( |m| m. name ( ) == HTTP_REQUESTS_TOTAL )
998+ . flat_map ( |m| match m. data ( ) {
999+ AggregatedMetrics :: U64 ( MetricData :: Sum ( sum) ) =>
1000+ sum. data_points ( ) . collect :: < Vec < _ > > ( ) ,
1001+ _ => Vec :: new ( ) ,
1002+ } )
1003+ . filter ( |dp| {
1004+ let mut attrs = dp. attributes ( ) ;
1005+ let layer_matches =
1006+ attrs. any ( |kv| kv. key . as_str ( ) == "layer" && kv. value . to_string ( ) == layer) ;
1007+ if !layer_matches {
1008+ return false ;
1009+ }
1010+ let mut attrs = dp. attributes ( ) ;
1011+ let endpoint_matches = attrs
1012+ . any ( |kv| kv. key . as_str ( ) == "endpoint" && kv. value . to_string ( ) == endpoint) ;
1013+ if !endpoint_matches {
1014+ return false ;
1015+ }
1016+ let mut attrs = dp. attributes ( ) ;
1017+ let method_matches =
1018+ attrs. any ( |kv| kv. key . as_str ( ) == "method" && kv. value . to_string ( ) == method) ;
1019+ if !method_matches {
1020+ return false ;
1021+ }
1022+ let mut attrs = dp. attributes ( ) ;
1023+ attrs. any ( |kv| {
1024+ kv. key . as_str ( ) == "status_code"
1025+ && kv. value . to_string ( ) == status_code. to_string ( )
1026+ } )
1027+ } )
1028+ . map ( |dp| dp. value ( ) )
1029+ . sum ( )
1030+ }
1031+
1032+ /// Collects every `endpoint` label value recorded under `http_requests_total`
1033+ /// (any layer), so tests can assert the actual short-id value never leaks.
1034+ fn recorded_endpoints (
1035+ exporter : & opentelemetry_sdk:: metrics:: InMemoryMetricExporter ,
1036+ ) -> Vec < String > {
1037+ use opentelemetry_sdk:: metrics:: data:: { AggregatedMetrics , MetricData } ;
1038+
1039+ let finished = exporter. get_finished_metrics ( ) . expect ( "metrics" ) ;
1040+ finished
1041+ . iter ( )
1042+ . flat_map ( |rm| rm. scope_metrics ( ) )
1043+ . flat_map ( |sm| sm. metrics ( ) )
1044+ . filter ( |m| m. name ( ) == HTTP_REQUESTS_TOTAL )
1045+ . flat_map ( |m| match m. data ( ) {
1046+ AggregatedMetrics :: U64 ( MetricData :: Sum ( sum) ) =>
1047+ sum. data_points ( ) . collect :: < Vec < _ > > ( ) ,
1048+ _ => Vec :: new ( ) ,
1049+ } )
1050+ . flat_map ( |dp| {
1051+ dp. attributes ( )
1052+ . filter_map ( |kv| {
1053+ if kv. key . as_str ( ) == "endpoint" {
1054+ Some ( kv. value . to_string ( ) )
1055+ } else {
1056+ None
1057+ }
1058+ } )
1059+ . collect :: < Vec < _ > > ( )
1060+ } )
1061+ . collect ( )
1062+ }
1063+
1064+ fn metrics_exporter ( ) -> (
1065+ opentelemetry_sdk:: metrics:: InMemoryMetricExporter ,
1066+ opentelemetry_sdk:: metrics:: SdkMeterProvider ,
1067+ ) {
1068+ use opentelemetry_sdk:: metrics:: {
1069+ InMemoryMetricExporter , PeriodicReader , SdkMeterProvider ,
1070+ } ;
1071+
1072+ let exporter = InMemoryMetricExporter :: default ( ) ;
1073+ let reader = PeriodicReader :: builder ( exporter. clone ( ) ) . build ( ) ;
1074+ let provider = SdkMeterProvider :: builder ( ) . with_reader ( reader) . build ( ) ;
1075+ ( exporter, provider)
1076+ }
1077+
1078+ /// A V2 POST to `/{mailbox}` decapsulated by the directory records
1079+ /// `http_requests_total{layer="v2", endpoint="/{mailbox}", method="POST",
1080+ /// status_code="200"}` exactly once, and the actual mailbox id never
1081+ /// reaches any label.
1082+ #[ tokio:: test]
1083+ async fn v2_decapsulated_post_records_mailbox_metric ( ) {
1084+ let ( exporter, provider) = metrics_exporter ( ) ;
1085+ let metrics = MetricsService :: new ( Some ( provider. clone ( ) ) ) ;
1086+ let svc = test_service_with_metrics ( None , metrics) . await ;
1087+
1088+ let id = valid_short_id_path ( ) ;
1089+ let req = Request :: builder ( )
1090+ . method ( Method :: POST )
1091+ . uri ( format ! ( "http://localhost/{id}" ) )
1092+ . body ( Body :: from ( b"small payload under 65k" . to_vec ( ) ) )
1093+ . unwrap ( ) ;
1094+
1095+ let res = svc. handle_decapsulated_request ( req) . await . expect ( "decap ok" ) ;
1096+ assert_eq ! ( res. status( ) , StatusCode :: OK ) ;
1097+
1098+ provider. force_flush ( ) . expect ( "flush failed" ) ;
1099+
1100+ assert_eq ! (
1101+ count_http_request_record( & exporter, "v2" , "/{mailbox}" , "POST" , 200 ) ,
1102+ 1 ,
1103+ "V2 POST must record exactly one http_requests_total under layer=v2 /{{mailbox}} POST 200"
1104+ ) ;
1105+ assert_eq ! (
1106+ count_http_request_record( & exporter, "gateway" , "/{mailbox}" , "POST" , 200 ) ,
1107+ 0 ,
1108+ "outer gateway layer must not be recorded when only the inner path runs"
1109+ ) ;
1110+ let endpoints = recorded_endpoints ( & exporter) ;
1111+ assert ! (
1112+ endpoints. iter( ) . all( |ep| !ep. contains( & id) ) ,
1113+ "actual mailbox id {id} leaked into endpoint labels: {endpoints:?}"
1114+ ) ;
1115+ }
1116+
1117+ /// A V2 GET to `/{mailbox}` that times out at the db returns 202 ACCEPTED
1118+ /// and is recorded as `layer="v2", endpoint="/{mailbox}", method="GET",
1119+ /// status_code="202"`.
1120+ #[ tokio:: test]
1121+ async fn v2_decapsulated_get_records_mailbox_metric ( ) {
1122+ let ( exporter, provider) = metrics_exporter ( ) ;
1123+ let metrics = MetricsService :: new ( Some ( provider. clone ( ) ) ) ;
1124+ let svc = test_service_with_metrics ( None , metrics) . await ;
1125+
1126+ let id = valid_short_id_path ( ) ;
1127+ let req = Request :: builder ( )
1128+ . method ( Method :: GET )
1129+ . uri ( format ! ( "http://localhost/{id}" ) )
1130+ . body ( Body :: empty ( ) )
1131+ . unwrap ( ) ;
1132+
1133+ let res = svc. handle_decapsulated_request ( req) . await . expect ( "decap ok" ) ;
1134+ assert_eq ! ( res. status( ) , StatusCode :: ACCEPTED ) ;
1135+
1136+ provider. force_flush ( ) . expect ( "flush failed" ) ;
1137+
1138+ assert_eq ! (
1139+ count_http_request_record( & exporter, "v2" , "/{mailbox}" , "GET" , 202 ) ,
1140+ 1 ,
1141+ "V2 GET timeout must record exactly one http_requests_total under layer=v2 /{{mailbox}} GET 202"
1142+ ) ;
1143+ }
1144+
1145+ /// An inner request whose path doesn't match any handler arm collapses to
1146+ /// `endpoint="other"` and is still recorded once under layer=v2 with the
1147+ /// catch-all 404 status.
1148+ #[ tokio:: test]
1149+ async fn v2_decapsulated_unknown_path_records_other_endpoint ( ) {
1150+ let ( exporter, provider) = metrics_exporter ( ) ;
1151+ let metrics = MetricsService :: new ( Some ( provider. clone ( ) ) ) ;
1152+ let svc = test_service_with_metrics ( None , metrics) . await ;
1153+
1154+ // A multi-segment path does not match `(POST, &["", id])`, so it falls
1155+ // through to the `_ => not_found()` arm instead of attempting a
1156+ // ShortId parse that would surface as a 400.
1157+ let req = Request :: builder ( )
1158+ . method ( Method :: POST )
1159+ . uri ( "http://localhost/unknown/path" )
1160+ . body ( Body :: empty ( ) )
1161+ . unwrap ( ) ;
1162+
1163+ let res = svc. handle_decapsulated_request ( req) . await . expect ( "decap ok" ) ;
1164+ assert_eq ! ( res. status( ) , StatusCode :: NOT_FOUND ) ;
1165+
1166+ provider. force_flush ( ) . expect ( "flush failed" ) ;
1167+
1168+ assert_eq ! (
1169+ count_http_request_record( & exporter, "v2" , "other" , "POST" , 404 ) ,
1170+ 1 ,
1171+ "unknown inner path must collapse to endpoint=other and record 404"
1172+ ) ;
1173+ }
9671174}
0 commit comments