ai-ml

Trace Claude Agent Tool Calls with OpenTelemetry (2026)

July 15, 2026

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

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 spec2 — 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 calls1
  • 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 involved4
  • @opentelemetry/api@1.9.1, @opentelemetry/sdk-trace-node@2.9.0, and @opentelemetry/resources@2.9.04
  • TypeScript 7.0.2 with strict and noUncheckedIndexedAccess enabled4
  • 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 too1. 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 year5. 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.name2.

Step 1: Set up the project

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:

{
  "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:

// 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 like6. 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:

// 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 export7. 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:

// 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);
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:

{
  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.OK0 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.

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

[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_choice4. 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 all2. 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 shape8. 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 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 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.

For the spec itself, the GenAI semantic conventions repository is the primary source now that the docs have moved off the main site1, 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 code2.

Footnotes

  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 3 4 5

  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) 2 3 4

  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). 2 3 4

  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 for the BatchSpanProcessor + OTLP exporter pattern referenced in Step 2.

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

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