Goal
One conversation = one Maple Agent Session, one turn per user message, with the user prompts, every model call (model, tokens, TTFT), every tool call (name, args, result, failures).
How it works: the Agent SDK emits nothing itself. query() spawns the Claude Code CLI, which has OpenTelemetry built in and exports spans claude_code.interaction (turn), claude_code.llm_request (model call), claude_code.tool (tool call), with phase children claude_code.tool.blocked_on_user / claude_code.tool.execution. All configuration is environment variables for that child process. No instrumentation package, no TracerProvider.
Known gaps (tell the user, don't try to fix): assistant reply text and cost are only on OTLP log events, which Maple's session views don't read, so transcripts have no assistant text and sessions show "unpriced"; no gen_ai.agent.name, so sub-agents get no separate lanes; tool arguments shown only for Bash (command) and Read/Edit/Write (file path).
Step 0: Detect
- Which surface:
- TypeScript:
@anthropic-ai/claude-agent-sdkinpackage.json. Need >= 0.3.283 (bundles Claude Code 2.1.283). Upgrade if older. - Python:
claude-agent-sdkinpyproject.toml/requirements*.txt. Need >= 0.2.160 (bundles 2.1.283). Upgrade if older. - The user wants their own
claudeCLI / IDE / desktop sessions in Maple: go to Step 2c. Checkclaude --version>= 2.1.283. - Check for
pathToClaudeCodeExecutable(TS) /cli_path(Py): a custom CLI binary must also be >= 2.1.283.
- TypeScript:
- Existing OpenTelemetry in the app: keep it. It can't carry the CLI's spans (the CLI exports on its own), but if the app has an active span when
query()runs, both SDKs pass it asTRACEPARENTand the turn nests under it. Do not add a second SDK/exporter for the agent. - Remove any hook-based instrumentor for the Agent SDK (OpenInference
openinference-instrumentation-claude-agent-sdk, Langfuse/LangSmith/Opik wrappers) if the user agrees: it duplicates every model call and its spans don't group by session in Maple. - Find where env is already set for the CLI:
options.env/ClaudeAgentOptions(env=...), Dockerfile, deploy manifests. - Settings files beat
options.env: whensettingSources/setting_sourcesis omitted (all sources) or includesuser/project, anenvblock in~/.claude/settings.jsonor the repo's.claude/settings.jsonoverrides the same keys passed inoptions.env(verified withOTEL_SERVICE_NAME). If those files setOTEL_*/CLAUDE_CODE_*keys, tell the user; for server apps that don't need file settings, suggestsettingSources: [](Pysetting_sources=[]). OmittedsettingSourcesalso loads the developer's personal plugins and MCP servers into the agent and its telemetry. - Find the conversation boundary: how the app calls
query()per user message, and whether it stores a session id (resume,sessionId,session_id,continue,ClaudeSDKClient).
Step 1: Key and region
- US endpoint
https://ingest.maple.dev, EU endpointhttps://ingest.eu.maple.dev. HeaderAuthorization=Bearer <key>. - Key in the user's prompt: use it. No key: use the literal
MAPLE_TEST(ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion. - Private
maple_sk_keys never go in browser code. Ingest keys are write-only. - Follow the repo's existing secret/env convention (e.g.
MAPLE_INGEST_KEYin.env). If there is none, inline the literal key; never ship a lookup that can come outundefined(Bearer undefinedis an opaque 401). - A 401
ingest_unauthorized("Invalid ingest key") with a key you trust usually means the key belongs to the other region (keys are region-bound): try the other endpoint.
Step 2a: TypeScript SDK
npm install @anthropic-ai/claude-agent-sdk@latest zod. Peers: zod ^4, @anthropic-ai/sdk, @modelcontextprotocol/sdk; install them explicitly if the package manager doesn't.
options.env REPLACES the child environment. Always spread process.env, and drop inherited TRACEPARENT/TRACESTATE. Build the env when calling query(), not at import, so values loaded later (dotenv) are included. Create maple-env.ts (adapt service name and environment; with no env convention, replace the key lookup and the throw with the literal key):
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",
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 ${key}`,
OTEL_SERVICE_NAME: "support-agent",
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production",
OTEL_TRACES_EXPORT_INTERVAL: "1000",
OTEL_LOGS_EXPORT_INTERVAL: "1000",
OTEL_LOG_USER_PROMPTS: "1",
OTEL_LOG_TOOL_DETAILS: "1",
OTEL_LOG_TOOL_CONTENT: "1",
}
}
Pass env: mapleEnv() on EVERY query() / startup() / session call in the codebase. If the call already sets env, merge rather than drop it: env: { ...mapleEnv(), ...existing }.
Step 2b: Python SDK
pip install -U claude-agent-sdk (or the repo's tool: uv add, poetry add). Python >= 3.10.
ClaudeAgentOptions.env MERGES over the inherited env, so pass only telemetry vars; remove inherited trace context from os.environ. Build the env when calling query(), not at import, so values loaded later (dotenv) are included. Create maple_env.py (same adaptations):
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",
"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": 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",
"OTEL_LOG_USER_PROMPTS": "1",
"OTEL_LOG_TOOL_DETAILS": "1",
"OTEL_LOG_TOOL_CONTENT": "1",
}
Import maple_env before the first query() / ClaudeSDKClient and pass env=maple_env() (merge with any existing env dict) on every ClaudeAgentOptions.
Alternative for both SDKs: set the same variables in the deployment environment (Dockerfile, k8s manifest) and omit env. Then make sure no TRACEPARENT is set there.
Step 2c: Claude Code CLI (the user's own sessions)
Merge into ~/.claude/settings.json (user settings; create if missing, keep existing keys):
{
"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"
}
}
- NOT the repo's
.claude/settings.json/.claude/settings.local.json: Claude Code >= 2.1.282 ignores the vars there that turn export on, set the endpoint, or capture content (CLAUDE_CODE_ENABLE_TELEMETRY,OTEL_LOG_*, ...). - Existing
OTEL_*keys in user settings, or managed settings /~/.claude/remote-settings.jsonsetting endpoint or headers: stop and ask the user; managed values override the user's, and replacing them would redirect their company's telemetry. - Tell the user to start a new
claudesession. The current session doesn't reload it. - Service name is
claude-code(terminal) orclaude-code-desktop(desktop Code tab). Session grouping needs no setup: oneclaudesession = one Maple session;/clearand--fork-sessionstart new ones.
Step 3: One session per conversation (SDK only)
Maple's session key for Claude Code is the session.id span attribute = the Claude session id. Every query() without resume starts a NEW session, so a chat backend calling query() once per message gets one Maple session per message unless it resumes.
- Store a UUID per conversation. First turn:
sessionId: uuid(TS) /session_id=uuid(Py). Later turns:resume: uuid/resume=uuid.sessionId/session_idmust be a valid UUID and can't be combined withresume. - If the app already captures
session_idfrom the init/result message and passesresume, keep it; that is correct. ClaudeSDKClient(Py) or a TSquery()with an async-iterable prompt keeps one session for all its turns: nothing to do.- Never set
forkSession/fork_sessionon normal turns (new session id). Never setOTEL_METRICS_INCLUDE_SESSION_ID=false(removessession.idfrom spans). resumeneeds the transcript under~/.claude/projects/on the same host. Multi-host / serverless: tell the user about thesessionStoreoption; don't implement it unasked.- Do not add
gen_ai.conversation.idormaple_ai.session.id: you can't attribute the CLI's spans, and Maple readssession.idfor this vendor.
TS pattern:
const firstTurn = !conversation.claudeSessionId
const sessionId = conversation.claudeSessionId ?? randomUUID()
conversation.claudeSessionId = sessionId // persist with the conversation
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
}
Python pattern:
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)):
...
Step 4: Content
OTEL_LOG_USER_PROMPTS=1: prompt in theuser_promptattribute ofclaude_code.interaction→ turn titles + user messages. Without it:<REDACTED>, untitled turns.OTEL_LOG_TOOL_DETAILS=1: Bashfull_command, Read/Edit/Writefile_path→ tool arguments; full error message on failed tools;subagent_type.OTEL_LOG_TOOL_CONTENT=1:tool.outputspan event → tool result (Read, Bash, Edit/Write with DETAILS; MCP/SDK tools, WebFetch, WebSearch on >= 2.1.283).- Ask the user before enabling content in production if the repo shows compliance constraints (PII handling, HIPAA, etc.); content flags send file contents and command output. Offer to leave them off; the session still works (untitled turns, no args/results).
- Do NOT enable
ENABLE_BETA_TRACING_DETAILED/BETA_TRACING_ENDPOINT: they redirect logs+traces and Maple doesn't read what they add. OTEL_LOG_ASSISTANT_RESPONSESonly affects theassistant_responselog event (not shown in session views). It defaults toOTEL_LOG_USER_PROMPTS; set0if prompts are approved and replies are not.
Step 5: Tools, errors, sub-agents
- Nothing to add. Each
claude_code.toolspan is one tool call named bytool_name(Bash,Read,mcp__<server>__<tool>,Agent). - Tool failures: SDK tool handlers must fail by throwing or returning
isError: true(Py"is_error": True), not by returning an ordinary success result that says "error". Maple marks the call failed fromclaude_code.tool.executionsuccess=false(error.type=error_class, result = the error). - Sub-agents (
agentsoption,.claude/agents/) run through theAgenttool; their spans nest under it in one trace. Maple shows no per-sub-agent lane (nogen_ai.agent.name). Don't try to fix it. - Background sub-agents: Claude Code 2.1.283 may run
Agentcalls in the background even when the model doesn't ask for it (seen with SDKagentsin string-promptquery()). Then the turn ends at once, every finished sub-agent starts a new turn whose prompt is a<task-notification>block, onequery()yields severalresultmessages, and SDK MCP tool calls inside those sub-agents can fail with "The tool call was interrupted before a result was received" (Maple counts them as failed tools). If the app usesagentsand expects one answer perquery(), addCLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"to its env (verified: one turn, one trace, parallel sub-agents still overlap). Otherwise consume the iterator to its end, not to the firstresult.
Step 6: Flush
- Keep
OTEL_TRACES_EXPORT_INTERVAL=1000andOTEL_LOGS_EXPORT_INTERVAL=1000. - Consume every
query()loop to itsresultmessage (returning from the loop atresult, as in Step 3, is fine: verified no span loss). Don'tbreak,close(), or abort a query before that; it kills the CLI before its final export. - Scripts / CLIs / one-shot jobs: after the last
query()completes, wait ~5 s before the process exits (await new Promise((r) => setTimeout(r, 5000))/await asyncio.sleep(5)). - Serverless: finish the loop before returning the response.
Step 7: Verify
Run one conversation: two turns (the second resuming the first), one of which calls a tool. If the app has no scriptable entry point (server, UI only), write a small driver for this run: one conversation, 2+ turns, a tool call, the Step 6 wait before exit. Pre-approve the tool in the driver (allowedTools: ["Bash"] / allowed_tools=["Bash"]); otherwise the call waits in blocked_on_user and never runs. Use MAPLE_TEST only if no real key; with the sentinel nothing is stored, so ask the user to check in Maple once they have a key. To see export errors: add CLAUDE_CODE_OTEL_DIAG_STDERR: "1" to the env and a stderr callback (TS options.stderr, Py ClaudeAgentOptions(stderr=...)); no [3P telemetry] errors should appear. For the CLI: claude --debug-file /tmp/claude.log, then grep 3P telemetry.
No [3P telemetry] errors is not proof of spans (no spans is silent too). Confirm in Maple, with the Maple MCP (list_agent_sessions with search=<session id> returns one row), or by pointing OTEL_EXPORTER_OTLP_ENDPOINT at a local OTLP listener once and checking claude_code.interaction / claude_code.llm_request spans arrive with session.id.
Then in Maple → Agent Sessions (https://app.maple.dev/agent-sessions, EU app.eu.maple.dev) check:
- Exactly one session for the conversation (not one per message), framework "Claude Agent SDK", service =
OTEL_SERVICE_NAME. - A second conversation run in the same process gets a different session.
- One turn per user message, titled with the prompt.
- LLM calls > 0 with a model name and input/output/cache tokens; TTFT present.
- Tool calls listed by real name (
mcp__<server>__<tool>,Bash, ...), args for Bash/file tools, results whenOTEL_LOG_TOOL_CONTENT=1. - A tool that threw is marked failed; successful tools are not.
- Sub-agent model/tool calls appear under the
Agenttool call in the same trace, and no turn is titled<task-notification>(if one is, see background sub-agents in Step 5). - Expected and not bugs: cost "unpriced"; no assistant text; no sub-agent lanes.
If spans exist but the turn nests under an unrelated trace, an inherited TRACEPARENT survived: fix the env stripping.
Reference notes (for explaining results to the user)
- Span map:
claude_code.interaction= a turn titled with the prompt;claude_code.llm_request= model call (model,input_tokens,output_tokens,cache_read_tokens,cache_creation_tokens,ttft_ms, stop reason, failure);claude_code.tool= tool call;claude_code.tool.blocked_on_user/claude_code.tool.execution= phases shown in the trace, not counted as calls. - Anthropic's
input_tokensexcludes both cache buckets; Maple counts it that way (total input = sum of the three). The CLI always streams and still records usage. - Model id is shown as Claude Code sent it (e.g.
anthropic/claude-haiku-4.5through OpenRouter). - A failed model request (
success=falseonclaude_code.llm_request, withstatus_codeanderror) counts as a failed LLM call. WithoutOTEL_LOG_TOOL_DETAILS=1a failed tool's error is only theerror_class(McpToolCallErrorfor SDK tools). - A call rejected by the user or
canUseTool:blocked_on_userspan withdecision=rejectand no execution; Maple shows a call without a result, not a failure. - An active app span is passed to the CLI as
TRACEPARENTby both SDKs (the turn nests under the request that triggered it; that's desired). Interactiveclaudeignores inboundTRACEPARENT; only SDK andclaude -pruns read it. - Content truncation: 60 KB per attribute (
CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH),[TRUNCATED ...]marker. - Any Claude Code process signed in with a Claude account, including SDK child processes, carries
user.email,user.account_uuidandorganization.idon every span/event. To drop or mask attributes, route through an OpenTelemetry Collector with aredactionorattributesprocessor. - Content never in session views: assistant replies (
assistant_responselog event), the system prompt, arguments of non-Bash/non-file tools (MCP and SDK tool args are only in thetool_resultlog event). - Cost: only on the
claude_code.api_requestlog event (cost_usd, withsession.id) and theclaude_code.cost.usagemetric; both are Claude Code's client-side estimate at list price unless managed settings setmodelPricing. In SDK apps, the result message'stotal_cost_usdis the same estimate perquery(). With logs on, Logs has oneclaude_code.user_prompt, oneclaude_code.api_requestper model call and oneclaude_code.tool_resultper tool run. /statusinclaudelists telemetry variables it ignored (repo settings). 401s or data going elsewhere: managed settings or~/.claude/remote-settings.jsonset endpoint/headers and win; the user must ask whoever manages Claude Code.- For
claude -pin CI, set the same two export intervals.
Do not
- Do not omit
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1: zero spans, while metrics/logs still flow and hide it. - Do not leave
OTEL_EXPORTER_OTLP_PROTOCOLunset orgrpc: Claude Code has no default; Maple ingest is OTLP/HTTP.