<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged angeticalai</title>
<link>https://coderlegion.com/tag/angeticalai</link>
<description>Powered by Question2Answer</description>
<item>
<title>Your AI Agent Is a Black Box — Instrumenting LLM Apps with OpenTelemetry GenAI Conventions</title>
<link>https://coderlegion.com/24350/your-ai-agent-is-a-black-box-instrumenting-llm-apps-with-opentelemetry-genai-conventions</link>
<description>&lt;p&gt;The support ticket says &quot;the assistant took forever and then gave up.&quot; You open your logs. There's a line that says &lt;code&gt;calling model...&lt;/code&gt;, a line eleven seconds later that says &lt;code&gt;done&lt;/code&gt;, and somewhere in between, three tool calls that logged nothing because they succeeded. The invoice at the end of the month says €4,180 and you cannot tell which feature spent it.&lt;/p&gt;
&lt;p&gt;This is the normal state of an LLM application, and it's not because the team was lazy. It's because everyone instruments these things ad hoc — a &lt;code&gt;logger.info&lt;/code&gt; here, a token counter bolted onto a wrapper there, a Grafana panel that measures the one thing somebody happened to care about in March. Six months later you have telemetry that only its author can read, and no way to compare last week to this week.&lt;/p&gt;
&lt;p&gt;The OpenTelemetry &lt;strong&gt;GenAI semantic conventions&lt;/strong&gt; are the fix: an agreed-upon schema for what an LLM call, a tool execution and an agent invocation look like as telemetry. Not a vendor SDK, not a platform — a naming convention that any backend can consume. This article is how to actually use it: the spans, the metrics, the content-capture question, working Python, and an honest account of the parts that will change under you.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cursuri-ai.ro/images/blog/otel-genai-observability-en.svg&quot; alt=&quot;LLM observability with OpenTelemetry — an agent trace with invoke_agent, chat and execute_tool spans plus gen_ai metrics&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-llm-apps-break-normal-observability&quot;&gt;Why LLM apps break normal observability&lt;/h3&gt;
&lt;p&gt;Three properties make a model call unlike the HTTP calls your existing instrumentation was designed for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every call costs money, and the cost is variable.&lt;/strong&gt; A database query is either fast or slow. A model call is fast or slow &lt;em&gt;and&lt;/em&gt; cheap or expensive, and the two aren't correlated — a short, quick response to a 90,000-token context is your most expensive request of the day. Latency dashboards will never show you that. You need token counts as first-class telemetry, dimensioned the same way your latency is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failure is often silent.&lt;/strong&gt; A &lt;code&gt;200 OK&lt;/code&gt; containing a hallucinated answer, a truncated response that hit &lt;code&gt;max_tokens&lt;/code&gt;, a tool call the model decided to skip — none of these raise. Your error rate stays at 0.0% while the feature is broken.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A single user action is a tree, not a call.&lt;/strong&gt; One &quot;summarise this and file a ticket&quot; fans out into an agent invocation, three inference calls, two tool executions and a retry. Averages across that tree are meaningless. You need the tree.&lt;/p&gt;
&lt;p&gt;Distributed tracing already solves the third problem, and it has for a decade. The GenAI conventions extend it to cover the first two.&lt;/p&gt;
&lt;h3 id=&quot;the-conventions-in-one-screen&quot;&gt;The conventions in one screen&lt;/h3&gt;
&lt;p&gt;Everything lives under the &lt;code&gt;gen_ai.*&lt;/code&gt; namespace, and it decomposes into three signals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Spans&lt;/strong&gt; — one per operation. An &lt;em&gt;inference&lt;/em&gt; span for a model call, plus agent-framework spans: &lt;code&gt;invoke_agent&lt;/code&gt;, &lt;code&gt;create_agent&lt;/code&gt;, &lt;code&gt;execute_tool&lt;/code&gt;, &lt;code&gt;invoke_workflow&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metrics&lt;/strong&gt; — pre-aggregated histograms for token usage, operation duration and streaming latency, so you're not computing cost by scanning traces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Events&lt;/strong&gt; — the prompt and completion content, opt-in, because it's PII.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the inference span, the naming rule is &lt;code&gt;{gen_ai.operation.name} {gen_ai.request.model}&lt;/code&gt; — so &lt;code&gt;chat claude-opus-5&lt;/code&gt;, not &lt;code&gt;anthropic_call&lt;/code&gt; or &lt;code&gt;llm&lt;/code&gt;. Two attributes are &lt;strong&gt;Required&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Attribute &lt;/th&gt;&lt;th&gt;Example&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gen_ai.operation.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;embeddings&lt;/code&gt;, &lt;code&gt;generate_content&lt;/code&gt;, &lt;code&gt;text_completion&lt;/code&gt;, &lt;code&gt;execute_tool&lt;/code&gt;, &lt;code&gt;invoke_agent&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gen_ai.provider.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;gcp.gemini&lt;/code&gt;, &lt;code&gt;aws.bedrock&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Then a set that's &lt;strong&gt;Conditionally Required&lt;/strong&gt; when it applies — &lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.conversation.id&lt;/code&gt;, &lt;code&gt;gen_ai.output.type&lt;/code&gt;, &lt;code&gt;gen_ai.request.choice.count&lt;/code&gt; (when it isn't 1), &lt;code&gt;gen_ai.request.stream&lt;/code&gt;, &lt;code&gt;gen_ai.request.seed&lt;/code&gt;, &lt;code&gt;error.type&lt;/code&gt; — and a &lt;strong&gt;Recommended&lt;/strong&gt; set that carries most of the analytical value: &lt;code&gt;gen_ai.request.temperature&lt;/code&gt;, &lt;code&gt;gen_ai.request.top_p&lt;/code&gt;, &lt;code&gt;gen_ai.request.max_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.response.id&lt;/code&gt;, &lt;code&gt;server.address&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That's the whole contract. If you emit those, any conforming backend can chart your LLM traffic without you writing a single custom query.&lt;/p&gt;
&lt;h3 id=&quot;instrumenting-a-call-by-hand&quot;&gt;Instrumenting a call by hand&lt;/h3&gt;
&lt;p&gt;Auto-instrumentation exists (we'll get to it), but writing one span manually is the fastest way to understand what the libraries are doing — and you'll need it anyway for anything the libraries don't cover.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import time
from opentelemetry import trace, metrics

tracer = trace.get_tracer(&quot;myapp.llm&quot;)
meter = metrics.get_meter(&quot;myapp.llm&quot;)

token_usage = meter.create_histogram(
    &quot;gen_ai.client.token.usage&quot;,
    unit=&quot;{token}&quot;,
    description=&quot;Number of input and output tokens used.&quot;,
)
op_duration = meter.create_histogram(
    &quot;gen_ai.client.operation.duration&quot;,
    unit=&quot;s&quot;,
    description=&quot;GenAI operation duration.&quot;,
)

PROVIDER = &quot;anthropic&quot;


def chat(client, model: str, messages: list, max_tokens: int = 1024):
    base = {
        &quot;gen_ai.operation.name&quot;: &quot;chat&quot;,
        &quot;gen_ai.provider.name&quot;: PROVIDER,
        &quot;gen_ai.request.model&quot;: model,
    }
    started = time.perf_counter()

    with tracer.start_as_current_span(
        f&quot;chat {model}&quot;,
        kind=trace.SpanKind.CLIENT,
        attributes={**base, &quot;gen_ai.request.max_tokens&quot;: max_tokens},
    ) as span:
        try:
            resp = client.messages.create(
                model=model, max_tokens=max_tokens, messages=messages
            )
        except Exception as exc:
            error_type = type(exc).__qualname__
            span.set_attribute(&quot;error.type&quot;, error_type)
            span.set_status(trace.StatusCode.ERROR, str(exc))
            op_duration.record(
                time.perf_counter() - started, {**base, &quot;error.type&quot;: error_type}
            )
            raise

        span.set_attributes({
            &quot;gen_ai.response.id&quot;: resp.id,
            &quot;gen_ai.response.model&quot;: resp.model,
            &quot;gen_ai.response.finish_reasons&quot;: [resp.stop_reason],
            &quot;gen_ai.usage.input_tokens&quot;: resp.usage.input_tokens,
            &quot;gen_ai.usage.output_tokens&quot;: resp.usage.output_tokens,
        })

        token_usage.record(resp.usage.input_tokens, {**base, &quot;gen_ai.token.type&quot;: &quot;input&quot;})
        token_usage.record(resp.usage.output_tokens, {**base, &quot;gen_ai.token.type&quot;: &quot;output&quot;})
        op_duration.record(time.perf_counter() - started, base)

        return resp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details in there that people get wrong:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Both request &lt;em&gt;and&lt;/em&gt; response model.&lt;/strong&gt; &lt;code&gt;gen_ai.request.model&lt;/code&gt; is what you asked for; &lt;code&gt;gen_ai.response.model&lt;/code&gt; is what answered. If you use an alias or a routing layer, these diverge, and the day they diverge unexpectedly is the day you want the data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;error.type&lt;/code&gt; goes on the metric too, not just the span.&lt;/strong&gt; Otherwise your duration histogram silently mixes successful 4-second calls with timeouts that died at 60, and your p95 becomes fiction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Record the metric even on the failure path.&lt;/strong&gt; The most common instrumentation bug in LLM code is a &lt;code&gt;record()&lt;/code&gt; call that only runs when nothing went wrong.&lt;/p&gt;
&lt;h3 id=&quot;streaming-where-the-interesting-latency-lives&quot;&gt;Streaming, where the interesting latency lives&lt;/h3&gt;
&lt;p&gt;For any user-facing generation, total duration is the wrong number. Users perceive &lt;strong&gt;time to first token&lt;/strong&gt;; total duration only tells you how long the sentence was. The conventions define histograms for exactly this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gen_ai.client.operation.time_to_first_chunk&lt;/code&gt; (seconds)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gen_ai.client.operation.time_per_output_chunk&lt;/code&gt; (seconds)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ttfc = meter.create_histogram(
    &quot;gen_ai.client.operation.time_to_first_chunk&quot;, unit=&quot;s&quot;,
    description=&quot;Time to first chunk of a streaming GenAI response.&quot;,
)
tpoc = meter.create_histogram(
    &quot;gen_ai.client.operation.time_per_output_chunk&quot;, unit=&quot;s&quot;,
    description=&quot;Time between chunks of a streaming GenAI response.&quot;,
)


def chat_stream(client, model, messages, max_tokens=1024):
    base = {&quot;gen_ai.operation.name&quot;: &quot;chat&quot;,
            &quot;gen_ai.provider.name&quot;: PROVIDER,
            &quot;gen_ai.request.model&quot;: model}
    started = time.perf_counter()
    first_chunk_at = None
    chunks = 0

    with tracer.start_as_current_span(
        f&quot;chat {model}&quot;, kind=trace.SpanKind.CLIENT,
        attributes={**base, &quot;gen_ai.request.stream&quot;: True},
    ) as span:
        with client.messages.stream(model=model, max_tokens=max_tokens,
                                    messages=messages) as stream:
            for text in stream.text_stream:
                if first_chunk_at is None:
                    first_chunk_at = time.perf_counter()
                    ttfc.record(first_chunk_at - started, base)
                chunks += 1
                yield text

            final = stream.get_final_message()

        total = time.perf_counter() - started
        if chunks &amp;gt; 1 and first_chunk_at is not None:
            tpoc.record((total - (first_chunk_at - started)) / (chunks - 1), base)
        span.set_attributes({
            &quot;gen_ai.usage.input_tokens&quot;: final.usage.input_tokens,
            &quot;gen_ai.usage.output_tokens&quot;: final.usage.output_tokens,
        })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note where the span ends: &lt;strong&gt;after&lt;/strong&gt; the stream is fully consumed. The single most common streaming instrumentation bug is a context manager that closes when the generator is &lt;em&gt;returned&lt;/em&gt; rather than when it's &lt;em&gt;exhausted&lt;/em&gt; — which produces a beautiful dashboard showing 40 ms LLM calls. Also note that &lt;code&gt;yield&lt;/code&gt; inside the span means the span stays open across the consumer's work; if your consumer is slow, that's now inside your latency. Sometimes that's what you want. Decide deliberately.&lt;/p&gt;
&lt;h3 id=&quot;agents-the-spans-that-make-a-trace-readable&quot;&gt;Agents: the spans that make a trace readable&lt;/h3&gt;
&lt;p&gt;An inference span alone tells you a model was called. It doesn't tell you &lt;em&gt;why&lt;/em&gt;, or which of the six steps in your agent loop burned the time. That's what the agent spans are for:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Span name &lt;/th&gt;&lt;th&gt;Kind &lt;/th&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;create_agent {gen_ai.agent.name}&lt;/code&gt; &lt;/td&gt;&lt;td&gt;CLIENT &lt;/td&gt;&lt;td&gt;&lt;code&gt;create_agent&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;invoke_agent {gen_ai.agent.name}&lt;/code&gt; &lt;/td&gt;&lt;td&gt;CLIENT or INTERNAL &lt;/td&gt;&lt;td&gt;&lt;code&gt;invoke_agent&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;invoke_workflow {gen_ai.workflow.name}&lt;/code&gt; &lt;/td&gt;&lt;td&gt;INTERNAL &lt;/td&gt;&lt;td&gt;&lt;code&gt;invoke_workflow&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;plan {gen_ai.agent.name}&lt;/code&gt; &lt;/td&gt;&lt;td&gt;INTERNAL &lt;/td&gt;&lt;td&gt;&lt;code&gt;plan&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;execute_tool {gen_ai.tool.name}&lt;/code&gt; &lt;/td&gt;&lt;td&gt;INTERNAL &lt;/td&gt;&lt;td&gt;&lt;code&gt;execute_tool&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Wrapping your agent loop is a dozen lines and changes what a trace is worth:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def run_agent(agent_name: str, conversation_id: str, task: str):
    with tracer.start_as_current_span(
        f&quot;invoke_agent {agent_name}&quot;,
        kind=trace.SpanKind.INTERNAL,
        attributes={
            &quot;gen_ai.operation.name&quot;: &quot;invoke_agent&quot;,
            &quot;gen_ai.provider.name&quot;: PROVIDER,
            &quot;gen_ai.agent.name&quot;: agent_name,
            &quot;gen_ai.conversation.id&quot;: conversation_id,
        },
    ):
        while not done:
            resp = chat(...)              # child inference span
            for call in tool_calls(resp):
                with tracer.start_as_current_span(
                    f&quot;execute_tool {call.name}&quot;,
                    kind=trace.SpanKind.INTERNAL,
                    attributes={
                        &quot;gen_ai.operation.name&quot;: &quot;execute_tool&quot;,
                        &quot;gen_ai.tool.name&quot;: call.name,
                        &quot;gen_ai.tool.call.id&quot;: call.id,
                        &quot;gen_ai.tool.type&quot;: &quot;function&quot;,
                    },
                ):
                    run_tool(call)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are matching metrics — &lt;code&gt;gen_ai.invoke_agent.duration&lt;/code&gt;, &lt;code&gt;gen_ai.invoke_agent.inference_calls&lt;/code&gt;, &lt;code&gt;gen_ai.invoke_agent.tool_calls&lt;/code&gt;, &lt;code&gt;gen_ai.execute_tool.duration&lt;/code&gt;, &lt;code&gt;gen_ai.invoke_workflow.duration&lt;/code&gt; — and &lt;code&gt;inference_calls&lt;/code&gt; per invocation is quietly the most useful number in the set. A histogram of &quot;how many model calls did it take to finish one task&quot; is your loop-efficiency metric; when its p95 doubles after a prompt change, you've found a regression that no eval and no latency panel would have surfaced. That interaction between telemetry and &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/ai-agents-architecture-and-automation&quot;&gt;agent architecture&lt;/a&gt; is where most of the real tuning happens.&lt;/p&gt;
&lt;h3 id=&quot;prompts-and-completions-the-part-you-should-think-about-twice&quot;&gt;Prompts and completions: the part you should think about twice&lt;/h3&gt;
&lt;p&gt;The conventions capture content in a dedicated event, &lt;code&gt;gen_ai.client.inference.operation.details&lt;/code&gt;, carrying:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gen_ai.input.messages&lt;/code&gt; — the chat history sent to the model, in order&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gen_ai.output.messages&lt;/code&gt; — one entry per output choice&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gen_ai.system_instructions&lt;/code&gt; — system-level guidance, when the API separates it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gen_ai.tool.definitions&lt;/code&gt; — the tools offered&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gen_ai.prompt.variable&lt;/code&gt; — template variables resolved at runtime&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of these is marked &lt;strong&gt;Opt-In&lt;/strong&gt;, and the spec attaches an explicit warning: &lt;em&gt;&quot;This attribute is likely to contain sensitive information including user/PII data.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Take it literally. Turning on content capture means your observability backend now stores user messages — which is a data-processing decision with GDPR consequences (retention, access control, subprocessor agreements, deletion requests), and, if what you're capturing includes AI-generated output about identifiable people, an EU AI Act conversation as well. &quot;We turned on the debug flag&quot; is not a lawful basis.&lt;/p&gt;
&lt;p&gt;In Python, content capture is gated by an environment variable rather than code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Off by default. Enable deliberately, per environment.
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Traceloop's OpenLLMetry instrumentations use their own switch, &lt;code&gt;TRACELOOP_TRACE_CONTENT=false&lt;/code&gt;, and default to capturing content &lt;em&gt;on&lt;/em&gt; — worth checking before you deploy one.)&lt;/p&gt;
&lt;p&gt;The pattern that works in practice: content capture &lt;strong&gt;on&lt;/strong&gt; in dev and staging, &lt;strong&gt;off&lt;/strong&gt; in production, with a sampled, redacted path for production debugging if you genuinely need one. You lose less than you'd think — traces without content still tell you the shape, the cost and the failure; and the qualitative &quot;was the answer good&quot; question belongs in an &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/llm-evaluation-and-testing&quot;&gt;evaluation harness&lt;/a&gt; with a curated dataset, not in your trace store.&lt;/p&gt;
&lt;h3 id=&quot;auto-instrumentation-and-the-version-problem&quot;&gt;Auto-instrumentation, and the version problem&lt;/h3&gt;
&lt;p&gt;You don't have to write all of this. But you do have to know which generation of the conventions your library speaks, because the conventions moved and most libraries haven't caught up uniformly.&lt;/p&gt;
&lt;p&gt;The two renames that will bite you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;v1.27.0&lt;/strong&gt; — &lt;code&gt;prompt_tokens&lt;/code&gt; → &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;completion_tokens&lt;/code&gt; → &lt;code&gt;output_tokens&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1.37.0&lt;/strong&gt; — &lt;code&gt;gen_ai.system&lt;/code&gt; → &lt;code&gt;gen_ai.provider.name&lt;/code&gt;, and per-message events replaced by structured &lt;code&gt;gen_ai.input.messages&lt;/code&gt; / &lt;code&gt;gen_ai.output.messages&lt;/code&gt; / &lt;code&gt;gen_ai.system_instructions&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instrumentations participating in the transition keep emitting the older shape &lt;strong&gt;by default&lt;/strong&gt; so they don't break your dashboards. To get current behaviour you opt in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The library landscape as it stands: &lt;code&gt;opentelemetry-instrumentation-openai-v2&lt;/code&gt; in OpenTelemetry Python Contrib; Traceloop's OpenLLMetry packages, including &lt;code&gt;opentelemetry-instrumentation-anthropic&lt;/code&gt;, for provider coverage; OpenInference for another take. Framework support is uneven — Pydantic AI emits the current structured format, Vercel's AI SDK ships a dedicated OTel package, some agent frameworks still default to the v1.36-era shape, and OpenAI's Agents SDK doesn't emit GenAI conventions natively at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And the honest caveat:&lt;/strong&gt; the &lt;code&gt;gen_ai.*&lt;/code&gt; surface is in &lt;strong&gt;Development&lt;/strong&gt; status. It moved out of the main &lt;code&gt;semantic-conventions&lt;/code&gt; repository (deprecated there in v1.42.0, June 2026) into a dedicated &lt;code&gt;semantic-conventions-genai&lt;/code&gt; repository, which at the time of writing has no tagged release and no stable schema URL. Core attributes you rely on — &lt;code&gt;error.type&lt;/code&gt;, &lt;code&gt;server.address&lt;/code&gt; — are stable. The GenAI-specific ones can still be renamed between versions.&lt;/p&gt;
&lt;p&gt;That's not a reason to wait. It's a reason to put your attribute names in one module instead of sprinkling string literals across forty call sites, so the next rename is one commit.&lt;/p&gt;
&lt;h3 id=&quot;what-to-actually-put-on-the-dashboard&quot;&gt;What to actually put on the dashboard&lt;/h3&gt;
&lt;p&gt;Instrumentation you don't query is a tax. Four panels earn their space:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cost per feature.&lt;/strong&gt; &lt;code&gt;gen_ai.client.token.usage&lt;/code&gt; split by &lt;code&gt;gen_ai.token.type&lt;/code&gt; and by your own feature dimension, multiplied by price per token. The conventions deliberately don't define a cost metric — prices change and live outside the app — so you derive it, and that derivation belongs in your dashboard, not in an attribute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;p95 time-to-first-chunk, per model.&lt;/strong&gt; The number your users feel. Alert on it; never alert on the mean.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool error rate.&lt;/strong&gt; &lt;code&gt;error.type&lt;/code&gt; on &lt;code&gt;execute_tool&lt;/code&gt; spans, grouped by &lt;code&gt;gen_ai.tool.name&lt;/code&gt;. In a healthy agent this is where problems show up first — the model is fine, the tool it's calling is returning garbage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inference calls per agent invocation.&lt;/strong&gt; The loop-efficiency signal described above. Prompt changes move it before they move anything else.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two traps while you build those:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cardinality.&lt;/strong&gt; &lt;code&gt;gen_ai.conversation.id&lt;/code&gt; and any user identifier belong on &lt;em&gt;spans&lt;/em&gt;, never on &lt;em&gt;metric&lt;/em&gt; attributes. One conversation id per metric series is how you turn a €200/month observability bill into a €9,000 one. Metrics get low-cardinality dimensions — provider, model, operation, feature, error type — and nothing else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sampling.&lt;/strong&gt; If you head-sample traces at 10% and compute cost from spans, your cost is wrong by roughly 10×, and not consistently. Metrics are not sampled: cost and latency come from metrics, root-cause comes from the sampled traces. Keep those jobs separate. This is standard SRE discipline applied to a new dependency — the same discipline that makes &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/ai-for-devops-and-sre-aiops&quot;&gt;AI infrastructure operable&lt;/a&gt; rather than merely deployed.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;There is no special &quot;LLM observability&quot; discipline. There's tracing, there are metrics, and there's a schema — and the value of the GenAI conventions isn't that they're clever, it's that they're &lt;em&gt;shared&lt;/em&gt;. Emit &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt; instead of &lt;code&gt;tokens_out&lt;/code&gt; and every backend, every dashboard template and every teammate who joins next quarter already knows what it means. Emit &lt;code&gt;execute_tool {name}&lt;/code&gt; as a child span and your agent stops being a black box in exactly one deploy.&lt;/p&gt;
&lt;p&gt;Start with the inference span and the two metrics. Add agent and tool spans the first time a trace leaves you guessing. Leave content capture off in production until you've had the data-protection conversation. Put the attribute names in one module, because the spec is still moving.&lt;/p&gt;
&lt;p&gt;Then go look at what your agent has actually been doing all this time. It is rarely what you assumed — and instrumenting it properly is the cheapest step between a demo that works and &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/courses/advanced-llm-integration-in-production&quot;&gt;an LLM feature that runs in production&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Written by the team behind &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/en/&quot;&gt;Cursuri-AI.ro&lt;/a&gt;, an AI education platform with hands-on English-language courses on production LLM integration, AI agents, evaluation, and shipping AI products.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sources &amp;amp; further reading:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OpenTelemetry — &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/semantic-conventions-genai&quot;&gt;GenAI semantic conventions repository&lt;/a&gt; (spans, agent spans, metrics, events; Development status)&lt;/li&gt;
&lt;li&gt;OpenTelemetry — &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/specs/semconv/gen-ai/&quot;&gt;Semantic conventions for GenAI operations&lt;/a&gt; (entry point and migration notice)&lt;/li&gt;
&lt;li&gt;OpenTelemetry Python Contrib — &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/opentelemetry-instrumentation-openai-v2/&quot;&gt;&lt;code&gt;opentelemetry-instrumentation-openai-v2&lt;/code&gt;&lt;/a&gt; (content capture and stability opt-in variables)&lt;/li&gt;
&lt;li&gt;Traceloop — &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/opentelemetry-instrumentation-anthropic/&quot;&gt;&lt;code&gt;opentelemetry-instrumentation-anthropic&lt;/code&gt;&lt;/a&gt; (OpenLLMetry provider instrumentation, &lt;code&gt;TRACELOOP_TRACE_CONTENT&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;This article is educational content. The GenAI semantic conventions are pre-stable and attribute names change between versions — verify against the current specification before you standardise on them.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24350/your-ai-agent-is-a-black-box-instrumenting-llm-apps-with-opentelemetry-genai-conventions</guid>
<pubDate>Sun, 09 Aug 2026 21:45:53 +0000</pubDate>
</item>
</channel>
</rss>