Skip to content

Commit c168b0e

Browse files
Merge pull request #440 from mbldatadog/feat/llm-obs-spans-details
feat(llm-obs): add spans details command exposing token counts and cost
2 parents 8ced5d4 + abf5cc6 commit c168b0e

2 files changed

Lines changed: 142 additions & 1 deletion

File tree

src/commands/llm_obs.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,33 @@ pub async fn spans_search(
383383
formatter::output(cfg, &resp)
384384
}
385385

386+
pub async fn spans_details(
387+
cfg: &Config,
388+
trace_id: String,
389+
span_ids: Vec<String>,
390+
from: Option<String>,
391+
to: Option<String>,
392+
) -> Result<()> {
393+
let mut body = serde_json::json!({
394+
"trace_id": trace_id,
395+
"span_ids": span_ids,
396+
});
397+
if let Some(f) = from {
398+
let from_ms = crate::util::parse_time_to_unix_millis(&f)
399+
.map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?;
400+
body["from"] = serde_json::json!(from_ms.to_string());
401+
}
402+
if let Some(t) = to {
403+
let to_ms = crate::util::parse_time_to_unix_millis(&t)
404+
.map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?;
405+
body["to"] = serde_json::json!(to_ms.to_string());
406+
}
407+
let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/span-details", body)
408+
.await
409+
.map_err(|e| anyhow::anyhow!("failed to get span details: {e:?}"))?;
410+
formatter::output(cfg, &resp)
411+
}
412+
386413
#[cfg(test)]
387414
mod tests {
388415

@@ -1347,7 +1374,7 @@ mod tests {
13471374
let mut server = mockito::Server::new_async().await;
13481375
let cfg = test_config(&server.url());
13491376

1350-
let body = r#"{"status":"success","data":{"spans":[{"span_id":"s-1","trace_id":"t-1","name":"llm-call","span_kind":"llm","ml_app":"my-app","status":"ok","duration_ms":42.0,"start_ms":1000000,"tags":[]}]}}"#;
1377+
let body = r#"{"status":"success","data":{"spans":[{"span_id":"s-1","trace_id":"t-1","name":"llm-call","span_kind":"llm","ml_app":"my-app","status":"ok","duration_ms":42.0,"start_ms":1000000,"tags":[],"llm_info":{"model_name":"claude-opus-4-6","model_provider":"anthropic","input_tokens":1024,"output_tokens":256,"total_tokens":1280}}]}}"#;
13511378
let _mock = mock_post(
13521379
&mut server,
13531380
"/api/unstable/llm-obs-mcp/v1/trace/search-spans",
@@ -1545,4 +1572,89 @@ mod tests {
15451572
assert!(result.is_err(), "should fail without auth");
15461573
cleanup_env();
15471574
}
1575+
1576+
#[tokio::test]
1577+
async fn test_llm_obs_spans_details() {
1578+
let _lock = lock_env().await;
1579+
let mut server = mockito::Server::new_async().await;
1580+
let cfg = test_config(&server.url());
1581+
1582+
let body = r#"{"status":"success","data":{"spans":[{"span_id":"s-1","trace_id":"t-1","name":"llm-call","kind":"llm","ml_app":"my-app","status":"ok","duration_ms":42.0,"start_ms":1000000,"tags":[],"llm_info":{"model_name":"claude-opus-4-6","model_provider":"anthropic","input_tokens":1024,"output_tokens":256,"total_tokens":1280},"metrics":{"input_tokens":1024,"output_tokens":256,"total_tokens":1280,"non_cached_input_tokens":512,"cache_read_input_tokens":512,"cache_write_input_tokens":0,"estimated_input_cost":3072000,"estimated_output_cost":5120000,"estimated_total_cost":8192000,"estimated_cache_read_input_cost":512000,"estimated_cache_write_input_cost":0,"estimated_non_cached_input_cost":2560000,"estimated_reasoning_output_cost":0,"reasoning_output_tokens":0},"content_info":{}}]}}"#;
1583+
let _mock = mock_post(
1584+
&mut server,
1585+
"/api/unstable/llm-obs-mcp/v1/trace/span-details",
1586+
200,
1587+
body,
1588+
)
1589+
.await;
1590+
1591+
let result = super::spans_details(&cfg, "t-1".into(), vec!["s-1".into()], None, None).await;
1592+
assert!(result.is_ok(), "spans_details failed: {:?}", result.err());
1593+
cleanup_env();
1594+
}
1595+
1596+
#[tokio::test]
1597+
async fn test_llm_obs_spans_details_404() {
1598+
let _lock = lock_env().await;
1599+
let mut server = mockito::Server::new_async().await;
1600+
let cfg = test_config(&server.url());
1601+
1602+
let _mock = mock_post(
1603+
&mut server,
1604+
"/api/unstable/llm-obs-mcp/v1/trace/span-details",
1605+
404,
1606+
r#"{"errors":["not found"]}"#,
1607+
)
1608+
.await;
1609+
1610+
let result = super::spans_details(
1611+
&cfg,
1612+
"t-missing".into(),
1613+
vec!["s-missing".into()],
1614+
None,
1615+
None,
1616+
)
1617+
.await;
1618+
assert!(result.is_err(), "should fail on 404");
1619+
cleanup_env();
1620+
}
1621+
1622+
#[tokio::test]
1623+
async fn test_llm_obs_spans_details_no_auth() {
1624+
let _lock = lock_env().await;
1625+
let cfg = Config {
1626+
api_key: None,
1627+
app_key: None,
1628+
access_token: None,
1629+
site: "datadoghq.com".into(),
1630+
site_explicit: false,
1631+
org: None,
1632+
output_format: OutputFormat::Json,
1633+
auto_approve: false,
1634+
agent_mode: false,
1635+
read_only: false,
1636+
};
1637+
1638+
let result = super::spans_details(&cfg, "t-1".into(), vec!["s-1".into()], None, None).await;
1639+
assert!(result.is_err(), "should fail without auth");
1640+
cleanup_env();
1641+
}
1642+
1643+
#[tokio::test]
1644+
async fn test_llm_obs_spans_details_invalid_from_returns_error() {
1645+
let _lock = lock_env().await;
1646+
let server = mockito::Server::new_async().await;
1647+
let cfg = test_config(&server.url());
1648+
1649+
let result = super::spans_details(
1650+
&cfg,
1651+
"t-1".into(),
1652+
vec!["s-1".into()],
1653+
Some("not-a-valid-time".into()),
1654+
None,
1655+
)
1656+
.await;
1657+
assert!(result.is_err(), "expected error for invalid --from value");
1658+
cleanup_env();
1659+
}
15481660
}

src/main.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8308,6 +8308,27 @@ enum LlmObsSpansActions {
83088308
#[arg(long, help = "Pagination cursor from a previous response")]
83098309
cursor: Option<String>,
83108310
},
8311+
/// Get detailed metadata and token/cost metrics for one or more spans
8312+
Details {
8313+
#[arg(long, help = "Trace ID (required)")]
8314+
trace_id: String,
8315+
#[arg(
8316+
long,
8317+
help = "Span ID(s) to fetch details for (repeat for multiple)",
8318+
required = true
8319+
)]
8320+
span_id: Vec<String>,
8321+
#[arg(
8322+
long,
8323+
help = "Start time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'"
8324+
)]
8325+
from: Option<String>,
8326+
#[arg(
8327+
long,
8328+
help = "End time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'"
8329+
)]
8330+
to: Option<String>,
8331+
},
83118332
}
83128333

83138334
#[derive(Subcommand)]
@@ -13878,6 +13899,14 @@ async fn main_inner() -> anyhow::Result<()> {
1387813899
)
1387913900
.await?;
1388013901
}
13902+
LlmObsSpansActions::Details {
13903+
trace_id,
13904+
span_id,
13905+
from,
13906+
to,
13907+
} => {
13908+
commands::llm_obs::spans_details(&cfg, trace_id, span_id, from, to).await?;
13909+
}
1388113910
},
1388213911
LlmObsActions::AnnotationQueues { action } => match action {
1388313912
LlmObsAnnotationQueuesActions::Create { file } => {

0 commit comments

Comments
 (0)