Skip to content
XLinkedIn
Sign Up →
Ask AI

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.

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.

Python

Terminal window
uv add "kelet[claude-agent-sdk]"
# or: pip install "kelet[claude-agent-sdk]"

TypeScript

Terminal window
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-logs

Pythonkelet.configure() is the only line you need; the wrap is automatic:

import kelet
from 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 before from claude_agent_sdk import query for the wrap to take effect on the module-level query symbol. ClaudeSDKClient users are unaffected — the class is patched on its module, so any post-configure import sees the wrapped version.

TypeScriptconfigure() 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' })) { /* ... */ }

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):

Terminal window
node --import kelet/claude-agent-sdk/register app.js
npx tsx --import kelet/claude-agent-sdk/register app.ts

Drop-in shim (Bun, or any runtime):

// Before
import { query, ClaudeSDKClient } from '@anthropic-ai/claude-agent-sdk';
// After
import { query, ClaudeSDKClient } from 'kelet/claude-agent-sdk/shim';

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.env values (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 before configure() or pass options.env to ClaudeAgentOptions.

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,
});

For each claude subprocess invocation, Kelet receives:

  • claude_code.interaction traces — the canonical session root span.
  • All Claude Code OTLP logs and metrics under the com.anthropic.claude_code scope (token counts, tool calls, model metadata).
  • One kelet.reasoning log record per ThinkingBlock yielded by query() / receive_messages / receive_response. Attributes: reasoning.text, reasoning.signature, reasoning.message_id, session.id.
  • One claude_code.sdk_query wrapper span (scope kelet.claude_agent_sdk) per query() / ClaudeSDKClient invocation — 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 per query(); 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 of query() 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 kelet
from 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.

The Kelet SDK injects OTEL_RESOURCE_ATTRIBUTES into the spawned claude subprocess’s env at spawn time:

Resource attributeSourceBecomes
gen_ai.conversation.idagentic_session(session_id=…)runs.session_id
enduser.idagentic_session(user_id=…)runs.metadata["enduser.id"]
gen_ai.agent.namekelet.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.

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).

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().

  • Mid-stream kelet.agent() change. OTEL_RESOURCE_ATTRIBUTES is immutable for a spawned subprocess. Switching agents while a CC stream is still iterating does NOT relabel that subprocess; new query() 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.tool spans for them carry tool-level data only.
  • No traffic to /api/logs or /api/metrics: most often the seven CC OTLP env vars aren’t reaching the claude subprocess. Verify with env | grep CLAUDE_CODE_ENABLE (or console.log(process.env.CLAUDE_CODE_ENABLE_TELEMETRY)). If empty, check that kelet.configure() runs before any CAS imports.
  • Bun + destructured imports: Bun ignores --import loader hooks; use the kelet/claude-agent-sdk/shim shim.
  • Local dev: point KELET_API_URL at http://localhost:4318 and run otel-desktop-viewer to see OTLP flow live.