Skip to content
Maple Docs
Open app
Browse the docs
On this page

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)AttributeValue
invoke_agent (INTERNAL)gen_ai.operation.nameinvoke_agent
gen_ai.agent.namesupport
gen_ai.conversation.idyour chat or thread id, see sessions
gen_ai.input.messages, gen_ai.output.messagesoptional, JSON string
chat (CLIENT)gen_ai.operation.namechat
gen_ai.provider.namethe API you called: openai, anthropic, gcp.gemini, openrouter…
gen_ai.request.model, gen_ai.response.modelmodel ids
gen_ai.response.idthe provider’s response id
gen_ai.usage.input_tokens, gen_ai.usage.output_tokensint
gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens, gen_ai.usage.reasoning.output_tokensint, optional
gen_ai.usage.costdouble in USD, optional
gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messagesJSON string
gen_ai.response.finish_reasonsstring array, e.g. ["stop"]
gen_ai.response.time_to_first_chunkdouble, in seconds, streamed calls
execute_tool (INTERNAL)gen_ai.operation.nameexecute_tool
gen_ai.tool.nameget_weather
gen_ai.tool.call.idthe id the model gave the tool call
gen_ai.tool.call.argumentsJSON string of an object
gen_ai.tool.call.resultthe 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 openai
pnpm add @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources openai
bun 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" openai
uv 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.id is missing or changes per request. Pass the conversation’s id on each turn’s invoke_agent span.
  • 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_agent span, the Node provider wasn’t registered with provider.register(), or the work ran in a new Python thread (pass the context with contextvars.copy_context().run(...)).
  • Every model call appears twice. A provider auto-instrumentation (OpenAI, Anthropic, OpenLLMetry, OpenInference) is also active. Keep your chat spans or the instrumentation, not both. See provider SDKs.