feat(hook): trace Claude Code subagent transcripts#11
Conversation
hassiebp
left a comment
There was a problem hiding this comment.
@milanagm Here's Claude's review. Can you please check which ones you deem actionable and which not?
PR #11 — feat(hook): trace Claude Code subagent transcripts
Recommendation: don't merge yet. The feature works in the happy paths the PR tests (all 29 new tests pass, and I ran them locally), but the review surfaced 7 confirmed correctness bugs — each verified with an actual repro against the PR branch, not just by reading the code. The most serious ones corrupt or silently drop turns in scenarios that are normal in real sessions, and one regression flattens the trace tree for every trace, subagent or not.
The PR adds subagent transcript discovery (transcript/subagents/*.meta.json → tool_use id mapping), defers turns that launched async agents until their task notification arrives, then re-splices the deferred rows and emits the subagent's LLM/tool calls under a dedicated span. The deferral/re-splice mechanism is where most of the bugs live.
Confirmed findings (most severe first)
- hooks/langfuse_hook.py:619 — re-splicing a deferred turn mid-batch corrupts both turns. Deferred rows are inserted immediately before the notification row, wherever it sits. Repro: pending turn 1 + batch [user2, asst2a, notification, asst2b] produced a truncated turn 2 (missing asst2b) and attached asst2b to the rebuilt turn 1. Worse: if the notification lands between user2 and its first assistant row, user2's turn is silently never traced. Mid-turn notifications are the normal real-world shape, since the assistant keeps responding after a notification.
- hooks/langfuse_hook.py:661 — synchronous Agent/Task turns are deferred until SessionEnd and can be lost entirely. has_subagent_transcript alone triggers deferral, but sync subagents write a transcript too while never producing the task notification that releases it. Repro confirmed the turn sits in pending_agent_turns across every subsequent hook run; the SessionEnd flush is the only release, so a killed session (no SessionEnd hook) never traces the turn at all.
- hooks/langfuse_hook.py:1449 — tool spans are no longer nested under their generation. The base parented Tool: X under gen_span._otel_span (base line 780, with an explicit comment); the head passes the turn root span all the way down, flattening the tree for all tool calls. Verified empirically with a span spy on the plain tool_turn fixture. No test asserts parenting (the conftest stubs discard it) and no commit mentions it — this looks like an unintended casualty of the refactor.
- hooks/langfuse_hook.py:617 — any row merely containing text pops the deferred turn. prepend_deferred_agent_turn_rows runs the tag extractor on every row without the is_task_notification_row gate that add_task_notification_row has. Repro: an assistant message quoting the notification (which sits verbatim in its context) popped the pending entry and spliced the deferred turn mid-conversation; the real notification later finds nothing.
- hooks/langfuse_hook.py:1038 — the subagent's final result is dropped from generation input. When a normal tool result and a ready async result both precede an assistant message, build_generation_input returns only previous_tool_results and the ready batch is popped and never re-queued. Repro: the subagent's output marker appeared in no generation input; removing the intervening tool made it appear.
- hooks/langfuse_hook.py:729 — a task notification with no open turn is consumed and its permanently lost. Confirmed with a task-id-only notification whose meta.json is missing (so the fallback map can't resolve it): the row is swallowed, the offset advances, and the deferred turn stays stuck without its final output.
- hooks/langfuse_hook.py:1500 — one malformed line drops the entire subagent trace. The all-or-nothing json.loads comprehension aborts on a partial last line (unlike read_new_jsonl, which skips per line and buffers the tail). The main window is the SessionEnd flush, which emits turns for still-running agents whose transcripts likely end mid-line.
- hooks/langfuse_hook.py:681 — deferred turns bloat the global state file. The same full, untruncated turn.rows are stored once per pending tool_use id (the pop-time content-fingerprint dedup at 583–606 exists to compensate), re-serialized with indent=2 on every hook run of every session, and the per-run updated refresh means the 30-day prune never fires while the session is active. Storing the turn once as {"tool_use_ids": [...], "rows": [...]} would remove both the duplication and the dedup machinery.
Notable but not blocking
- The client is now created before payload validation (main():1745), skipping the bounded shutdown on early returns — confirmed low impact (nothing is enqueued), but restoring the base ordering is a one-line fix.
- Notification detection leans on prose/text heuristics ("Async agent launched successfully", startswith(""), filename parsing for the task-id map) rather than the structured origin.kind field the code already checks — any Claude Code copy change silently breaks deferral.
- Intentional and fine: generation tool_calls no longer inline the tool input (test-locked, input still on the tool span), and non-recursive nested-subagent emission is explicitly documented by a test.
Bottom line
Findings 1, 2, and 4 all stem from the same design choice — keying deferral and re-splicing on text heuristics and row-position splicing. Fixing that mechanism (gate on is_task_notification_row/origin.kind, splice only at turn boundaries, defer only on an explicit async signal rather than transcript existence) would resolve the worst three together. Finding 3 is likely a one-line fix (thread generation_span._otel_span into the tool batch). I'd ask for those before merging; the rest can be follow-ups.
Summary
Closes LFE-10510
Adds tracing support for Claude Code subagent transcripts so Agent/Task work is included in the same Langfuse conversation trace instead of being missed or emitted as disconnected traces.
This PR:
Conversational Turn,LLM Call, andSubagent LLM CallTesting
Manually replayed existing Claude Code transcripts (JSONL files) through the hook and verified the resulting Langfuse traces in the dashboard as well as "live" testing on cloud.
Checked scenarios:
tool-use-idAlso ran: