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
38 changes: 38 additions & 0 deletions plugins/trace/hooks/hook_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python3

import base64
import binascii
import sys
import time


def hex_to_base64(hex_value: str) -> int:
raw = binascii.unhexlify(hex_value)
print(base64.b64encode(raw).decode("ascii"))
return 0


def now_ns() -> int:
print(time.time_ns())
return 0


def main() -> int:
if len(sys.argv) < 2:
raise SystemExit("usage: hook_utils.py <command> [args]")

command = sys.argv[1]
if command == "hex_to_base64":
if len(sys.argv) != 3:
raise SystemExit("usage: hook_utils.py hex_to_base64 <hex>")
return hex_to_base64(sys.argv[2])
if command == "now_ns":
if len(sys.argv) != 2:
raise SystemExit("usage: hook_utils.py now_ns")
return now_ns()

raise SystemExit(f"unknown command: {command}")


if __name__ == "__main__":
raise SystemExit(main())
96 changes: 81 additions & 15 deletions plugins/trace/hooks/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ export PL_QUEUE_DRAIN_LIMIT="${PROMPTLAYER_QUEUE_DRAIN_LIMIT:-10}"
export PL_OTLP_CONNECT_TIMEOUT="${PROMPTLAYER_OTLP_CONNECT_TIMEOUT:-5}"
export PL_OTLP_MAX_TIME="${PROMPTLAYER_OTLP_MAX_TIME:-12}"
export PL_PLUGIN_VERSION="1.0.0"
export PL_CC_VERSION="$(claude --version 2>/dev/null || echo 'unknown')"
PL_CC_VERSION="$(claude --version 2>/dev/null || echo 'unknown')"
export PL_CC_VERSION
export PL_USER_AGENT="promptlayer-claude-plugin/${PL_PLUGIN_VERSION} claude-code/${PL_CC_VERSION}"
PL_HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PL_HOOKS_DIR

mkdir -p "$(dirname "$PL_LOG_FILE")"
mkdir -p "$PL_SESSION_STATE_DIR"
Expand Down Expand Up @@ -66,6 +69,61 @@ generate_span_id() {
uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' | cut -c1-16
}

parse_traceparent() {
local raw="${1:-}"
[[ -z "$raw" ]] && return 1

raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"

if [[ ! "$raw" =~ ^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.+)?$ ]]; then
return 1
fi

local version="${BASH_REMATCH[1]}"
local trace_id="${BASH_REMATCH[2]}"
local parent_span_id="${BASH_REMATCH[3]}"
local trace_flags="${BASH_REMATCH[4]}"
local suffix="${BASH_REMATCH[5]:-}"

if [[ "$version" == "ff" ]]; then
return 1
fi
if [[ "$version" == "00" && -n "$suffix" ]]; then
return 1
fi
if [[ "$trace_id" == "00000000000000000000000000000000" ]]; then
return 1
fi
if [[ "$parent_span_id" == "0000000000000000" ]]; then
return 1
fi

printf '%s %s %s %s\n' "$version" "$trace_id" "$parent_span_id" "$trace_flags"
}

load_initial_trace_context() {
PL_INITIAL_TRACEPARENT_VERSION=""
PL_INITIAL_TRACE_ID=""
PL_INITIAL_PARENT_SPAN_ID=""
PL_INITIAL_TRACE_FLAGS=""
PL_INITIAL_TRACE_CONTEXT_SOURCE="generated"

local raw="${PROMPTLAYER_TRACEPARENT:-}"
if [[ -z "$raw" ]]; then
return 0
fi

local parsed
if ! parsed="$(parse_traceparent "$raw")"; then
log "WARN" "Ignoring invalid PROMPTLAYER_TRACEPARENT"
return 1
fi

read -r PL_INITIAL_TRACEPARENT_VERSION PL_INITIAL_TRACE_ID PL_INITIAL_PARENT_SPAN_ID PL_INITIAL_TRACE_FLAGS <<<"$parsed"
PL_INITIAL_TRACE_CONTEXT_SOURCE="external_traceparent"
return 0
}

normalize_hex_id() {
local raw="$1"
local expected_len="$2"
Expand Down Expand Up @@ -93,22 +151,11 @@ normalize_hex_id() {

hex_to_base64() {
local hex="$1"
python3 - "$hex" <<'PY'
import base64
import binascii
import sys

h = sys.argv[1]
raw = binascii.unhexlify(h)
print(base64.b64encode(raw).decode("ascii"))
PY
python3 "$PL_HOOKS_DIR/hook_utils.py" hex_to_base64 "$hex"
}

now_ns() {
python3 - <<'PY'
import time
print(time.time_ns())
PY
python3 "$PL_HOOKS_DIR/hook_utils.py" now_ns
}

session_state_file() {
Expand Down Expand Up @@ -197,9 +244,10 @@ ensure_session_initialized() {
local requested_start_ns="${2:-}"
[[ -z "$sid" ]] && return 1

local trace_id session_span_id session_start_ns init_source root_emitted pending_tool_calls
local trace_id session_span_id session_parent_span_id session_start_ns init_source root_emitted pending_tool_calls
trace_id="$(get_session_state "$sid" trace_id)"
session_span_id="$(get_session_state "$sid" session_span_id)"
session_parent_span_id="$(get_session_state "$sid" session_parent_span_id)"
session_start_ns="$(get_session_state "$sid" session_start_ns)"
init_source="$(get_session_state "$sid" session_init_source)"
root_emitted="$(get_session_state "$sid" session_root_emitted)"
Expand All @@ -220,6 +268,18 @@ ensure_session_initialized() {
if [[ -z "$pending_tool_calls" ]]; then
set_session_state "$sid" pending_tool_calls "[]"
fi
if [[ -z "$session_parent_span_id" ]]; then
set_session_state "$sid" session_parent_span_id ""
fi
if [[ -z "$(get_session_state "$sid" session_traceparent_version)" ]]; then
set_session_state "$sid" session_traceparent_version ""
fi
if [[ -z "$(get_session_state "$sid" session_trace_flags)" ]]; then
set_session_state "$sid" session_trace_flags ""
fi
if [[ -z "$(get_session_state "$sid" trace_context_source)" ]]; then
set_session_state "$sid" trace_context_source "generated"
fi
if [[ -z "$(get_session_state "$sid" session_end_requested)" ]]; then
set_session_state "$sid" session_end_requested "false"
fi
Expand All @@ -231,15 +291,21 @@ ensure_session_initialized() {

# Fallback path for SDK environments that do not surface SessionStart.
[[ -z "$requested_start_ns" ]] && requested_start_ns="$(now_ns)"
load_initial_trace_context || true
[[ -z "$trace_id" ]] && trace_id="${PL_INITIAL_TRACE_ID:-}"
[[ -z "$trace_id" ]] && trace_id="$(generate_trace_id)"
[[ -z "$session_span_id" ]] && session_span_id="$(generate_span_id)"

set_session_state "$sid" trace_id "$trace_id"
set_session_state "$sid" session_span_id "$session_span_id"
set_session_state "$sid" session_parent_span_id "${PL_INITIAL_PARENT_SPAN_ID:-}"
set_session_state "$sid" session_start_ns "$requested_start_ns"
set_session_state "$sid" current_turn_start_ns ""
set_session_state "$sid" pending_tool_calls "[]"
set_session_state "$sid" session_init_source "lazy_init"
set_session_state "$sid" session_traceparent_version "${PL_INITIAL_TRACEPARENT_VERSION:-}"
set_session_state "$sid" session_trace_flags "${PL_INITIAL_TRACE_FLAGS:-}"
set_session_state "$sid" trace_context_source "${PL_INITIAL_TRACE_CONTEXT_SOURCE:-generated}"
set_session_state "$sid" session_root_emitted "false"
set_session_state "$sid" session_end_requested "false"
set_session_state "$sid" stop_in_flight "false"
Expand Down
3 changes: 2 additions & 1 deletion plugins/trace/hooks/session_end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ trap 'release_session_lock' EXIT

trace_id="$(get_session_state "$session_id" trace_id)"
session_span_id="$(get_session_state "$session_id" session_span_id)"
session_parent_span_id="$(get_session_state "$session_id" session_parent_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_start_ns="$(get_session_state "$session_id" current_turn_start_ns)"
Expand All @@ -37,7 +38,7 @@ trap - EXIT
# span_id conflict, so this safely updates the end time and lifecycle attribute.
end_ns="$(now_ns)"
attrs='{"source":"claude-code","hook":"SessionEnd","node_type":"WORKFLOW","session.lifecycle":"complete"}'
emit_span "$trace_id" "$session_span_id" "" "Claude Code session" "1" "$session_start_ns" "$end_ns" "$attrs" || true
emit_span "$trace_id" "$session_span_id" "$session_parent_span_id" "Claude Code session" "1" "$session_start_ns" "$end_ns" "$attrs" || true

acquire_session_lock "$session_id" || exit 0
trap 'release_session_lock' EXIT
Expand Down
20 changes: 19 additions & 1 deletion plugins/trace/hooks/session_start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ if [[ -n "$existing_trace_id" && -n "$existing_session_span_id" ]]; then
if [[ -z "$(get_session_state "$session_id" pending_tool_calls)" ]]; then
set_session_state "$session_id" pending_tool_calls "[]"
fi
if [[ -z "$(get_session_state "$session_id" session_parent_span_id)" ]]; then
set_session_state "$session_id" session_parent_span_id ""
fi
if [[ -z "$(get_session_state "$session_id" session_traceparent_version)" ]]; then
set_session_state "$session_id" session_traceparent_version ""
fi
if [[ -z "$(get_session_state "$session_id" session_trace_flags)" ]]; then
set_session_state "$session_id" session_trace_flags ""
fi
if [[ -z "$(get_session_state "$session_id" trace_context_source)" ]]; then
set_session_state "$session_id" trace_context_source "generated"
fi
if [[ -z "$(get_session_state "$session_id" session_end_requested)" ]]; then
set_session_state "$session_id" session_end_requested "false"
fi
Expand All @@ -31,16 +43,22 @@ if [[ -n "$existing_trace_id" && -n "$existing_session_span_id" ]]; then
exit 0
fi

trace_id="$(generate_trace_id)"
load_initial_trace_context || true
trace_id="${PL_INITIAL_TRACE_ID:-}"
[[ -z "$trace_id" ]] && trace_id="$(generate_trace_id)"
span_id="$(generate_span_id)"
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_parent_span_id "${PL_INITIAL_PARENT_SPAN_ID:-}"
set_session_state "$session_id" session_start_ns "$start_ns"
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"
set_session_state "$session_id" session_traceparent_version "${PL_INITIAL_TRACEPARENT_VERSION:-}"
set_session_state "$session_id" session_trace_flags "${PL_INITIAL_TRACE_FLAGS:-}"
set_session_state "$session_id" trace_context_source "${PL_INITIAL_TRACE_CONTEXT_SOURCE:-generated}"
set_session_state "$session_id" session_root_emitted "false"
set_session_state "$session_id" session_end_requested "false"
set_session_state "$session_id" stop_in_flight "false"
Expand Down
8 changes: 5 additions & 3 deletions plugins/trace/hooks/stop_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ ensure_session_initialized "$session_id"

trace_id="$(get_session_state "$session_id" trace_id)"
session_span_id="$(get_session_state "$session_id" session_span_id)"
session_parent_span_id="$(get_session_state "$session_id" session_parent_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)"
session_end_requested="$(get_session_state "$session_id" session_end_requested)"
Expand Down Expand Up @@ -99,7 +100,7 @@ else
session_lifecycle_attr="in_progress"
fi
session_attrs="{\"source\":\"claude-code\",\"hook\":\"$session_hook_attr\",\"node_type\":\"WORKFLOW\",\"session.lifecycle\":\"$session_lifecycle_attr\"}"
add_span_to_batch "$trace_id" "$session_span_id" "" "Claude Code session" "1" "$session_start_ns" "$turn_end_ns" "$session_attrs" || true
add_span_to_batch "$trace_id" "$session_span_id" "$session_parent_span_id" "Claude Code session" "1" "$session_start_ns" "$turn_end_ns" "$session_attrs" || true
emitted_root="true"

while IFS= read -r tool; do
Expand Down Expand Up @@ -127,7 +128,7 @@ fi
if [[ "$session_end_requested" == "true" ]]; then
end_ns="$(now_ns)"
session_end_attrs='{"source":"claude-code","hook":"SessionEnd","node_type":"WORKFLOW","session.lifecycle":"deferred_finalize"}'
add_span_to_batch "$trace_id" "$session_span_id" "" "Claude Code session" "1" "$session_start_ns" "$end_ns" "$session_end_attrs" || true
add_span_to_batch "$trace_id" "$session_span_id" "$session_parent_span_id" "Claude Code session" "1" "$session_start_ns" "$end_ns" "$session_end_attrs" || true
emitted_root="true"
fi

Expand All @@ -145,6 +146,7 @@ latest_end_requested="$(get_session_state "$session_id" session_end_requested)"
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_parent_span_id="$(get_session_state "$session_id" session_parent_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)"
Expand All @@ -158,7 +160,7 @@ if [[ "$need_finalize_root" == "true" && -n "$latest_trace_id" && -n "$latest_se
release_session_lock
end_ns="$(now_ns)"
finalize_attrs='{"source":"claude-code","hook":"SessionEnd","node_type":"WORKFLOW","session.lifecycle":"deferred_finalize"}'
emit_span "$latest_trace_id" "$latest_session_span_id" "" "Claude Code session" "1" "$latest_session_start_ns" "$end_ns" "$finalize_attrs" || true
emit_span "$latest_trace_id" "$latest_session_span_id" "$latest_session_parent_span_id" "Claude Code session" "1" "$latest_session_start_ns" "$end_ns" "$finalize_attrs" || true
acquire_session_lock "$session_id" || exit 0
set_session_state "$session_id" stop_in_flight "false"
set_session_state "$session_id" session_root_emitted "true"
Expand Down
1 change: 1 addition & 0 deletions plugins/trace/testdata/session_end_input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"session_id":"example-session-id"}
1 change: 1 addition & 0 deletions plugins/trace/testdata/stop_input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"session_id":"example-session-id","transcript_path":"plugins/trace/testdata/stop_transcript.jsonl"}
2 changes: 2 additions & 0 deletions plugins/trace/testdata/stop_transcript.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"sessionId":"example-session-id","type":"user","timestamp":"2026-03-16T12:00:00Z","message":{"content":[{"type":"text","text":"hello"}]}}
{"sessionId":"example-session-id","type":"assistant","timestamp":"2026-03-16T12:00:01Z","message":{"model":"claude-sonnet-4-6","id":"msg_example_1","stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":6},"content":[{"type":"text","text":"Hi there"}]}}
40 changes: 40 additions & 0 deletions scripts/assert_session_span_payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python3

import base64
import json
import sys


def main() -> int:
queue_file, expected_trace_id, expected_parent_span_id = sys.argv[1:4]

with open(queue_file, encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]

if not lines:
raise SystemExit("Assertion failed: expected queued OTLP payload")

payload = json.loads(lines[-1])
spans = payload["resourceSpans"][0]["scopeSpans"][0]["spans"]
session_span = next((span for span in spans if span.get("name") == "Claude Code session"), None)
if session_span is None:
raise SystemExit("Assertion failed: missing Claude Code session span")

trace_id = base64.b64decode(session_span["traceId"]).hex()
if trace_id != expected_trace_id:
raise SystemExit(
f"Assertion failed: session span trace ID mismatch\n expected: {expected_trace_id}\n actual: {trace_id}"
)

parent = session_span.get("parentSpanId")
parent_hex = base64.b64decode(parent).hex() if parent else ""
if parent_hex != expected_parent_span_id:
raise SystemExit(
f"Assertion failed: session span parent mismatch\n expected: {expected_parent_span_id}\n actual: {parent_hex}"
)

return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading