# 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.

import LanguageTabs from "../../../components/docs/LanguageTabs.astro"
import LanguageTab from "../../../components/docs/LanguageTab.astro"

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](https://github.com/open-telemetry/semantic-conventions-genai) 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](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-agent-tracing-opentelemetry) skill and follows it.

```text
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:

```text
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](#group-turns-into-one-session) |
| | `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:

```json
[
  { "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:

```bash
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.

<LanguageTabs label="Language" tabs={[{ id: "typescript", label: "TypeScript" }, { id: "python", label: "Python" }]}>
<LanguageTab id="typescript">

TypeScript (Node.js 20 or newer):

```bash
npm install @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources openai
```

```ts
// 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()
```

</LanguageTab>
<LanguageTab id="python">

Python (3.10 or newer):

```bash
pip install "opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45" openai
```

```py
# 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)
```

</LanguageTab>
</LanguageTabs>

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](https://github.com/MapleTechLabs/maple/blob/main/skills/maple-agent-tracing-opentelemetry/references/typescript.md) and [Python](https://github.com/MapleTechLabs/maple/blob/main/skills/maple-agent-tracing-opentelemetry/references/python.md). 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:

```ts
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:

```ts
// 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:

```ts
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](/docs/agent-tracing/provider-sdks).

## Related

- [Agent Sessions overview](/docs/agent-sessions/overview)
- [All agent tracing guides](/docs/agent-tracing)
- [Provider SDKs](/docs/agent-tracing/provider-sdks), for auto-instrumented OpenAI, Anthropic and Gemini clients
- [GenAI spans](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md) and [GenAI agent spans](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md)
