A customer reports that the support agent told them their refund had been issued. It had not. Somewhere in a run of nine model calls and six tool calls, the agent decided a failed API call had succeeded. If all you have is application logs, you now spend an afternoon grepping timestamps across three services. If you have a trace, you open one tree, see the execute_tool issue_refund span marked as an error, and see the next model call summarising it as a success.
This is the guide we wish we had when we started instrumenting agents with OpenTelemetry: which conventions to follow as of October 2026, how to instrument a Python agent by hand and with libraries, how to keep prompts and personal data out of places they should not be, how to sample without losing the runs that matter, and how to turn token counts into cost per tenant. Our earlier article on reliable agents in production introduced the basic span types. This one goes much further into the plumbing.
Why traces beat logs for LLM applications
Logs are lines. An agent run is a tree: a request starts an agent, the agent calls a model, the model asks for tools, tools call other services, and the loop repeats. Three properties of LLM systems make the tree shape matter more than it does for ordinary services.
- Causality is the whole question. A wrong answer is usually caused several steps earlier: the wrong document retrieved, a tool timeout, a truncated output. Parent and child spans answer “what led to this” directly.
- Cost and latency are spread across many calls. A slow run might be one slow model call or twenty fast ones.
- Failures are often silent. A model that hits its output limit returns a 200, and a tool error can be paraphrased away by the next model call. Only structured attributes such as finish reasons and error types expose them.
The GenAI semantic conventions as of October 2026
OpenTelemetry’s GenAI semantic conventions now live in their own repository, open-telemetry/semantic-conventions-genai. They define what to call each span, which attributes to set and what values they take. Every document and nearly every gen_ai.* attribute is still marked Development; only borrowed general attributes such as error.type and server.address are Stable.1
The spans that matter for agents
| Operation | Span name | Kind | What it represents |
|---|---|---|---|
invoke_agent | invoke_agent {gen_ai.agent.name} | INTERNAL (in-process) or CLIENT (remote agent) | One agent invocation; the natural root of a run |
plan | plan {gen_ai.agent.name} | INTERNAL | A planning or task-decomposition phase, when the framework can identify one |
chat, generate_content, text_completion | {gen_ai.operation.name} {gen_ai.request.model} | CLIENT | One inference call to a model |
execute_tool | execute_tool {gen_ai.tool.name} | INTERNAL | One tool execution, including tools your own code runs |
invoke_workflow | invoke_workflow {gen_ai.workflow.name} | INTERNAL | An application-defined workflow that triggers several agents or calls |
The table summarises the inference, agent and tool span definitions.123 The tool span definition asks developers to instrument tools their own code runs, without recording two spans for one call.3
The attributes we always set
| Attribute | Requirement on inference spans | Why it matters |
|---|---|---|
gen_ai.operation.name, gen_ai.provider.name | Required | Lets every backend recognise the span as a GenAI span |
gen_ai.request.model, gen_ai.response.model | Conditionally required / Recommended | The response model is what actually ran, and what you bill against |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | Recommended | Input should include cached tokens; report billed counts where they differ |
gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens, gen_ai.usage.reasoning.output_tokens | Recommended when applicable | Cache reads and reasoning tokens are priced differently; you need them for real cost |
gen_ai.response.finish_reasons | Recommended | Truncation by length is one of the commonest silent failures |
gen_ai.conversation.id | Conditionally required when available | Groups runs into a session; do not fake it from a trace ID |
error.type | Conditionally required on error (Stable) | Low-cardinality error class such as timeout or a provider error code |
The token rules come straight from the inference span definition, including the instruction that input tokens include cached tokens and that billed counts win when a provider reports both.1 The conversation ID guidance is worth reading closely: instrumentations should not invent one from a UUID, trace ID or content hash, but application developers who own conversation history may add it themselves.3 The conventions also list attributes that should be set at span creation because samplers may need them: operation name, provider, request model and server address on inference spans, and agent and operation name on tool spans.13
Expect churn, and pin it
“Development” is not a formality. On 6 October 2026 the conventions replaced gen_ai.client.operation.duration with gen_ai.client.inference.duration for inference calls, and renamed the streaming chunk metrics.4 As of the next day, the repository’s unreleased changelog held nine fragments marked breaking.5 None of this argues against adopting the conventions. It argues for pinning your instrumentation versions, reading changelogs before upgrades, and building dashboards on a small set of attributes you control.
Design the span tree before you write code
Decide what one run looks like in the trace view, then instrument to match it. For a single tool-using agent, the shape we aim for is:
POST /v1/support/messages (HTTP server span, auto)
└── invoke_agent support_triage (root of the run)
├── chat claude-sonnet-5-5 (model call 1)
├── execute_tool lookup_order
│ └── GET orders-api /orders/{id} (HTTP client span, auto)
├── chat claude-sonnet-5-5 (model call 2)
├── execute_tool issue_refund [ERROR]
└── chat claude-sonnet-5-5 (model call 3, final answer)Three design rules keep these trees useful:
- One run, one trace. If the agent is resumed from a queue or after a human approval, propagate the trace context with the job so the resumed work joins the original tree, or link it with a span link when it must be a new trace.
- Put your own facts in your own namespace. Run outcome, budget usage, policy verdicts, tenant and feature belong under a prefix such as
app., not insidegen_ai.*. - Summarise the run on the root span. Total tokens, step count and outcome on
invoke_agentmake the root span queryable on its own, and give a tail sampler something to decide on.
Instrumenting a Python agent
Start with instrumentation libraries
You rarely need to hand-write inference spans for a supported SDK. The options we see in Python, as of early October 2026:
| Project | What it is | Attribute schema |
|---|---|---|
| OpenTelemetry Python GenAI | Official instrumentations in their own repository, released as 1.2b0 for OpenAI, Anthropic, Bedrock (boto3), Google GenAI, LangChain, LlamaIndex, OpenAI Agents, DSPy and others6 | The GenAI semantic conventions; the OpenAI package uses the latest experimental conventions unconditionally7 |
| OpenInference (Arize) | Conventions and instrumentation plugins “complementary to OpenTelemetry”, covering many providers and agent frameworks8 | OpenInference’s own conventions; natively understood by Phoenix and accepted by several other backends |
| OpenLLMetry (Traceloop) | Open-source extensions built on OpenTelemetry, initialised with Traceloop.init()9 | Its own attribute set alongside OpenTelemetry; check how your backend maps it |
All three export standard OTLP, so choose by which schema your backend renders best. Mixing them is where double-counted tokens come from: if a framework and an SDK instrumentation both emit a span for the same model call, your cost dashboard doubles. Pick one per call path.
With the official OpenAI package, the setup is a few lines. Content capture is off unless you opt in.7
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-genai-openai
# Content capture is off by default. Values: no_content (default),
# span_only, event_only, span_and_event.
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=no_content
from opentelemetry.instrumentation.genai.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument() # chat completions, Responses API, embeddingsAdd manual spans for the agent and its tools
Libraries cover the model call. The agent loop, the tools and your business context are yours to instrument. Here is the skeleton we use, written against the plain OpenTelemetry SDK so it works with any backend.
import json
from opentelemetry import trace, baggage, context
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.processor.baggage import BaggageSpanProcessor
from opentelemetry.trace import SpanKind, Status, StatusCode
provider = TracerProvider(resource=Resource.create({
"service.name": "support-agent",
"service.version": "2026.10.2",
"deployment.environment.name": "prod",
}))
# Copy app.* baggage (tenant, feature) onto every span in the run.
provider.add_span_processor(BaggageSpanProcessor(lambda key: key.startswith("app.")))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) # endpoint from env
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("support-agent")
MODEL = "claude-sonnet-5-5"
def run_agent(task, *, tenant_id: str, feature: str, conversation_id: str):
ctx = baggage.set_baggage("app.tenant_id", tenant_id) # opaque ID, not a name
ctx = baggage.set_baggage("app.feature", feature, context=ctx)
token = context.attach(ctx)
try:
with tracer.start_as_current_span(
"invoke_agent support_triage",
kind=SpanKind.INTERNAL,
attributes={ # set at creation: samplers see them
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "support_triage",
"gen_ai.agent.version": "14",
"gen_ai.conversation.id": conversation_id, # ours, not invented
"gen_ai.request.model": MODEL,
},
) as root:
result, totals = agent_loop(task) # calls the functions below
root.set_attribute("app.run.total_tokens", totals.tokens) # int, for tail sampling
root.set_attribute("app.run.steps", totals.steps)
root.set_attribute("app.run.outcome", result.outcome) # done | budget_stop | escalated
return result
finally:
context.detach(token)
def call_model(messages, tools):
with tracer.start_as_current_span(
f"chat {MODEL}",
kind=SpanKind.CLIENT,
attributes={
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": MODEL,
"gen_ai.request.max_tokens": 2048,
"server.address": "api.anthropic.com",
},
) as span:
try:
resp = client.messages.create(model=MODEL, max_tokens=2048,
messages=messages, tools=tools)
except Exception as exc:
span.set_attribute("error.type", type(exc).__name__)
span.set_status(Status(StatusCode.ERROR))
raise
u = resp.usage
cache_read = u.cache_read_input_tokens or 0
cache_write = u.cache_creation_input_tokens or 0
span.set_attribute("gen_ai.response.id", resp.id)
span.set_attribute("gen_ai.response.model", resp.model)
span.set_attribute("gen_ai.response.finish_reasons", [resp.stop_reason])
# Anthropic's input_tokens excludes cached tokens; the convention wants the total.
span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens + cache_read + cache_write)
span.set_attribute("gen_ai.usage.cache_read.input_tokens", cache_read)
span.set_attribute("gen_ai.usage.cache_write.input_tokens", cache_write)
span.set_attribute("gen_ai.usage.output_tokens", u.output_tokens)
return resp
def execute_tool(call, *, capture_content: bool = False):
with tracer.start_as_current_span(
f"execute_tool {call.name}",
kind=SpanKind.INTERNAL,
attributes={
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": call.name,
"gen_ai.tool.call.id": call.id,
"gen_ai.tool.type": "function",
"gen_ai.agent.name": "support_triage",
},
) as span:
if capture_content: # opt-in, see next section
span.set_attribute("gen_ai.tool.call.arguments", json.dumps(redact(call.input)))
try:
return TOOLS[call.name](**call.input)
except ToolError as exc:
span.set_attribute("error.type", exc.code) # e.g. "timeout", "not_found"
span.set_status(Status(StatusCode.ERROR, exc.code))
raiseOne detail has bitten us repeatedly: providers disagree about what “input tokens” means. Anthropic’s input_tokens excludes cache reads and writes, and the total is the sum of all three fields;10 the convention wants the total. Get this wrong and both your cache hit ratio and your cost figures are wrong. And if a library already instruments the model call, do not also create the chat span by hand.
Capturing prompts and completions safely
Prompts, completions, tool arguments and tool results are the most useful debugging data you have and the most dangerous to store. The conventions treat them as opt-in: instrumentations should not capture them by default, and the relevant attributes carry warnings that they are likely to contain personal data.13 The conventions describe three patterns:3
| Pattern | Where content goes | When we use it |
|---|---|---|
| Don’t record (default) | Nowhere; spans carry only metadata and token counts | Regulated data, or before anyone has decided who may read content |
| On span attributes | gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages | Development and staging, or a backend whose storage already meets your privacy obligations |
| External storage with references | An object store with its own access controls and retention; the span keeps a pointer | Production, where the conventions recommend it |
The external-storage pattern is supported through an upload hook that instrumentations should call regardless of the sampling decision.3 In the official Python packages, setting OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload and an OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH (any fsspec path, such as a bucket) enables the built-in uploader.7 The conventions still mark a common format for the references themselves as to-do, so check what your backend can follow.
Whatever you choose in the application, enforce it again in the Collector. Application settings drift; an environment variable copied from staging is enough to start shipping prompts to a vendor. We run two pipelines from one receiver: a vendor-bound copy with content attributes deleted, and a restricted copy that keeps content but masks obvious identifiers. The attributes processor handles deletion; the redaction processor masks values matching blocked patterns and, with allow_all_keys, leaves other attributes alone.1112
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
tail_sampling: # explained in the next section
decision_wait: 60s
num_traces: 200000
policies:
- name: keep-errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: keep-slow-runs
type: latency
latency: {threshold_ms: 45000}
- name: keep-expensive-runs
type: numeric_attribute
numeric_attribute: {key: app.run.total_tokens, min_value: 200000}
- name: keep-flagged-outcomes
type: string_attribute
string_attribute: {key: app.run.outcome, values: [budget_stop, escalated]}
- name: baseline
type: probabilistic
probabilistic: {sampling_percentage: 10}
attributes/strip-content: # vendor copy: no content at all
actions:
- {key: gen_ai.system_instructions, action: delete}
- {key: gen_ai.input.messages, action: delete}
- {key: gen_ai.output.messages, action: delete}
- {key: gen_ai.tool.call.arguments, action: delete}
- {key: gen_ai.tool.call.result, action: delete}
redaction/pii: # restricted copy: content kept, identifiers masked
allow_all_keys: true
blocked_values:
- '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+[.][A-Za-z.]{2,}' # email addresses
- '[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{1,4}' # card-like numbers
summary: info
batch: {}
exporters:
otlphttp/vendor:
endpoint: https://otlp.vendor.example
headers:
authorization: "${env:VENDOR_OTLP_AUTH}"
otlphttp/restricted:
endpoint: https://traces-restricted.internal.example
service:
pipelines:
traces/vendor:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, attributes/strip-content, batch]
exporters: [otlphttp/vendor]
traces/restricted:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, redaction/pii, batch]
exporters: [otlphttp/restricted]The redaction processor’s own documentation is candid that it is one line of defence, not a compliance programme.12 Regexes catch email addresses and card numbers; they do not catch a customer describing their medical condition in free text. If the content is sensitive, the external storage pattern with proper access control is the real answer.
Sampling: keep every error and every expensive run
Agent traces are large, so sampling is tempting. There are two places to do it.
- Head sampling decides when the trace starts, usually by trace ID ratio in the SDK. It is cheap and stateless, but it decides before anything interesting has happened, so it drops errors and expensive runs at the same rate as boring ones.
- Tail sampling decides after the trace is complete, in the Collector. The policies in the config above keep any trace with an error status, any run over 45 seconds, any run whose root span reports more than 200,000 tokens, runs that ended in a budget stop or escalation, and a 10% baseline of everything else.13
Tail sampling has real operational costs, and the processor’s README spells them out. All spans of a trace must reach the same Collector instance, so at scale you need a first layer running the load-balancing exporter keyed on trace ID and a second layer doing the sampling. The processor holds traces in memory while it waits: decision_wait defaults to 30 seconds and num_traces to 50,000.13 Agent runs are long, so the wait has to cover your slowest normal run, or the decision is made before the root span arrives with its totals. For runs that last many minutes, we make the keep-or-drop decision in the application at the end of the run instead.
There is also a respectable argument for not sampling at all. Honeycomb’s agent documentation advises against sampling GenAI telemetry at all,14 and we understand why: a conversation with half its runs sampled away is hard to debug and impossible to evaluate. Our rule of thumb: do not sample until volume forces you to, never sample errors, and never compute cost from sampled data.
Cost attribution per tenant and feature
The conventions give you token counts, not money. There is no cost attribute in the GenAI registry, which is the right call: prices change, discounts differ by contract, and cost belongs in a versioned rate card you control. Attribution needs two things on every span: who and what.
The skeleton above puts app.tenant_id and app.feature into OpenTelemetry Baggage at the start of the run, and the baggage span processor copies them onto every span that starts in that context.15 The same README carries a warning to take seriously: baggage is propagated in outgoing HTTP headers, so never put sensitive information in it.15 Use opaque IDs, and check whether your propagators send baggage to third-party APIs, model providers included.
A worked example
Take one run of a support agent on Claude Sonnet 5.5. Anthropic’s list prices as of October 2026 are $2 per million input tokens, $2.50 for 5-minute cache writes, $0.10 for cache reads and $10 for output.16 The run’s spans add up to 60,000 input tokens, of which 45,000 were cache reads and 3,000 cache writes, plus 2,500 output tokens (illustrative figures).
| Component | Tokens | Rate per million | Cost |
|---|---|---|---|
| Cache reads | 45,000 | $0.10 | $0.0045 |
| Cache writes (5 min) | 3,000 | $2.50 | $0.0075 |
| Uncached input | 12,000 | $2.00 | $0.0240 |
| Output | 2,500 | $10.00 | $0.0250 |
| Total | $0.0610 |
Price the same run naively, as 60,000 input tokens at the full input rate plus output, and you get $0.145, more than double. Teams that compute cost from gen_ai.usage.input_tokens alone overstate the cost of well-cached agents and cannot see when a prompt change silently breaks caching.
Because sampled traces undercount, we feed cost from an unsampled source: a third Collector pipeline with no tail sampling and all content stripped, exporting small usage spans to a warehouse table. The query is then ordinary SQL against a rate card with validity dates:
-- genai_usage: one row per inference span, from an unsampled, content-free pipeline
SELECT u.tenant_id,
u.feature,
SUM( (u.input_tokens - u.cache_read - u.cache_write) * r.input_per_mtok
+ u.cache_read * r.cache_read_per_mtok
+ u.cache_write * r.cache_write_5m_per_mtok
+ u.output_tokens * r.output_per_mtok ) / 1e6 AS cost_usd,
COUNT(DISTINCT u.trace_id) AS runs
FROM genai_usage u
JOIN rate_card r
ON r.model = u.response_model
AND u.ts >= r.valid_from AND u.ts < r.valid_to
WHERE u.ts >= DATE '2026-09-01'
GROUP BY u.tenant_id, u.feature
ORDER BY cost_usd DESC;Divide by successful runs, not all runs, and you have the number that matters most: cost per successful task.
Linking traces to evals and user feedback
Traces tell you what happened. Evals and feedback tell you whether it was any good. They are far more useful joined.
- Return the trace ID with every response. Store it next to thumbs-up, thumbs-down or a support ticket, and a complaint becomes one click from the full run.
- Record online eval results against the span. The conventions define a
gen_ai.evaluation.resultevent with the evaluation name, a score value and label, and an explanation. It should be parented to the GenAI span being evaluated, or carrygen_ai.response.idwhen that is not possible.17 - Turn bad traces into test cases. Every run a human had to correct is a candidate fixture for your offline eval suite. Our guide to retrofitting evals covers how to mine traces for this.
from opentelemetry.trace import format_trace_id, format_span_id
def trace_ref() -> dict:
ctx = trace.get_current_span().get_span_context()
return {"trace_id": format_trace_id(ctx.trace_id),
"span_id": format_span_id(ctx.span_id)}
# In the request handler: return trace_ref() to the client with the answer.
# In the feedback handler: store {trace_id, rating, comment, tenant_id}.
# In the nightly job: pull low-rated traces, review, promote to eval fixtures.Choosing a backend
Every backend below accepts OTLP. They differ in which attribute schema they render natively and what they expect from you. These are the facts we verified from each vendor’s documentation.
| Backend | OpenTelemetry support | Watch out for |
|---|---|---|
| Langfuse | OTLP endpoint at /api/public/otel over HTTP (JSON or protobuf); aims to follow the GenAI conventions and also maps OpenInference attributes18 | No gRPC; user and session IDs must be on every span, not only the root, for filtering |
| Arize Phoenix | Accepts traces over OTLP; OpenInference is native19; since 15.10.0 (May 2026) converts gen_ai.* attributes to OpenInference on arrival20 | Where both schemas are present, OpenInference values win |
| Grafana | Tempo stores OTLP traces and TraceQL queries span attributes, e.g. { span.gen_ai.operation.name = "execute_tool" && status = error }21; Grafana Cloud AI Observability is built on OpenLIT and OpenTelemetry22 | Raw Tempo gives you queries, not LLM-aware screens; check which instrumentation the AI Observability dashboards expect before you rely on them |
| Datadog | Ingests OTLP traces following GenAI conventions 1.37+ or OpenInference without the Datadog SDK or Agent, routed with the dd-otlp-source=llmobs header23 | Frameworks that emitted pre-1.37 names need OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental |
| Honeycomb | Agent Timeline built on GenAI attributes; requires gen_ai.conversation.id, gen_ai.agent.name and gen_ai.operation.name14 | Spans without an agent name show as “Unknown”; give each agent in a multi-agent system a unique name |
Because everything speaks OTLP, you need not choose early. We often send the content-free copy to the existing APM vendor and the restricted copy to a self-hosted LLM tool.
Dashboards and alerts that matter
Most LLM dashboards we inherit are token counters. These are the panels we build first, all derivable from the attributes above:
| Panel | Built from | Alert when |
|---|---|---|
| Run outcome mix | app.run.outcome on invoke_agent | Budget stops or escalations jump above their usual band |
| Cost per successful task, by feature | Unsampled usage pipeline and rate card | Daily value drifts more than an agreed percentage |
| Cache hit ratio | cache_read.input_tokens / input_tokens | Sudden drop after a deploy: someone changed the prompt prefix |
| Truncation rate | gen_ai.response.finish_reasons containing a length stop | Any sustained rise; truncated outputs fail quietly |
| Tool error rate, by tool | execute_tool spans with error.type | One tool’s error rate spikes; usually a downstream outage |
| Model latency and errors, by provider | Inference span duration and error.type | p95 or 429/5xx rate crosses threshold; check fallbacks fired |
| Steps per run | app.run.steps | Distribution shifts right: a prompt or tool change is causing loops |
| Online eval scores and feedback | gen_ai.evaluation.result events, feedback joined by trace ID | Rolling score drops below the baseline for a release |
Keep tenant and user IDs out of metric labels. They belong on spans, where high cardinality is cheap, not on metrics, where it is not.
Failure modes we see in practice
- Orphaned spans. Context is lost across thread pools, async tasks or job queues, and tool spans appear as separate traces. Test propagation explicitly for every hop.
- Double-counted tokens. Two instrumentations wrap the same call. Symptom: dashboard cost is a clean multiple of the provider invoice.
- Wrong input token totals. Provider fields mapped without reading their definitions, so cached tokens are dropped or counted twice.
- Dashboards break on upgrade. A convention rename lands with a library update. Pin versions and alert on “no data” for key panels.
- Split or truncated traces from tail sampling. Spans for one trace land on different Collector instances, or the decision fires before the run ends.
- Oversized attributes. Full message histories exceed backend attribute limits and are silently cut. The conventions themselves warn that content may exceed backend limits.3
Checklist
- One trace per agent run, with
invoke_agentas the root and context propagated across queues and resumptions. - Inference spans from one instrumentation per call path, following the GenAI conventions, with versions pinned.
execute_toolspans for every tool, including tools your own code runs, witherror.typeon failure.- Token totals that include cache reads and writes, with the cache and reasoning breakdowns recorded separately.
- Run summary on the root span: total tokens, steps, outcome.
- Content capture off by default; production content in access-controlled storage, not on spans.
- Collector strips content from vendor-bound telemetry and masks identifiers in the restricted copy.
- No sampling until needed; when needed, tail sampling that keeps all errors, slow runs and expensive runs.
- Tenant and feature on every span via baggage, as opaque IDs that never reach third parties.
- Cost computed from an unsampled usage stream and a versioned rate card, reported per successful task.
- Trace IDs stored with user feedback; eval results recorded against the evaluated span.
- Alerts on outcome mix, cache hit ratio, truncation, tool errors, provider errors and eval scores.
Instrumentation is the part of AI operations that pays for itself the first time something goes wrong in production. If you want help setting up tracing, cost attribution and alerting for your agents, that is what our AI operations work covers.
Sources
- OpenTelemetry GenAI semantic conventions, “Client inference” (status: Development), repository state of 7 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/docs/gen-ai/client-inference.md
- OpenTelemetry GenAI semantic conventions, “GenAI agent and framework spans” (status: Development), repository state of 7 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/docs/gen-ai/gen-ai-agent-spans.md
- OpenTelemetry GenAI semantic conventions, “GenAI spans” (execute tool span; capturing instructions, inputs and outputs), repository state of 7 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/docs/gen-ai/gen-ai-spans.md
- open-telemetry/semantic-conventions-genai, commit 4f85037, “Use gen_ai.client.inference.duration metric instead of gen_ai.client.operation.duration”, 6 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/commit/4f85037ef86e92c510d2ef881a58f1076f6fc0e4
- open-telemetry/semantic-conventions-genai, unreleased changelog fragments (changelog.d), repository state of 7 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/tree/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/changelog.d
- OpenTelemetry Python GenAI Instrumentations, repository README (released instrumentations, 1.2b0), state of 6 Oct 2026 — github.com/open-telemetry/opentelemetry-python-genai/blob/435999a03f312640162fbc3cc6b22a8a3019c5ba/README.md
- OpenTelemetry, opentelemetry-instrumentation-genai-openai README (content capture, upload hook), state of 6 Oct 2026 — github.com/open-telemetry/opentelemetry-python-genai/blob/435999a03f312640162fbc3cc6b22a8a3019c5ba/instrumentation/opentelemetry-instrumentation-genai-openai/README.rst
- Arize AI, OpenInference repository README — github.com/Arize-ai/openinference
- Traceloop, OpenLLMetry repository README — github.com/traceloop/openllmetry
- Anthropic, Claude API documentation, “Prompt caching” (usage fields) — platform.claude.com/docs/en/build-with-claude/prompt-caching
- OpenTelemetry Collector Contrib, Attributes processor README — github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/attributesprocessor
- OpenTelemetry Collector Contrib, Redaction processor README — github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/redactionprocessor
- OpenTelemetry Collector Contrib, Tail sampling processor README — github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor
- Honeycomb documentation, “Send data from AI agents” (Agent Timeline, GenAI attributes) — docs.honeycomb.io/send-data/agents
- OpenTelemetry Python Contrib, opentelemetry-processor-baggage README — github.com/open-telemetry/opentelemetry-python-contrib/tree/main/processor/opentelemetry-processor-baggage
- Anthropic, Claude API documentation, “Pricing” — platform.claude.com/docs/en/about-claude/pricing
- OpenTelemetry GenAI semantic conventions, “Events” (gen_ai.evaluation.result), repository state of 7 Oct 2026 — github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/docs/gen-ai/gen-ai-events.md
- Langfuse documentation, “OpenTelemetry” native integration — langfuse.com/integrations/native/opentelemetry
- Arize Phoenix documentation, “Tracing” — arize.com/docs/phoenix/tracing/llm-traces
- Arize Phoenix release notes, “OTel GenAI Semantic Convention Auto-Conversion,” 15 May 2026 — arize.com/docs/phoenix/release-notes/05-2026/05-15-2026-otel-semconv-conversion
- Grafana Tempo documentation, “Construct a TraceQL query” — grafana.com/docs/tempo/latest/traceql/construct-traceql-queries/
- Grafana Cloud documentation, “AI Observability” — grafana.com/docs/grafana-cloud/monitor-applications/ai-observability/
- Datadog documentation, LLM Observability “OpenTelemetry Instrumentation” — docs.datadoghq.com/llm_observability/instrumentation/otel_instrumentation/
Specifications, library versions and vendor documentation were checked against each primary source as of early October 2026. The GenAI conventions are linked at the repository state of 7 October 2026 because they change frequently. Code and configuration are illustrative sketches rather than drop-in implementations; the worked example uses illustrative token counts and list prices.
