From 6db1ebc8cd9378377e353cc95a07f31e645e128b Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 20 Oct 2026 23:04:06 +0800 Subject: [PATCH] fix(tracing): separate waits and cap dangling tools --- bt-daemon/src/sink/braintrust.rs | 3 ++ bt-daemon/src/translate/claude.rs | 24 +++++++++++++--- bt-daemon/src/translate/codex.rs | 48 ++++++++++++++++++++++--------- bt-daemon/src/translate/mod.rs | 2 ++ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/bt-daemon/src/sink/braintrust.rs b/bt-daemon/src/sink/braintrust.rs index 100a1ecd..998047bf 100644 --- a/bt-daemon/src/sink/braintrust.rs +++ b/bt-daemon/src/sink/braintrust.rs @@ -601,6 +601,9 @@ fn destination_components(destination: &TraceDestination, project: &str) -> Dest fn map_span_type(t: SpanType) -> SdkSpanType { match t { SpanType::Task => SdkSpanType::Task, + // Braintrust's span type enum has no dedicated wait value. Keep waits + // as task spans so their elapsed time cannot be mistaken for tool time. + SpanType::Wait => SdkSpanType::Task, SpanType::Llm => SdkSpanType::Llm, SpanType::Tool => SdkSpanType::Tool, } diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index adb0b6fe..f4ff937d 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -716,13 +716,21 @@ impl ClaudeTranslator { // PreToolUse only proves that Claude proposed a call. Approval is // recorded once a terminal hook or transcript result proves whether // the call executed or was denied. - let metadata = tool_metadata(event, &tool_name, &call_id, None, &input); + let mut metadata = tool_metadata(event, &tool_name, &call_id, None, &input); + let span_type = if tool_name == "AskUserQuestion" { + if let Some(fields) = metadata.as_object_mut() { + fields.insert("wait_kind".into(), json!("user_question")); + } + SpanType::Wait + } else { + SpanType::Tool + }; ops.push(SpanOp::Insert(SpanRow { span_id: span_id.clone(), root_span_id: self.root_span_id.clone(), parent_span_ids: vec![parent_id.clone()], name: tool_span_name(&tool_name, &input), - span_type: SpanType::Tool, + span_type, start_ms: Some(event.ts_ms), input: Some(input.clone()), metadata: Some(metadata.clone()), @@ -756,7 +764,7 @@ impl ClaudeTranslator { let error = (approval != ToolApproval::Denied) .then(|| forced_error.or_else(|| tool_error(&event.payload))) .flatten(); - let metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input); + let mut metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input); if let Some(pending) = self.pending_tools.remove(&call_id) { // A PreToolUse hook may rewrite the call via `updatedInput`, so the // terminal hook's `tool_input` is what the call ran (or was denied) @@ -782,6 +790,14 @@ impl ClaudeTranslator { let Some(parent_id) = self.parent_for(event, ops) else { return; }; + let span_type = if tool_name == "AskUserQuestion" { + if let Some(fields) = metadata.as_object_mut() { + fields.insert("wait_kind".into(), json!("user_question")); + } + SpanType::Wait + } else { + SpanType::Tool + }; let duration = event .payload .get("duration_ms") @@ -793,7 +809,7 @@ impl ClaudeTranslator { turn_span_id: self.owner_of(&parent_id), parent_span_ids: vec![parent_id], name: tool_span_name(&tool_name, &input), - span_type: SpanType::Tool, + span_type, start_ms: Some(event.ts_ms.saturating_sub(duration)), end_ms: Some(event.ts_ms), input: Some(input), diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index bc3d6b80..eb0a1765 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -37,6 +37,7 @@ use std::sync::{Arc, OnceLock}; const SPAWN_AGENT_TOOL: &str = "spawn_agent"; const MISSING_TOOL_OUTPUT_ERROR: &str = "Tool output missing before turn ended"; +const MAX_OPEN_TOOL_SPAN_MS: i64 = 24 * 60 * 60 * 1000; /// A hook can arrive after a daemon restart or against an existing rollout. /// Keep a single translator batch small even when the unread suffix is large. const CATCH_UP_BYTE_BUDGET: usize = 64 * 1024; @@ -117,6 +118,8 @@ impl TranslatorFactory for crate::agents::Codex { effective_root_span_id: root_span_id, external_parent_span_id: None, root_opened: false, + main_root_start_ms: None, + first_observed_ms: None, session_source: None, permission_mode: None, root_cwd: None, @@ -179,7 +182,7 @@ struct Scope { open_turns: Vec, message_history: Vec, open_llm: Option, - open_tools: HashMap, // call_id -> (tool span_id, turn_id) + open_tools: HashMap, // call_id -> (tool span_id, turn_id, start_ms) last_turn_end_ms: Option, last_turn_output: Option, turn_seq: u32, @@ -242,6 +245,8 @@ struct CodexTranslator { effective_root_span_id: String, external_parent_span_id: Option, root_opened: bool, + main_root_start_ms: Option, + first_observed_ms: Option, session_source: Option, permission_mode: Option, root_cwd: Option, @@ -262,6 +267,7 @@ impl AgentTranslator for CodexTranslator { self.pending.is_none(), "Codex translator has pending catch-up work; drain it before handling another event" ); + self.first_observed_ms.get_or_insert(event.ts_ms); let payload = &event.payload; let mut ops = Vec::new(); @@ -759,6 +765,10 @@ impl CodexTranslator { }; let cwd = str_field(payload, "cwd"); self.root_cwd = cwd.clone(); + let root_start_ms = self + .first_observed_ms + .map_or(ts, |first_observed| ts.max(first_observed)); + self.main_root_start_ms = Some(root_start_ms); let mut md = Map::new(); for k in ["id", "cwd", "cli_version"] { if let Some(v) = str_field(payload, k) { @@ -797,7 +807,7 @@ impl CodexTranslator { parent_span_ids: self.scope_root_parent_span_ids(scope), name, span_type: SpanType::Task, - start_ms: Some(ts), + start_ms: Some(root_start_ms), input: Some(json!({ "model": scope.model, "cwd": cwd, @@ -1180,7 +1190,7 @@ impl CodexTranslator { tags: if tags.is_empty() { None } else { Some(tags) }, ..Default::default() })); - scope.open_tools.insert(call_id, (span_id, turn_id)); + scope.open_tools.insert(call_id, (span_id, turn_id, ts)); } fn on_tool_output( @@ -1195,7 +1205,7 @@ impl CodexTranslator { return; }; push_tool_result(scope, Some(&call_id), payload); - let Some((span_id, turn_id)) = scope.open_tools.remove(&call_id) else { + let Some((span_id, turn_id, _start_ms)) = scope.open_tools.remove(&call_id) else { return; }; if let Some(turn) = scope @@ -1347,16 +1357,19 @@ impl CodexTranslator { let call_ids: Vec = scope .open_tools .iter() - .filter(|(_, (_, owner))| owner == turn_id) + .filter(|(_, (_, owner, _))| owner == turn_id) .map(|(call_id, _)| call_id.clone()) .collect(); for call_id in call_ids { - if let Some((span_id, _)) = scope.open_tools.remove(&call_id) { + if let Some((span_id, _, start_ms)) = scope.open_tools.remove(&call_id) { ops.push(SpanOp::Merge(SpanRow { span_id, root_span_id: self.effective_root_span_id.clone(), parent_span_ids: parent_span_ids.clone(), - end_ms, + end_ms: Some(end_ms.map_or_else( + || start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS), + |end| end.min(start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS)), + )), metadata: Some(tool_approval_metadata(Some(ToolApproval::Approved))), error: Some(MISSING_TOOL_OUTPUT_ERROR.to_string()), ..Default::default() @@ -1455,9 +1468,10 @@ impl CodexTranslator { .main_path .as_ref() .and_then(|path| self.scopes.get(path)); - let end_ms = main_scope + let observed_end_ms = main_scope .and_then(|scope| scope.last_turn_end_ms) .unwrap_or(fallback_ts); + let end_ms = observed_end_ms.max(self.main_root_start_ms.unwrap_or(observed_end_ms)); let output = main_scope.and_then(|scope| scope.last_turn_output.clone()); ops.push(SpanOp::Merge(SpanRow { span_id: self.root_span_id.clone(), @@ -1525,19 +1539,27 @@ impl CodexTranslator { ..Default::default() })); } - let tools: Vec<(String, String, String)> = scope + let tools: Vec<(String, String, i64, String)> = scope .open_tools .drain() - .map(|(_, (span_id, turn_id))| { - (span_id, turn_id, MISSING_TOOL_OUTPUT_ERROR.to_string()) + .map(|(_, (span_id, turn_id, start_ms))| { + ( + span_id, + turn_id, + start_ms, + MISSING_TOOL_OUTPUT_ERROR.to_string(), + ) }) .collect(); - for (sid, turn_id, error) in tools { + for (sid, turn_id, start_ms, error) in tools { ops.push(SpanOp::Merge(SpanRow { span_id: sid, root_span_id: self.effective_root_span_id.clone(), parent_span_ids: self.turn_parent_span_ids(&turn_id), - end_ms, + end_ms: Some(end_ms.map_or_else( + || start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS), + |end| end.min(start_ms.saturating_add(MAX_OPEN_TOOL_SPAN_MS)), + )), metadata: Some(tool_approval_metadata(Some(ToolApproval::Approved))), error: Some(error), ..Default::default() diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs index 5adbc61b..848e6ee8 100644 --- a/bt-daemon/src/translate/mod.rs +++ b/bt-daemon/src/translate/mod.rs @@ -48,6 +48,8 @@ pub enum SpanType { Task, Llm, Tool, + /// A human response or approval interval, separate from agent execution. + Wait, } /// Immutable versions captured when a coding-agent span is first created.