Claude Code Tracing with Litefuse
Claude Code is Anthropic’s terminal-based coding agent. This integration uses Claude Code’s Stop hook to send each conversation turn to Litefuse — no Claude Code source changes required.
The hook parses Claude Code’s session transcript (a JSONL file under ~/.claude/projects/) and emits one Litefuse trace per user turn, with separate observations for each LLM call (thinking / text / tool decision) and each tool execution.
For AI — automated install
If you’re chatting with Claude Code right now, paste this prompt and the agent will handle the whole install end-to-end:
Read https://litefuse.ai/SKILL.md and follow the instructions to install and configure Litefuse for Claude Code.
The skill will ask for your Litefuse API keys (or walk you through signing up if you don’t have an account yet), then configure everything in place. For step-by-step manual setup instead, continue below.
What gets captured
| Data | Captured as | Notes |
|---|---|---|
| User prompt | trace input | text + image block summary |
Each thinking / text / tool_use block from the assistant | generation observation | one observation per JSONL row, real timestamps |
| Tool executions (input + output) | tool observation | with a structured toolUseResult summary per tool type |
Token usage (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens) | usage_details on generation | attached only to the last row of each Anthropic message, so totals don’t inflate |
| Model name | model on generation | used by Litefuse for cost computation |
| API errors / retries / rate limits | event observation, level=ERROR | from Claude Code’s system rows |
Tool errors (is_error=true) | tool observation, level=ERROR | with status_message preview |
| Session grouping | trace session_id | Claude Code session UUID |
| User identity | trace user_id | $LITEFUSE_USER_ID, falls back to getpass.getuser() |
| Claude Code version | trace release | from JSONL version field |
| Environmental context | trace metadata | cwd, git branch, entrypoint, permission mode, sidechain flag |
| Per-observation identity | metadata | uuid, parentUuid, requestId, message.id — JSONL field names preserved verbatim |
Trace structure
A typical multi-tool turn produces a trace shaped like:
Claude Code - Turn 7 (root span, AS_ROOT, trace headers)
├── user message (event — carries the user prompt)
├── Thinking (#1) (generation)
├── Text response (#2) (generation)
├── Decision to call tool: Bash (#3) (generation, usage_details here)
├── Tool call: Bash (#3) (tool)
├── Thinking (#4) (generation)
├── Decision to call tool: Edit (#5) (generation, usage_details here)
├── Tool call: Edit (#5) (tool)
└── Final response (#6) (generation)Design notes:
user messageevent opens every turn. Instantaneous event-typed observation carrying the user’s prompt as input. Pairs visually with the closingFinal response (#N)generation so the trace timeline reads top-to-bottom as a clean opening/closing structure.- Per-row fidelity: each JSONL row becomes its own observation.
thinking,text, andtool_useblocks of the same Anthropic message are NOT merged. - Unique span names: the
(#N)step suffix keeps observation names unique within a turn so Litefuse’s graph view stays linear even when the same tool is called multiple times. - Real timestamps: span start/end come from the JSONL
timestampfield, not the hook’s wall clock — the Litefuse timeline reflects when things actually happened, including cold-start and queue latency. - No double-counted tokens: every JSONL row of a multi-row message carries the same
usageblock; the hook attachesusage_detailsonly to the last row. - 1ms gap: consecutive sibling spans are separated by 1 ms so Litefuse’s graph view doesn’t treat boundary-touching observations as parallel branches.
- Namespaced metadata under
claude_code.*. Each Hermes-or-JSONL-specific field lives under that single key (e.g.claude_code.uuid,claude_code.requestId,claude_code.service_tier). Litefuse-standard fields (sessionId,userId,tags,release,usageDetails) stay at the top level. Sparse: fields that aren’t present in the source JSONL row are absent from metadata entirely, not padded withnull. - In-progress turns are deferred. Claude Code’s Stop hook can fire while the agent is still mid-loop (between tool dispatches). If a turn’s last assistant row is anything other than a
textblock (e.g., atool_useawaiting the next tool result), the hook defers emission — it rewinds its byte offset and waits for the next Stop firing, by which time the closingtext“Final response” has usually landed. A per-sessionemitted_user_uuidsset ensures previously-emitted turns are not re-emitted on the rewind. Trade-off: if the agent ever ends a turn without a final text response (e.g., killed mid-tool-loop), that turn won’t get a trace.
Quick Start
Prerequisites
- Python ≥ 3.10 (Langfuse SDK v4 requirement). Check with
python3 --version. macOS’s systempython3is usually 3.9 — install a newer one via Homebrew (brew install python@3.13, or any other 3.10+ minor) and make sure it’s first onPATHaspython3. - A Litefuse project at https://litefuse.cloud with public + secret keys.
Create a virtualenv and install Langfuse v4
Isolate the hook’s dependencies in a dedicated venv:
python3 -m venv ~/.claude/hooks/.venv
~/.claude/hooks/.venv/bin/pip install --upgrade pip
~/.claude/hooks/.venv/bin/pip install 'langfuse>=4,<5'Download the hook script
mkdir -p ~/.claude/hooks
curl -fsSL https://litefuse.ai/integrations/claude-code/litefuse_hook.py \
-o ~/.claude/hooks/litefuse_hook.py
chmod +x ~/.claude/hooks/litefuse_hook.pyThe source is also browseable at the same URL — feel free to read it before deploying.
Configure ~/.claude/settings.json
Add the Stop hook and Litefuse credentials:
{
"env": {
"TRACE_TO_LANGFUSE": "true",
"LANGFUSE_PUBLIC_KEY": "pk-lf-xxx",
"LANGFUSE_SECRET_KEY": "sk-lf-xxx",
"LANGFUSE_BASE_URL": "https://litefuse.cloud"
},
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME\"/.claude/hooks/.venv/bin/python3 \"$HOME\"/.claude/hooks/litefuse_hook.py"
}
]
}
]
}
}For per-project use, put the same env block in <project>/.claude/settings.local.json instead.
Verify
Send a message in Claude Code, then watch the hook log:
tail -f ~/.claude/state/litefuse_hook.log
# Expected: "Processed N turns in X.XXs (session=...)"Open the project in Litefuse — each user message becomes one trace with the structure shown above.
Environment variables
| Variable | Required | Description |
|---|---|---|
TRACE_TO_LANGFUSE | Yes | Set to "true" to enable the hook. Any other value short-circuits the script. |
LANGFUSE_PUBLIC_KEY | Yes | Litefuse project public key (pk-lf-...). |
LANGFUSE_SECRET_KEY | Yes | Litefuse project secret key (sk-lf-...). |
LANGFUSE_BASE_URL | No | Defaults to https://cloud.langfuse.com. Set to https://litefuse.cloud (or your self-hosted URL). |
LITEFUSE_USER_ID | No | Overrides the trace user_id. Falls back to getpass.getuser() then hostname. |
CC_LANGFUSE_DEBUG | No | Set to "true" for verbose hook logging. |
CC_LANGFUSE_MAX_CHARS | No | Truncation threshold (in characters) for span inputs/outputs. Default 1000000 (~1MB of text). |
Trace metadata reference
All Claude-Code-specific fields are namespaced under claude_code.* in observation metadata. JSONL field names are preserved verbatim (camelCase / snake_case as they appear in the original transcript). Litefuse-standard fields (sessionId, userId, tags, release, usageDetails) stay at the top level via OTel trace attributes — they’re not duplicated under claude_code.
Trace root span — claude_code.* (from the user message row):
uuid,parentUuid,promptId,isMeta,origin— row identitycwd,gitBranch,version,entrypoint,userType,isSidechain,permissionMode— Claude Code environmental contextsessionId,turn_number,transcript_path,models_used,source— deriveduser_text_truncated,user_text_orig_len— set when the user prompt is truncateduser_content_text_blocks,user_content_image_blocks,user_content_image_media_types— set when the user sent images
user message event — claude_code.*:
uuid,parentUuid,promptId— same identity fields as the trace rootuser_content_*,user_text_*— same content / truncation summaries as the trace root
Generation observation — claude_code.* (from the assistant row):
uuid,parentUuid,requestId— JSONL row identityid(Anthropic message id),stop_reason,stop_sequence,stop_details— Anthropic message fieldsservice_tier,speed,inference_geo,iterations— Anthropic usage metadatacache_creation(ephemeral_1h / ephemeral_5m breakdown),server_tool_use— informational; NOT summed intousage_detailsdiagnostics— Anthropic’scache_miss_reasonetc.; useful whencache_creation_input_tokensis unexpectedly largecontext_management,container— Anthropic-side, when presentstep_index,is_last_in_message— derivedassistant_text_truncated,assistant_text_orig_len, … — only when the text was truncated
Tool observation — claude_code.*:
name,tool_use_id— from thetool_useblockuuid,parentUuid,sourceToolAssistantUUID— from theuserrow carrying thetool_resultis_error— from thetool_resultblocktoolUseResult— flat summary; fields vary by tool type:- Bash:
command,stdout_len,stderr_len,has_stderr,interrupted - WebFetch / WebSearch:
code,codeText,bytes - Task (subagent):
agentId,agentType - BashOutput:
backgroundTaskId,assistantAutoBackgrounded - Glob / Grep / Read:
appliedLimit,appliedOffset,itemCount
- Bash:
input_truncated,input_orig_len,output_truncated,output_orig_len— truncation info
Event observation — claude_code.* (from system rows — API errors, retries, hook outcomes):
- All system row fields preserved verbatim:
subtype,level,cause,error,uuid,parentUuid,stopReason,toolUseID,hookCount,hookErrors,hookInfos,retryAttempt,maxRetries,retryInMs,preventedContinuation,hasOutput - The
stop_hook_summarysubtype is intentionally filtered out (records this script’s own execution).
How it works
On every Stop hook firing the script does:
- Reads new bytes from the session’s JSONL transcript since the last
offset(state cached in~/.claude/state/langfuse_state.json, keyed bysha256(session_id::transcript_path)). - Parses rows; deduplicates by
uuid(the transcript occasionally repeats rows on session resume). - Groups rows into turns: each real
usermessage starts a turn; subsequent assistant rows +tool_resultrows + non-summarysystemrows attach to it. - Checks each turn’s
user_msg.uuidagainst the per-sessionemitted_user_uuidsset (also cached in state). Already-emitted turns are skipped — protects against double-emission when the offset rewinds for deferral (see step 6). - Evaluates the LAST turn’s completeness: if its final assistant row contains a
textblock (= the Claude Code “Final response” closing marker), the turn is complete and emits. Otherwise — typically because the agent’s still mid-loop, with the latest assistant row being atool_useawaiting the next iteration — the hook defers. - On defer: rewind
offsetto its pre-read value so the next Stop firing re-reads the same bytes. By then the missingtextrow has usually landed and the turn becomes emittable. Theemitted_user_uuidsset keeps the earlier completed turns from being re-emitted during this rewind. - For each completed turn, emits a Litefuse trace via Langfuse SDK v4’s OTel layer, using explicit start/end timestamps from the JSONL so the timeline reflects when each block actually arrived.
- Saves new
offset+emitted_user_uuidsso the next hook invocation continues from where this one stopped.
The hook is fail-open: any unexpected error is logged to ~/.claude/state/litefuse_hook.log and the script exits 0, so it never blocks Claude Code’s main loop.
Troubleshooting
No traces appear in Litefuse. Tail ~/.claude/state/litefuse_hook.log. An empty log means the hook isn’t running — confirm TRACE_TO_LANGFUSE=true is set and settings.json’s command path is correct (the venv’s python3 plus the hook script).
Hook log says Langfuse client init failed. The venv’s Python doesn’t have a working langfuse install. Re-run:
~/.claude/hooks/.venv/bin/pip install --upgrade 'langfuse>=4,<5'Hook log says Missing session_id or transcript_path; exiting. Claude Code didn’t pass the expected payload on stdin. Confirm you’re on a Claude Code version that emits Stop hook payloads (see Claude Code hooks).
Token totals look wrong. The hook attaches usage_details only to the LAST row of each Anthropic message — verify by inspecting individual generation observations in Litefuse: only Decision to call tool: X (#N) / Final response (#N) rows carry token counts; Thinking (#N) / Text response (#N) from the same message do not.
A turn never appears as a trace. Look at the hook log — if you see Processed N turns, deferred 1 (in-progress), the latest turn is being held back because its last assistant row isn’t a text block. The next Stop firing will re-evaluate it. If the turn was killed mid-tool-loop (no text closing row will ever land), the deferral becomes permanent and no trace is emitted. Send any follow-up message in Claude Code and the deferred turn will be re-checked: if there’s now a newer user message after it, the dedup-on-followup path emits it regardless of completeness (a past turn followed by a fresh user message is “as complete as it’ll ever be”).
Test the hook manually:
TRACE_TO_LANGFUSE=true \
LANGFUSE_PUBLIC_KEY="pk-lf-..." \
LANGFUSE_SECRET_KEY="sk-lf-..." \
LANGFUSE_BASE_URL="https://litefuse.cloud" \
~/.claude/hooks/.venv/bin/python3 ~/.claude/hooks/litefuse_hook.py
# Errors appear in the log file.Enable debug logging:
# Add to settings.json env block:
"CC_LANGFUSE_DEBUG": "true"Resources
- Claude Code hooks documentation
- Langfuse Python SDK v4
- Litefuse Cloud
- Hook script source:
litefuse_hook.py