Skip to content

Commit b1dc4e5

Browse files
committed
Test V2 metric recording after decapsulation
Adds three tests for handle_decapsulated_request's metric recordings: POST happy path (layer=v2, endpoint=/{mailbox}, method=POST, status=200), GET timeout (status=202), and the catch-all 404 arm (endpoint=other). Also guards that the actual short-id value never reaches any metric label. test_service delegates to test_service_with_metrics so tests can inject a provider-backed MetricsService.
1 parent 1fc2b3f commit b1dc4e5

1 file changed

Lines changed: 208 additions & 1 deletion

File tree

payjoin-mailroom/src/directory.rs

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)