Capture Claude Code telemetry#
Maida can receive Claude Code’s OpenTelemetry events and beta traces without patching agent code. The receiver binds to loopback by default and writes only to local Maida storage.
Core capture-to-gate workflow#
The OTel receiver is the default capture path. The passive command hook is a fallback when exporter access or tool detail is unavailable. Both transports write the same source-capture contract and converge at one framework-agnostic normalizer:
Claude Code ── OTLP logs/traces ─┐
├─ capture segment ─ import ─ Maida trace ─ gate
Claude Code ── command hooks ────┘ └─ scenario run
After import, baseline creation, assertion policy, structural diff, report
formatting, and trace viewing are transport-independent. maida scenario run
owns the reproducible headless orchestration and uses the OTel path internally;
it evaluates through the same stored-run service as maida diff --capture.
Choose one entry point:
maida capture claude-codefor normal local and CI telemetry capture.maida capture claude-hookfor the observer-only lifecycle fallback.maida scenario runfor pinned, isolated prompt fixtures and automatic capture/import/evaluation.
Start the receiver:
maida capture claude-code
In a second terminal, configure Claude Code to export logs and traces over OTLP HTTP/protobuf:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
export OTEL_LOG_USER_PROMPTS=0
export OTEL_LOG_ASSISTANT_RESPONSES=0
export OTEL_LOG_TOOL_CONTENT=0
claude -p "Inspect the project and report its test command."
The receiver exposes GET /healthz, POST /v1/logs, and
POST /v1/traces. It requires protobuf requests from the claude-code
service, validates a complete export batch before writing, and rejects
malformed known signals. Unknown newer signals are retained so an additive
Claude Code update does not silently discard source evidence.
Captures are stored under:
~/.maida/captures/claude-code/<hashed-session-id>/<segment>/
├── manifest.json
├── logs.jsonl
└── spans.jsonl
Session directory names never contain the raw Claude session ID. Existing Maida redaction and field-size limits apply recursively before persistence; numeric duration and token counters remain available for regression policy. Exact exporter retries are deduplicated, while the receiver rejects a retry that reuses a source identity with different content.
Import a captured session#
Stop the receiver after Claude exits, then import the session into the normal Maida run store:
maida import claude-code --session-id "$CLAUDE_SESSION_ID"
The command selects the latest immutable segment by default. Pass
--segment 0001 to select one explicitly or --json for a machine-readable
summary. Selection notices go to stderr, leaving JSON stdout clean.
Import creates a synthetic session root with interaction spans beneath it,
maps Claude model and tool activity onto Maida’s existing LLM_CALL and
TOOL_CALL semantics, and keeps source IDs, commands, file paths, subagent
topology, Claude version, and mapping version in sanitized maida.meta.
Trace spans supply topology when present; logs enrich those spans and provide
a complete fallback when trace export is unavailable. Unknown source records
remain ordinary structural spans rather than creating Claude-specific Maida
event types.
The normalized trace ID and span IDs are deterministic. Re-importing identical
source data is a no-op. If the source bytes change after import, Maida refuses
to overwrite the installed run. The trace schema remains at its current
version; normal maida baseline, maida diff, and maida view commands work
without a Claude-specific downstream path. The legacy maida assert interface
can still inspect one imported completed trace during migration.
Gate a capture locally#
Use capture-backed diff mode to import the latest segment and run the same behavioral gate that produces Maida’s PR comment:
maida diff --capture "$CLAUDE_SESSION_ID" \
--baseline .maida/baselines/my_agent.json \
--policy .maida/policy.yaml \
--format markdown
The command exits 0 when policy checks pass, 1 for a behavioral regression,
2 for invalid input or capture data, and 10 for an import/runtime failure.
The selected segment and idempotent import result are reported on stderr;
stdout contains only the requested text, JSON, or Markdown assertion report.
Run isolated scenarios#
maida scenario run turns checked-in Claude Code prompts into reproducible
local or CI gates. It defaults to .maida/scenarios.yaml:
version: 1
claude:
executable: claude
version: 2.1.220
model: claude-haiku-4-5-20251001
settings: .claude/settings.json
mcp_config: .mcp.json
timeout_seconds: 60
max_budget_usd: 0.10
max_turns: 2
allowed_tools: [Read, Write]
scenarios:
- id: edit-config
fixture:
root: tests/fixtures/scenarios/edit-config
files:
- input.txt
- .claude/settings.json
- .mcp.json
prompt: Update input.txt exactly as requested by its first line.
baseline: .maida/baselines/edit-config.json
policy: .maida/policy.yaml
All paths are relative to the project root. Fixture entries, baselines, and
policies must be Git-tracked regular files. settings and mcp_config are
paths inside every fixture and must appear in its files list. Settings must
not install hooks, override the runner environment, or enable permission
bypass. The MCP file must contain an mcpServers object; use an empty object
when a scenario needs no MCP servers:
{"mcpServers": {}}
Before execution, Maida verifies the exact Claude Code semantic version, full model ID, config contents, unique scenario IDs, baselines, policies, and tracked paths. For each scenario it:
Copies only the declared fixture files into a fresh temporary workspace.
Starts an ephemeral receiver on
127.0.0.1and disables prompt/tool-content telemetry logging.Invokes
claude -pthrough an argv list with project-only settings, strict MCP config,dontAsk, an explicit tool allowlist, no session persistence, native budget/turn caps, and no dangerous permission bypass.Terminates the whole process group on timeout, imports the capture, and evaluates it through the same assertion, structural diff, and report formatters as
maida diff --capture.
Run all scenarios or one selected ID:
maida scenario run
maida scenario run --scenario edit-config --format json
The report never retains raw Claude stdout or stderr. A scenario status is
pass, assertion_failed, or agent_failed. Invalid manifest/environment
preflight exits 2; runtime agent or capture failure takes precedence and
exits 10; otherwise an assertion failure exits 1, and all-pass exits 0.
Claude’s official headless mode and CLI reference document the underlying non-interactive and safety flags.
Use --host and --port to change the bind address. Keep the receiver on a
trusted interface: it intentionally has no authentication because its default
use is a local process or an isolated CI job.
Command-hook fallback#
When OTLP is unavailable or does not include the tool input you need, install
the passive command hook below in the project’s .claude/settings.json. The
example is ten nonblank lines and observes every supported lifecycle event:
{
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}],
"PreToolUse": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}],
"PostToolUse": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}],
"PostToolUseFailure": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}],
"PermissionDenied": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}],
"SessionEnd": [{"hooks": [{"type": "command", "command": "maida capture claude-hook"}]}]
}
}
Claude sends one JSON object on stdin for each command-hook invocation. The handler writes no stdout and returns no allow, deny, retry, or context fields, so it never changes Claude’s tool or permission behavior. Successful capture exits 0. Capture errors use exit 10 rather than Claude’s blocking exit code 2.
Hook records use the same hashed-session capture directory and normalizer as
OTLP. PreToolUse and its terminal event are paired by tool_use_id; successful,
failed, denied, preless, and incomplete calls all remain importable as ordinary
Maida TOOL_CALL spans. The raw session ID and transcript path are not stored.
Tool inputs and results pass through Maida’s recursive redaction and field-size
limits before the atomic append.
startup, resume, fork, and clear start a new immutable capture segment.
The compact SessionStart source stays in the active segment. SessionEnd
closes and imports its segment automatically. If Claude exits abruptly before
that event, recover the active segment explicitly:
maida import claude-code --session-id "$CLAUDE_SESSION_ID"
See Claude Code’s official monitoring reference for exporter variables and the beta trace hierarchy.
See Claude Code’s official hooks reference for command-hook stdin, lifecycle, matcher, and exit-code behavior.