Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/otlp-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Initial mapping scaffold:

- SessionStart -> root/task span
- UserPromptSubmit -> turn/task span
- PostToolUse -> tool span
- Stop -> llm span
- UserPromptSubmit -> in-flight turn timing state
- PostToolUse -> tool span (child of session root)
- Stop -> llm span (child of session root)
- SessionEnd -> terminal task span

All spans are sent via OTLP/HTTP JSON `ExportTraceServiceRequest`.
1 change: 0 additions & 1 deletion plugins/pl-trace-claude-code/hooks/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ ensure_session_initialized() {
set_session_state "$sid" trace_id "$trace_id"
set_session_state "$sid" session_span_id "$session_span_id"
set_session_state "$sid" session_start_ns "$requested_start_ns"
set_session_state "$sid" current_turn_span_id ""
set_session_state "$sid" current_turn_start_ns ""
set_session_state "$sid" pending_tool_calls "[]"
set_session_state "$sid" session_init_source "lazy_init"
Expand Down
46 changes: 44 additions & 2 deletions plugins/pl-trace-claude-code/hooks/parse_stop_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ def stringify(value):
return json.dumps(value, ensure_ascii=False)


def content_to_text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
continue
serialized = stringify(block)
if serialized:
parts.append(serialized)
return "\n".join(parts).strip()
if isinstance(content, dict) and content.get("type") == "text":
text = content.get("text")
if isinstance(text, str):
return text
return stringify(content)


def message_text(content):
if isinstance(content, str):
return content
Expand Down Expand Up @@ -106,6 +128,7 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
llms = []
pending_tool_uses = []
pending_payload_idx = 0
saw_human_input = False

turn_start_ns = turn_start_fallback
turn_end_ns = turn_start_fallback
Expand All @@ -120,6 +143,16 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
turn_end_ns = timestamp_ns

rec_type = rec.get("type")
if rec_type == "queue-operation":
operation = stringify(rec.get("operation"))
if operation == "enqueue":
content = content_to_text(rec.get("content"))
if content:
history.append({"role": "user", "content": content})
last_input_ns = timestamp_ns or last_input_ns
saw_human_input = True
continue

if rec_type == "user":
content = rec.get("message", {}).get("content")
if is_tool_result_user(rec):
Expand Down Expand Up @@ -184,9 +217,10 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
last_input_ns = timestamp_ns or last_input_ns
continue

user_text = stringify(content)
user_text = content_to_text(content)
history.append({"role": "user", "content": user_text})
last_input_ns = timestamp_ns or last_input_ns
saw_human_input = True
continue

if rec_type != "assistant":
Expand Down Expand Up @@ -232,6 +266,11 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
}
)

# Claude can emit intermediate assistant records that contain only
# empty thinking blocks. Those should not consume the user's prompt.
if not output_text and not tool_calls:
continue

llm_start_ns = last_input_ns or timestamp_ns or turn_start_ns
llm_end_ns = timestamp_ns or llm_start_ns
if llm_start_ns is None:
Expand Down Expand Up @@ -265,9 +304,11 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
completion_item["tool_calls"] = tool_calls
flatten_indexed("gen_ai.completion", [completion_item], attrs)

span_name = "LLM Call (User)" if saw_human_input else "LLM call"

llms.append(
{
"name": "LLM call",
"name": span_name,
"start_ns": int(llm_start_ns),
"end_ns": int(llm_end_ns),
"attributes": attrs,
Expand All @@ -279,6 +320,7 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp
assistant_history["tool_calls"] = tool_calls
history.append(assistant_history)
llm_input_cursor = len(history)
saw_human_input = False

if turn_start_ns is None:
turn_start_ns = int(datetime.now(timezone.utc).timestamp() * 1_000_000_000)
Expand Down
6 changes: 2 additions & 4 deletions plugins/pl-trace-claude-code/hooks/post_tool_use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@ tool_output="$(echo "$input" | jq -c '.tool_response // .output // {}')"
ensure_session_initialized "$session_id"

trace_id="$(get_session_state "$session_id" trace_id)"
parent_span_id="$(get_session_state "$session_id" current_turn_span_id)"
[[ -z "$trace_id" ]] && exit 0
if [[ -z "$parent_span_id" ]]; then
parent_span_id="$(generate_span_id)"
set_session_state "$session_id" current_turn_span_id "$parent_span_id"
turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
if [[ -z "$turn_start_ns" ]]; then
set_session_state "$session_id" current_turn_start_ns "$(now_ns)"
fi

Expand Down
8 changes: 4 additions & 4 deletions plugins/pl-trace-claude-code/hooks/session_end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ trace_id="$(get_session_state "$session_id" trace_id)"
session_span_id="$(get_session_state "$session_id" session_span_id)"
session_start_ns="$(get_session_state "$session_id" session_start_ns)"
stop_in_flight="$(get_session_state "$session_id" stop_in_flight)"
current_turn_span_id="$(get_session_state "$session_id" current_turn_span_id)"
current_turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
[[ -z "$trace_id" || -z "$session_span_id" ]] && exit 0
[[ -z "$session_start_ns" ]] && session_start_ns="$(now_ns)"
[[ -z "$stop_in_flight" ]] && stop_in_flight="false"

if [[ -n "$current_turn_span_id" || "$stop_in_flight" == "true" ]]; then
if [[ -n "$current_turn_start_ns" || "$stop_in_flight" == "true" ]]; then
set_session_state "$session_id" session_end_requested "true"
log "INFO" "SessionEnd deferred until Stop session_id=$session_id"
exit 0
Expand All @@ -43,9 +43,9 @@ acquire_session_lock "$session_id" || exit 0
trap 'release_session_lock' EXIT

stop_in_flight="$(get_session_state "$session_id" stop_in_flight)"
current_turn_span_id="$(get_session_state "$session_id" current_turn_span_id)"
current_turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
[[ -z "$stop_in_flight" ]] && stop_in_flight="false"
if [[ -n "$current_turn_span_id" || "$stop_in_flight" == "true" ]]; then
if [[ -n "$current_turn_start_ns" || "$stop_in_flight" == "true" ]]; then
set_session_state "$session_id" session_end_requested "true"
log "INFO" "SessionEnd deferred until Stop session_id=$session_id"
exit 0
Expand Down
1 change: 0 additions & 1 deletion plugins/pl-trace-claude-code/hooks/session_start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ start_ns="$(now_ns)"
set_session_state "$session_id" trace_id "$trace_id"
set_session_state "$session_id" session_span_id "$span_id"
set_session_state "$session_id" session_start_ns "$start_ns"
set_session_state "$session_id" current_turn_span_id ""
set_session_state "$session_id" current_turn_start_ns ""
set_session_state "$session_id" pending_tool_calls "[]"
set_session_state "$session_id" session_init_source "session_start_hook"
Expand Down
39 changes: 23 additions & 16 deletions plugins/pl-trace-claude-code/hooks/stop_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ acquire_session_lock "$session_id" || exit 0
ensure_session_initialized "$session_id"

trace_id="$(get_session_state "$session_id" trace_id)"
turn_span_id="$(get_session_state "$session_id" current_turn_span_id)"
session_span_id="$(get_session_state "$session_id" session_span_id)"
turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
pending_tool_calls="$(get_session_state "$session_id" pending_tool_calls)"
Expand All @@ -55,25 +54,36 @@ session_start_ns="$(get_session_state "$session_id" session_start_ns)"
[[ -z "$session_end_requested" ]] && session_end_requested="false"
[[ -z "$session_start_ns" ]] && session_start_ns="$(now_ns)"

if [[ -z "$turn_span_id" ]]; then
turn_span_id="$(generate_span_id)"
turn_start_ns="$(now_ns)"
fi
[[ -z "$turn_start_ns" ]] && turn_start_ns="$(now_ns)"

# Keep lock scope short: snapshot + clear turn-specific mutable state.
set_session_state "$session_id" stop_in_flight "true"
set_session_state "$session_id" current_turn_span_id ""
set_session_state "$session_id" current_turn_start_ns ""
set_session_state "$session_id" pending_tool_calls "[]"

release_session_lock

emitted_root="false"

parse_transcript_with_retry() {
local attempts=0
local parsed llm_count
while true; do
parsed="$(PL_PENDING_TOOL_CALLS="$pending_tool_calls" python3 "$SCRIPT_DIR/parse_stop_transcript.py" "$transcript_path" "$turn_start_ns" "$session_id")"
llm_count="$(echo "$parsed" | jq -r '.llms | length')"
if [[ "$llm_count" -gt 0 || $attempts -ge 10 ]]; then
echo "$parsed"
return 0
fi
attempts=$((attempts + 1))
sleep 0.2
done
}

if [[ -z "$transcript_path" || ! -f "$transcript_path" ]]; then
log "WARN" "Stop missing transcript session_id=$session_id"
else
parsed="$(PL_PENDING_TOOL_CALLS="$pending_tool_calls" python3 "$SCRIPT_DIR/parse_stop_transcript.py" "$transcript_path" "$turn_start_ns" "$session_id")"
parsed="$(parse_transcript_with_retry)"

turn_start_ns="$(echo "$parsed" | jq -r '.turn.start_ns')"
turn_end_ns="$(echo "$parsed" | jq -r '.turn.end_ns')"
Expand All @@ -92,17 +102,14 @@ else
add_span_to_batch "$trace_id" "$session_span_id" "" "Claude Code session" "1" "$session_start_ns" "$turn_end_ns" "$session_attrs" || true
emitted_root="true"

turn_attrs='{"source":"claude-code","hook":"UserPromptSubmit","node_type":"WORKFLOW"}'
add_span_to_batch "$trace_id" "$turn_span_id" "$session_span_id" "Turn" "1" "$turn_start_ns" "$turn_end_ns" "$turn_attrs" || true

while IFS= read -r tool; do
[[ -z "$tool" ]] && continue
span_id="$(generate_span_id)"
name="$(echo "$tool" | jq -r '.name')"
start_ns="$(echo "$tool" | jq -r '.start_ns')"
end_ns="$(echo "$tool" | jq -r '.end_ns')"
attrs="$(echo "$tool" | jq -c '.attributes')"
add_span_to_batch "$trace_id" "$span_id" "$turn_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true
add_span_to_batch "$trace_id" "$span_id" "$session_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true
done < <(echo "$parsed" | jq -c '.tools[]?')

while IFS= read -r llm; do
Expand All @@ -112,7 +119,7 @@ else
start_ns="$(echo "$llm" | jq -r '.start_ns')"
end_ns="$(echo "$llm" | jq -r '.end_ns')"
attrs="$(echo "$llm" | jq -c '.attributes')"
add_span_to_batch "$trace_id" "$span_id" "$turn_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true
add_span_to_batch "$trace_id" "$span_id" "$session_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true
done < <(echo "$parsed" | jq -c '.llms[]?')
fi

Expand All @@ -135,15 +142,15 @@ if [[ "$emitted_root" == "true" ]]; then
fi

latest_end_requested="$(get_session_state "$session_id" session_end_requested)"
latest_turn_span_id="$(get_session_state "$session_id" current_turn_span_id)"
latest_turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
latest_trace_id="$(get_session_state "$session_id" trace_id)"
latest_session_span_id="$(get_session_state "$session_id" session_span_id)"
latest_session_start_ns="$(get_session_state "$session_id" session_start_ns)"
[[ -z "$latest_end_requested" ]] && latest_end_requested="false"
[[ -z "$latest_session_start_ns" ]] && latest_session_start_ns="$(now_ns)"

need_finalize_root="false"
if [[ "$latest_end_requested" == "true" && -z "$latest_turn_span_id" ]]; then
if [[ "$latest_end_requested" == "true" && -z "$latest_turn_start_ns" ]]; then
need_finalize_root="true"
fi

Expand All @@ -158,10 +165,10 @@ if [[ "$need_finalize_root" == "true" && -n "$latest_trace_id" && -n "$latest_se
fi

latest_end_requested="$(get_session_state "$session_id" session_end_requested)"
latest_turn_span_id="$(get_session_state "$session_id" current_turn_span_id)"
latest_turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
[[ -z "$latest_end_requested" ]] && latest_end_requested="false"

if [[ "$latest_end_requested" == "true" && -z "$latest_turn_span_id" ]]; then
if [[ "$latest_end_requested" == "true" && -z "$latest_turn_start_ns" ]]; then
rm -f "$PL_SESSION_STATE_DIR/$session_id.json"
log "INFO" "SessionEnd finalized by Stop session_id=$session_id"
fi
Expand Down
9 changes: 3 additions & 6 deletions plugins/pl-trace-claude-code/hooks/user_prompt_submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ session_id="$(echo "$input" | jq -r '.session_id // empty')"
ensure_session_initialized "$session_id"

trace_id="$(get_session_state "$session_id" trace_id)"
parent_span_id="$(get_session_state "$session_id" session_span_id)"
[[ -z "$trace_id" || -z "$parent_span_id" ]] && exit 0

turn_span_id="$(generate_span_id)"
session_span_id="$(get_session_state "$session_id" session_span_id)"
[[ -z "$trace_id" || -z "$session_span_id" ]] && exit 0
start_ns="$(now_ns)"

set_session_state "$session_id" current_turn_span_id "$turn_span_id"
set_session_state "$session_id" current_turn_start_ns "$start_ns"
set_session_state "$session_id" pending_tool_calls "[]"

log "INFO" "UserPromptSubmit captured session_id=$session_id turn_span_id=$turn_span_id"
log "INFO" "UserPromptSubmit captured session_id=$session_id"
Loading