diff --git a/plugins/trace/hooks/hook_utils.py b/plugins/trace/hooks/hook_utils.py new file mode 100644 index 0000000..e5466eb --- /dev/null +++ b/plugins/trace/hooks/hook_utils.py @@ -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 [args]") + + command = sys.argv[1] + if command == "hex_to_base64": + if len(sys.argv) != 3: + raise SystemExit("usage: hook_utils.py hex_to_base64 ") + 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()) diff --git a/plugins/trace/hooks/lib.sh b/plugins/trace/hooks/lib.sh index c53eecf..d936140 100755 --- a/plugins/trace/hooks/lib.sh +++ b/plugins/trace/hooks/lib.sh @@ -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" @@ -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" @@ -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() { @@ -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)" @@ -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 @@ -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" diff --git a/plugins/trace/hooks/session_end.sh b/plugins/trace/hooks/session_end.sh index 21bbe5f..3c2dd2f 100755 --- a/plugins/trace/hooks/session_end.sh +++ b/plugins/trace/hooks/session_end.sh @@ -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)" @@ -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 diff --git a/plugins/trace/hooks/session_start.sh b/plugins/trace/hooks/session_start.sh index 5bd612a..fe3d282 100755 --- a/plugins/trace/hooks/session_start.sh +++ b/plugins/trace/hooks/session_start.sh @@ -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 @@ -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" diff --git a/plugins/trace/hooks/stop_hook.sh b/plugins/trace/hooks/stop_hook.sh index 8b0e68b..fe7c0eb 100755 --- a/plugins/trace/hooks/stop_hook.sh +++ b/plugins/trace/hooks/stop_hook.sh @@ -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)" @@ -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 @@ -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 @@ -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)" @@ -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" diff --git a/plugins/trace/testdata/session_end_input.json b/plugins/trace/testdata/session_end_input.json new file mode 100644 index 0000000..b4a612e --- /dev/null +++ b/plugins/trace/testdata/session_end_input.json @@ -0,0 +1 @@ +{"session_id":"example-session-id"} diff --git a/plugins/trace/testdata/stop_input.json b/plugins/trace/testdata/stop_input.json new file mode 100644 index 0000000..6ffd845 --- /dev/null +++ b/plugins/trace/testdata/stop_input.json @@ -0,0 +1 @@ +{"session_id":"example-session-id","transcript_path":"plugins/trace/testdata/stop_transcript.jsonl"} diff --git a/plugins/trace/testdata/stop_transcript.jsonl b/plugins/trace/testdata/stop_transcript.jsonl new file mode 100644 index 0000000..cd399f6 --- /dev/null +++ b/plugins/trace/testdata/stop_transcript.jsonl @@ -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"}]}} diff --git a/scripts/assert_session_span_payload.py b/scripts/assert_session_span_payload.py new file mode 100644 index 0000000..d2fcc5c --- /dev/null +++ b/scripts/assert_session_span_payload.py @@ -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()) diff --git a/scripts/replay-fixtures.sh b/scripts/replay-fixtures.sh index 8e87fd9..6ca04ef 100755 --- a/scripts/replay-fixtures.sh +++ b/scripts/replay-fixtures.sh @@ -9,11 +9,185 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -export TRACE_TO_PROMPTLAYER="true" -export PROMPTLAYER_API_KEY="pl_test_key" -export PROMPTLAYER_OTLP_ENDPOINT="http://127.0.0.1:9/v1/traces" +TRACEPARENT_VALID="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" +TRACEPARENT_FUTURE="01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03" +TRACEPARENT_FUTURE_SUFFIXED="02-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-05-deadbeef" +TRACE_ID_VALID="4bf92f3577b34da6a3ce929d0e0e4736" +PARENT_SPAN_ID_VALID="00f067aa0ba902b7" +SESSION_ID="example-session-id" -# Expect failure to send (no server), but script should still run. -cat plugins/trace/testdata/session_start_input.json | bash plugins/trace/hooks/session_start.sh || true +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + if [[ "$actual" != "$expected" ]]; then + echo "Assertion failed: $message" + echo " expected: $expected" + echo " actual: $actual" + exit 1 + fi +} + +assert_matches() { + local actual="$1" + local pattern="$2" + local message="$3" + if [[ ! "$actual" =~ $pattern ]]; then + echo "Assertion failed: $message" + echo " pattern: $pattern" + echo " actual: $actual" + exit 1 + fi +} + +state_value() { + local home="$1" + local sid="$2" + local key="$3" + jq -r ".${key} // empty" "$home/.claude/state/promptlayer_sessions/$sid.json" +} + +run_hook() { + local home="$1" + local hook="$2" + local input_file="$3" + shift 3 + + env \ + HOME="$home" \ + TRACE_TO_PROMPTLAYER="true" \ + PROMPTLAYER_API_KEY="pl_test_key" \ + PROMPTLAYER_OTLP_ENDPOINT="http://127.0.0.1:9/v1/traces" \ + PROMPTLAYER_OTLP_CONNECT_TIMEOUT="1" \ + PROMPTLAYER_OTLP_MAX_TIME="1" \ + "$@" \ + bash "$hook" <"$input_file" +} + +assert_session_span_payload() { + local queue_file="$1" + local expected_trace_id="$2" + local expected_parent_span_id="$3" + + python3 scripts/assert_session_span_payload.py "$queue_file" "$expected_trace_id" "$expected_parent_span_id" +} + +new_home() { + local dir + dir="$(mktemp -d "${TMPDIR:-/tmp}/pl-fixture-home.XXXXXX")" + mkdir -p "$dir/.claude/state" + echo "$dir" +} + +cleanup_home() { + local home="$1" + rm -rf "$home" +} + +test_valid_traceparent_session_end() { + local home + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_VALID" + + assert_eq "$(state_value "$home" "$SESSION_ID" trace_id)" "$TRACE_ID_VALID" "valid traceparent should reuse upstream trace ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_parent_span_id)" "$PARENT_SPAN_ID_VALID" "valid traceparent should store upstream parent span ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_traceparent_version)" "00" "v00 traceparent should store version" + assert_eq "$(state_value "$home" "$SESSION_ID" session_trace_flags)" "01" "v00 traceparent should store flags" + assert_eq "$(state_value "$home" "$SESSION_ID" trace_context_source)" "external_traceparent" "trace context source should record external parent" + + run_hook "$home" "plugins/trace/hooks/session_end.sh" "plugins/trace/testdata/session_end_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_VALID" + + assert_session_span_payload "$home/.claude/state/promptlayer_otlp_queue.ndjson" "$TRACE_ID_VALID" "$PARENT_SPAN_ID_VALID" +} + +test_valid_traceparent_stop_hook() { + local home + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_VALID" + run_hook "$home" "plugins/trace/hooks/user_prompt_submit.sh" "plugins/trace/testdata/session_end_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_VALID" + run_hook "$home" "plugins/trace/hooks/stop_hook.sh" "plugins/trace/testdata/stop_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_VALID" + + assert_session_span_payload "$home/.claude/state/promptlayer_otlp_queue.ndjson" "$TRACE_ID_VALID" "$PARENT_SPAN_ID_VALID" +} + +test_missing_traceparent_fallback() { + local home trace_id + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" + + trace_id="$(state_value "$home" "$SESSION_ID" trace_id)" + assert_matches "$trace_id" '^[0-9a-f]{32}$' "missing traceparent should generate a trace ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_parent_span_id)" "" "missing traceparent should not set a parent span ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_traceparent_version)" "" "missing traceparent should not set a version" + assert_eq "$(state_value "$home" "$SESSION_ID" session_trace_flags)" "" "missing traceparent should not set flags" + assert_eq "$(state_value "$home" "$SESSION_ID" trace_context_source)" "generated" "missing traceparent should record generated trace context" + + run_hook "$home" "plugins/trace/hooks/session_end.sh" "plugins/trace/testdata/session_end_input.json" + + assert_session_span_payload "$home/.claude/state/promptlayer_otlp_queue.ndjson" "$trace_id" "" +} + +test_invalid_traceparent_fallback() { + local home trace_id + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" \ + "PROMPTLAYER_TRACEPARENT=bogus-value" + + trace_id="$(state_value "$home" "$SESSION_ID" trace_id)" + assert_matches "$trace_id" '^[0-9a-f]{32}$' "invalid traceparent should fall back to a generated trace ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_parent_span_id)" "" "invalid traceparent should not set a parent span ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_traceparent_version)" "" "invalid traceparent should not set a version" + assert_eq "$(state_value "$home" "$SESSION_ID" session_trace_flags)" "" "invalid traceparent should not set flags" + assert_eq "$(state_value "$home" "$SESSION_ID" trace_context_source)" "generated" "invalid traceparent should record generated trace context" +} + +test_non_zero_zero_version_traceparent() { + local home + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_FUTURE" + + assert_eq "$(state_value "$home" "$SESSION_ID" trace_id)" "$TRACE_ID_VALID" "non-00 traceparent should reuse upstream trace ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_parent_span_id)" "$PARENT_SPAN_ID_VALID" "non-00 traceparent should store upstream parent span ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_traceparent_version)" "01" "non-00 traceparent should store version" + assert_eq "$(state_value "$home" "$SESSION_ID" session_trace_flags)" "03" "non-00 traceparent should store flags" + assert_eq "$(state_value "$home" "$SESSION_ID" trace_context_source)" "external_traceparent" "non-00 traceparent should record external parent" +} + +test_future_version_traceparent_with_suffix() { + local home + home="$(new_home)" + trap 'cleanup_home "$home"' RETURN + + run_hook "$home" "plugins/trace/hooks/session_start.sh" "plugins/trace/testdata/session_start_input.json" \ + "PROMPTLAYER_TRACEPARENT=$TRACEPARENT_FUTURE_SUFFIXED" + + assert_eq "$(state_value "$home" "$SESSION_ID" trace_id)" "$TRACE_ID_VALID" "future traceparent with suffix should reuse upstream trace ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_parent_span_id)" "$PARENT_SPAN_ID_VALID" "future traceparent with suffix should store upstream parent span ID" + assert_eq "$(state_value "$home" "$SESSION_ID" session_traceparent_version)" "02" "future traceparent with suffix should store version" + assert_eq "$(state_value "$home" "$SESSION_ID" session_trace_flags)" "05" "future traceparent with suffix should store flags" +} + +test_valid_traceparent_session_end +test_valid_traceparent_stop_hook +test_missing_traceparent_fallback +test_invalid_traceparent_fallback +test_non_zero_zero_version_traceparent +test_future_version_traceparent_with_suffix echo "Fixture replay completed"