Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
52 changes: 45 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
This project will be a rust re-implementation of the python script in `docs/langfuse_hook.py`.
This project sends Claude Code and OpenCode session traces to Langfuse for observability.

This web page explains the usage: https://langfuse.com/integrations/other/claude-code
## Architecture

The reason to reimplement in rust is:
- Single Rust binary that supports three input sources
- Claude Code: invoked as a Stop hook, reads transcript JSONL
- OpenCode: invoked by a TypeScript plugin, receives messages via stdin
- Pi: invoked by a TypeScript extension, receives session entries via stdin

1. I want a really simple way to install this for our teams of developers
2. I want to avoid python environment messing about
3. Each turn takes 0.5 - 1.0s to send the trace, which will be annoying soon. I want to look at ways of speeding this up as much as possible.
## Key files

In addition there is a bug in the current python script where tags are not being successfully sent. It is not clear why but it smells like a bug in the new langfuse python sdk v4.
- `src/main.rs` — entry point, dispatches on `Input` enum
- `src/source.rs` — `Source` enum (ClaudeCode | Opencode | PiAgent)
- `src/payload.rs` — parses stdin into `Input` enum
- `src/opencode.rs` — normalizes OpenCode SDK message format to Claude-format Values
- `src/pi_agent.rs` — normalizes Pi session entry format to Claude-format Values
- `src/transcript.rs` — reads Claude Code JSONL transcript
- `src/turns.rs` — groups messages into user/assistant/tool turns
- `src/emit.rs` — builds Langfuse ingestion batch
- `src/tags.rs` — gathers env tags (repo, branch, user, host, os, agent version)
- `src/state.rs` — persisted cursor state per session
- `src/log.rs` — logging to `~/.local/share/code-trace/`
- `plugin/pi-agent/code-trace.ts` — pi extension

## State location

State moved from `~/.claude/state/` to `~/.local/share/code-trace/`. Migration happens on first run.

## Building

```bash
cargo build --release
```

## Testing

```bash
cargo test
```

## Adding a new source

1. Add variant to `src/source.rs` `Source` enum
2. Add variant to `src/payload.rs` `Input` enum with parsing
3. Add message normalizer (e.g. `src/opencode.rs`) if needed
4. Update `src/tags.rs` `gather_env_tags` for source-specific tags
5. Update `src/emit.rs` `build_ingestion_batch` for source-specific trace name/metadata
6. Update `src/main.rs` match arm for the new source
7. Update `tests/integration_test.rs` with fixture
85 changes: 76 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
# code-trace

Send [Claude Code](https://docs.anthropic.com/en/docs/claude-code) session traces to [Langfuse](https://langfuse.com) for observability.
Send [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) session traces to [Langfuse](https://langfuse.com) for observability.

Runs as a Claude Code [Stop hook](https://docs.anthropic.com/en/docs/claude-code/hooks) — after each assistant response, it reads the session transcript, assembles conversational turns, and sends them to Langfuse as structured traces with generations and tool spans.
Runs as a Claude Code [Stop hook](https://docs.anthropic.com/en/docs/claude-code/hooks) or an OpenCode plugin — after each assistant response, it reads the session transcript, assembles conversational turns, and sends them to Langfuse as structured traces with generations and tool spans.

Written in Rust for fast startup and zero runtime dependencies. The process forks after assembling the payload — the parent exits immediately while the child sends the HTTP request in the background, adding minimal latency to your workflow.

## Supported agents

| Agent | Integration |
|-------|-------------|
| Claude Code | Stop hook (settings.json) |
| OpenCode | Plugin (`.opencode/plugins/` or npm) |

## What you get in Langfuse

Each turn produces:
Expand All @@ -24,6 +31,7 @@ Traces are automatically tagged with:
| `host:<hostname>` | `host:codex` |
| `os:<platform>` | `os:linux` |
| `cc-version:<ver>` | `cc-version:2.1.100 (Claude Code)` |
| `oc-version:<ver>` | `oc-version:0.4.5 (OpenCode)` |

## Install

Expand All @@ -35,6 +43,12 @@ curl -sfL https://raw.githubusercontent.com/isotoma/code-trace/main/install.sh |

This installs the binary to `~/.local/bin/code-trace`. Make sure `~/.local/bin` is in your `PATH`.

To also install the OpenCode plugin:

```bash
curl -sfL https://raw.githubusercontent.com/isotoma/code-trace/main/install.sh | bash -s -- --opencode
```

### From source

```bash
Expand All @@ -52,9 +66,11 @@ cp target/release/code-trace ~/.local/bin/

## Configuration

### 1. Register the hook
### Claude Code

Add to `~/.claude/settings.json`:
#### 1. Register the hook

The install script does this automatically. If not, add to `~/.claude/settings.json`:

```json
{
Expand All @@ -73,7 +89,7 @@ Add to `~/.claude/settings.json`:
}
```

### 2. Set credentials per project
#### 2. Set credentials per project

Add to `.claude/settings.local.json` in your project root:

Expand All @@ -89,6 +105,36 @@ Add to `.claude/settings.local.json` in your project root:

Or set them globally if you want tracing on all projects.

### OpenCode

#### 1. Install the plugin

Copy `plugin/code-trace.ts` to your OpenCode plugins directory:

```bash
mkdir -p ~/.config/opencode/plugins/
cp plugin/code-trace.ts ~/.config/opencode/plugins/code-trace.ts
```

Or add to your `opencode.json`:

```json
{
"plugin": ["code-trace"]
}
```

#### 2. Set environment variables

Enable tracing in your shell profile (`.bashrc`, `.zshrc`, etc.):

```bash
export TRACE_TO_LANGFUSE=true
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...
export LANGFUSE_BASE_URL=https://cloud.langfuse.com # optional, defaults to cloud.langfuse.com
```

## Environment variables

| Variable | Required | Description |
Expand All @@ -97,21 +143,42 @@ Or set them globally if you want tracing on all projects.
| `LANGFUSE_PUBLIC_KEY` | Yes | Langfuse public key |
| `LANGFUSE_SECRET_KEY` | Yes | Langfuse secret key |
| `LANGFUSE_BASE_URL` | No | Langfuse host (default: `https://cloud.langfuse.com`) |
| `CC_TRACE_DEBUG` | No | Set to `true` for debug logging |
| `CODE_TRACE_DEBUG` | No | Set to `true` for debug logging (alias: `CC_TRACE_DEBUG`) |

The `CC_LANGFUSE_` prefix is also accepted for all Langfuse variables (e.g. `CC_LANGFUSE_PUBLIC_KEY`).

## How it works

### Claude Code

1. Claude Code calls the hook after each assistant response, passing a JSON payload on stdin with the `sessionId` and `transcriptPath`
2. The binary reads new lines from the transcript JSONL file (tracking offset in `~/.claude/state/code_trace_state.json`)
2. The binary reads new lines from the transcript JSONL file (tracking offset in `~/.local/share/code-trace/state.json`)
3. Messages are grouped into turns (user message + assistant responses + tool results)
4. A batch of Langfuse ingestion events is built (`trace-create`, `generation-create`, `span-create`)
5. The process forks — the parent exits immediately, while the child sends the batch to the Langfuse API via HTTP and logs the result

## Logs
### OpenCode

1. The OpenCode plugin hooks into the `session.idle` event after each assistant response
2. It fetches new messages since the last processed message (tracked per-session in `~/.local/share/code-trace/opencode_cursor.json`)
3. Messages are assembled into turns and piped to the `code-trace` binary over stdin
4. The binary forks — the parent returns immediately, while the child sends the batch to the Langfuse API via HTTP and logs the result

## State and logs

State is stored in `~/.local/share/code-trace/`:
- `state.json` — turn cursor per session
- `state.lock` — file lock for concurrent access
- `opencode_cursor.json` — OpenCode per-session message cursor
- `code_trace.log` — trace log

Note: State was previously stored in `~/.claude/state/`. On first run, existing state is migrated automatically.

Enable debug logging with `CODE_TRACE_DEBUG=true`.

## State migration

Logs are written to `~/.claude/state/code_trace.log`. Enable debug logging with `CC_TRACE_DEBUG=true`.
On first run after an update, any existing state in `~/.claude/state/code_trace_state.json` is migrated to `~/.local/share/code-trace/state.json`.

## License

Expand Down
101 changes: 96 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ REPO="isotoma/code-trace"
BINARY="code-trace"
INSTALL_DIR="${HOME}/.local/bin"
SETTINGS_FILE="${HOME}/.claude/settings.json"
OPENCODE_PLUGIN_DIR="${HOME}/.config/opencode/plugins"
PI_EXTENSION_DIR="${HOME}/.pi/agent/extensions"

# Parse flags
INSTALL_OPENCODE=false
if [ "${1:-}" = "--opencode" ] || [ "${1:-}" = "-o" ]; then
INSTALL_OPENCODE=true
fi

INSTALL_PI=false
if [ "${1:-}" = "--pi" ] || [ "${1:-}" = "-p" ]; then
INSTALL_PI=true
fi

detect_opencode() {
[ -d "${HOME}/.config/opencode" ] || [ -f "${HOME}/.config/opencode/opencode.json" ]
}

detect_pi() {
[ -d "${HOME}/.pi/agent" ]
}

# Detect platform
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
Expand Down Expand Up @@ -65,15 +86,29 @@ if ! echo "${PATH}" | tr ':' '\n' | grep -qx "${INSTALL_DIR}"; then
fi
fi

# Register the Claude Code hook
# Determine plugin source location
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_SRC=""
if [ -f "${SCRIPT_DIR}/plugin/code-trace.ts" ]; then
PLUGIN_SRC="${SCRIPT_DIR}/plugin/code-trace.ts"
elif [ -f "${SCRIPT_DIR}/../plugin/code-trace.ts" ]; then
PLUGIN_SRC="$(cd "${SCRIPT_DIR}/../plugin" && pwd)/code-trace.ts"
fi

PI_PLUGIN_SRC=""
if [ -f "${SCRIPT_DIR}/plugin/pi-agent/code-trace.ts" ]; then
PI_PLUGIN_SRC="${SCRIPT_DIR}/plugin/pi-agent/code-trace.ts"
elif [ -f "${SCRIPT_DIR}/../plugin/pi-agent/code-trace.ts" ]; then
PI_PLUGIN_SRC="$(cd "${SCRIPT_DIR}/../plugin/pi-agent" && pwd)/code-trace.ts"
fi

# Register Claude Code hook
HOOK_ENTRY='{"type":"command","command":"code-trace"}'

if [ -f "${SETTINGS_FILE}" ]; then
# Check if code-trace hook is already registered
if grep -q "code-trace" "${SETTINGS_FILE}"; then
echo "Hook already registered in ${SETTINGS_FILE}"
else
# Merge the hook into existing settings using python (available on macOS and most Linux)
python3 -c "
import json, sys

Expand All @@ -84,7 +119,6 @@ hook = {'type': 'command', 'command': 'code-trace'}
hooks = settings.setdefault('hooks', {})
stop = hooks.setdefault('Stop', [])

# Find or create the hooks list entry
for entry in stop:
if 'hooks' in entry:
entry['hooks'].append(hook)
Expand Down Expand Up @@ -118,8 +152,63 @@ EOF
echo "Created ${SETTINGS_FILE} with hook"
fi

# Install OpenCode plugin
install_opencode_plugin() {
if [ -z "${PLUGIN_SRC}" ] || [ ! -f "${PLUGIN_SRC}" ]; then
echo ""
echo "Note: Plugin source not found at ${PLUGIN_SRC}. Skipping OpenCode plugin install."
echo "You can manually copy plugin/code-trace.ts to ${OPENCODE_PLUGIN_DIR}/"
return
fi

mkdir -p "${OPENCODE_PLUGIN_DIR}"
cp "${PLUGIN_SRC}" "${OPENCODE_PLUGIN_DIR}/code-trace.ts"
echo "Installed OpenCode plugin to ${OPENCODE_PLUGIN_DIR}/code-trace.ts"
}

# Install Pi Agent extension
install_pi_extension() {
if [ -z "${PI_PLUGIN_SRC}" ] || [ ! -f "${PI_PLUGIN_SRC}" ]; then
echo ""
echo "Note: Pi extension source not found at ${PI_PLUGIN_SRC}. Skipping Pi Agent extension install."
echo "You can manually copy plugin/pi-agent/code-trace.ts to ${PI_EXTENSION_DIR}/"
return
fi

mkdir -p "${PI_EXTENSION_DIR}"
cp "${PI_PLUGIN_SRC}" "${PI_EXTENSION_DIR}/code-trace.ts"
echo "Installed Pi Agent extension to ${PI_EXTENSION_DIR}/code-trace.ts"
}

if [ "${INSTALL_OPENCODE}" = true ]; then
install_opencode_plugin
elif detect_opencode; then
echo ""
echo "OpenCode detected. Install the code-trace plugin?"
echo " ${OPENCODE_PLUGIN_DIR}/code-trace.ts"
echo ""
read -p "Install OpenCode plugin? [y/N] " -r
if [[ "${REPLY}" =~ ^[Yy]$ ]]; then
install_opencode_plugin
fi
fi

if [ "${INSTALL_PI}" = true ]; then
install_pi_extension
elif detect_pi; then
echo ""
echo "Pi Agent detected. Install the code-trace extension?"
echo " ${PI_EXTENSION_DIR}/code-trace.ts"
echo ""
read -p "Install Pi Agent extension? [y/N] " -r
if [[ "${REPLY}" =~ ^[Yy]$ ]]; then
install_pi_extension
fi
fi

echo ""
echo "Done! To enable tracing, add to your project's .claude/settings.local.json:"
echo "Done! To enable tracing, add to your project's .claude/settings.local.json (Claude Code)"
echo "or set environment variables for OpenCode and Pi Agent:"
echo ""
cat << 'EOF'
{
Expand All @@ -130,3 +219,5 @@ cat << 'EOF'
}
}
EOF
echo ""
echo "For OpenCode and Pi Agent extensions, set these environment variables in your shell profile."
Loading