---
title: Trace Claude Agent Tool Calls with OpenTelemetry (2026)
description: 'Instrument a Claude tool-calling agent with OpenTelemetry GenAI semantic conventions in TypeScript: real spans, nested tool calls, cost tracking. 2026.'
url: https://nerdleveltech.com/ar/opentelemetry-claude-agent-tracing-typescript-tutorial
language: en
date: '2026-07-15'
category: ai-ml
tags:
  - opentelemetry
  - claude
  - ai agents
  - typescript
  - observability
  - genai semantic conventions
  - tool use
  - tracing
markdown_url: https://nerdleveltech.com/ar/opentelemetry-claude-agent-tracing-typescript-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/opentelemetry-claude-agent-tracing-typescript-tutorial'
terms: https://nerdleveltech.com/llms.txt
note: No Arabic translation of this page is published yet; this is the English text, as shown on the Arabic page.
---

# Trace Claude Agent Tool Calls with OpenTelemetry (2026)

When a tool-calling agent misbehaves — a wrong tool gets picked, a call takes eight seconds, a run costs ten times what you expected — a plain `console.log` trail rarely tells you which step did it. OpenTelemetry's GenAI semantic conventions give agent runs a standard shape: one span per model call, one span per tool call, one parent span for the whole run, all carrying the same `gen_ai.*` attribute names regardless of which model or framework you're using[^1].

## TL;DR

You'll build a small TypeScript agent that calls the Claude API with one tool, and wrap it in three kinds of OpenTelemetry spans: `invoke_agent` for the whole run, `chat` for each model call, and `execute_tool` for each tool call. Every span carries the current `gen_ai.*` attributes — including `gen_ai.provider.name`, which has quietly replaced `gen_ai.system` in the spec[^2] — and a custom `SpanProcessor` reads the token-usage attributes straight off finished spans to print a real dollar cost per call. No collector, no Docker, no vendor SDK — just `@opentelemetry/api` and a console exporter. Runtime: `@anthropic-ai/sdk@0.111.0`, `@opentelemetry/api@1.9.1`, Node.js 20+. Build time: 25-30 minutes.

## What you'll learn

- How the OpenTelemetry GenAI semantic conventions name spans and attributes for model calls, agent runs, and tool calls[^1]
- How to register a Node tracer provider and print real spans to the console with zero external infrastructure
- How to wrap a Claude tool-calling loop in `invoke_agent`, `chat`, and `execute_tool` spans that nest correctly across `await` boundaries
- How to read `gen_ai.usage.*` attributes off a finished span and turn them into an actual dollar cost with a custom `SpanProcessor`
- Why `gen_ai.system` is no longer the attribute to reach for, and what replaced it
- How to point these same spans at a real backend once you're past prototyping

## Prerequisites

- Node.js 20 or later (Node 24 is the current Active LTS line as of this writing; Node 22 moved to Maintenance LTS on 2025-10-21)[^3]
- `@anthropic-ai/sdk@0.111.0` — the base Messages API SDK, not the Agent SDK; this is a plain tool-calling loop with no agent framework involved[^4]
- `@opentelemetry/api@1.9.1`, `@opentelemetry/sdk-trace-node@2.9.0`, and `@opentelemetry/resources@2.9.0`[^4]
- TypeScript `7.0.2` with `strict` and `noUncheckedIndexedAccess` enabled[^4]
- An `ANTHROPIC_API_KEY` if you want to run this against the live API — the code below is type-checked against the real SDK and its tracing logic is executed end to end against stubbed responses; see Verification for exactly what ran and what didn't

## Why not just log the tool calls?

A log line tells you *that* something happened. A span tells you *where it sits* relative to everything else — which model call it followed, which tool call it triggered, how long it took relative to its parent, and how it connects to spans from other parts of your system if you're already using OpenTelemetry for anything else.

The GenAI semantic conventions standardize this shape specifically for LLM work: a `chat` span always carries `gen_ai.request.model` and `gen_ai.usage.output_tokens` in the same attribute names whether you're calling Claude, GPT, or Gemini, so a dashboard built for one provider mostly works for the others too[^1]. That portability is the entire point of a semantic convention — it's why the spec exists as a shared vocabulary instead of every vendor inventing its own.

The GenAI conventions are still evolving — as of mid-2026 they remain in "Development" status overall, though the core client-call spans exited experimental status earlier in the year[^5]. Expect attribute names to keep shifting at the margins; this tutorial was verified against the spec as published on 2026-07-15, and one shift already happened that trips up a lot of existing tutorials: `gen_ai.system` is gone from the current registry, replaced by `gen_ai.provider.name`[^2].

## Step 1: Set up the project

```bash
mkdir claude-agent-tracing && cd claude-agent-tracing
npm init -y
npm install @anthropic-ai/sdk@0.111.0 @opentelemetry/api@1.9.1 \
  @opentelemetry/sdk-trace-node@2.9.0 @opentelemetry/resources@2.9.0
npm install -D typescript@7.0.2 tsx@4.23.1 @types/node@26.1.1
```

Add `"type": "module"` to `package.json` — every package here ships ESM, and mixing module systems is where most of these tutorials go sideways. Then create `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}
```

`moduleResolution: "bundler"` is the setting that keeps this simple — it lets `tsc` resolve the SDK's package exports the same way `tsx` does at runtime, so what type-checks is what actually runs.

## Step 2: Register a tracer

`tracing.ts` sets up a `NodeTracerProvider` with a `ConsoleSpanExporter` so spans print to your terminal — no collector, no Jaeger, no network calls. This file has to run before anything else that creates a span, so every other file imports it first:

```typescript
// tracing.ts
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import {
  ConsoleSpanExporter,
  SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-node";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { trace } from "@opentelemetry/api";
import { CostLoggingProcessor } from "./cost-processor.js";

const provider = new NodeTracerProvider({
  resource: resourceFromAttributes({
    "service.name": "weather-agent",
  }),
  spanProcessors: [
    new SimpleSpanProcessor(new ConsoleSpanExporter()),
    new CostLoggingProcessor(),
  ],
});

provider.register();

export const tracer = trace.getTracer("weather-agent", "1.0.0");
```

`SimpleSpanProcessor` exports each span the moment it ends, which is what you want for a terminal demo — in production you'd reach for `BatchSpanProcessor` and an OTLP exporter instead, which is exactly what a Collector-based pipeline looks like[^6]. `spanProcessors` takes an array, so nothing stops you from running the console exporter and a custom processor side by side — that's what `CostLoggingProcessor` is, and you'll write it in Step 5.

## Step 3: Build the instrumented agent loop

The agent has one tool (`get_weather`, returning fixture data so the tutorial doesn't depend on a real weather API) and a loop that calls Claude, executes any tool calls it asks for, and repeats until Claude stops asking. Every layer gets its own span:

```typescript
// agent.ts
import Anthropic from "@anthropic-ai/sdk";
import type { MessageParam, Tool } from "@anthropic-ai/sdk/resources/messages";
import { tracer } from "./tracing.js";
import { SpanStatusCode, type Span } from "@opentelemetry/api";
import { randomUUID } from "node:crypto";

const MODEL = "claude-sonnet-5";

const WEATHER_TOOL: Tool = {
  name: "get_weather",
  description: "Get the current weather for a city.",
  input_schema: {
    type: "object",
    properties: {
      city: { type: "string", description: "City name, e.g. 'Cairo'" },
    },
    required: ["city"],
  },
};

function getWeather(city: string): string {
  const fixtures: Record<string, string> = {
    Cairo: "32°C, clear skies",
    Dublin: "14°C, light rain",
  };
  return fixtures[city] ?? `${city}: 21°C, partly cloudy`;
}

/** Runs each tool call inside its own `execute_tool {name}` span. */
function runTool(name: string, id: string, input: unknown): string {
  return tracer.startActiveSpan(`execute_tool ${name}`, (span: Span) => {
    span.setAttribute("gen_ai.operation.name", "execute_tool");
    span.setAttribute("gen_ai.tool.name", name);
    span.setAttribute("gen_ai.tool.call.id", id);
    try {
      const city = (input as { city: string }).city;
      const result = getWeather(city);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

/** Runs one model turn inside a `chat {model}` span with GenAI attributes. */
async function chatTurn(
  client: Anthropic,
  messages: MessageParam[],
): Promise<Anthropic.Message> {
  return tracer.startActiveSpan(`chat ${MODEL}`, async (span: Span) => {
    span.setAttribute("gen_ai.operation.name", "chat");
    span.setAttribute("gen_ai.provider.name", "anthropic");
    span.setAttribute("gen_ai.request.model", MODEL);
    try {
      const response = await client.messages.create({
        model: MODEL,
        max_tokens: 1024,
        tools: [WEATHER_TOOL],
        messages,
      });

      span.setAttribute("gen_ai.response.model", response.model);
      span.setAttribute("gen_ai.usage.input_tokens", response.usage.input_tokens);
      span.setAttribute("gen_ai.usage.output_tokens", response.usage.output_tokens);
      if (response.usage.cache_creation_input_tokens) {
        span.setAttribute(
          "gen_ai.usage.cache_creation.input_tokens",
          response.usage.cache_creation_input_tokens,
        );
      }
      if (response.usage.cache_read_input_tokens) {
        span.setAttribute(
          "gen_ai.usage.cache_read.input_tokens",
          response.usage.cache_read_input_tokens,
        );
      }
      if (response.stop_reason) {
        span.setAttribute("gen_ai.response.finish_reasons", [response.stop_reason]);
      }
      span.setStatus({ code: SpanStatusCode.OK });
      return response;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

/** Root span for the whole agent turn — one or more chat turns plus any tool calls. */
export async function runAgent(client: Anthropic, userMessage: string): Promise<string> {
  return tracer.startActiveSpan(`invoke_agent weather-assistant`, async (root: Span) => {
    root.setAttribute("gen_ai.operation.name", "invoke_agent");
    root.setAttribute("gen_ai.agent.name", "weather-assistant");
    root.setAttribute("gen_ai.conversation.id", randomUUID());

    const messages: MessageParam[] = [{ role: "user", content: userMessage }];

    try {
      for (let turn = 0; turn < 4; turn++) {
        const response = await chatTurn(client, messages);
        messages.push({ role: "assistant", content: response.content });

        if (response.stop_reason !== "tool_use") {
          const text = response.content
            .filter((b): b is Anthropic.TextBlock => b.type === "text")
            .map((b) => b.text)
            .join("\n");
          root.setStatus({ code: SpanStatusCode.OK });
          return text;
        }

        const toolResults: Anthropic.ToolResultBlockParam[] = [];
        for (const block of response.content) {
          if (block.type === "tool_use") {
            const result = runTool(block.name, block.id, block.input);
            toolResults.push({
              type: "tool_result",
              tool_use_id: block.id,
              content: result,
            });
          }
        }
        messages.push({ role: "user", content: toolResults });
      }
      throw new Error("Agent did not finish within the turn budget");
    } catch (err) {
      root.recordException(err as Error);
      root.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      root.end();
    }
  });
}
```

Two details worth calling out. First, `tracer.startActiveSpan` takes an async callback here, and the nesting still works correctly across every `await` — Node's context manager propagates the active span through `AsyncLocalStorage`, so a `chat` span started inside `runAgent`'s callback, and an `execute_tool` span started inside a loop after that, both pick up the right parent automatically. You never pass a span or a context object by hand.

Second, every span attribute name here is a plain string — `"gen_ai.usage.input_tokens"`, not an imported constant. You *can* import constants like `ATTR_GEN_AI_USAGE_INPUT_TOKENS` from `@opentelemetry/semantic-conventions@1.43.0`, but only from its `/incubating` subpath — inspecting the package directly shows the GenAI constants live in `experimental_attributes`, re-exported through `@opentelemetry/semantic-conventions/incubating`, not through the package's stable root export[^8]. Writing the strings out by hand avoids taking on an incubating-marked import for the 13 attribute names this tutorial sets, though the constants are a reasonable choice if you'd rather have `tsc` catch a typo'd attribute name for you.

## Step 4: Run it and read the trace

Wire up a real entry point:

```typescript
// index.ts
import "./tracing.js"; // side-effecting import: register the tracer FIRST
import Anthropic from "@anthropic-ai/sdk";
import { runAgent } from "./agent.js";

const client = new Anthropic();

const answer = await runAgent(client, "What's the weather in Cairo?");
console.log("\n=== Final answer ===");
console.log(answer);
```

```bash
export ANTHROPIC_API_KEY=sk-ant-...
npx tsx index.ts
```

Note the import order in `index.ts`: `./tracing.js` comes first, on its own line, even though `agent.ts` also imports it. Technically the ES module spec evaluates dependencies before the importing module's own top-level code runs, so the transitive import from `agent.ts` would register the tracer in time either way — but relying on that is fragile the moment someone reorders imports during a refactor. Importing `tracing.js` explicitly, first, in every entry point is the pattern that doesn't break later.

Since this automated pipeline has no live `ANTHROPIC_API_KEY`, the tracing logic itself was verified against a stubbed client that returns fixture responses shaped exactly like the real API — same `usage`, `stop_reason`, and `content` fields the real SDK returns. Here's the real console output from that run, showing one `chat` span, one `execute_tool` span, a second `chat` span, and the root `invoke_agent` span, in the order they end:

```text
{
  name: 'chat claude-sonnet-5',
  traceId: 'bfae7c5f1f4e5ca6ccd96ed8c6eb1c2a',
  attributes: {
    'gen_ai.operation.name': 'chat',
    'gen_ai.provider.name': 'anthropic',
    'gen_ai.request.model': 'claude-sonnet-5',
    'gen_ai.response.model': 'claude-sonnet-5',
    'gen_ai.usage.input_tokens': 512,
    'gen_ai.usage.output_tokens': 41,
    'gen_ai.response.finish_reasons': [ 'tool_use' ]
  },
  status: { code: 1 }
}
{
  name: 'execute_tool get_weather',
  traceId: 'bfae7c5f1f4e5ca6ccd96ed8c6eb1c2a',
  attributes: {
    'gen_ai.operation.name': 'execute_tool',
    'gen_ai.tool.name': 'get_weather',
    'gen_ai.tool.call.id': 'toolu_01demo'
  },
  status: { code: 1 }
}
{
  name: 'chat claude-sonnet-5',
  traceId: 'bfae7c5f1f4e5ca6ccd96ed8c6eb1c2a',
  attributes: {
    'gen_ai.operation.name': 'chat',
    'gen_ai.provider.name': 'anthropic',
    'gen_ai.request.model': 'claude-sonnet-5',
    'gen_ai.response.model': 'claude-sonnet-5',
    'gen_ai.usage.input_tokens': 568,
    'gen_ai.usage.output_tokens': 19,
    'gen_ai.response.finish_reasons': [ 'end_turn' ]
  },
  status: { code: 1 }
}
{
  name: 'invoke_agent weather-assistant',
  traceId: 'bfae7c5f1f4e5ca6ccd96ed8c6eb1c2a',
  attributes: {
    'gen_ai.operation.name': 'invoke_agent',
    'gen_ai.agent.name': 'weather-assistant',
    'gen_ai.conversation.id': '1d6f4098-6663-4f30-a0a4-70f06fba80c4'
  },
  status: { code: 1 }
}

=== Final answer ===
It's 32°C and clear in Cairo right now.
```

(Trimmed to the fields that matter for this walkthrough — the real `ConsoleSpanExporter` output also includes `spanId`, `parentSpanContext`, `duration`, and `instrumentationScope` on every span.) All four spans share one `traceId`, which is what makes this a *trace* rather than four unrelated log lines — and the `parentSpanContext.spanId` on each of the three child spans (visible in the untrimmed output) matches the `invoke_agent` span's own `id`, confirming the nesting from Step 3 actually holds at runtime, not just in the code's structure. `status: { code: 1 }` is `SpanStatusCode.OK` — `0` is unset, `2` is an error.

## Step 5: Turn span attributes into a dollar figure

The payoff of putting `gen_ai.usage.*` on the span instead of just logging it separately: any processor downstream — this one, or a real OTel Collector processor — can cost a call without touching your application code at all.

```typescript
// cost-processor.ts
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-node";

// Verified against platform.claude.com/docs/en/about-claude/pricing (2026-07-15).
// USD per million tokens. Sonnet 5 is in its introductory pricing window
// through 2026-08-31; it becomes $3 / $15 after that.
const PRICE_PER_MTOK: Record<string, { input: number; output: number }> = {
  "claude-sonnet-5": { input: 2, output: 10 },
  "claude-opus-4-8": { input: 5, output: 25 },
  "claude-haiku-4-5": { input: 1, output: 5 },
};

export class CostLoggingProcessor implements SpanProcessor {
  onStart(): void {}

  onEnd(span: ReadableSpan): void {
    if (!span.name.startsWith("chat ")) return;

    const model = span.attributes["gen_ai.response.model"] as string | undefined;
    const inputTokens = span.attributes["gen_ai.usage.input_tokens"] as number | undefined;
    const outputTokens = span.attributes["gen_ai.usage.output_tokens"] as number | undefined;
    const rates = model ? PRICE_PER_MTOK[model] : undefined;
    if (!rates || inputTokens === undefined || outputTokens === undefined) return;

    const cost =
      (inputTokens / 1_000_000) * rates.input + (outputTokens / 1_000_000) * rates.output;

    console.log(
      `[cost] ${span.name}: ${inputTokens} in + ${outputTokens} out tokens ≈ $${cost.toFixed(6)}`,
    );
  }

  shutdown(): Promise<void> {
    return Promise.resolve();
  }

  forceFlush(): Promise<void> {
    return Promise.resolve();
  }
}
```

Run the same demo again and two more lines appear, computed live as each `chat` span ends:

```text
[cost] chat claude-sonnet-5: 512 in + 41 out tokens ≈ $0.001434
[cost] chat claude-sonnet-5: 568 in + 19 out tokens ≈ $0.001326
```

That's roughly $0.0028 for the whole two-turn run — one weather lookup, one final answer. One gotcha worth knowing before you're surprised by a real bill: as soon as `tools` is non-empty, Claude adds a fixed system-prompt token overhead to every call — 354 to 474 tokens for Sonnet 5, depending on `tool_choice`[^4]. It's invisible in the prompt you wrote, but it's real, it's billed, and it's exactly the kind of thing you'd only notice by watching `gen_ai.usage.input_tokens` on a span instead of estimating token counts from your own message text.

## Verification

**What was actually run:** `tracing.ts`, `cost-processor.ts`, `agent.ts`, and `index.ts` — all four files together, matching what's shown above, plus a fifth internal test harness that stubs `client.messages.create` — were type-checked with `tsc --noEmit` (`strict: true`, `noUncheckedIndexedAccess: true`) against the real, installed `@anthropic-ai/sdk@0.111.0` and `@opentelemetry/*` packages at the versions pinned in Prerequisites. Zero errors.

The tracing logic itself was executed via `tsx` against a stubbed `client.messages.create` that returns two fixture responses shaped like the real API (same `usage`, `stop_reason`, and `content` fields) — that's the run the console output in Step 4 came from, reproduced verbatim, including the hand-verified cost math: `512 / 1e6 × 2 + 41 / 1e6 × 10 = 0.001434`, `568 / 1e6 × 2 + 19 / 1e6 × 10 = 0.001326`.

**What was not run:** the live `client.messages.create()` call against the real Claude API in `index.ts`. This automated writing pipeline has no `ANTHROPIC_API_KEY`. If you have one, `npx tsx index.ts` should behave identically to the Step 4 output — same span shape, same attribute names — with real token counts and a real model response in place of the fixtures.

## Troubleshooting

**Spans print with no parent, even though you called them inside `runAgent`.** You're probably calling `tracer.startSpan()` instead of `tracer.startActiveSpan()`. `startSpan` creates a span but doesn't make it the *active* context, so anything started after it has no parent to attach to. `startActiveSpan` does both in one call — use it unless you have a specific reason not to.

**A `chat` span is missing `gen_ai.usage.*` entirely.** Check that you're reading `response.usage`, not something off the raw HTTP response — the SDK's `Message` type already parses `usage.input_tokens` and `usage.output_tokens` as numbers, so if they're `undefined` the response object itself is probably not what you think it is (a caught error being treated as a response, for example).

**Cost lines never print.** `CostLoggingProcessor.onEnd` only fires for spans named `chat *` — if you renamed the span, update the `startsWith` check too. Also confirm the processor is actually registered: `spanProcessors` on `NodeTracerProvider` takes an array, and forgetting to add your custom processor alongside the console exporter is an easy way to lose it silently (no error, just no cost lines).

**`execute_tool` spans nest under the wrong `chat` span, or don't nest at all.** This usually means a tool call is being kicked off from outside the `for` loop in `runAgent` — for example, in a `Promise.all` that races multiple tools without wrapping each one in its own `tracer.startActiveSpan` call from *within* the active context. Each tool call needs to start its span while the parent's context is still active on the current async chain.

**Everything compiles but nothing prints when you run it.** Double check `tracing.ts` is actually imported — and imported first — in whatever file you run. A provider that's never registered doesn't error, it just silently no-ops every span you create.

## Is gen_ai.provider.name the same as gen_ai.system?

`gen_ai.provider.name` is the current attribute for identifying the AI provider (`anthropic`, `openai`, `gcp.vertex_ai`) on a span — `gen_ai.system` is deprecated and doesn't appear in the current attribute registry at all[^2]. In practice, plenty of instrumentation libraries haven't finished the migration yet — real bug reports against provider SDKs and agent frameworks in mid-2026 are still asking for `gen_ai.provider.name` and `gen_ai.operation.name` to be added because a library only emits the older shape[^7].

If you're following an older tutorial, a cookbook, or an auto-instrumentation library that still sets `gen_ai.system`, it's not necessarily broken — it just hasn't caught up — but it's worth checking which name a given library actually emits before you build a dashboard or alert on one or the other.

This is also a reminder that the GenAI conventions are genuinely still moving: the whole spec migrated off the main `opentelemetry.io/docs/specs/semconv/` site into its own dedicated repository, and the old attribute-reference pages there now just say "Moved"[^1]. Bookmark the new repo, not the old URL.

## Next steps and further reading

This tutorial builds the loop from the raw Messages API — if you haven't built a tool-calling agent before and want the mechanics of `tool_use`/`tool_result` handling in more depth, [the Claude tool use agentic loop tutorial](https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial) covers that ground first.

Once you're past a terminal demo and want these same spans in a real backend — Jaeger, Honeycomb, Datadog, anything that speaks OTLP — swap the `ConsoleSpanExporter` in Step 2 for an OTLP exporter pointed at a Collector; [the OpenTelemetry Collector + Node.js tracing tutorial](https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial) covers that half of the pipeline: the Collector config, the `batch` processor, and viewing the result in Jaeger's UI.

And if this agent needs a human to approve its tool calls before they run — useful once `execute_tool` spans start showing calls you didn't expect — see [adding human-in-the-loop approval to the Claude Agent SDK](https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial).

For the spec itself, the GenAI semantic conventions repository is the primary source now that the docs have moved off the main site[^1], and the attribute registry page is the fastest way to check whether a given `gen_ai.*` name is still current before you ship it in production code[^2].

## Related reads

- [AI SRE Agents in 2026: What They Can Really Do](https://nerdleveltech.com/ai-sre-agents-autonomous-incident-remediation)
- [Claude Agent SDK Observability With OpenTelemetry (2026)](https://nerdleveltech.com/claude-agent-sdk-opentelemetry-typescript-tutorial)
- [OpenTelemetry gen_ai Attributes: 60 Deprecated in 2026](https://nerdleveltech.com/opentelemetry-gen-ai-attributes-deprecated) — which of the attribute names used above were renamed, removed, or merely moved

[^1]: OpenTelemetry, GenAI Semantic Conventions repository — https://github.com/open-telemetry/semantic-conventions-genai (schema URL `https://opentelemetry.io/schemas/gen-ai/1.42.0`; the canonical source as of this writing — the older `opentelemetry.io/docs/specs/semconv/gen-ai/` guide pages now redirect to a "Moved" notice pointing here; fetched 2026-07-15)
[^2]: OpenTelemetry, Gen AI attribute registry — https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/ (full `gen_ai.*` attribute table with descriptions and example values, including `gen_ai.provider.name`; `gen_ai.system` does not appear in this table; fetched 2026-07-15)
[^3]: endoflife.date, Node.js — https://endoflife.date/api/v1/products/nodejs (Node 24: Active LTS, support window 2025-10-28 to 2026-10-20; Node 22: moved to Maintenance LTS 2025-10-21; fetched 2026-07-15)
[^4]: Anthropic, Pricing — https://platform.claude.com/docs/en/about-claude/pricing (Claude Sonnet 5: $2/$10 per MTok input/output, introductory through 2026-08-31, then $3/$15; tool-use system-prompt token overhead table; fetched 2026-07-15 directly from the pricing page, not a search summary). Package versions — `npm view <package> version` against the npm registry directly: `@anthropic-ai/sdk@0.111.0`, `@opentelemetry/api@1.9.1`, `@opentelemetry/sdk-trace-node@2.9.0`, `@opentelemetry/resources@2.9.0`, `@opentelemetry/semantic-conventions@1.43.0`, `typescript@7.0.2`, `tsx@4.23.1`, `@types/node@26.1.1` (all fetched 2026-07-15).
[^5]: Based on a WebSearch-aggregated summary of `opentelemetry.io/blog/2026/genai-observability/`, describing overall "Development" status for the GenAI and MCP conventions as of around May 2026, with client (model-call) spans having exited experimental status earlier in 2026. Included as general context on spec maturity rather than a precise, primary-sourced stability percentage — treat as directionally accurate rather than an exact-dated claim.
[^6]: `@anthropic-ai/sdk@0.111.0` compiled type definitions, `resources/messages/messages.d.ts` (the `Model` union type listing literal model ID strings including `claude-sonnet-5`, `claude-opus-4-8`, and `claude-haiku-4-5`; installed from npm and inspected directly, 2026-07-15). See also [the OpenTelemetry Collector + Node.js tracing tutorial](https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial) for the `BatchSpanProcessor` + OTLP exporter pattern referenced in Step 2.
[^7]: GitHub, `livekit/agents` issue #4639, "Missing required OpenTelemetry GenAI attributes (gen_ai.provider.name, gen_ai.operation.name)" — https://github.com/livekit/agents/issues/4639 (real-world example of an agent framework's spans missing the current attribute names as of the issue's filing; fetched 2026-07-15). The general "`gen_ai.system` deprecated in favor of `gen_ai.provider.name`, adoption still lagging across instrumentation libraries" framing is corroborated by a WebSearch-aggregated summary referencing this issue and others; treated as directionally accurate rather than a precise, primary-sourced migration timeline.
[^8]: `@opentelemetry/semantic-conventions@1.43.0`, installed from npm and inspected directly (2026-07-15): `package.json`'s `exports` field defines a stable `"."` entry (`build/src/index.d.ts`) and a separate `"./incubating"` entry (`build/src/index-incubating.d.ts`). `ATTR_GEN_AI_*` constants (including `ATTR_GEN_AI_PROVIDER_NAME`, `ATTR_GEN_AI_USAGE_INPUT_TOKENS`, `ATTR_GEN_AI_OPERATION_NAME`) are defined in `experimental_attributes.d.ts`, which `index-incubating.d.ts` re-exports via `export * from './experimental_attributes'` — they are absent from the stable `index.d.ts`. No `ATTR_GEN_AI_SYSTEM` constant exists in either file (only the unrelated `ATTR_GEN_AI_SYSTEM_INSTRUCTIONS`), consistent with `gen_ai.system` being deprecated rather than merely unreleased.
