Skip to content

Commit d784fba

Browse files
test(otel): assert metric attributes and bucket bounds structurally
Replace the OTLP JSON substring match with parsed assertions on the reconcile passes counter attribute and the duration histogram's explicitBounds (12 seconds-scale boundaries, lowest 1ms). agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@929dc21
1 parent 35b0aed commit d784fba

1 file changed

Lines changed: 64 additions & 9 deletions

File tree

tests/otel_export.rs

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,40 @@ fn run_with_capture(
4747
.expect("run st2 up --once under otelite")
4848
}
4949

50+
/// Flatten an OTLP/HTTP-JSON `ExportMetricsServiceRequest` into its metric records.
51+
fn metric_records(line: &str) -> Vec<serde_json::Value> {
52+
let mut records = Vec::new();
53+
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
54+
return records;
55+
};
56+
let Some(resource_metrics) = value.get("resourceMetrics").and_then(|v| v.as_array()) else {
57+
return records;
58+
};
59+
for resource_metric in resource_metrics {
60+
let Some(scope_metrics) =
61+
resource_metric.get("scopeMetrics").and_then(|v| v.as_array())
62+
else {
63+
continue;
64+
};
65+
for scope_metric in scope_metrics {
66+
if let Some(batch) = scope_metric.get("metrics").and_then(|v| v.as_array()) {
67+
records.extend(batch.iter().cloned());
68+
}
69+
}
70+
}
71+
records
72+
}
73+
74+
/// A data point's string-valued attribute by key, if present.
75+
fn string_attr<'a>(point: &'a serde_json::Value, key: &str) -> Option<&'a str> {
76+
point["attributes"]
77+
.as_array()?
78+
.iter()
79+
.find(|attr| attr["key"].as_str() == Some(key))?["value"]
80+
.get("stringValue")
81+
.and_then(|v| v.as_str())
82+
}
83+
5084
#[test]
5185
fn st2_exports_spans_to_otelite_when_endpoint_is_set() {
5286
let Some(otelite) = std::env::var_os("ST2_OTELITE_BIN").map(PathBuf::from) else {
@@ -87,15 +121,36 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() {
87121
// duration histogram sample.
88122
let metrics =
89123
std::fs::read_to_string(cap_dir.join("metrics.ndjson")).expect("metrics.ndjson written");
90-
for expected_name in ["reconcile_passes_total", "reconcile_pass_duration_seconds"] {
91-
assert!(
92-
metrics.contains(&format!("\"name\":\"{expected_name}\"")),
93-
"metric `{expected_name}` missing from capture:\n{metrics}"
94-
);
95-
}
96-
assert!(
97-
metrics.contains(r#""key":"result","value":{"stringValue":"pass"}"#),
98-
"reconcile passes counter must carry result=pass:\n{metrics}"
124+
let all_metrics: Vec<serde_json::Value> =
125+
metrics.lines().flat_map(metric_records).collect();
126+
127+
let passes = all_metrics
128+
.iter()
129+
.find(|m| m["name"].as_str() == Some("reconcile_passes_total"))
130+
.expect("reconcile passes counter missing from capture");
131+
let pass_point = &passes["sum"]["dataPoints"][0];
132+
assert_eq!(
133+
string_attr(pass_point, "result"),
134+
Some("pass"),
135+
"reconcile passes counter must carry result=pass:\n{passes}"
136+
);
137+
138+
// The view must replace the SDK's millisecond-tuned default boundaries with seconds-scale
139+
// buckets, or every sub-second pass sample collapses into the lowest bucket (P2).
140+
let duration = all_metrics
141+
.iter()
142+
.find(|m| m["name"].as_str() == Some("reconcile_pass_duration_seconds"))
143+
.expect("reconcile pass duration histogram missing from capture");
144+
let bounds = &duration["histogram"]["dataPoints"][0]["explicitBounds"];
145+
assert_eq!(
146+
bounds.as_array().map(Vec::len),
147+
Some(12),
148+
"duration histogram must carry the 12 seconds-scale boundaries:\n{duration}"
149+
);
150+
assert_eq!(
151+
bounds[0].as_f64(),
152+
Some(0.001),
153+
"lowest duration bucket must be 1ms, not the SDK default:\n{duration}"
99154
);
100155
}
101156

0 commit comments

Comments
 (0)