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 trace gives every step of one piece of work the same trace id. Each step is a span with a parent, so the steps form a tree. Context propagation 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?
A turn is the unit you debug ("why this answer?") and the unit you sample. Whole conversations make traces long-lived and force all-or-nothing sampling. Splitting a turn across traces breaks the very link you wanted between what was retrieved and what the model said.
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:
- embed query, about 40 ms: the question becomes a vector.
- vector search, about 80 ms: the 8 closest catalogue and help-page chunks come back. The span records their document ids.
- 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_stocktool. - 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.
- model call 2, about 1.6 s: the model reads everything again plus the tool result and writes the answer, about 250 tokens.
- 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.
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 time to first token 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?
A gap right after each model span, with fast model spans, is the shape of a span that ends too early: the streaming answer is still arriving after the span closed. Vector search has its own span, so it can't hide in the gap. Sampling changes which traces you keep, not the length of spans inside them.
Attributes: the questions you can ask later
A span is only as useful as its attributes. 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.
| Attribute | Example | The question it answers |
|---|---|---|
gen_ai.operation.name | chat, embeddings, execute_tool | What kind of step was this? |
gen_ai.provider.name | the provider's id | Which provider served it? |
gen_ai.request.model | large-latest | What did we ask for? |
gen_ai.response.model | large-2026-09 | What actually answered? |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | 5,000 and 250 | Why did it cost this much and take this long? |
gen_ai.response.finish_reasons | stop, length | Did the answer finish or get cut off? |
mangrove.feature, mangrove.conversation_id | app_assistant, c-81d2 | Which feature, which conversation? |
mangrove.prompt.template_version | assistant-v14 | Which prompt was live? |
mangrove.retrieval.doc_ids | art-88412#allergens | What did the model read? |
mangrove.usage.cached_input_tokens, mangrove.cost_usd | 0 and 0.0188 | Is the cache working, what did this cost? |
mangrove.tool.status, mangrove.output_check | ok, timeout; pass, blocked | Did 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 cardinality: 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?
The request model is the same string every day, so it can't show a change. The prompt version shows your own changes, not the provider's. Finish reasons say whether answers completed. Only the model the provider says answered moves when the alias moves.
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.messagesandgen_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 collector is where those rules live. Every service's OpenTelemetry SDK batches its spans and sends them over OTLP 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.
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.
Head sampling decides at the start of the turn, for example a random 10%. It is cheap and blind: it drops 90% of the errors too. Tail sampling 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 cache hit rate (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.modelper 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.
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; }Wrap every model call in a span
startActiveSpanmakes the new span the active one, so it becomes a child of the turn's server span automatically.modelClient.chatis your own provider-neutral wrapper.TypeScriptimport { 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(); } }); }Give retrieval and tools their own spans
Same pattern, smaller. Record ids and statuses, not content.
TypeScriptexport 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 | emptyPut 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]Query the per-call table for the first dashboard
Each row was written by
writeCallRowwith 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;Outputday | 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,900Two 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.
CREATE TABLE model_price (model text PRIMARY KEY, input_per_m numeric(8,2), cached_input_per_m numeric(8,2), output_per_m numeric(8,2));
INSERT INTO model_price VALUES ('large-2026-09', 3.00, 0.30, 15.00), ('small-2026-06', 0.80, 0.08, 4.00);
CREATE TABLE llm_span (trace_id text, feature text, model text, input_tokens int, cached_input_tokens int, output_tokens int, started_at timestamp);
INSERT INTO llm_span VALUES
('t-101', 'app_assistant', 'large-2026-09', 5000, 0, 250, '2026-10-05 09:12:00'),
('t-101', 'app_assistant', 'large-2026-09', 5200, 0, 260, '2026-10-05 09:12:03'),
('t-102', 'app_assistant', 'large-2026-09', 4800, 3000, 240, '2026-10-05 13:40:00'),
('t-103', 'app_assistant', 'small-2026-06', 4000, 3000, 180, '2026-10-05 21:05:00'),
('t-201', 'ask_ops', 'large-2026-09', 7000, 0, 300, '2026-10-05 10:05:00'),
('t-201', 'ask_ops', 'large-2026-09', 7400, 6000, 280, '2026-10-05 10:05:05'),
('t-301', 'visit_notes', 'large-2026-09', 180000, 150000, 7000, '2026-10-05 23:30:00'),
('t-302', 'visit_notes', 'large-2026-09', 160000, 150000, 6200, '2026-10-05 23:40:00'),
('t-104', 'app_assistant', 'large-2026-09', 5000, 3000, 250, '2026-10-06 08:30:00'),
('t-104', 'app_assistant', 'large-2026-09', 5100, 3000, 260, '2026-10-06 08:30:04'),
('t-105', 'app_assistant', 'small-2026-06', 3900, 3000, 200, '2026-10-06 00:10:00'),
('t-202', 'ask_ops', 'small-2026-06', 3000, 2000, 200, '2026-10-06 11:00:00'),
('t-203', 'ask_ops', 'large-2026-09', 6500, 0, 350, '2026-10-06 15:20:00'),
('t-303', 'visit_notes', 'large-2026-09', 175000, 150000, 7500, '2026-10-06 23:35:00');
-- 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;
[
{"day": "2026-10-05", "feature": "app_assistant", "cost_usd": 0.05},
{"day": "2026-10-05", "feature": "ask_ops", "cost_usd": 0.04},
{"day": "2026-10-05", "feature": "visit_notes", "cost_usd": 0.41},
{"day": "2026-10-06", "feature": "app_assistant", "cost_usd": 0.02},
{"day": "2026-10-06", "feature": "ask_ops", "cost_usd": 0.03},
{"day": "2026-10-06", "feature": "visit_notes", "cost_usd": 0.23}
]
Show a solution
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.
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.
| Span | Time | Attributes |
|---|---|---|
| turn | 3.4 s | feature app_assistant, template assistant-v14, status ok |
| embed query | 38 ms | status ok |
| vector search | 85 ms | k 8, doc_ids: art-55102#allergens, art-55102#ingredients, help-allergen-policy, and 5 more chunks of art-55102 and art-55131 |
| model call | 2.9 s | response model large-2026-09, 4,700 in, 230 out, finish stop |
| output check | 31 ms | output_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?
Keeping about 10% of normal turns makes the sum roughly a tenth of the truth, plus the kept expensive turns: about $2,300 against $21,000 fits. A cache pricing bug would make your number higher, not lower, and a time-zone shift moves a few hours, not 90% of the month.
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?
Traces are read by many engineers and kept for weeks, so they are the wrong home for every customer's words. Support needs a per-customer view with its own access rules, and engineering needs a small redacted sample. Refusing all text blocks real debugging and the online eval sample; a span name is still stored, so it is no safer.
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?
The failed spans are short and say 429, and the gap is the client's back-off before the retry, so the time goes into waiting, not into a slower model or search. Because each retry is its own span, the trace shows the cause directly. Sampling has no effect on how long a request takes.
Flashcards
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
- Semantic conventions for generative AI systems, OpenTelemetry (status: Development)
- OpenTelemetry documentation, OpenTelemetry
- OpenTelemetry JavaScript, OpenTelemetry documentation
- OpenTelemetry Collector, OpenTelemetry documentation
- Tail sampling processor, OpenTelemetry Collector contrib repository