@@ -277,52 +277,41 @@ fn parse_group_by_facets(group_by: Option<String>, limit: u32) -> Vec<serde_json
277277 . unwrap_or_default ( )
278278}
279279
280- fn print_analytics_table ( resp : & serde_json:: Value , facets : & [ String ] , compute : & str ) -> Result < ( ) > {
280+ fn flatten_analytics_response (
281+ resp : & serde_json:: Value ,
282+ facets : & [ String ] ,
283+ compute : & str ,
284+ ) -> Result < Vec < serde_json:: Value > > {
281285 let buckets = resp
282286 . get ( "buckets" )
283287 . and_then ( |b| b. as_array ( ) )
284288 . ok_or_else ( || anyhow:: anyhow!( "unexpected response: missing 'buckets' array" ) ) ?;
285289
286- if buckets. is_empty ( ) {
287- println ! ( "No results." ) ;
288- return Ok ( ( ) ) ;
289- }
290-
291- let mut table = comfy_table:: Table :: new ( ) ;
292- let mut headers: Vec < String > = facets. to_vec ( ) ;
293- headers. push ( compute. to_string ( ) ) ;
294- table. set_header ( & headers) ;
295-
296- for bucket in buckets {
297- let by = bucket. get ( "by" ) . and_then ( |b| b. as_object ( ) ) ;
298- let computes = bucket. get ( "computes" ) . and_then ( |c| c. as_object ( ) ) ;
299-
300- let mut row: Vec < String > = facets
301- . iter ( )
302- . map ( |facet| {
303- by. and_then ( |b| b. get ( facet) )
304- . map ( |v| match v {
305- serde_json:: Value :: String ( s) => s. clone ( ) ,
306- other => other. to_string ( ) ,
307- } )
308- . unwrap_or_default ( )
309- } )
310- . collect ( ) ;
311-
312- let val = computes
313- . and_then ( |c| c. get ( "c0" ) )
314- . map ( |v| match v {
315- serde_json:: Value :: Number ( n) => n. to_string ( ) ,
316- other => other. to_string ( ) ,
317- } )
318- . unwrap_or_default ( ) ;
319- row. push ( val) ;
320-
321- table. add_row ( row) ;
322- }
323-
324- println ! ( "{table}" ) ;
325- Ok ( ( ) )
290+ let rows = buckets
291+ . iter ( )
292+ . map ( |bucket| {
293+ let by = bucket. get ( "by" ) . and_then ( |b| b. as_object ( ) ) ;
294+ let computes = bucket. get ( "computes" ) . and_then ( |c| c. as_object ( ) ) ;
295+
296+ let mut row = serde_json:: Map :: new ( ) ;
297+ for facet in facets {
298+ let val = by
299+ . and_then ( |b| b. get ( facet) )
300+ . cloned ( )
301+ . unwrap_or ( serde_json:: Value :: Null ) ;
302+ row. insert ( facet. clone ( ) , val) ;
303+ }
304+ let val = computes
305+ . and_then ( |c| c. get ( "c0" ) )
306+ . cloned ( )
307+ . unwrap_or ( serde_json:: Value :: Null ) ;
308+ row. insert ( compute. to_string ( ) , val) ;
309+
310+ serde_json:: Value :: Object ( row)
311+ } )
312+ . collect ( ) ;
313+
314+ Ok ( rows)
326315}
327316
328317#[ allow( clippy:: too_many_arguments) ]
@@ -365,7 +354,8 @@ pub async fn spans_analytics(
365354 let resp = client:: raw_post ( cfg, "/api/unstable/llm-obs-query-rewriter/timeseries" , body)
366355 . await
367356 . map_err ( |e| anyhow:: anyhow!( "failed to run spans analytics: {e:?}" ) ) ?;
368- print_analytics_table ( & resp, & facets, & compute)
357+ let rows = flatten_analytics_response ( & resp, & facets, & compute) ?;
358+ formatter:: output ( cfg, & rows)
369359}
370360
371361#[ cfg( test) ]
@@ -465,4 +455,94 @@ mod tests {
465455 let result = parse_group_by_facets ( Some ( "a,b,c" . into ( ) ) , 25 ) ;
466456 assert ! ( result. iter( ) . all( |v| v[ "limit" ] == 25 ) ) ;
467457 }
458+
459+ // --- flatten_analytics_response ---
460+
461+ #[ test]
462+ fn test_flatten_analytics_single_facet ( ) {
463+ let resp = serde_json:: json!( {
464+ "buckets" : [
465+ { "by" : { "span_name" : "llm.call" } , "computes" : { "c0" : 42 } } ,
466+ { "by" : { "span_name" : "tool.run" } , "computes" : { "c0" : 7 } } ,
467+ ]
468+ } ) ;
469+ let rows = flatten_analytics_response ( & resp, & [ "span_name" . into ( ) ] , "count" ) . unwrap ( ) ;
470+ assert_eq ! ( rows. len( ) , 2 ) ;
471+ assert_eq ! ( rows[ 0 ] [ "span_name" ] , "llm.call" ) ;
472+ assert_eq ! ( rows[ 0 ] [ "count" ] , 42 ) ;
473+ assert_eq ! ( rows[ 1 ] [ "span_name" ] , "tool.run" ) ;
474+ assert_eq ! ( rows[ 1 ] [ "count" ] , 7 ) ;
475+ }
476+
477+ #[ test]
478+ fn test_flatten_analytics_multiple_facets ( ) {
479+ let resp = serde_json:: json!( {
480+ "buckets" : [
481+ {
482+ "by" : { "span_name" : "llm.call" , "@ml_app" : "my-app" } ,
483+ "computes" : { "c0" : 10 }
484+ }
485+ ]
486+ } ) ;
487+ let rows =
488+ flatten_analytics_response ( & resp, & [ "span_name" . into ( ) , "@ml_app" . into ( ) ] , "count" )
489+ . unwrap ( ) ;
490+ assert_eq ! ( rows. len( ) , 1 ) ;
491+ assert_eq ! ( rows[ 0 ] [ "span_name" ] , "llm.call" ) ;
492+ assert_eq ! ( rows[ 0 ] [ "@ml_app" ] , "my-app" ) ;
493+ assert_eq ! ( rows[ 0 ] [ "count" ] , 10 ) ;
494+ }
495+
496+ #[ test]
497+ fn test_flatten_analytics_no_facets ( ) {
498+ // Total aggregate with no group-by: one bucket, no "by" fields, just the compute.
499+ let resp = serde_json:: json!( {
500+ "buckets" : [
501+ { "by" : { } , "computes" : { "c0" : 99 } }
502+ ]
503+ } ) ;
504+ let rows = flatten_analytics_response ( & resp, & [ ] , "count" ) . unwrap ( ) ;
505+ assert_eq ! ( rows. len( ) , 1 ) ;
506+ assert_eq ! ( rows[ 0 ] [ "count" ] , 99 ) ;
507+ // No extra keys beyond the compute label.
508+ assert_eq ! ( rows[ 0 ] . as_object( ) . unwrap( ) . len( ) , 1 ) ;
509+ }
510+
511+ #[ test]
512+ fn test_flatten_analytics_empty_buckets ( ) {
513+ let resp = serde_json:: json!( { "buckets" : [ ] } ) ;
514+ let rows = flatten_analytics_response ( & resp, & [ "span_name" . into ( ) ] , "count" ) . unwrap ( ) ;
515+ assert ! ( rows. is_empty( ) ) ;
516+ }
517+
518+ #[ test]
519+ fn test_flatten_analytics_missing_buckets_key ( ) {
520+ let resp = serde_json:: json!( { "data" : [ ] } ) ;
521+ let err = flatten_analytics_response ( & resp, & [ ] , "count" ) . unwrap_err ( ) ;
522+ assert ! ( err. to_string( ) . contains( "missing 'buckets' array" ) ) ;
523+ }
524+
525+ #[ test]
526+ fn test_flatten_analytics_missing_facet_value_is_null ( ) {
527+ // If a bucket's "by" object is missing a facet, the cell should be null.
528+ let resp = serde_json:: json!( {
529+ "buckets" : [
530+ { "by" : { } , "computes" : { "c0" : 5 } }
531+ ]
532+ } ) ;
533+ let rows = flatten_analytics_response ( & resp, & [ "span_name" . into ( ) ] , "count" ) . unwrap ( ) ;
534+ assert_eq ! ( rows[ 0 ] [ "span_name" ] , serde_json:: Value :: Null ) ;
535+ }
536+
537+ #[ test]
538+ fn test_flatten_analytics_missing_compute_is_null ( ) {
539+ // If a bucket has no "computes" object, the compute cell should be null.
540+ let resp = serde_json:: json!( {
541+ "buckets" : [
542+ { "by" : { "span_name" : "llm.call" } }
543+ ]
544+ } ) ;
545+ let rows = flatten_analytics_response ( & resp, & [ "span_name" . into ( ) ] , "count" ) . unwrap ( ) ;
546+ assert_eq ! ( rows[ 0 ] [ "count" ] , serde_json:: Value :: Null ) ;
547+ }
468548}
0 commit comments