Trace any AI agent with the OpenTelemetry GenAI conventions
Write the OpenTelemetry GenAI spans Maple reads by hand, in any language, so a custom agent loop shows up in Agent Sessions.
Use this guide when no other guide covers your agent, for example a hand-written agent loop. You write the agent spans yourself with any OpenTelemetry SDK: an invoke_agent span per user turn, a chat span per model call and an execute_tool span per tool call.
Tested with the OpenTelemetry JS SDK 2.11 on Node.js 26 and the Python SDK 1.45 on Python 3.14, against the GenAI conventions as of September 2026.
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-opentelemetry skill and follows it.
Set up Maple agent tracing for my hand-rolled agent in this project, using the OpenTelemetry GenAI conventions.
Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-agent-tracing-opentelemetry -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.
The spans and attributes Maple reads
One user message produces one trace:
invoke_agent support gen_ai.conversation.id = chat_42
├── chat openai/gpt-4o-mini model call: asks for get_weather
├── execute_tool get_weather tool call
└── chat openai/gpt-4o-mini model call: final answer
Set gen_ai.operation.name on every span. Agent Sessions ignores spans without it.
| Span (kind) | Attribute | Value |
|---|---|---|
invoke_agent (INTERNAL) | gen_ai.operation.name | invoke_agent |
gen_ai.agent.name | support | |
gen_ai.conversation.id | your chat or thread id, see sessions | |
gen_ai.input.messages, gen_ai.output.messages | optional, JSON string | |
chat (CLIENT) | gen_ai.operation.name | chat |
gen_ai.provider.name | the API you called: openai, anthropic, gcp.gemini, openrouter… | |
gen_ai.request.model, gen_ai.response.model | model ids | |
gen_ai.response.id | the provider’s response id | |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | int | |
gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens, gen_ai.usage.reasoning.output_tokens | int, optional | |
gen_ai.usage.cost | double in USD, optional | |
gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages | JSON string | |
gen_ai.response.finish_reasons | string array, e.g. ["stop"] | |
gen_ai.response.time_to_first_chunk | double, in seconds, streamed calls | |
execute_tool (INTERNAL) | gen_ai.operation.name | execute_tool |
gen_ai.tool.name | get_weather | |
gen_ai.tool.call.id | the id the model gave the tool call | |
gen_ai.tool.call.arguments | JSON string of an object | |
gen_ai.tool.call.result | the tool’s result: a string as is, anything else as a JSON string |
Mark a failed span with status ERROR and an error.type attribute, even when you return a tool’s error to the model as its result.
Put token usage on chat spans only. Input tokens include cached tokens and output tokens include reasoning: for Anthropic, add the cache reads and writes to input_tokens; for Gemini, add thoughtsTokenCount to candidatesTokenCount. On OpenAI’s streaming API, set stream_options: { include_usage: true }, or streamed calls report no tokens. Maple doesn’t price tokens, so a session without gen_ai.usage.cost shows as unpriced. OpenRouter returns the cost in usage.cost.
The message format
gen_ai.input.messages and gen_ai.output.messages are JSON arrays of {role, parts} messages, serialized to a string:
[
{ "role": "user", "parts": [{ "type": "text", "content": "What's the weather in Berlin?" }] },
{
"role": "assistant",
"parts": [{ "type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": { "city": "Berlin" } }]
},
{ "role": "tool", "parts": [{ "type": "tool_call_response", "id": "call_1", "response": "{\"temperature_c\":21}" }] }
]
Output messages add a finish_reason to each message. gen_ai.system_instructions is an array of parts without a role: [{"type":"text","content":"You are a concise assistant."}].
Set the message attributes as JSON strings. Maple doesn’t read span events, logs or indexed keys like gen_ai.prompt.0.content. Leave OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT unset, because a truncated message array no longer parses.
To keep prompts and results out of Maple, skip the content attributes (gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages, gen_ai.tool.call.arguments, gen_ai.tool.call.result). The transcript is then empty and everything else still shows up.
Export spans to Maple
Point the OTLP exporter at Maple with the standard variables:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
For an EU organization, use https://ingest.eu.maple.dev. The exporters append /v1/traces themselves.
TypeScript (Node.js 20 or newer):
npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources openaipnpm add @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources openaibun add @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources openai// tracing.ts: import this first in every entry point
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"
import { resourceFromAttributes } from "@opentelemetry/resources"
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export const provider = new NodeTracerProvider({
resource: resourceFromAttributes({
"service.name": "support-agent",
"deployment.environment.name": "production",
}),
// Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
})
// Registers the global provider and the async context manager, so spans nest across awaits
provider.register() Python (3.10 or newer):
pip install "opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45" openaiuv add "opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45" openai# tracing.py: import this first in every entry point
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider(
resource=Resource.create(
{"service.name": "support-agent", "deployment.environment.name": "production"}
)
)
# Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider) If your app already has a TracerProvider (Sentry, Datadog, opentelemetry-instrument, NodeSDK), add the BatchSpanProcessor to it instead of creating a second one.
Name the tracer after your app, like support-agent. A tracer named after a framework or gateway, such as openrouter or langsmith, makes Maple treat your spans as that framework’s.
Instrument the agent loop
Complete loops that stream, call tools and record every attribute above are in the skill: TypeScript and Python. Wrap your own loop’s existing calls the same way.
This is the execute_tool span from the TypeScript version. The chat span follows the same pattern around each model call:
async function runTool(agent: Agent, call: ToolCall) {
const name = call.function.name
return tracer.startActiveSpan(
`execute_tool ${name}`,
{
kind: SpanKind.INTERNAL,
attributes: {
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": name,
"gen_ai.tool.type": "function",
"gen_ai.tool.call.id": call.id,
"gen_ai.tool.call.arguments": call.function.arguments || "{}",
},
},
async (span) => {
try {
const result = await agent.tools[name]!.run(JSON.parse(call.function.arguments || "{}"))
const output = typeof result === "string" ? result : json(result ?? null)
span.setAttribute("gen_ai.tool.call.result", output)
return output
} catch (error) {
markFailed(span, error)
return json({ error: error instanceof Error ? error.message : String(error) })
} finally {
span.end()
}
},
)
}
Start the chat and execute_tool spans inside the invoke_agent span’s callback, so they land in the same trace. For a sub-agent, run its loop inside the delegating tool’s execute_tool span and give it a distinct gen_ai.agent.name.
Group turns into one session
Set gen_ai.conversation.id on the invoke_agent span of every turn, using the id your app already has for the conversation. A chat backend passes it once per user message:
// One history per conversation. Store it in your database in a real backend.
const histories = new Map<string, ChatCompletionMessageParam[]>()
export async function handleMessage(chatId: string, text: string, onText?: (delta: string) => void) {
const history = histories.get(chatId) ?? []
histories.set(chatId, history)
history.push({ role: "user", content: text })
return runAgent(assistant, history, { conversationId: chatId, onText })
}
Without the id, or with one generated per request, each trace becomes its own one-turn session named trace:<trace id>. Don’t give sub-agents their own id; they inherit the session from the trace.
If a framework writes its session id under a key Maple doesn’t read, wrap each turn in your own span that carries maple_ai.session.id, and run the framework inside it:
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()
}
},
)
Put maple_ai.session.id only on your own wrapper span, never on the framework’s spans.
Flush before a short-lived process exits
BatchSpanProcessor exports every few seconds, so a script, CLI or Lambda can exit before the last batch is sent. At the end of a script, call await provider.shutdown() (TypeScript) or provider.shutdown() (Python). In a serverless handler, call provider.forceFlush() (force_flush() in Python) before returning.
Check that it works
Run one conversation with two messages and a tool call, then open Agent Sessions in Maple. After a few seconds you should see one session with your conversation id, one turn per message with its transcript, and chat and execute_tool spans nested under each turn’s invoke_agent span. Hand-written spans show the framework as Unidentified.
Troubleshooting
- Nothing shows up in Agent Sessions, but the trace is in Traces. No span has
gen_ai.operation.name. Add it to every span. - Every message is its own session.
gen_ai.conversation.idis missing or changes per request. Pass the conversation’s id on each turn’sinvoke_agentspan. - The transcript is empty, but tokens are there. The messages are plain text, in span events, or cut by an attribute length limit. Set them as JSON strings and unset
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT. - Model and tool spans are separate traces. The spans weren’t started inside the
invoke_agentspan, the Node provider wasn’t registered withprovider.register(), or the work ran in a new Python thread (pass the context withcontextvars.copy_context().run(...)). - Every model call appears twice. A provider auto-instrumentation (OpenAI, Anthropic, OpenLLMetry, OpenInference) is also active. Keep your
chatspans or the instrumentation, not both. See provider SDKs.
Related
- Agent Sessions overview
- All agent tracing guides
- Provider SDKs, for auto-instrumented OpenAI, Anthropic and Gemini clients
- GenAI spans and GenAI agent spans