AI Development

GenAI Telemetry: Instrument the Turn, Not the Model Call

The OpenTelemetry GenAI conventions give you a vocabulary. They do not give you a span tree. Most LLM products we audit have thousands of perfectly attributed model-call spans and nothing that covers the thing a user is actually waiting for.

KAKabir AnandLead Developer
9 min read
Editorial photograph of a long timber beam spanning a gap between two stone piers, with several short offcuts stacked underneath it

The dashboard said the model was fine. p95 on the chat span was 2.4 seconds, token usage flat week over week, error rate under one percent. The client's support team said the assistant took "about half a minute" to answer anything. Both were true. There were roughly eight thousand spans a day in that trace store and not one of them covered the interval the user was sitting through.

This is the most common instrumentation failure we walk into on LLM products now, and it is never caused by missing telemetry. It is caused by telemetry that describes the SDK instead of the product. Auto-instrumentation hooks the client library, so you get one span per model call — correctly named, correctly attributed, genuinely useful for the question it answers. Meanwhile a single user turn in that product was 4.1 model calls on average, two tool executions, one retry, and about three seconds of our own code between them.

The OpenTelemetry GenAI semantic conventions have made the vocabulary part of this a solved problem. There is now an agreed way to say model, operation, token counts and finish reason across three signal types, and it works whether you are calling a hosted API, a coding agent, or something you run yourself. What the conventions do not do — cannot do — is decide the shape of your trace. That is an architecture decision, and it is the one nobody makes on purpose.

The model call is the wrong unit

A chat span is a faithful record of one request to one provider. It is the right unit for exactly one audience: the people debugging the provider integration. It is the wrong unit for everyone else, because almost every question that gets asked about an LLM feature in production is a question about a turn.

  • “What does this feature cost per conversation?”— token attributes live on the call. Summing them needs a parent to sum them under. Without one, cost lands on the model name, which tells you which model is expensive and nothing about which product surface is spending.
  • “How long did the user wait?”— the sum of the call durations is not the answer, and neither is the max. On the product above, the four calls added up to 8.4 seconds inside an 11.4-second turn. The missing three seconds were retrieval, schema validation, and one synchronous write we had forgotten was on that path.
  • “Is the retry path healthy?”— a call that succeeded on the third attempt and a call that succeeded on the first are two indistinguishable spans unless something above them knows they were the same attempt at the same thing. Retry rate is a turn-level property that people keep trying to measure at call level.

None of that is an argument against per-call spans. Keep them. The argument is that they are leaves, and a tree with only leaves is a pile.

Flat vector illustration of several short horizontal bars of different lengths lying loose, next to the same bars gathered under one long bracket
Same spans, same attributes, same storage cost. The only difference is whether anything above them knows they belong together.

The span we add before anything else

The conventions already have a name for the unit we want. Alongside chat, embeddings and execute_tool there is invoke_agent — a span that covers an agent doing a piece of work rather than a client making a request. Almost nobody emits it, because auto-instrumentation cannot: only your code knows where a turn starts and ends. So that is the first thing we write, before touching a single dashboard.

One turn, four calls, and the part no span covers

SPAN TREEDURATIONinvoke_agentone user turn11.4 schat2.1 sexecute_tool3.4 schat — 4290.3 schat — retry2.6 s2.1 s here, and no child span covers iteventsmessages + tool argumentscaptured nothing by defaultits own exporterown retention, own access list
Three of the 11.4 seconds sit outside every model span — 2.1 in the tail, the rest in the gaps between calls. On a per-call dashboard that time does not exist, which is exactly why nobody had gone looking for it.

Once that parent exists, the rest of the tree mostly builds itself. Auto-instrumentation attaches the chat spans to whatever is active, tool wrappers give you execute_tool, and retries stop being anonymous because they are now siblings under one turn. The work is not volume. It is deciding where the boundary goes and then being disciplined about propagating context across every async hop, which in practice means one wrapper that everything on the path is required to go through.

Attributes that earn their storage

lib/telemetry/turn.ts
1import { trace } from "@opentelemetry/api";
2 
3const tracer = trace.getTracer("assistant");
4 
5// every model call on this path becomes a child of this span
6export function withTurn<T>(ctx: TurnContext, run: () => Promise<T>) {
7  return tracer.startActiveSpan("invoke_agent support-triage", async (span) => {
8    span.setAttributes({
9      "gen_ai.operation.name": "invoke_agent",
10      "gen_ai.agent.name": "support-triage",
11      // ours, not the spec's — these are the joins we query on
12      "acetrum.turn.surface": ctx.surface,  // "inbox" | "widget" | "batch"
13      "acetrum.turn.tier": ctx.tier,     // bounded set, safe to group by
14    });
15    try { return await run(); } finally { span.end(); }
16  });
17}

Two rules on the custom attributes, both learned the expensive way. Keep them in a namespace you own, so a future version of the conventions can never collide with them. And keep every one of them low cardinality — the tenant id goes on, the user id does not, the conversation id lives in the trace id where it belongs. We once put a request fingerprint on a turn span and turned a metrics backend into a per-request database over a weekend.

Content capture is a second pipeline, not a boolean

The conventions capture no prompt content and no tool arguments by default. Model name, operation, token counts, finish reason, duration — all metadata, none of it a customer's words. Turning content on is a single environment variable, and that switch is the most consequential line in the whole setup, because it silently reclassifies your observability stack.

The moment prompts flow into traces, a system built for operational data starts holding support tickets, contract clauses, medical questions and pasted credentials. It usually has a broader access list than your production database, a retention policy nobody chose deliberately, and a vendor in a region your DPA does not name. None of that is a reason to never capture content — you cannot debug a bad answer from token counts. It is a reason to treat capture as its own pipeline with its own decisions.

  • Separate exporter, separate backend. Content events do not go where spans go. Different endpoint, shorter retention, an access list that is reviewed like database access is reviewed. If a single config flag can put customer text in front of the whole engineering org, the flag is the vulnerability.
  • Sample, don’t switch.On is a bad default and off is a bad debugging story. We capture a small fixed percentage plus anything on an errored or flagged turn — which is where the interesting content lives anyway. Turn-level sampling matters here: sampling calls independently gives you half a conversation and no way to reconstruct the rest.
  • Redact at the SDK, not the backend.Backend redaction means the raw text already crossed the network and already landed on somebody else’s disk. The processor that strips it has to run in your process, before the exporter, and it has to be tested like the rest of the code.
  • Write down what the capture is for. Every content pipeline we have inherited was switched on to debug one incident and never switched off. Give it a stated purpose and an expiry date in the same commit.
Isometric illustration of two parallel conveyor lines leaving one machine, one open and continuing into a large hall, the other passing through a small enclosed booth
The metadata line and the content line leave the same instrumentation and should never arrive in the same place. Getting this wrong is not a telemetry mistake, it is a data-handling one.

One more thing that surprises people: the metrics side of the conventions is where most of the operational value actually sits. Token usage and operation duration as histograms, dimensioned by model and operation, cost a fraction of what traces cost and answer the majority of the recurring questions. We keep traces sampled and metrics complete, not the other way round.

What has to be true before we call it instrumented

This is the checklist we run at the end of the work. It is deliberately about questions the telemetry can answer, not about which packages are installed — a project can have every GenAI instrumentation library on npm and fail all six.

Six questions the traces have to answer

  • Pick any slow user turn from yesterday. Can you see it as one span, with every model call, tool call and retry underneath it?
  • Can you attribute a month of token spend to a product surface without joining anything by hand?
  • Does a retried call look different from a first-attempt call in the data, without reading the logs?
  • Does the turn span's duration minus the sum of its children come out to something you can explain?
  • If content capture is on, can you say who can read it, for how long it is kept, and which incident it was enabled for?
  • Swap the model provider tomorrow. Do the dashboards keep working, or do they need rebuilding?

That last one is the reason to use the conventions at all rather than inventing your own field names. Portable attributes mean a provider swap is a config change and not a dashboard rewrite, and on the two products where we have since changed models, it was.

If you only do one thing from all of this, add the parent span. Not the metrics, not the redaction processor, not the vendor evaluation — the parent span. It is under a hundred lines, it makes every span you already emit more useful without changing any of them, and it is the piece that no library will ever add for you, because only your code knows where the turn begins.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.