Goal: every conversation = one Maple Agent Session. Each user message = one trace rooted at an invoke_agent span, with a chat span per model call (model, tokens, transcript) and an execute_tool span per tool call (args, result, failures).
Mechanism: you write the spans. Maple classifies a span only by gen_ai.operation.name, groups a trace by gen_ai.conversation.id, and reads content only from span attributes. Hand-written spans show as framework "Unidentified" (vendor unknown:genai); that is expected.
Step 0: Detect
- Language and entry points (web server, workers, scripts, serverless handlers).
- Is a supported framework the real agent runtime? (
@mastra/core,ai,agents/@cloudflare/ai-chat,genkit/@genkit-ai/*,@openai/agents/openai-agents,langchain/langgraph,pydantic-ai,crewai,google-adk,llama-index,strands-agents,smolagents,agno,dspy,haystack-ai,agent-framework, Spring AI,litellm, Claude Agent SDK). If yes, stop and usemaple-agent-tracing-<framework>instead; use this skill only for the parts that framework doesn't cover, or for themaple_ai.session.idwrapper (Step 4). - Existing OTel setup. Search for
TracerProvider,NodeTracerProvider,NodeSDK,registerOTel,set_tracer_provider,opentelemetry-instrument,logfire.configure,sentry_sdk.init/Sentry.init,otel.SetTracerProvider. Exists → add Maple's exporter/processor to it; never create a second provider. - Existing GenAI auto-instrumentation on the model client (
@opentelemetry/instrumentation-openai,opentelemetry-instrumentation-openai-v2, OpenLLMetryTraceloop.init, OpenInferenceOpenAIInstrumentor,logfire.instrument_openai). Pick one source ofchatspans: either keep that instrumentation (then seemaple-agent-tracing-provider-sdks) or remove it and writechatspans here. Both = every model call twice. - Find in the code: the agent loop (where one user message is handled), every model call site, every tool dispatch, sub-agent calls, and where the conversation/chat/thread id lives in the request.
Step 1: Key and region
- US:
https://ingest.maple.dev. EU:https://ingest.eu.maple.dev. - Header:
Authorization=Bearer <key>. - Key given in the 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. - Never put a private
maple_sk_key in browser code. - Follow the repo's secret/env convention if it has one. Otherwise inline is acceptable: ingest keys are write-only.
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
The exporters append /v1/traces.
- The SDK exporters read these env vars when the exporter is constructed. If the app loads
.env(dotenv,load_dotenv(),--env-file), load it at the top of the tracing module, before the provider is built; otherwise the exporter silently targetslocalhost:4318with no key. - If the header is built from your own env var and it is unset, log one warning (
MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled) and skip the Maple exporter so the app runs normally. Never throw or exit over the key, and never let it becomeBearer undefined(opaque 401) or a bareKeyErroron import. Or inline the key when the repo has no env convention. - 401
ingest_unauthorized/ "Invalid ingest key" with a key you trust: keys are region-bound, so it usually belongs to the other region. Try the other endpoint.
Step 2: Install + init
Read the reference for the language and adapt it:
- TypeScript/Node:
references/typescript.md - Python:
references/python.md - Other languages: the language's OTel SDK with an OTLP/HTTP exporter, following the steps below.
Rules:
- Init module is imported first in every entry point. Set a real
service.nameanddeployment.environment.name. - Name the tracer after the app (e.g.
support-agent). Neveropenrouter,langsmith,litellm,haystack,ai,gen_ai: Maple fingerprints frameworks by scope name and would treat your spans as that framework's. - Keep the project's loop structure; add spans around its existing calls. Use the reference's complete loop only when there is no loop yet.
Step 3: The three spans (exact keys)
invoke_agent (kind INTERNAL, name invoke_agent <agent>), around one agent run; for a user turn it is the trace root:
gen_ai.operation.name=invoke_agent,gen_ai.agent.name,gen_ai.conversation.id(Step 4)- optional:
gen_ai.input.messages(the user message),gen_ai.output.messages(final answer)
chat (kind CLIENT, name chat <model>), around each model call:
- at start:
gen_ai.operation.name=chat(orgenerate_content/text_completion),gen_ai.provider.name,gen_ai.request.model,gen_ai.system_instructions,gen_ai.input.messages - at end:
gen_ai.response.id,gen_ai.response.model,gen_ai.response.finish_reasons(string array),gen_ai.output.messages, usage (Step 7),gen_ai.response.time_to_first_chunk(double, SECONDS, streamed calls)
execute_tool (kind INTERNAL, name execute_tool <tool>), around each tool call:
gen_ai.operation.name=execute_tool,gen_ai.tool.name(real name),gen_ai.tool.call.id(the model's call id),gen_ai.tool.type=functiongen_ai.tool.call.arguments: JSON string of an objectgen_ai.tool.call.result: JSON string of the tool's return value; a string return can be set as its plain text.
Message JSON (input.messages/output.messages): array of {role, parts}; parts {type:"text",content}, {type:"tool_call",id,name,arguments:<object>}, {type:"tool_call_response",id,response}, {type:"reasoning",content}. Output messages add finish_reason. system_instructions = array of parts, no role: [{"type":"text","content":"..."}]. Always a JSON string attribute; plain-text messages don't render.
Also read: a message may carry content (string or part array) instead of parts. gen_ai.response.model wins over gen_ai.request.model when both are set.
Legacy spellings are read as fallbacks (use current names in new code): gen_ai.system (→ gen_ai.provider.name, renamed in semconv 1.37), gen_ai.usage.prompt_tokens/completion_tokens, whole-value gen_ai.prompt/gen_ai.completion, gen_ai.usage.cache_creation.input_tokens (→ cache_write), gen_ai.usage.total_cost (→ cost).
provider.name = the API actually called: openai, anthropic, gcp.gemini, gcp.vertex_ai, aws.bedrock, azure.ai.openai, mistral_ai, groq, x_ai, deepseek, or openrouter for OpenRouter.
Step 4: Session id (required)
- The id is read only from spans that have
gen_ai.operation.name; on an unclassified span it is ignored. - Set
gen_ai.conversation.idon the turn'sinvoke_agentspan from the app's conversation/chat/thread id. Same value for every message of a conversation; different across conversations. One classified span per trace is enough; every span in the trace joins. - Never:
uuid4()/randomUUID()per request, the trace id, a module-level constant, a per-process default. No real id (single-shot script) → generate one per conversation, not per message, and reuse it. - Sub-agents in the same trace: no id (they inherit the trace's session) or the same id. Two different ids in one trace → the lexically larger silently wins.
- Chat backends: one message list per conversation (keyed by that id, persisted), never one global list.
Escape hatch, only when a framework's spans carry a session key Maple ignores for that framework (LiteLLM, Haystack): wrap each turn in your own span with gen_ai.operation.name=invoke_agent, gen_ai.agent.name, and maple_ai.session.id=, and run the framework inside it. Rules:
- Only on your own wrapper span, never on framework spans (it re-vendors the span to
maple: framework decoding lost, usage read as inclusive of cache). - Use the same value the framework would use for the session.
- Not needed for hand-written spans: use
gen_ai.conversation.id. - The session then shows framework Maple.
// chatId comes from your request; frameworkAgent is the framework's agent
await tracer.startActiveSpan(
"invoke_agent support",
{
attributes: {
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "support",
"maple_ai.session.id": chatId,
},
},
async (span) => {
try {
return await frameworkAgent.run(message) // the framework's spans nest under this one
} finally {
span.end()
}
},
)
Step 5: Content
- Content =
gen_ai.system_instructions,gen_ai.input.messages,gen_ai.output.messages(chat),gen_ai.tool.call.arguments,gen_ai.tool.call.result(execute_tool). On span attributes only: span events, log records, and indexed keys (gen_ai.prompt.0.content,llm.input_messages.0.*) are not read. - Do not set
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT/OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT; if the platform sets one, unset it. Truncated JSON is dropped whole. To cap size, drop the oldest messages whole before serializing. - Replace base64 images/files in history with a placeholder part (ingest rejects requests > 20 MiB with 413).
- User wants no content → skip those five attributes (everything else still works; transcript empty). Wants redaction → redact inside
toSemconv/to_semconvbefore serializing, or an OTel Collectorredaction/transformprocessor. - Never put API keys or
Authorizationheaders in any attribute.
Step 6: Tools, errors, sub-agents
- Tool failure: set status ERROR with the error message as description, set
error.type(exception class or error code), nogen_ai.tool.call.result, then return the error to the model as the tool result so the loop continues. Keep the message specific: Maple's tool pages group failures by it. - Maple counts any span as failed if it has status ERROR, a non-empty
error.type, orgen_ai.response.status=failed. - Tools that return
{"error": ...}instead of raising: mark the span failed the same way when you detect it. - Model call failure: status ERROR +
error.type(HTTP status or exception class), rethrow. - Agent run failure: same on
invoke_agent. - Sub-agent: call its loop inside the delegating tool's
execute_toolspan, soexecute_tool ask_x→invoke_agent x→ itschat/execute_tool. Distinctgen_ai.agent.nameper agent (lanes need it). - Delegation detection: an
execute_toolspan whose only child is aninvoke_agentspan is drawn as a delegation into a lane named after the child'sgen_ai.agent.name; the tool's arguments/result become the lane's input/output. Two agents with the same name share one lane; aninvoke_agentspan without a name gets no lane. - Parallel tools: start each
execute_toolspan inside the turn's context (NodePromise.allkeeps it; Python threads needcontextvars.copy_context().run).
Step 7: Tokens and cost
- Usage on
chatspans only, never cumulative totals oninvoke_agent. - Keys:
gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.cache_read.input_tokens,gen_ai.usage.cache_write.input_tokens,gen_ai.usage.reasoning.output_tokens(ints).total_tokensis not read. - Totals as the spec defines them:
input_tokens= every prompt token, cache reads and writes included;output_tokens= every completion token, reasoning included. OpenAI, OpenRouter and Gemini'spromptTokenCountalready count that way. Anthropic'sinput_tokensexcludes cache: sendinput_tokens + cache_read_input_tokens + cache_creation_input_tokens. Gemini'scandidatesTokenCountexcludes thoughts: sendcandidatesTokenCount + thoughtsTokenCount. - Streaming OpenAI-compatible:
stream_options: {include_usage: true}. OpenAI sends usage in an extra last chunk with emptychoices; OpenRouter always sends usage +coston the chunk carryingfinish_reason. Readchunk.usagebefore skipping chunks without choices. - OpenRouter (Claude included):
prompt_tokensalready includescached_tokens→ provideropenrouter, copy as is. Optional:prompt_tokens_details.cache_write_tokens→gen_ai.usage.cache_write.input_tokens. - Cost:
gen_ai.usage.cost(double, USD) onchatspans. OpenRouter returnsusage.cost→ copy it. Other providers return none → compute only if the project has a price table; otherwise leave it (Maple shows "unpriced"; it never prices tokens). gen_ai.response.idalways (dedupes against gateway mirrors such as OpenRouter Broadcast).
Step 8: Flush
- Node script/CLI:
await provider.shutdown()infinally. Serverless:await provider.forceFlush()before returning (insidewaitUntil/after()if available). Both reject when an export failed: add.catch((err) => console.error("telemetry flush failed", err))so a Maple outage can't crash the app. Long-running server: flush onSIGTERM, nothing per request. - Python script:
provider.shutdown()infinally. Lambda:force_flush()infinally. Notebooks/workers:force_flush()per cell/task.
Step 9: Verify
If the app has no scriptable entry point (server, REPL, UI only), write a small driver for this run: one conversation id, 2+ turns, at least one tool call, flush before exit.
Run one real conversation: 2+ messages with the same id, one streamed reply if the app streams, one tool call, one failing tool if one exists, one sub-agent call if the app delegates; then a second conversation. Wait ~30 s; Maple → Agent Sessions, filter by service name. Check:
- One session per conversation; session id = the conversation id; second conversation = different session; no
trace:<id>sessions. - One turn per user message, labeled with it; transcript shows system instructions, user messages, assistant replies, tool calls and results.
- Spans
invoke_agent <agent>→chat <model>/execute_tool <tool>, all in the turn's trace (no orphan roots). - Every
chatspan: model, provider, response id, input+output tokens (streamed ones too), finish reasons; TTFT in seconds on streamed calls. - Tool spans: real names, call ids matching the model's tool calls, JSON args and results.
- The failing tool is failed with its message; successful tools and model calls are not.
- Sub-agents: own lane with their
gen_ai.agent.name, same session. - Cost present only if spans carry
gen_ai.usage.cost; else "unpriced". - No attribute contains an API key,
Bearer,sk-or-,maple_sk_.
With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.
Check without Maple access: the run exits with no export errors on stderr (Failed to export, OTLPExporterError, 401 lines) AND a temporary console exporter (ConsoleSpanExporter + SimpleSpanProcessor) shows the expected span tree with gen_ai.conversation.id, and every messages attribute parses with JSON.parse/json.loads. Silence alone proves nothing: no spans also looks silent.