Claude Agent SDK
If your agents use @anthropic-ai/claude-agent-sdk (or the TypeScript @anthropic-ai/claude-agent-sdk), Kelet auto-routes the bundled claude CLI’s OTLP traces, logs, and metrics — plus captures the redacted thinking/reasoning blocks Claude Code doesn’t ship in its native telemetry. No env vars, no per-call config.
How it works
Section titled “How it works”The Claude Agent SDK spawns a claude subprocess for every query() / ClaudeSDKClient call. Claude Code emits OpenTelemetry traces, logs, and metrics when seven env vars are set: CLAUDE_CODE_ENABLE_TELEMETRY, OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTER, OTEL_TRACES_EXPORTER, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS.
kelet.configure() injects those seven keys automatically (Python: into ClaudeAgentOptions.env; TypeScript: into process.env set-if-missing). On top of that, Kelet wraps query and ClaudeSDKClient to emit kelet.reasoning log records for each ThinkingBlock Claude Code redacts in its own OTLP.
Install
Section titled “Install”Python
uv add "kelet[claude-agent-sdk]"# or: pip install "kelet[claude-agent-sdk]"TypeScript
npm install kelet @anthropic-ai/claude-agent-sdk# Optional — enables reasoning capture via OTLP logs:npm install @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http @opentelemetry/api-logsQuick start (no code changes)
Section titled “Quick start (no code changes)”Python — kelet.configure() is the only line you need; the wrap is automatic:
import keletfrom claude_agent_sdk import query
kelet.configure(api_key="...", project="my-agent")
async for message in query(prompt="hello"): ...Import order matters.
kelet.configure()must run beforefrom claude_agent_sdk import queryfor the wrap to take effect on the module-levelquerysymbol.ClaudeSDKClientusers are unaffected — the class is patched on its module, so any post-configure import sees the wrapped version.
TypeScript — configure() populates process.env, and the spawned subprocess inherits it:
import { configure } from 'kelet';
configure({ apiKey: process.env.KELET_API_KEY!, project: 'my-agent',});
import { query } from '@anthropic-ai/claude-agent-sdk';for await (const msg of query({ prompt: 'hello' })) { /* ... */ }TypeScript loader / shim
Section titled “TypeScript loader / shim”ESM module bindings are frozen — you cannot mutate them after import. If you also pass a custom options.env (which would otherwise replace process.env in the spawned subprocess), use one of:
Loader (Node.js / tsx):
node --import kelet/claude-agent-sdk/register app.jsnpx tsx --import kelet/claude-agent-sdk/register app.tsDrop-in shim (Bun, or any runtime):
// Beforeimport { query, ClaudeSDKClient } from '@anthropic-ai/claude-agent-sdk';
// Afterimport { query, ClaudeSDKClient } from 'kelet/claude-agent-sdk/shim';Conflict handling
Section titled “Conflict handling”OTLP env vars are single-valued per signal — the SDK can only point at one backend at a time.
- Python: Kelet overrides any conflicting env vars (the override is scoped to the spawned subprocess, never touches
os.environ). A one-shot WARNING names the keys it overrode so you know. - TypeScript: Kelet does not override existing
process.envvalues (the mutation is process-wide, so we’d clobber your other OTel pipelines). A one-shot WARNING names the deferred keys. To force routing to Kelet, unset the conflicting vars beforeconfigure()or passoptions.envtoClaudeAgentOptions.
Opt out
Section titled “Opt out”Pass inject_cc_telemetry=False (Python) / injectCcTelemetry: false (TypeScript) to disable env injection entirely. If you opt out without setting CLAUDE_CODE_ENABLE_TELEMETRY=1 yourself, the SDK emits a one-shot informational warning so you don’t lose CC telemetry by accident.
kelet.configure(api_key="...", project="my-agent", inject_cc_telemetry=False)configure({ apiKey: process.env.KELET_API_KEY!, project: 'my-agent', injectCcTelemetry: false,});What gets captured
Section titled “What gets captured”For each claude subprocess invocation, Kelet receives:
claude_code.interactiontraces — the canonical session root span.- All Claude Code OTLP logs and metrics under the
com.anthropic.claude_codescope (token counts, tool calls, model metadata). - One
kelet.reasoninglog record per ThinkingBlock yielded byquery()/receive_messages/receive_response. Attributes:reasoning.text,reasoning.signature,reasoning.message_id,session.id. - One
claude_code.sdk_querywrapper span (scopekelet.claude_agent_sdk) perquery()/ClaudeSDKClientinvocation — a temporal envelope used by Kelet’s per-session reconciler.
Session grouping (multi-query() workflows)
Section titled “Session grouping (multi-query() workflows)”Kelet’s session id and Claude Code’s session id serve different purposes:
- CC
session.id(span attribute) — state lifetime. CC mints a fresh UUID perquery(); this id is what CC uses for context windows and conversation resumes. Don’t override unless you mean to. - Kelet
agentic_session(session_id="S")— observation grouping. Wraps any number ofquery()calls into one row in Kelet’s sessions view.
Wrap multi-turn workflows in agentic_session(...) and Kelet aggregates them under one session id even though CC emits N distinct session.id UUIDs:
import keletfrom claude_agent_sdk import query
kelet.configure(api_key="...", project="prod")
with kelet.agentic_session(session_id="planner-2026-05-22", user_id="u-1"): async for msg in query("Plan the migration"): ... async for msg in query("Now write the migration scripts"): ...# Both queries surface as one Kelet session "planner-2026-05-22".import { agenticSession, configure } from 'kelet';import * as sdk from '@anthropic-ai/claude-agent-sdk';
configure({ apiKey: '...', project: 'prod' });
await agenticSession({ sessionId: 'planner-2026-05-22', userId: 'u-1' }, async () => { for await (const m of sdk.query({ prompt: 'Plan the migration' })) { /* ... */ } for await (const m of sdk.query({ prompt: 'Now write the scripts' })) { /* ... */ }});Without agentic_session, each query() becomes its own row — that’s CC’s natural behavior.
How it works
Section titled “How it works”The Kelet SDK injects OTEL_RESOURCE_ATTRIBUTES into the spawned claude subprocess’s env at spawn time:
| Resource attribute | Source | Becomes |
|---|---|---|
gen_ai.conversation.id | agentic_session(session_id=…) | runs.session_id |
enduser.id | agentic_session(user_id=…) | runs.metadata["enduser.id"] |
gen_ai.agent.name | kelet.agent(name=…) | runs.agent_name (outer) |
metadata.<k> | agentic_session(**kwargs) | runs.metadata["metadata.<k>"] |
CC’s own SDK then stamps these on the Resource of every span/log it emits, and Kelet’s workflow extractor reads them. Because OTEL_RESOURCE_ATTRIBUTES is set once at spawn and immutable, the propagation survives the entire CC subprocess lifetime — including hours-long agent loops where the wrapping query() returned to the host long ago.
Identity: enduser.id vs CC’s user.id
Section titled “Identity: enduser.id vs CC’s user.id”CC always stamps its own user.id on every span — an anonymous installation identifier. Kelet maps agentic_session(user_id=…) to enduser.id (the OTel GenAI semconv slot for the human end-user) instead, so the two coexist without collision. CC’s installation-scoped user.id is intentionally NOT promoted to runs.metadata (it’s fleet metadata, not customer-attribution data).
Trace-graph linkage
Section titled “Trace-graph linkage”Claude Code’s monitoring docs define claude_code.interaction as the root span CC emits per query()/turn. When the claude subprocess inherits a TRACEPARENT/TRACESTATE env pair from its caller (the W3C trace-context propagator stamps these onto the active span at spawn time), CC’s instrumentation attaches claude_code.interaction as a child of that caller span. Kelet’s host-side claude_code.sdk_query wrapper span is the active context at spawn time, so it ends up as the parent — no host-side action required beyond calling configure().
Known limitations
Section titled “Known limitations”- Mid-stream
kelet.agent()change.OTEL_RESOURCE_ATTRIBUTESis immutable for a spawned subprocess. Switching agents while a CC stream is still iterating does NOT relabel that subprocess; newquery()calls inside the new block start fresh subprocesses with the updated identity. - Bash / MCP / hook subprocesses don’t inherit OTEL_*. Per CC monitoring-usage docs, Claude Code does not pass
OTEL_*environment variables to the subprocesses it spawns. Bash commands and MCP server processes started inside a CC session are NOT instrumented;claude_code.toolspans for them carry tool-level data only.
Troubleshooting
Section titled “Troubleshooting”- No traffic to
/api/logsor/api/metrics: most often the seven CC OTLP env vars aren’t reaching theclaudesubprocess. Verify withenv | grep CLAUDE_CODE_ENABLE(orconsole.log(process.env.CLAUDE_CODE_ENABLE_TELEMETRY)). If empty, check thatkelet.configure()runs before any CAS imports. - Bun + destructured imports: Bun ignores
--importloader hooks; use thekelet/claude-agent-sdk/shimshim. - Local dev: point
KELET_API_URLathttp://localhost:4318and runotel-desktop-viewerto see OTLP flow live.