Skip to content

Commit 44e9d63

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 8e03ea6 commit 44e9d63

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
@@ -71,6 +71,40 @@ fn spawn_capture(otelite: &Path, out_dir: &Path) -> (std::process::Child, String
7171
}
7272
}
7373

74+
/// Flatten an OTLP/HTTP-JSON `ExportMetricsServiceRequest` into its metric records.
75+
fn metric_records(line: &str) -> Vec<serde_json::Value> {
76+
let mut records = Vec::new();
77+
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
78+
return records;
79+
};
80+
let Some(resource_metrics) = value.get("resourceMetrics").and_then(|v| v.as_array()) else {
81+
return records;
82+
};
83+
for resource_metric in resource_metrics {
84+
let Some(scope_metrics) =
85+
resource_metric.get("scopeMetrics").and_then(|v| v.as_array())
86+
else {
87+
continue;
88+
};
89+
for scope_metric in scope_metrics {
90+
if let Some(batch) = scope_metric.get("metrics").and_then(|v| v.as_array()) {
91+
records.extend(batch.iter().cloned());
92+
}
93+
}
94+
}
95+
records
96+
}
97+
98+
/// A data point's string-valued attribute by key, if present.
99+
fn string_attr<'a>(point: &'a serde_json::Value, key: &str) -> Option<&'a str> {
100+
point["attributes"]
101+
.as_array()?
102+
.iter()
103+
.find(|attr| attr["key"].as_str() == Some(key))?["value"]
104+
.get("stringValue")
105+
.and_then(|v| v.as_str())
106+
}
107+
74108
#[test]
75109
fn st2_exports_spans_to_otelite_when_endpoint_is_set() {
76110
let Some(otelite) = std::env::var_os("ST2_OTELITE_BIN").map(PathBuf::from) else {
@@ -130,15 +164,36 @@ fn st2_exports_spans_to_otelite_when_endpoint_is_set() {
130164
// duration histogram sample.
131165
let metrics =
132166
std::fs::read_to_string(cap_dir.join("metrics.ndjson")).expect("metrics.ndjson written");
133-
for expected_name in ["reconcile_passes_total", "reconcile_pass_duration_seconds"] {
134-
assert!(
135-
metrics.contains(&format!("\"name\":\"{expected_name}\"")),
136-
"metric `{expected_name}` missing from capture:\n{metrics}"
137-
);
138-
}
139-
assert!(
140-
metrics.contains(r#""key":"result","value":{"stringValue":"pass"}"#),
141-
"reconcile passes counter must carry result=pass:\n{metrics}"
167+
let all_metrics: Vec<serde_json::Value> =
168+
metrics.lines().flat_map(metric_records).collect();
169+
170+
let passes = all_metrics
171+
.iter()
172+
.find(|m| m["name"].as_str() == Some("reconcile_passes_total"))
173+
.expect("reconcile passes counter missing from capture");
174+
let pass_point = &passes["sum"]["dataPoints"][0];
175+
assert_eq!(
176+
string_attr(pass_point, "result"),
177+
Some("pass"),
178+
"reconcile passes counter must carry result=pass:\n{passes}"
179+
);
180+
181+
// The view must replace the SDK's millisecond-tuned default boundaries with seconds-scale
182+
// buckets, or every sub-second pass sample collapses into the lowest bucket (P2).
183+
let duration = all_metrics
184+
.iter()
185+
.find(|m| m["name"].as_str() == Some("reconcile_pass_duration_seconds"))
186+
.expect("reconcile pass duration histogram missing from capture");
187+
let bounds = &duration["histogram"]["dataPoints"][0]["explicitBounds"];
188+
assert_eq!(
189+
bounds.as_array().map(Vec::len),
190+
Some(12),
191+
"duration histogram must carry the 12 seconds-scale boundaries:\n{duration}"
192+
);
193+
assert_eq!(
194+
bounds[0].as_f64(),
195+
Some(0.001),
196+
"lowest duration bucket must be 1ms, not the SDK default:\n{duration}"
142197
);
143198
}
144199

0 commit comments

Comments
 (0)