Skip to content

Commit c82315a

Browse files
mbldatadogclaude
andcommitted
refactor(llm-obs): use formatter::output for spans analytics output
Replace hand-rolled comfy_table rendering with formatter::output so that spans analytics respects the -o flag (json, yaml, table, csv, tsv) like every other command. Buckets are flattened into a list of row objects before being passed to the formatter. Add unit tests covering single/multi-facet, no-facet aggregate, empty buckets, missing buckets key, and null fallbacks. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent f3513f9 commit c82315a

1 file changed

Lines changed: 122 additions & 42 deletions

File tree

src/commands/llm_obs.rs

Lines changed: 122 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)