@@ -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]
75109fn 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