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