# OpenTelemetry GenAI semantic conventions: a practical guide to agent and tool spans

> OpenTelemetry GenAI semantic conventions, shown on a real agent run: the three span types, the attributes that matter, and where the conventions stop.

Source: https://hashspan.dev/blog/opentelemetry-genai-semantic-conventions

The OpenTelemetry GenAI semantic conventions name the spans an AI agent produces: `invoke_agent` for the run, `chat` for each model call, `execute_tool` for each tool. Every span also gets standard `gen_ai.*` attributes, so any backend can read an agent's trace.

That's the definition. Below is what it looks like on a real run, and the one place where the conventions stop.

> **TL;DR**
> - Three operations cover most agent traces: `invoke_agent`, `chat` and `execute_tool`. The `gen_ai.*` names are still in Development.
> - `gen_ai.tool.call.id` joins the model's tool request, the tool span and the next model call.
> - Message content should be opt-in. The AI SDK records it by default, so check yours.

## What are they, and where do they stand in 2026?

[Semantic conventions](https://hashspan.dev/learn/otel-semantic-conventions) are shared names. If every library calls a model call `chat` and puts the model in `gen_ai.request.model`, one dashboard works for all of them.

The GenAI set has lived in its own repository, [open-telemetry/semantic-conventions-genai](https://github.com/open-telemetry/semantic-conventions-genai), since May 2026. Every page in it is marked **Development**, so the `gen_ai.*` names can still change. Shared attributes such as `error.type` come from the stable core conventions. The repository has no releases, so this post pins the commit it was checked against: `e07f4eb`, 2 October 2026.

## Which spans does an agent run produce?

I ran the example agent from the [hashspan](https://github.com/selimaytac/hashspan) repository. It's built with the AI SDK and has two tools: `pay_vendor` and `withdraw_from_vault`. A scripted model makes the tool calls, so it runs offline. These are the GenAI spans it exported (hashspan's own spans come in the last section):

<ThemedImage
dark="/visuals/opentelemetry-genai-semantic-conventions/genai-span-tree.dark.svg"
light="/visuals/opentelemetry-genai-semantic-conventions/genai-span-tree.light.svg"
alt="Span tree of one agent run: invoke_agent contains step 1, 2 and 3; each step has a chat span (kind CLIENT) and, in steps 1 and 2, an execute_tool span (kind INTERNAL). The step spans use the custom operation name agent_step."
/>

Three things to notice:

1. **The [span kind](https://hashspan.dev/learn/otel-span-kind) follows where the work happens.** `chat` is `CLIENT` because it calls a model API. `execute_tool` is `INTERNAL` because your code runs it. `invoke_agent` has two variants in the spec: `CLIENT` for a remote agent service, `INTERNAL` for an agent in your process, like this one.
2. **`step N` isn't in the conventions.** The AI SDK adds it with `gen_ai.operation.name` = `agent_step`. That's allowed: when no well-known value fits, a library may use its own.
3. **The root span is named `invoke_agent scripted-model`.** The spec says `invoke_agent {gen_ai.agent.name}`, which would be `invoke_agent treasury-agent`. It's a SHOULD, not a MUST, but it means a query on span names won't find this agent by its name. Filter on the `gen_ai.agent.name` attribute instead.

Here are the same spans laid out in time:

<TraceWaterfall
title="treasury-agent · one run on a local chain"
meta="durations in ms"
rows={[
{ name: 'invoke_agent scripted-model', start: 0, width: 100, kind: 'parent', label: '47.0' },
{ name: 'step 1', depth: 1, start: 6.4, width: 54.5, kind: 'parent', label: '25.6' },
{ name: 'chat scripted-model', depth: 2, start: 6.4, width: 4.3, kind: 'llm', label: '2.0' },
{ name: 'execute_tool pay_vendor', depth: 2, start: 12.8, width: 44.9, kind: 'tool', label: '21.1' },
{ name: 'step 2', depth: 1, start: 61.7, width: 36.6, kind: 'parent', label: '17.2' },
{ name: 'chat scripted-model', depth: 2, start: 61.7, width: 1.9, kind: 'llm', label: '0.9' },
{ name: 'execute_tool withdraw_from_vault', depth: 2, start: 63.8, width: 32.8, kind: 'tool', label: '15.4' },
{ name: 'step 3', depth: 1, start: 97.9, width: 0.8, kind: 'parent', label: '0.3' },
{ name: 'chat scripted-model', depth: 2, start: 97.9, width: 0.8, kind: 'llm', label: '0.1' },
]}
caption="GenAI spans only. ai 7.0.127 and @ai-sdk/otel 1.0.127, Node 24, local Anvil chain. The scripted model answers in about a millisecond; a real one takes seconds."
/>

## Which attributes matter in practice?

The spec lists dozens. These are the ones you'll query. All of them except `error.type` were set in the run; "If available" and "If it failed" are the spec's Conditionally Required.

| Attribute · span | Level |
|---|---|
| `gen_ai.operation.name` · all three | Required |
| `gen_ai.provider.name` · chat | Required |
| `gen_ai.request.model` · chat | If available |
| `gen_ai.usage.*_tokens` · chat | Recommended |
| `gen_ai.agent.name` · invoke_agent | If available |
| `gen_ai.tool.name` · execute_tool | Required |
| `gen_ai.tool.call.id` · execute_tool | Recommended |
| `error.type` · any | If it failed |

[Attributes](https://hashspan.dev/learn/otel-span-attributes) like these are what make "tokens per model" or "slowest tool" a one-line query in your backend.

The attribute I'd query first is `gen_ai.tool.call.id`. The model asks for a tool by id, the tool span records the same id, and the result goes back to the model with it:

<ThemedImage
dark="/visuals/opentelemetry-genai-semantic-conventions/genai-tool-call-id.dark.svg"
light="/visuals/opentelemetry-genai-semantic-conventions/genai-tool-call-id.light.svg"
alt="The id call_1 appears in three spans: in the output messages of the step 1 chat span as a tool_call, as gen_ai.tool.call.id on execute_tool pay_vendor, and in the input messages of the step 2 chat span as a tool_call_response."
/>

When an agent calls the same tool twice in one step, the id is what tells the two results apart.

## What does the AI SDK record by default?

The conventions are careful with content. Prompts, messages and tool results are sensitive and large. The spec says instrumentations SHOULD NOT capture them by default, and describes three patterns:

<ThemedImage
dark="/visuals/opentelemetry-genai-semantic-conventions/genai-content-capture.dark.svg"
light="/visuals/opentelemetry-genai-semantic-conventions/genai-content-capture.light.svg"
alt="Three content-capture patterns: don't record (the spec's default), record on the span as opt-in attributes (fine for development), or store elsewhere and keep a reference (for production). AI SDK 7 points at the second: recording is on by default."
/>

The AI SDK takes the second pattern unless you say otherwise: `recordInputs` and `recordOutputs` are both on by default. The example sets neither, and its spans carried the system prompt, the user's message, the tool definitions, the tool arguments and the tool results.

For an agent that moves money, the tool results contain transaction hashes. Turn recording off where that matters:

```ts
const agent = new ToolLoopAgent({
  // …model, tools
  telemetry: {
    functionId: 'treasury-agent',
    recordInputs: false,
    recordOutputs: false,
  },
});
```

I reran the example with this change. The messages, instructions, tool definitions, arguments and results were gone; span names, `gen_ai.tool.call.id` and token counts stayed.

One more addition is worth knowing about. Since 30 September there's a `gen_ai.main_agent` entity: a resource that names the top-level agent of a process. It needs a stable id from a provider, like a Bedrock agent ARN. The spec forbids generating one in memory, so a local agent like this one doesn't emit it. That's by design.

## Where do the conventions stop?

The withdrawal in this run reverted on chain. Here's how the trace answers "did it work?":

<ThemedImage
dark="/visuals/opentelemetry-genai-semantic-conventions/genai-where-it-stops.dark.svg"
light="/visuals/opentelemetry-genai-semantic-conventions/genai-where-it-stops.light.svg"
alt="execute_tool withdraw_from_vault has status unset because the tool returned an object, with no exception or error code; the word reverted only appears inside the opt-in tool result. Below the line where the GenAI conventions end, hashspan's send and confirm spans show the confirm span with status ERROR, blockchain.tx.status reverted, the revert reason and the fee that was still paid."
/>

The tool span stays clean, and the instrumentation can't do better. OpenTelemetry's error rules count an operation as [failed](https://hashspan.dev/learn/otel-span-status) when it throws or returns an error code. The tool did neither: it returned an object, and the AI SDK has no way to know that `"status": "reverted"` inside it means the withdrawal never happened. The conventions describe the agent's side of the call. What the call did out in the world is outside their scope.

That's the gap hashspan fills for on-chain tools. It adds `send` and `confirm` as child spans of `execute_tool`, with names from its own [semantic conventions](https://hashspan.dev/docs/reference/semantic-conventions):

<AttributeCard
span="confirm 31337"
status="bad"
rows={[
['blockchain.tx.status', 'reverted'],
['blockchain.tx.revert.reason', 'WithdrawalLimitExceeded(100000000000000000, 1000000000000000000)'],
['blockchain.tx.fee', '40616290500000'],
['gen_ai.agent.name', 'treasury-agent'],
]}
caption="The confirm span of the reverted withdrawal, from the same run (local chain 31337, fee in wei)."
/>

Note the last row. hashspan writes the agent's `gen_ai.agent.name` on its own spans too, so "which agent spent what" doesn't need a join. The full story of why the trace used to stop at the tool is in [Your agent's trace stops at the tool call](https://hashspan.dev/blog/agent-trace-stops-at-the-tool-call).

## Run it yourself

The run needs no API key. In a clone of the repository, with Docker running and Node.js 22.18 or later (see the [local lab](https://hashspan.dev/docs/local-lab)):

```sh
make lab-up   # Jaeger on http://localhost:16686
make demo     # the agent, a local chain, both transactions
```

To wire the same thing into your own AI SDK agent, follow the [AI SDK recipe](https://hashspan.dev/docs/recipes/ai-sdk). The GenAI conventions will keep moving while they're in Development. I'll update this post when they do.
