Trace Claude Agent SDK agents and Claude Code sessions with OpenTelemetry
Turn on Claude Code's built-in OpenTelemetry so each Agent SDK conversation or Claude Code session shows up in Maple as one Agent Session.
The Claude Agent SDK and Claude Code export OpenTelemetry spans for each turn, model request and tool call once you set a few environment variables. There is nothing to install.
Spans only exist with CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1, and in the SDK every query() starts a new session unless you resume the conversation’s session id.
Tested with @anthropic-ai/claude-agent-sdk 0.3.283 (TypeScript), claude-agent-sdk 0.2.160 (Python) and Claude Code 2.1.283.
Quick setup with a coding agent
Copy this prompt into a coding agent that can run shell commands, such as Claude Code, Codex or Cursor. It installs the maple-agent-tracing-claude-agent-sdk skill and follows it.
Set up Maple agent tracing for the Claude Agent SDK in this project.
Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-agent-tracing-claude-agent-sdk -y`, then follow it.
My Maple ingest key is maple_pk_... and my organization is in the US region.
Your ingest key is in Settings → Ingestion. If your organization is in the EU region, change US to EU in the prompt.
Pass the telemetry variables to the CLI
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf is required, because Claude Code has no default protocol. EU organizations use https://ingest.eu.maple.dev.
The snippets below drop any inherited TRACEPARENT, which Claude Code’s Bash tool and most CI systems set. Otherwise your agent’s turns nest inside that outer trace.
Never set an exporter to console in an SDK app. It breaks the SDK’s message stream.
npm install @anthropic-ai/claude-agent-sdk zodpnpm add @anthropic-ai/claude-agent-sdk zodbun add @anthropic-ai/claude-agent-sdk zodIn TypeScript, options.env replaces the child’s environment, so spread process.env to keep PATH and ANTHROPIC_API_KEY:
// maple-env.ts: built per query() so values loaded later (dotenv) are included
let warnedNoKey = false
export function mapleEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = { ...process.env }
delete env.TRACEPARENT
delete env.TRACESTATE
const key = process.env.MAPLE_INGEST_KEY
if (!key) {
// A missing key turns telemetry off; the agent still runs.
if (!warnedNoKey) console.warn("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
warnedNoKey = true
return env
}
return {
...env,
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1", // spans; without it there are none
OTEL_TRACES_EXPORTER: "otlp",
OTEL_LOGS_EXPORTER: "otlp", // optional: cost and replies, under Logs
OTEL_METRICS_EXPORTER: "otlp", // optional: token and cost counters
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
OTEL_EXPORTER_OTLP_ENDPOINT: "https://ingest.maple.dev",
OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer ${key}`,
OTEL_SERVICE_NAME: "support-agent",
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production",
OTEL_TRACES_EXPORT_INTERVAL: "1000",
OTEL_LOGS_EXPORT_INTERVAL: "1000",
// Content, off by default. See "Choose what content to record" below.
OTEL_LOG_USER_PROMPTS: "1",
OTEL_LOG_TOOL_DETAILS: "1",
OTEL_LOG_TOOL_CONTENT: "1",
}
} pip install claude-agent-sdkuv add claude-agent-sdkIn Python, ClaudeAgentOptions.env is merged over the inherited environment, so pass only the telemetry variables and remove TRACEPARENT from os.environ:
# maple_env.py
import logging
import os
os.environ.pop("TRACEPARENT", None)
os.environ.pop("TRACESTATE", None)
_warned_no_key = False
def maple_env() -> dict[str, str]:
"""Telemetry env for the Claude Code CLI, built per query()."""
global _warned_no_key
key = os.environ.get("MAPLE_INGEST_KEY")
if not key:
# A missing key turns telemetry off; the agent still runs.
if not _warned_no_key:
logging.getLogger(__name__).warning("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
_warned_no_key = True
return {}
return {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1", # spans; without it there are none
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp", # optional: cost and replies, under Logs
"OTEL_METRICS_EXPORTER": "otlp", # optional: token and cost counters
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://ingest.maple.dev",
"OTEL_EXPORTER_OTLP_HEADERS": f"Authorization=Bearer {key}",
"OTEL_SERVICE_NAME": "support-agent",
"OTEL_RESOURCE_ATTRIBUTES": "deployment.environment.name=production",
"OTEL_TRACES_EXPORT_INTERVAL": "1000",
"OTEL_LOGS_EXPORT_INTERVAL": "1000",
# Content, off by default. See "Choose what content to record" below.
"OTEL_LOG_USER_PROMPTS": "1",
"OTEL_LOG_TOOL_DETAILS": "1",
"OTEL_LOG_TOOL_CONTENT": "1",
} Pass the env on every query() call, as in the next section. Or set the same variables in your Dockerfile or deployment manifest and skip env, as long as no TRACEPARENT is set there.
An env block in ~/.claude/settings.json or the project’s .claude/settings.json overrides options.env. Server apps can pass settingSources: [] (Python setting_sources=[]) to skip settings files.
Claude Code in your terminal, IDE or desktop app
Put the variables under env in ~/.claude/settings.json, then start a new claude session:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://ingest.maple.dev",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer YOUR_INGEST_KEY",
"OTEL_LOG_USER_PROMPTS": "1",
"OTEL_LOG_TOOL_DETAILS": "1",
"OTEL_LOG_TOOL_CONTENT": "1"
}
}
A repository’s .claude/settings.json can’t set these variables. Use your user settings, your shell or managed settings. Terminal sessions report the service claude-code.
Resume the session on every turn
A chat backend that calls query() once per message without resuming gets one Maple session per message, and the agent forgets the previous message.
Store a UUID with each conversation. Pass it as sessionId on the first turn and as resume on every turn after:
import { randomUUID } from "node:crypto"
import { query } from "@anthropic-ai/claude-agent-sdk"
import { mapleEnv } from "./maple-env"
type Conversation = { claudeSessionId?: string }
export async function reply(conversation: Conversation, text: string) {
const firstTurn = !conversation.claudeSessionId
const sessionId = conversation.claudeSessionId ?? randomUUID()
conversation.claudeSessionId = sessionId
for await (const message of query({
prompt: text,
options: { env: mapleEnv(), ...(firstTurn ? { sessionId } : { resume: sessionId }) },
})) {
if (message.type === "result") return message.subtype === "success" ? message.result : undefined
}
} import uuid
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from maple_env import maple_env
async def reply(conversation: dict, text: str) -> str | None:
first_turn = "claude_session_id" not in conversation
session_id = conversation.setdefault("claude_session_id", str(uuid.uuid4()))
session = {"session_id": session_id} if first_turn else {"resume": session_id}
async for message in query(prompt=text, options=ClaudeAgentOptions(env=maple_env(), **session)):
if isinstance(message, ResultMessage):
return message.result
return None resume reads the earlier turns from ~/.claude/projects/ on the same machine. If messages can land on different hosts, use the SDK’s sessionStore option. A Python ClaudeSDKClient, or a TypeScript query() fed an async iterable, keeps one session for all its turns and needs none of this.
Choose what content to record
Claude Code redacts content by default. OTEL_LOG_USER_PROMPTS=1 records prompts, which title each turn. OTEL_LOG_TOOL_DETAILS=1 records Bash commands, file paths and tool error messages. OTEL_LOG_TOOL_CONTENT=1 records tool results, including any secrets in files Claude reads or in command output. Turn on only what your Maple organization is allowed to store.
Let each turn finish exporting
Let every query() loop reach its result message. Breaking out early, calling close() or aborting kills the CLI before it exports the turn. In a script, keep the process alive about 5 seconds after the last query(). On serverless platforms, finish the loop before returning the response.
Check that it works
Run a two-turn conversation through reply() with at least one tool call, then open Agent Sessions. You should see one session with the framework Claude Agent SDK, one turn per message titled with the prompt, model calls with their tokens, and the tool calls by name.
The transcript has no assistant replies and cost shows as unpriced. Both are on log events under Logs when the logs exporter is on.
Troubleshooting
- Metrics and logs arrive, but no sessions. Set
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1andOTEL_TRACES_EXPORTER=otlp. - Nothing arrives at all.
OTEL_EXPORTER_OTLP_PROTOCOLis unset orgrpc. Sethttp/protobuf.CLAUDE_CODE_OTEL_DIAG_STDERR=1prints export errors to the SDK’sstderrcallback. - Every message is its own session. Resume the conversation’s session id as shown above, and don’t set
forkSession. - Your agent’s turns appear inside another trace. The process inherited a
TRACEPARENT. Drop it andTRACESTATEfrom the environment. - The CLI ignores your values. They are in a repository’s
.claude/settings.json, or a settings file overridesoptions.env. Use~/.claude/settings.json, or passsettingSources: []. - Extra turns titled
<task-notification>. Claude Code ran sub-agents in the background. AddCLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"to the env.
Related
- Agent Sessions overview: what Maple builds from these spans.
- Provider SDKs: tracing direct calls with the Anthropic SDK instead of the Agent SDK.
- Claude Code Monitoring reference: every variable, span attribute and event.