1. Courses
  2. AI Engineering
  3. AI Engineering in Production
  4. Stop 1

35 minAdvanced

Observability for LLM systems

An LLM feature fails quietly: the request returns 200, the answer reads well, and it is wrong, slow or twice as expensive as planned. This lesson shows you how to trace every model call, retrieval step and tool call, which attributes to record, how to keep customers' words out of places they don't belong, and how to read the first dashboards and a bad answer's trace.

By the end you can

  • Instrument one conversation turn as one trace, with a span for every embedding, search, model call, tool call and output check
  • Record the attributes that answer real questions, using the OpenTelemetry GenAI conventions plus your own: requested and answering model, tokens, cost, prompt version, document ids, tool status and feedback
  • Keep prompt and answer text safe: capture off by default, ids instead of text, redaction, short retention and access control
  • Sample traces so you keep every error and expensive turn, and compute cost from data that was never sampled
  • Build the first dashboards (cost per feature, p95 by step, error and refusal rates, model mix) and debug one bad answer from its trace

The everyday picture

Think of a busy restaurant kitchen. Every order gets a paper ticket. The ticket travels from station to station, and each station stamps it with the time: taken at 8:02, grill 8:05, waiting at the pass until 8:17, served 8:19. When a customer says the soup arrived cold, the manager doesn't ask each cook whether they did their job. Every cook did. The manager reads the ticket and sees the soup sat at the pass for twelve minutes while the grill caught up.

The ticket has two more habits worth copying. It records what was ordered and which station touched it, but not what the customers said to each other at the table. And it is thrown away after a few days, while the daily totals (covers, waste, revenue) are kept for years. A trace is the ticket for one answer from your AI feature: one record that every step stamps, with the facts you need to explain what happened, and nothing private that you don't.

One answer is many steps

Three months after launch, you take over Mangrove's app assistant. It answers about 15,000 customer conversations a day (approximate) about products, allergens, substitutes and delivery, with about 2.5 model calls per conversation. When a parent complains that it called a cereal bar nut free, the team today has log lines: one from the API ("POST /assistant 200 4.1 s"), one from the retrieval service, one from the model client with token counts. Nothing reliably says which lines belong to that one answer. The same customer asked three questions in a minute, and thousands of other customers were asking at the same time.

You met the fix in Observability: logs, metrics and traces and used it for Ask Ops in Shipping an agent safely. A gives every step of one piece of work the same trace id. Each step is a with a parent, so the steps form a tree. carries the trace id across services, so the retrieval service's span joins the API's trace.

LLM features need traces more than most code, for four reasons:

  • Failures look like success. A wrong answer comes back with HTTP 200 and a normal finish reason. Status codes can't tell you about quality; only the facts about what went in can: which documents, which prompt version, which model.
  • Cost varies per call. One call reads 3,000 tokens, the next 40,000. Cost lives on the call, so it must be recorded on the call.
  • The same input can give a different output. You can't always reproduce a bad answer later. The trace of the real one is often all you have.
  • Your most important dependency can change behind a name. If you call a model alias such as "latest", the provider can move it to a new model version and your code never changes.

Choose the unit first. For a chat assistant, use one trace per conversation turn: one customer message and everything that happens until the answer is shown. A whole conversation can last twenty minutes while the customer cooks dinner; a trace that long is hard to view, and most tools sample whole traces, so you would keep or drop a whole conversation at once. Put a conversation id on every turn's root span so you can line the turns up again.

Quick check

What should one trace cover for the app assistant?

The span tree of one turn

A customer asks "Is the oat and honey bar in stock, and does it have nuts?" Here is the turn as spans. The root span turn is the server span for the request, with the feature name and conversation id. Under it, in order:

  1. embed query, about 40 ms: the question becomes a vector.
  2. vector search, about 80 ms: the 8 closest catalogue and help-page chunks come back. The span records their document ids.
  3. model call 1, about 1.9 s: the large model reads about 5,000 input tokens (system prompt and tool definitions 3,000, chunks 1,500, conversation 500) and asks for the check_stock tool.
  4. check_stock, about 120 ms: your code runs the tool. It is a child of the turn, a sibling of the model calls, because the model only asked; your code did the work.
  5. model call 2, about 1.6 s: the model reads everything again plus the tool result and writes the answer, about 250 tokens.
  6. output check, about 30 ms: code checks the answer, for example that any allergen claim matches the product's catalogue record. It records pass or blocked.
one turn, one trace bars to scale 0 1 s 2 s 3 s turn 3.8 s embed query 40 ms vector search 80 ms model call 1 1.9 s check_stock 120 ms model call 2 1.6 s output check 30 ms model calls: 3.5 s retrieval, tool: 0.27 s call 1: 5,000 tokens in tool span is a child of the turn
One turn as a waterfall. Retrieval and the tool take about 0.27 s; the two model calls take about 3.5 of the 3.8 seconds, and each one reads the whole 5,000-token prompt. That is where latency and cost work starts.

Read the waterfall from the top. About 92% of the time is in the two model calls, and a turn that uses a tool pays for the 5,000-token prompt twice. Retrieval is fast; making it faster would save almost nothing. Within a model call, the time until the first token grows with the input length and any queue at the provider, and the rest grows with the output length. When you stream, record the as its own attribute, because that is what the customer feels first.

Two details decide whether the waterfall tells the truth. First, with streaming, the span must end when the last chunk arrives. A common bug ends the span when the client returns the stream object, so every model call looks like 300 ms. Second, every retry is its own child span. When the provider answers 429 (too many requests) and your client waits two seconds and tries again, you want to see two model-call spans, the first with error status, instead of one mysterious 4-second call. Tool timeouts work the same way: the check_stock span ends with status error and an attribute saying timeout.

Quick check

The dashboard says model calls have a p95 of 0.4 s, but customers wait about 6 s for full answers, and the traces show a big gap after each model-call span. What is the most likely cause?

Attributes: the questions you can ask later

A span is only as useful as its . OpenTelemetry's semantic conventions for generative AI give the common ones agreed names, so any tool can read them. Their status is still Development: names have changed between versions and can change again. For example, recent versions use gen_ai.provider.name where older ones used gen_ai.system. Pin the version you follow, write it down, and put your own fields under your own prefix.

AttributeExampleThe question it answers
gen_ai.operation.namechat, embeddings, execute_toolWhat kind of step was this?
gen_ai.provider.namethe provider's idWhich provider served it?
gen_ai.request.modellarge-latestWhat did we ask for?
gen_ai.response.modellarge-2026-09What actually answered?
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens5,000 and 250Why did it cost this much and take this long?
gen_ai.response.finish_reasonsstop, lengthDid the answer finish or get cut off?
mangrove.feature, mangrove.conversation_idapp_assistant, c-81d2Which feature, which conversation?
mangrove.prompt.template_versionassistant-v14Which prompt was live?
mangrove.retrieval.doc_idsart-88412#allergensWhat did the model read?
mangrove.usage.cached_input_tokens, mangrove.cost_usd0 and 0.0188Is the cache working, what did this cost?
mangrove.tool.status, mangrove.output_checkok, timeout; pass, blockedDid the tool and the guard work?

Record both models. The request model is what your code asked for; most providers return the exact version that answered in every response. If you record only the request, a provider moving an alias to a new version is invisible. With both, one chart of gen_ai.response.model per day shows the hour it changed.

Compute cost on the span. Multiply tokens by the price of the model that answered, from a price table kept in the repository. The illustrative prices in this course are $3 per million input tokens and $15 per million output tokens for the large model, $0.80 and $4 for the small one, with cached input reads at about 10% of the input price (illustrative; check your provider's current price page). One of today's calls costs 5,000 × $3/M + 250 × $15/M ≈ $0.0188. If the answering model has no row in the price table, record that, and alert on it: a model you have never priced is a model you have never evaluated. Providers report cached tokens differently (some include them in the input count, some report them separately), so normalise them in your client wrapper and document what input_tokens means.

Ids, not text. Document ids such as art-88412#allergens, the prompt template version and the catalogue snapshot date let you rebuild most of what the model saw without storing any of it in the trace. Keep prompts in the repository with versions, as you did for the eval suite in Evals: knowing if it got better, so "it got worse on Tuesday" leads to what changed on Tuesday.

Feedback arrives later. A thumbs down comes minutes after the answer, in a different request. Send the trace id to the app with each answer; when feedback arrives, record it with that trace id (a small span or a row in a feedback table). Do the same for a re-ask within 10 minutes and a hand-off to a human agent. These are the user signals the online evals in Evals that never stop start from.

One rule about : ids with millions of values (conversation id, document ids) belong on spans, never as labels on metrics. Each metric label value becomes its own time series, and 15,000 new conversation ids a day will break most metrics systems and their bill.

Quick check

The code still asks for "large-latest" every time, and allergen thumbs-down rose sharply one Saturday. Which attribute shows whether the provider changed the model behind the alias?

Prompt and answer text: capture with care

Customers type more than you expect: "my son Arif is allergic to peanuts, can you deliver to 14 Lake Road, call me on…". A message about a child's allergy is health information about a named person. Traces are read by many engineers, kept for weeks, and often sent to a separate system. So treat content as the most sensitive thing in the pipeline:

  • Off by default. The GenAI conventions treat message content (in recent versions, attributes such as gen_ai.input.messages and gen_ai.output.messages) as opt-in, and instrumentation libraries don't record it unless you turn it on. Keep it off in production.
  • Ids first. Most debugging needs only ids, versions, token counts and statuses. With the document ids, the catalogue snapshot and the template version you can rebuild the prompt except the customer's own words.
  • When you need text, capture a sample, redacted twice. Redact in the service before export (phone numbers, email addresses, card-like numbers, street addresses), then again in the collector as a safety net. Keep it in a separate store with stricter access (named people, every read logged).
  • Short retention. For example, content 7 days, spans without content 30 days, the slim per-call rows without text 13 months for trends. Decide with whoever owns data protection, and write it down.

A is where those rules live. Every service's OpenTelemetry SDK batches its spans and sends them over to the collector you run. The collector deletes or masks sensitive attributes, decides which traces to keep, and exports them to the trace store. Putting the rules in one place means a new service or a third-party library can't forget them, and you can change trace store without touching application code.

Assistant API OTel SDK spans, OTLP 1 row per call Collector redact content tail-sample llm_span every call no text errors, slow, costly + 10% SQL Trace store full traces, 30 d Dashboards cost, p95, model 2% a day rules Eval sample to the judge Alerts new model, cost
Two paths out of the service. Full traces go through the collector, which redacts and samples them, to the trace store for debugging and the daily eval sample. One slim row per model call, with no text and no sampling, goes to the warehouse, where cost and model-mix dashboards and their alerts are computed.

Sampling, and the first dashboards

The assistant makes about 37,500 model calls a day (15,000 conversations × 2.5). With retrieval, tool and check spans that is roughly 150,000 spans a day, and if a sample carries prompt text, each model-call span holds about 20 KB. You don't need all of it, but you need the right part of it.

decides at the start of the turn, for example a random 10%. It is cheap and blind: it drops 90% of the errors too. waits until the turn's spans have arrived, then decides. For an LLM feature, keep every trace that has an error, every slow turn (over 8 s), every expensive one (over 12,000 input tokens), every answer the output check blocked, plus 10% of the rest. Tail sampling needs all of a trace's spans at the same collector instance, so with several instances you route spans by trace id first (the collector has a load-balancing exporter for this).

A thumbs down arrives after the sampler has decided. Two answers work. The slim per-call rows keep the trace id, document ids, model and prompt version for every turn, which explains most bad answers even when the full trace is gone. Some teams also keep every full trace for a day or two before sampling down, so a turn with negative feedback can still be pinned.

Now the first dashboards. Build them from the unsampled per-call rows, never from the sampled trace store:

  • Cost per feature per day against the budget line. A $10,000 monthly budget is about $330 a day.
  • Input tokens per call as a histogram, with the (cached input tokens divided by input tokens). A floor where no call is below about 3,000 tokens is the stable prefix showing up on every call.
  • p95 by step: embed, search, each model call, each tool, and the full answer, plus p95 time to first token.
  • Errors and refusals: provider errors and timeouts per hour, answers cut off (finish reason length), tool errors, output-check blocks, and refusals flagged by the output check.
  • Model mix: calls per gen_ai.response.model per day, and the share sent to each model by the router.

Then add alerts that someone will act on: a new value of the answering model, a call with no price, daily cost above 120% of the budget line, p95 full answer over 8 s for 15 minutes.

When one answer is wrong, debug it from its trace in a fixed order. Start from the thumbs down or complaint, open the trace by its id, and read top down. What did retrieval return, and were those the right products? Which prompt version and which model answered? What did each tool return? Did the output check run, and what did it check? Find the first wrong step, as you did for agents in Evaluating agents; everything after it is a consequence. If no attribute can tell you whether a step was right, that missing attribute is your next instrumentation task.

Build it

Instrument the assistant's NestJS service with the OpenTelemetry JavaScript API, price every call, set up a collector sketch for redaction and sampling, and query the per-call table.

  1. Keep a price table and a cost function in the repository

    Key prices by the model that answered. Model names here are placeholders, and the prices are illustrative; check your provider's current price page.

    TypeScript
    // prices.ts: dollars per million tokens (illustrative)
    export const PRICES: Record<string, { input: number; cachedInput: number; output: number }> = {
      'large-2026-06': { input: 3.0, cachedInput: 0.3, output: 15.0 },
      'large-2026-09': { input: 3.0, cachedInput: 0.3, output: 15.0 },
      'small-2026-06': { input: 0.8, cachedInput: 0.08, output: 4.0 },
    };
    
    export type Usage = { inputTokens: number; cachedInputTokens: number; outputTokens: number };
    
    export function costUsd(model: string, u: Usage): number | null {
      const p = PRICES[model];
      if (!p) return null;                                  // never priced, so never evaluated: alert
      const fresh = u.inputTokens - u.cachedInputTokens;    // our wrapper: inputTokens includes cached reads
      return (fresh * p.input + u.cachedInputTokens * p.cachedInput + u.outputTokens * p.output) / 1_000_000;
    }
  2. Wrap every model call in a span

    startActiveSpan makes the new span the active one, so it becomes a child of the turn's server span automatically. modelClient.chat is your own provider-neutral wrapper.

    TypeScript
    import { trace, SpanKind, SpanStatusCode } from '@opentelemetry/api';
    import { costUsd } from './prices';
    
    const tracer = trace.getTracer('app-assistant', '2.3.0');
    
    export function tracedChat(req: ChatRequest, ctx: TurnContext): Promise<ChatResult> {
      // span name convention: "{operation} {requested model}"
      return tracer.startActiveSpan(`chat ${req.model}`, { kind: SpanKind.CLIENT }, async (span) => {
        span.setAttributes({
          'gen_ai.operation.name': 'chat',
          'gen_ai.provider.name': ctx.providerName,         // older conventions: gen_ai.system
          'gen_ai.request.model': req.model,                // what we asked for, maybe an alias
          'mangrove.feature': ctx.feature,
          'mangrove.conversation_id': ctx.conversationId,
          'mangrove.prompt.template_version': req.templateVersion,
          'mangrove.retrieval.doc_ids': ctx.docIds,         // ids only, never chunk text
        });
        try {
          const res = await modelClient.chat(req);          // resolves after the last chunk
          const cost = costUsd(res.model, res.usage);
          span.setAttributes({
            'gen_ai.response.model': res.model,             // what actually answered
            'gen_ai.response.finish_reasons': [res.finishReason],
            'gen_ai.usage.input_tokens': res.usage.inputTokens,
            'gen_ai.usage.output_tokens': res.usage.outputTokens,
            'mangrove.usage.cached_input_tokens': res.usage.cachedInputTokens,
            'mangrove.ttft_ms': res.timeToFirstTokenMs,
            'mangrove.cost_usd': cost ?? -1,                // -1 means unpriced
          });
          await writeCallRow(span.spanContext().traceId, ctx, res, cost);  // the slim, unsampled row
          return res;
        } catch (err) {
          span.recordException(err as Error);
          span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
          throw err;
        } finally {
          span.end();
        }
      });
    }
  3. Give retrieval and tools their own spans

    Same pattern, smaller. Record ids and statuses, not content.

    TypeScript
    export function tracedSearch(queryVector: number[], k: number) {
      return tracer.startActiveSpan('vector_search catalogue', async (span) => {
        try {
          const hits = await catalogue.search(queryVector, { k });
          span.setAttribute('mangrove.retrieval.k', k);
          span.setAttribute('mangrove.retrieval.doc_ids', hits.map((h) => h.id));
          return hits;
        } finally {
          span.end();
        }
      });
    }
    // Tools: span name `execute_tool check_stock`, gen_ai.tool.name, and
    // mangrove.tool.status = ok | timeout | error | empty
  4. Put redaction and sampling in the collector

    A sketch for the OpenTelemetry Collector (the contrib distribution has these processors). Check names and options against the docs for the version you run.

    YAML
    # collector.yaml (sketch)
    receivers:
      otlp:
        protocols: { grpc: {}, http: {} }
    processors:
      attributes/drop-content:              # content stays out unless a sampled capture path adds it
        actions:
          - { key: gen_ai.input.messages, action: delete }
          - { key: gen_ai.output.messages, action: delete }
      redaction:                            # safety net: mask values that look like cards or emails
        allow_all_keys: true
        blocked_values:
          - "[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}"
          - "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}"
      tail_sampling:
        decision_wait: 30s                  # LLM turns are slow; wait for the last span
        policies:                           # a trace is kept if any policy keeps it
          - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
          - { name: slow, type: latency, latency: { threshold_ms: 8000 } }
          - name: expensive
            type: numeric_attribute
            numeric_attribute: { key: gen_ai.usage.input_tokens, min_value: 12000, max_value: 10000000 }
          - name: blocked
            type: string_attribute
            string_attribute: { key: mangrove.output_check, values: [blocked] }
          - { name: rest, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
      batch: {}
    exporters:
      otlphttp/trace-store:
        endpoint: https://traces.internal.example
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [attributes/drop-content, redaction, tail_sampling, batch]
          exporters: [otlphttp/trace-store]
  5. Query the per-call table for the first dashboard

    Each row was written by writeCallRow with the cost already computed. Group by the model that answered, so a model change appears as a new row.

    SQL
    -- Daily cost, cache hit rate and median input size per feature and answering model
    SELECT started_at::date                                                 AS day,
           feature,
           response_model,
           count(*)                                                         AS calls,
           round(sum(cost_usd)::numeric, 2)                                 AS cost_usd,
           round(100.0 * sum(cached_input_tokens) / sum(input_tokens), 1)  AS cache_hit_pct,
           percentile_cont(0.5) WITHIN GROUP (ORDER BY input_tokens)        AS median_input
    FROM llm_span
    WHERE started_at >= now() - interval '14 days'
    GROUP BY 1, 2, 3
    ORDER BY 1, 2, 3;
    Output
    day        | feature       | response_model | calls  | cost_usd | cache_hit_pct | median_input
    2026-10-06 | app_assistant | large-2026-09  | 37,480 |   702.35 |           0.0 |        4,980
    2026-10-06 | ask_ops       | large-2026-09  |  5,210 |   131.40 |           0.0 |        6,900

    Two numbers jump out: a cache hit rate of zero, and every call on one large model.

Bring it to your team

Ask these in the next review of any LLM feature.

  • Can we open the trace of any answer a customer complains about, from the id the app shows support?
  • Do we record both the model we asked for and the model that answered, and does anything alert when the second one changes?
  • Where is cost computed, and do our cost totals come from unsampled data?
  • Is prompt and answer text captured anywhere, who can read it, and when is it deleted?
  • Which attribute would tell us that retrieval returned the wrong product?
  • Which version of the GenAI conventions do we follow, and who updates it?

Red flags

  • "We log the full prompt and answer for every call, it helps debugging."
  • A cost dashboard built on the sampled trace store.
  • Only the request model recorded, or the model called through an alias with no record of what answered.
  • Conversation ids or document ids used as metric labels.
  • Model-call spans that all last a few hundred milliseconds on a streaming feature.

Practice

Cost per feature per day from token counts

The warehouse has a simplified export of model calls, llm_span(trace_id, feature, model, input_tokens, cached_input_tokens, output_tokens, started_at), where input_tokens includes the cached ones, and a price table model_price(model, input_per_m, cached_input_per_m, output_per_m) in dollars per million tokens. Return day, feature and cost_usd (dollars, rounded to 2 decimals) for each feature on each day, ordered by day, then feature. Cached tokens are charged at the cached price and the rest of the input at the normal input price.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Cost in dollars per feature per day, rounded to 2 decimals.
-- input_tokens includes cached_input_tokens.
SELECT s.feature, sum(s.input_tokens) AS input_tokens
FROM llm_span s
GROUP BY s.feature;
Ctrl + Enter runs
Show a solution
SQL
SELECT s.started_at::date AS day,
       s.feature,
       round(sum((s.input_tokens - s.cached_input_tokens) * p.input_per_m
                 + s.cached_input_tokens * p.cached_input_per_m
                 + s.output_tokens * p.output_per_m) / 1000000, 2) AS cost_usd
FROM llm_span s
JOIN model_price p ON p.model = s.model
GROUP BY s.started_at::date, s.feature
ORDER BY day, s.feature;

Subtract the cached tokens before charging the normal input price, or you pay for them twice. The nightly visit-notes job shows why it matters: with its 150,000-token cached prefix it costs $0.41 on 5 October; charging every input token at the full price would say $1.22.

Pass the exercise to finish this task.

Find the first wrong step in a bad answer's trace

A parent gave a thumbs down to this answer: "The Sunny Oat Bar Honey has no nuts listed, so it should be fine." The product she asked about, art-55120 (Sunny Oat Bar Honey), contains hazelnut. Here is the turn's trace. Find the first wrong step, explain why nothing caught it, and name the attribute (or attributes) that would have made it obvious from the trace and from a dashboard.

SpanTimeAttributes
turn3.4 sfeature app_assistant, template assistant-v14, status ok
embed query38 msstatus ok
vector search85 msk 8, doc_ids: art-55102#allergens, art-55102#ingredients, help-allergen-policy, and 5 more chunks of art-55102 and art-55131
model call2.9 sresponse model large-2026-09, 4,700 in, 230 out, finish stop
output check31 msoutput_check pass (allergen claim cites a retrieved chunk)
  • You name retrieval as the first wrong step: no chunk came from art-55120; art-55102 is a different product with a similar name
  • You explain why the model and the output check both "worked": the model answered from what it was given, and the check only confirmed the claim cited some retrieved chunk, not a chunk of the product asked about
  • You propose attributes that compare the product asked about with the products retrieved, such as a question product id, the retrieved product ids and a match flag
  • You turn the attribute into something watched: a dashboard rate or alert, and a new golden case from this failure
Show a model answer

The first wrong step is the vector search. The parent asked about art-55120, but every product chunk came from art-55102 (probably Sunny Oat Bar Choco, a near-identical name) and art-55131. The model then answered correctly from the wrong facts, and the output check passed because it only checks that an allergen claim cites a retrieved chunk. Everything after the search is a consequence.

The trace has doc ids but nothing says which product the customer meant. Add mangrove.question.product_id (from the product page the question came from, or the product the assistant resolved), mangrove.retrieval.product_ids (distinct products in the chunks), and mangrove.retrieval.product_match (true if the question's product is among them). Then the trace shows match false at a glance, and Tomás can chart the mismatch rate per day for allergen questions and alert above, say, 1%. Make the output check stricter (an allergen claim must cite a chunk of the product asked about, otherwise block and say the assistant can't confirm), and add this case to the golden set's allergen category. The retrieval fix itself, filtering chunks by product id, belongs to the retrieval work later in the course.

Check yourself

Tomás builds a cost dashboard on the trace store. It says the assistant cost about $2,300 last month, but the provider's invoice says about $21,000. The collector keeps errors, slow and expensive turns and 10% of the rest. What is the most likely explanation?

A support lead asks you to capture the full prompt and answer of every conversation in the traces, so that support staff can search them when customers complain. What is the best response?

After Tuesday's release, p95 full answer rose from 6.5 s to 9 s. Many traces now show three model-call spans per turn: the first ends after 0.2 s with error status and a 429 response, then the next starts about 2 s later. What explains it, and what do you do?

Flashcards

Open my review

Key takeaways

  • Trace each turn as one tree: embed, search, model calls, tools and output check, each a span with honest timings, retries included.
  • Attributes are the point: requested and answering model, tokens, cached tokens, cost, prompt version, document ids, tool and check status, and feedback joined by trace id.
  • Treat prompt and answer text as the most sensitive data in the pipeline: off by default, ids instead, redacted samples, short retention, named access.
  • Tail-sample traces to keep what matters, and compute every total from unsampled per-call rows.
  • Dashboards and alerts on cost per feature, tokens and cache hits, p95 by step, errors and model mix find problems that no status code shows.

Sources and further reading

Episode transcriptThe ticket that follows every answer · about 11 minutes · tap any line to play from there
  1. NadiaImran, a parent writes in. The app assistant told her a cereal bar was nut free. It wasn't. Where would you look first?
  2. ImranThe logs, I suppose. The API logged the request, the model client logged the tokens, retrieval logged something too.
  3. NadiaAnd which of those lines belong to her answer? The same customer asked three questions in a minute, and thousands of other people were asking at the same time.
  4. ImranI'd be matching timestamps by hand. And guessing.
  5. NadiaThat's today's episode. How to trace an LLM feature so that one bad answer leads you straight to the step that went wrong, without filling your systems with customers' private words.
  6. NadiaStart in a restaurant kitchen. Every order gets a paper ticket, and every station stamps it with the time. Taken at eight oh two, grill at eight oh five, waiting at the pass until eight seventeen.
  7. ImranSo when the soup arrives cold, the manager reads the ticket instead of asking every cook.
  8. NadiaRight. Every cook did their job. The ticket shows the soup sat at the pass for twelve minutes. And notice what the ticket doesn't hold. It records what was ordered, not what the customers said at the table. And it's thrown away after a few days, while the daily totals are kept for years.
  9. ImranSo a trace is the ticket for one answer.
  10. NadiaExactly. One trace id shared by every step, and each step is a span with a start, a duration, a parent and some attributes.
  11. ImranWe traced Ask Ops already, in the agents course. Why does the app assistant need more than normal API tracing?
  12. NadiaFour reasons. A wrong answer comes back with status two hundred, so status codes tell you nothing about quality. Cost changes call by call with the number of tokens. The same question can get a different answer, so you can't always reproduce it later. And the model can change behind a name.
  13. ImranBehind a name?
  14. NadiaIf you call an alias like latest, the provider can move it to a new model version, and your code never changes. Hold on to that one. It comes back.
  15. ImranWhat's one trace, then? The whole conversation?
  16. NadiaOne turn. One customer message and everything until the answer shows. A conversation can last twenty minutes while someone cooks dinner, and most tools keep or drop whole traces. So you trace the turn and put a conversation id on it to link the turns.
  17. ImranWalk me through a turn. The first diagram is a waterfall, right?
  18. NadiaYes. If you can see it, there are bars laid out over about four seconds. If not, picture a timeline. Embedding the question takes about forty milliseconds. Vector search, about eighty. Then a long bar, the first model call, about one point nine seconds.
  19. NadiaThat call asks for the check stock tool. The tool takes about a hundred and twenty milliseconds. Then a second model call writes the answer, about one point six seconds. And a quick output check at the end, about thirty milliseconds.
  20. ImranSo the two model calls are three and a half of the three point eight seconds. Speeding up retrieval would barely matter.
  21. NadiaAnd each model call reads the whole prompt, about five thousand tokens. A turn that uses a tool pays for that prompt twice.
  22. ImranWhy is the tool a child of the turn and not of the first model call?
  23. NadiaBecause the model only asked for it. Your code ran it. The span tree should show who did the work.
  24. ImranAny traps in the timings?
  25. NadiaTwo big ones. With streaming, the span must end when the last chunk arrives. A common bug ends it when the client returns the stream object, so every model call looks like three hundred milliseconds while customers wait six seconds.
  26. NadiaAnd every retry is its own span. When the provider says too many requests and your client waits two seconds, you want to see a short failed span and then the retry, not one mysterious slow call.
  27. ImranNow the attributes. That's where I'd get lost.
  28. NadiaOpenTelemetry has semantic conventions for generative AI. Agreed names like the operation name, the provider name, the request model, the response model, and input and output tokens. They're still marked development, so names change. The provider name used to be called gen AI system.
  29. ImranSo pin the version you follow, and put your own fields under your own prefix.
  30. NadiaYes. Your own fields are the feature name, the conversation id, the prompt template version, the retrieved document ids, cached input tokens, the cost, the tool status and the output check result.
  31. ImranWhy both a request model and a response model? Isn't that the same thing?
  32. NadiaIt's the most useful pair in the list. The request model is what your code asked for, maybe the alias. The response model is the version that actually answered. Most providers return it on every response. Chart it by day and you see the exact hour an alias moved.
  33. ImranAnd cost goes on the span itself?
  34. NadiaTokens times the price of the model that answered, from a price table in the repository. With the course's illustrative prices, one of today's calls is about one point nine cents. And if the answering model has no price, alert. A model you've never priced is a model you've never evaluated.
  35. ImranFeedback comes later though. The thumbs down arrives minutes after the answer.
  36. NadiaSo the app keeps the trace id with each answer, and the feedback is recorded against that id. Same for a re-ask within ten minutes, or a hand-off to a human agent.
  37. ImranCan I just log the whole prompt and answer? It would make debugging easy.
  38. NadiaThat's the trap. Customers type things like my son is allergic to peanuts, deliver to this address, call me on this number. That's health information about a named child. Traces are read by many engineers and kept for weeks.
  39. ImranSo capture is off.
  40. NadiaOff by default. Record ids instead: document ids, the prompt version, the catalogue snapshot. With those you can rebuild most of the prompt. When you really need text, capture a sample, redact it in the service and again in the collector, keep it about seven days, and only named people can read it.
  41. ImranThe second diagram shows where that happens.
  42. NadiaTwo paths leave the service. Spans go to a collector you run. It deletes content, masks things that look like card numbers or emails, and decides which traces to keep, then sends them to the trace store. That feeds debugging and the daily sample for the judge.
  43. NadiaThe second path is one slim row per model call into a warehouse table. No text, and no sampling. Dashboards and alerts are built on that.
  44. ImranWhy keep two paths?
  45. NadiaBecause of sampling. The assistant makes about thirty-seven thousand five hundred model calls a day. You keep the right traces: every error, every slow turn, every expensive one, every answer the output check blocked, and ten percent of the rest. That's called tail sampling, deciding after the trace finishes.
  46. ImranAnd if Tomás builds the cost dashboard on the sampled traces, it's ten times too low.
  47. NadiaAnd the error rate looks several times too high. Totals always come from data that was never sampled.
  48. ImranSo what did the first week of traces show us?
  49. NadiaFive numbers for Ken. Cost per conversation about four point seven cents, so roughly twenty-one thousand dollars a month against a ten thousand dollar budget. One hundred percent of calls on the large model, because the router was switched off during the September incident and never turned back on.
  50. NadiaA cache hit rate of zero, because a request id and a timestamp sit at the top of the system prompt. And about sixty-four percent of input tokens were a repeated prefix.
  51. ImranAnd the fifth?
  52. NadiaThe response model showed a version nobody recognised. The old API logs had the same field. It changed on the twelfth of September, nine days before the customer complaint, while every request still asked for latest.
  53. ImranSo the team guessed the small model was to blame, turned the router off, and the change was in the large model all along.
  54. NadiaDoubling the bill and fixing nothing. Tomás added two alerts that day: a new answering model, and any call with no price.
  55. ImranWhat does the build actually involve?
  56. NadiaFirst a price table and a small cost function, keyed by the model that answered, subtracting cached tokens before charging the full input price. Then you wrap every model call in a span with the OpenTelemetry JavaScript API, setting the gen AI attributes and your own.
  57. NadiaRetrieval and tools get the same pattern, with ids and statuses only. Then a collector sketch: drop content, redact, tail-sample. And finally a SQL query over the per-call table for cost, cache hit rate and model mix per day.
  58. ImranQuestions I'll ask in the next review. Can we open the trace of any answer a customer complains about? Do we record the model that answered, and does anything alert when it changes? Do our cost totals come from unsampled data?
  59. NadiaAnd one about people. Who can read captured text, and when is it deleted?
  60. NadiaA puzzle from the practice. A parent asks about the Sunny Oat Bar Honey. The search returns eight chunks, all from two other products with similar names. The model says no nuts listed. The output check passes. Which step went wrong first? Take a second.
  61. ImranThe search. The model answered correctly from the wrong facts, and the check only confirmed the claim cited some chunk.
  62. NadiaExactly. And the missing attribute is the product asked about compared with the products retrieved. Add a match flag and you can chart the mismatch rate every day.
  63. ImranMy summary. One, trace every turn as one tree, with honest timings and every retry. Two, record both models, tokens and cost on the span, plus ids instead of text. Three, sample traces for debugging, but take totals from rows that were never sampled.
  64. NadiaGood. I'd add that a trace store full of data helps nobody without an alert. The model change sat in plain sight for nine days.
  65. ImranSo next time we stop that happening again.
  66. NadiaNext episode. Evals that keep running after launch, a judge you can trust, and how to catch a provider's model update before your customers do.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

KPI dashboard notebook

Weekly revenue, orders, average basket and active customers, with a chart per KPI.

Open in the Data Lab
Next stopEvals that never stop
Was this lesson helpful?
Questions and discussion

Loading the discussion…