From 59b9a5b38b275d1051ebbcd7dee65a157e4f53ca Mon Sep 17 00:00:00 2001 From: Kyle Ahn Date: Wed, 24 Dec 2025 19:03:14 -0800 Subject: [PATCH] support distributed bt spans --- skills/trace-claude-code/SKILL.md | 69 +++++++++++ skills/trace-claude-code/hooks/common.sh | 74 ++++++++++++ .../trace-claude-code/hooks/session_start.sh | 109 ++++++++++++----- .../hooks/user_prompt_submit.sh | 114 +++++++++++++----- 4 files changed, 303 insertions(+), 63 deletions(-) diff --git a/skills/trace-claude-code/SKILL.md b/skills/trace-claude-code/SKILL.md index 0c1bda3..7c903f8 100644 --- a/skills/trace-claude-code/SKILL.md +++ b/skills/trace-claude-code/SKILL.md @@ -123,6 +123,7 @@ Replace `/path/to/hooks/` with the actual path to this skill's hooks directory. | `BRAINTRUST_API_KEY` | Yes | Your Braintrust API key | | `BRAINTRUST_CC_PROJECT` | No | Project name (default: `claude-code`) | | `BRAINTRUST_CC_DEBUG` | No | Set to `"true"` for verbose logging | +| `BRAINTRUST_SESSION_CONTEXT_FILE` | No | Path to session context file for distributed tracing (default: `/tmp/braintrust_session.json`) | ## Viewing traces @@ -228,6 +229,74 @@ hooks/ └── session_end.sh # Finalizes trace ``` +## Distributed tracing + +When Claude Code runs as part of a larger system (agent orchestrators, Slack bots, CI/CD pipelines), you can nest Claude Code traces under a parent span for end-to-end visibility. + +### How it works + +Before starting Claude Code, write a session context file containing the parent span information: + +```json +{ + "parent_span": "", + "project": "optional-project-override" +} +``` + +The hooks automatically detect this file and nest the Claude Code session under the specified parent span. + +### Example: Python orchestrator + +```python +import json +import subprocess +import braintrust + +logger = braintrust.init_logger(project="my-orchestrator") + +with logger.start_span(name="orchestrator_task") as span: + # Write context for Claude Code + with open("/tmp/braintrust_session.json", "w") as f: + json.dump({"parent_span": span.export()}, f) + + # Start Claude Code - it will automatically nest under this span + subprocess.run(["claude", "--prompt", "Implement the feature"]) + + # Optional: clean up + os.remove("/tmp/braintrust_session.json") +``` + +### Result in Braintrust UI + +``` +[Orchestrator Task] +└── [Claude Code: my-project] + ├── Turn 1 + │ ├── claude-sonnet-4-... (llm) + │ ├── Read: src/app.ts (tool) + │ └── claude-sonnet-4-... (llm) + └── Turn 2 + └── ... +``` + +### Session context schema + +| Field | Required | Description | +|-------|----------|-------------| +| `parent_span` | Yes | Braintrust SDK exported span string (from `span.export()`) | +| `project` | No | Override project name | + +### Custom file location + +Set `BRAINTRUST_SESSION_CONTEXT_FILE` to use a custom path: + +```bash +export BRAINTRUST_SESSION_CONTEXT_FILE=/tmp/session_${SESSION_ID}.json +``` + +This is useful in multi-tenant environments where multiple Claude Code sessions run concurrently. + ## Alternative: SDK integration For programmatic use with the Claude Agent SDK, use the native Braintrust integration: diff --git a/skills/trace-claude-code/hooks/common.sh b/skills/trace-claude-code/hooks/common.sh index 2427ffd..e3d6fe3 100755 --- a/skills/trace-claude-code/hooks/common.sh +++ b/skills/trace-claude-code/hooks/common.sh @@ -6,6 +6,7 @@ # Config export LOG_FILE="$HOME/.claude/state/braintrust_hook.log" export STATE_FILE="$HOME/.claude/state/braintrust_state.json" +export SESSION_CONTEXT_FILE="${BRAINTRUST_SESSION_CONTEXT_FILE:-/tmp/braintrust_session.json}" export DEBUG="${BRAINTRUST_CC_DEBUG:-false}" export API_KEY="${BRAINTRUST_API_KEY}" export PROJECT="${BRAINTRUST_CC_PROJECT:-claude-code}" @@ -179,3 +180,76 @@ get_username() { get_os() { uname -s 2>/dev/null || echo "unknown" } + +# Parse span IDs from Braintrust SDK exported format (SpanComponentsV3) +# Format: version(1) + object_type(1) + num_uuids(1) + [field_id(1) + uuid(16)]... + JSON +# Field IDs: 1=OBJECT_ID, 2=ROW_ID, 3=SPAN_ID, 4=ROOT_SPAN_ID +# Output: span_id and root_span_id separated by space +parse_exported_span() { + local exported="$1" + [ -z "$exported" ] && return 1 + + local result + result=$(python3 -c " +import base64 +import sys +from uuid import UUID + +try: + data = base64.b64decode('$exported') + if len(data) < 3: + sys.exit(1) + + num_uuids = data[2] + uuids = {} + offset = 3 + + for _ in range(num_uuids): + if offset + 17 > len(data): + break + field_id = data[offset] + uuid_bytes = data[offset + 1:offset + 17] + uuid_str = str(UUID(bytes=uuid_bytes)) + uuids[field_id] = uuid_str + offset += 17 + + span_id = uuids.get(3, '') # SPAN_ID + root_span_id = uuids.get(4, '') # ROOT_SPAN_ID + + if span_id and root_span_id: + print(f'{span_id} {root_span_id}') + else: + sys.exit(1) +except Exception: + sys.exit(1) +" 2>/dev/null) + + [ -z "$result" ] && return 1 + echo "$result" +} + +# Read session context from file for distributed tracing +# Sets: PARENT_SPAN_ID (for span_parents), TRACE_ROOT_ID (for root_span_id), CONTEXT_PROJECT +get_session_context() { + PARENT_SPAN_ID="" + TRACE_ROOT_ID="" + CONTEXT_PROJECT="" + + if [ -f "$SESSION_CONTEXT_FILE" ]; then + local exported project + exported=$(jq -r '.parent_span // empty' "$SESSION_CONTEXT_FILE" 2>/dev/null) + project=$(jq -r '.project // empty' "$SESSION_CONTEXT_FILE" 2>/dev/null) + + if [ -n "$exported" ]; then + local parsed + parsed=$(parse_exported_span "$exported") + if [ -n "$parsed" ]; then + PARENT_SPAN_ID=$(echo "$parsed" | cut -d' ' -f1) + TRACE_ROOT_ID=$(echo "$parsed" | cut -d' ' -f2) + debug "Parsed session context: parent=$PARENT_SPAN_ID root=$TRACE_ROOT_ID" + fi + fi + + [ -n "$project" ] && CONTEXT_PROJECT="$project" + fi +} diff --git a/skills/trace-claude-code/hooks/session_start.sh b/skills/trace-claude-code/hooks/session_start.sh index 7a584bf..1ae4723 100755 --- a/skills/trace-claude-code/hooks/session_start.sh +++ b/skills/trace-claude-code/hooks/session_start.sh @@ -38,7 +38,7 @@ if [ -n "$EXISTING_ROOT" ]; then fi # Create root span for the session -ROOT_SPAN_ID="$SESSION_ID" +SESSION_SPAN_ID="$SESSION_ID" TIMESTAMP=$(get_timestamp) # Extract workspace info if available @@ -50,41 +50,88 @@ HOSTNAME=$(get_hostname) USERNAME=$(get_username) OS=$(get_os) -EVENT=$(jq -n \ - --arg id "$ROOT_SPAN_ID" \ - --arg span_id "$ROOT_SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg session "$SESSION_ID" \ - --arg workspace "$WORKSPACE_NAME" \ - --arg cwd "$WORKSPACE" \ - --arg hostname "$HOSTNAME" \ - --arg username "$USERNAME" \ - --arg os "$OS" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - created: $created, - input: ("Session: " + $workspace), - metadata: { - session_id: $session, - workspace: $cwd, - hostname: $hostname, - username: $username, - os: $os, - source: "claude-code" - }, - span_attributes: { - name: ("Claude Code: " + $workspace), - type: "task" - } - }') +# Check for external parent span context (distributed tracing) +get_session_context + +if [ -n "$PARENT_SPAN_ID" ] && [ -n "$TRACE_ROOT_ID" ]; then + # Nested mode: Claude Code session is child of external span + ROOT_SPAN_ID="$TRACE_ROOT_ID" + debug "Nesting under parent span: $PARENT_SPAN_ID (root: $TRACE_ROOT_ID)" + + EVENT=$(jq -n \ + --arg id "$SESSION_SPAN_ID" \ + --arg span_id "$SESSION_SPAN_ID" \ + --arg root_span_id "$ROOT_SPAN_ID" \ + --arg parent_span_id "$PARENT_SPAN_ID" \ + --arg created "$TIMESTAMP" \ + --arg session "$SESSION_ID" \ + --arg workspace "$WORKSPACE_NAME" \ + --arg cwd "$WORKSPACE" \ + --arg hostname "$HOSTNAME" \ + --arg username "$USERNAME" \ + --arg os "$OS" \ + '{ + id: $id, + span_id: $span_id, + root_span_id: $root_span_id, + span_parents: [$parent_span_id], + created: $created, + input: ("Session: " + $workspace), + metadata: { + session_id: $session, + workspace: $cwd, + hostname: $hostname, + username: $username, + os: $os, + source: "claude-code" + }, + span_attributes: { + name: ("Claude Code: " + $workspace), + type: "task" + } + }') +else + # Standalone mode: Claude Code session is its own root + ROOT_SPAN_ID="$SESSION_SPAN_ID" + debug "Creating standalone session (no parent context)" + + EVENT=$(jq -n \ + --arg id "$SESSION_SPAN_ID" \ + --arg span_id "$SESSION_SPAN_ID" \ + --arg root_span_id "$ROOT_SPAN_ID" \ + --arg created "$TIMESTAMP" \ + --arg session "$SESSION_ID" \ + --arg workspace "$WORKSPACE_NAME" \ + --arg cwd "$WORKSPACE" \ + --arg hostname "$HOSTNAME" \ + --arg username "$USERNAME" \ + --arg os "$OS" \ + '{ + id: $id, + span_id: $span_id, + root_span_id: $root_span_id, + created: $created, + input: ("Session: " + $workspace), + metadata: { + session_id: $session, + workspace: $cwd, + hostname: $hostname, + username: $username, + os: $os, + source: "claude-code" + }, + span_attributes: { + name: ("Claude Code: " + $workspace), + type: "task" + } + }') +fi ROW_ID=$(insert_span "$PROJECT_ID" "$EVENT") || { log "ERROR" "Failed to create session root"; exit 0; } # Save session state set_session_state "$SESSION_ID" "root_span_id" "$ROOT_SPAN_ID" +set_session_state "$SESSION_ID" "session_span_id" "$SESSION_SPAN_ID" set_session_state "$SESSION_ID" "project_id" "$PROJECT_ID" set_session_state "$SESSION_ID" "turn_count" "0" set_session_state "$SESSION_ID" "tool_count" "0" diff --git a/skills/trace-claude-code/hooks/user_prompt_submit.sh b/skills/trace-claude-code/hooks/user_prompt_submit.sh index 2ef2a1c..a37e68e 100755 --- a/skills/trace-claude-code/hooks/user_prompt_submit.sh +++ b/skills/trace-claude-code/hooks/user_prompt_submit.sh @@ -25,12 +25,13 @@ PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) # Get session info ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") +SESSION_SPAN_ID=$(get_session_state "$SESSION_ID" "session_span_id") PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") # If no session root exists yet, we'll create it if [ -z "$ROOT_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then PROJECT_ID=$(get_project_id "$PROJECT") || { log "ERROR" "Failed to get project"; exit 0; } - ROOT_SPAN_ID="$SESSION_ID" + SESSION_SPAN_ID="$SESSION_ID" # Get workspace name from cwd CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) @@ -41,42 +42,90 @@ if [ -z "$ROOT_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then USERNAME=$(get_username) OS=$(get_os) - EVENT=$(jq -n \ - --arg id "$ROOT_SPAN_ID" \ - --arg span_id "$ROOT_SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg session "$SESSION_ID" \ - --arg workspace "$WORKSPACE_NAME" \ - --arg hostname "$HOSTNAME" \ - --arg username "$USERNAME" \ - --arg os "$OS" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - created: $created, - input: ("Session: " + $workspace), - metadata: { - session_id: $session, - workspace: $workspace, - hostname: $hostname, - username: $username, - os: $os, - source: "claude-code" - }, - span_attributes: { - name: ("Claude Code: " + $workspace), - type: "task" - } - }') + # Check for external parent span context (distributed tracing) + get_session_context + + if [ -n "$PARENT_SPAN_ID" ] && [ -n "$TRACE_ROOT_ID" ]; then + # Nested mode: Claude Code session is child of external span + ROOT_SPAN_ID="$TRACE_ROOT_ID" + debug "Nesting under parent span: $PARENT_SPAN_ID (root: $TRACE_ROOT_ID)" + + EVENT=$(jq -n \ + --arg id "$SESSION_SPAN_ID" \ + --arg span_id "$SESSION_SPAN_ID" \ + --arg root_span_id "$ROOT_SPAN_ID" \ + --arg parent_span_id "$PARENT_SPAN_ID" \ + --arg created "$TIMESTAMP" \ + --arg session "$SESSION_ID" \ + --arg workspace "$WORKSPACE_NAME" \ + --arg hostname "$HOSTNAME" \ + --arg username "$USERNAME" \ + --arg os "$OS" \ + '{ + id: $id, + span_id: $span_id, + root_span_id: $root_span_id, + span_parents: [$parent_span_id], + created: $created, + input: ("Session: " + $workspace), + metadata: { + session_id: $session, + workspace: $workspace, + hostname: $hostname, + username: $username, + os: $os, + source: "claude-code" + }, + span_attributes: { + name: ("Claude Code: " + $workspace), + type: "task" + } + }') + else + # Standalone mode: Claude Code session is its own root + ROOT_SPAN_ID="$SESSION_SPAN_ID" + debug "Creating standalone session (no parent context)" + + EVENT=$(jq -n \ + --arg id "$SESSION_SPAN_ID" \ + --arg span_id "$SESSION_SPAN_ID" \ + --arg root_span_id "$ROOT_SPAN_ID" \ + --arg created "$TIMESTAMP" \ + --arg session "$SESSION_ID" \ + --arg workspace "$WORKSPACE_NAME" \ + --arg hostname "$HOSTNAME" \ + --arg username "$USERNAME" \ + --arg os "$OS" \ + '{ + id: $id, + span_id: $span_id, + root_span_id: $root_span_id, + created: $created, + input: ("Session: " + $workspace), + metadata: { + session_id: $session, + workspace: $workspace, + hostname: $hostname, + username: $username, + os: $os, + source: "claude-code" + }, + span_attributes: { + name: ("Claude Code: " + $workspace), + type: "task" + } + }') + fi insert_span "$PROJECT_ID" "$EVENT" >/dev/null || true set_session_state "$SESSION_ID" "root_span_id" "$ROOT_SPAN_ID" + set_session_state "$SESSION_ID" "session_span_id" "$SESSION_SPAN_ID" set_session_state "$SESSION_ID" "project_id" "$PROJECT_ID" log "INFO" "Created session root: $SESSION_ID" fi +[ -z "$SESSION_SPAN_ID" ] && SESSION_SPAN_ID="$ROOT_SPAN_ID" + # Increment turn count and create Turn span TURN_COUNT=$(get_session_state "$SESSION_ID" "turn_count") TURN_COUNT=${TURN_COUNT:-0} @@ -90,11 +139,12 @@ START_TIME=$(date +%s) PROMPT_PREVIEW="${PROMPT:0:100}" [ ${#PROMPT} -gt 100 ] && PROMPT_PREVIEW="${PROMPT_PREVIEW}..." -# Create Turn container span +# Create Turn container span (parented to session span, not root) EVENT=$(jq -n \ --arg id "$TURN_SPAN_ID" \ --arg span_id "$TURN_SPAN_ID" \ --arg root_span_id "$ROOT_SPAN_ID" \ + --arg parent_span_id "$SESSION_SPAN_ID" \ --arg created "$TIMESTAMP" \ --arg prompt "$PROMPT" \ --argjson turn "$TURN_COUNT" \ @@ -103,7 +153,7 @@ EVENT=$(jq -n \ id: $id, span_id: $span_id, root_span_id: $root_span_id, - span_parents: [$root_span_id], + span_parents: [$parent_span_id], created: $created, input: $prompt, metrics: {