Skip to content
Open
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
69 changes: 69 additions & 0 deletions skills/trace-claude-code/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": "<braintrust_exported_span_string>",
"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:
Expand Down
74 changes: 74 additions & 0 deletions skills/trace-claude-code/hooks/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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 "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do this without python? Right now there's no requirement that you need a working python interpreter and ideally would like to keep it that way.

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
}
109 changes: 78 additions & 31 deletions skills/trace-claude-code/hooks/session_start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading