Education › AI Engineering › Stage 4: MLOps & AIOps

LLM observability

Tracing prompts and tool calls, quality and drift monitoring, feedback loops.

Advanced ~30 min read Module 15 of 16

The golden signals of the SRE track will tell you that your LLM feature is up, fast and free of HTTP errors. They will not tell you that it has begun giving wrong answers, because a confident hallucination returns status 200 in 800 milliseconds, just as a correct answer does. With LLM systems, the failure that matters is the quality of the output, and ordinary monitoring cannot see it. This module extends the observability of the SRE track to cover it: what to record for every request, how to trace an agent's chain of calls, how to measure quality in production, and how to turn every bad answer into a permanent test.

After this module you can
  • Explain why conventional monitoring misses the main failure mode of LLM systems
  • Decide what to capture for each request, and handle the privacy implications of logging prompts
  • Trace multi-step LLM applications and agents with OpenTelemetry spans
  • Measure quality in production with explicit feedback, implicit signals and sampled LLM judges
  • Detect drift, and close the loop from production failures back into the eval dataset

Status 200, and wrong

Conventional observability rests on an assumption: a request that fails announces itself, through an error code, an exception or a timeout. LLM applications break that assumption. Look at what can go wrong without any of those occurring.

  • The model hallucinates a command, a policy or a figure.
  • Retrieval returns the wrong documents, so the answer is fluent and grounded in irrelevant text.
  • The provider updates the model, and the tone or the format shifts, breaking a parser downstream for one request in fifty.
  • An edit to the prompt, meant to fix one case, degrades another category that nobody checked.
  • An agent loops through fifteen tool calls where three would have done, and succeeds at five times the cost.
  • Users discover a kind of question that the system handles badly, and quietly stop using it.

Each of these returns 200. None of them moves the error rate. Latency may even improve. You therefore need a second layer of signals on top of the first, not in place of it.

LayerQuestionSignals
System healthIs it up, fast and affordable?Request rate, errors, latency, time to first token, tokens, cost, rate-limit responses
BehaviourWhat did it actually do?Traces of each step: retrieval, model calls, tool calls, with their inputs and outputs
QualityWas the output good?User feedback, implicit signals, sampled judge scores, rates of guard-rail triggers
DriftIs it changing over time?Distributions of inputs, of retrieval scores, and of output length and format, against a baseline

The first layer is the SRE track, applied without alteration, and it still matters: a provider outage or a rate limit is an ordinary incident. The rest of this module is about the other three layers.

What to capture for every request

The unit of LLM observability is a structured record of each model call, rich enough to reproduce and explain it later. Metrics are derived from these records. The reverse is impossible: you cannot recover what the model said from a latency histogram.

GroupFields
IdentityTrace ID, span ID, timestamp, feature name, environment, a pseudonymous user or session ID
VersionsProvider, the exact model identifier, the prompt version, the retrieval configuration version, the application version
RequestThe messages or the rendered prompt, the tool definitions offered, the sampling parameters
ResponseThe output text, the tool calls requested, the stop reason
UsageInput tokens, cached tokens, output tokens, the computed cost
TimingTotal latency, time to first token, time spent queued, retries
ContextThe IDs and scores of retrieved documents, and the results of tools, or references to them
OutcomeThe result of validation, guard-rail flags, user feedback, the judge's score, when they arrive

The stop reason deserves its own metric. A rise in responses that ended because they reached the token limit means truncated answers. A rise in refusals means that a change to a prompt, or to a model, has made the system over-cautious, or that someone is probing it.

Watch out

Prompts and responses are the most sensitive data that you will ever log. Users paste in passwords, customer records and medical details, and retrieved documents may be confidential. Decide this deliberately: redact known patterns such as keys, emails and card numbers before storing; restrict who can read the raw text; set a short retention period for it, and keep the metadata for longer; honour requests for deletion; and check what your contracts and your privacy policy actually allow. In regulated settings, store the metadata, and only a sample, or nothing, of the content.

Logging everything in full becomes expensive at scale, and the usual answer is a tiered scheme. Keep the metadata for every request, since it is small and drives all the metrics. Keep the full content for a random sample, together with every request that failed validation, triggered a guard rail, received negative feedback, or was unusually slow or costly. That is the same reasoning as tail sampling in the OpenTelemetry module: keep what is interesting.

Tracing chains and agents

A single user request to a RAG assistant, or to an agent, fans out into many operations: rewrite the query, embed it, search, rerank, call the model, call a tool, call the model again. When the answer is wrong, the question is which step failed, and a flat log cannot answer it. Distributed tracing, from the OpenTelemetry module, fits exactly: one trace per user request, and one span per step, nested.

text
trace 7d1c...  POST /assistant/ask                                  total 6,840 ms   cost 0.0214

assistant.ask                     [=================================================] 6840 ms
  rag.rewrite_query  (llm, small)  [==]                                                 310 ms   in 412  out 28
  rag.embed_query                     [=]                                                45 ms
  rag.vector_search   k=50               [=]                                             38 ms   top score 0.71
  rag.rerank          50 -> 5               [===]                                       260 ms
  llm.generate        turn 1                   [===========]                           1900 ms   in 3,180 out 96   stop=tool_use
  tool.get_error_rate service=checkout                      [==]                        210 ms
  tool.get_recent_deploys                                   [==]                        190 ms
  llm.generate        turn 2                                    [===================]  3700 ms   in 3,610 out 402  stop=end

One look shows where the time and the money went, what was retrieved, which tools ran and with which arguments, and how many turns the agent took. When an answer is wrong, you open its trace and read the retrieved passages and the tool results that the model actually saw. In most cases the fault is visible at once: the right document was never retrieved, or a tool returned an error that the model papered over.

python
import time

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer(__name__)


def traced_llm_call(messages: list[dict], *, model: str, prompt_version: str, **params) -> dict:
    """call_llm is a stand-in returning {'text', 'stop_reason', 'input_tokens',
    'cached_tokens', 'output_tokens'}. Attribute names here are illustrative."""
    with tracer.start_as_current_span("llm.generate") as span:
        span.set_attribute("llm.model", model)
        span.set_attribute("llm.prompt_version", prompt_version)
        span.set_attribute("llm.temperature", params.get("temperature", 1.0))
        started = time.perf_counter()
        try:
            reply = call_llm(messages, model=model, **params)
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
            raise
        span.set_attribute("llm.latency_ms", round((time.perf_counter() - started) * 1000))
        span.set_attribute("llm.input_tokens", reply["input_tokens"])
        span.set_attribute("llm.cached_tokens", reply["cached_tokens"])
        span.set_attribute("llm.output_tokens", reply["output_tokens"])
        span.set_attribute("llm.stop_reason", reply["stop_reason"])
        return reply
  • OpenTelemetry defines semantic conventions for generative AI, with attribute names beginning gen_ai., for the model, the token usage and the operations. They are still evolving, so check the current specification, and prefer an instrumentation library that tracks it to hard-coded names of your own.
  • Put large payloads, meaning the full prompt and the response, in span events, or in a separate store that is referred to by ID. They are not suitable as attributes, which are meant to be small and searchable.
  • Propagate the trace ID into your logs and your feedback events, so that a thumbs-down can be joined to the exact trace that produced it.
  • Several open-source and commercial platforms specialise in tracing LLM applications. The model above is common to all of them. Choose on the basis of data residency, cost and how well each integrates with what you already run.

Measuring quality in production

Offline evals measure the cases that you thought of. Production measures reality. There are three sources of signals about quality, and you want all three, because each is weak where another is strong.

SourceExamplesStrengthWeakness
Explicit feedbackThumbs up or down, a rating, a correction, "report a problem"Direct and unambiguousA tiny share of users respond, and mostly the unhappy ones
Implicit signalsThe user rephrases and asks again; abandons the session; copies the answer; accepts or rejects a suggestion; escalates to a personAvailable for every interactionIndirect; needs interpretation for each product
Automated judgesAn LLM judge that scores faithfulness, relevance or tone on a sample of live trafficScalable, consistent, and chosen by youCosts money; must be validated against people

The implicit signals are underrated. For a coding assistant, the rate at which suggestions are accepted is the best quality metric that exists. For a support assistant, it is the rate at which conversations end without being escalated to a person. For a search or RAG product, it is whether the user asked again in different words within a minute. Find the behaviour in your own product that reveals satisfaction, and instrument it.

python
import random

JUDGE_SAMPLE_RATE = 0.05          # judge 5% of normal traffic...


def should_judge(record: dict) -> bool:
    """...and everything that looks suspicious."""
    if record["feedback"] == "negative" or record["validation_failed"]:
        return True
    if record["retrieval_top_score"] < 0.35:       # weak retrieval: a likely hallucination
        return True
    return random.random() < JUDGE_SAMPLE_RATE


def judge_online(record: dict) -> None:
    """Runs asynchronously, off the request path. judge_faithfulness is from the evals module."""
    verdict = judge_faithfulness(record["passages"], record["question"], record["answer"])
    emit_metric("llm_judge_faithful", 1 if verdict["verdict"] == "pass" else 0,
                labels={"feature": record["feature"], "prompt_version": record["prompt_version"],
                        "model": record["model"]})
    store_verdict(record["trace_id"], verdict)
  • Run the judges asynchronously, never in the request path. The user does not wait for the grading.
  • Label every quality metric with the prompt version and the model version. The whole point is to see that version 12 is worse than version 11.
  • Treat quality as an SLI, in the SRE sense: "at least 95% of sampled answers are judged faithful, over 7 days". Give it an error budget and an alert on its burn rate.
  • Guard rails are signals too. Count how often the output validation fails, how often a retry was needed, and how often the checks on input or output fired. A rising rate is an early warning.
  • Look at the numbers by segment: language, category of user, topic, length of input. An average conceals the group for whom it is broken.

Drift

An LLM application can degrade with no change to your code, because four things around it move.

What driftsExampleHow to detect it
InputsA new product launches, and users ask questions that the documentation does not cover. A new market brings a new language.Embed a sample of the queries and watch the clusters; track the language, the length and the topic mix; watch the rate of "not found" answers
RetrievalThe corpus grows stale, or a re-index changes the chunking, and the top scores fallTrack the distribution of top-k similarity scores, and the share of queries that have no passage above a threshold
The modelThe provider updates a model behind an alias, or you upgrade on purposePin versions; record the model identifier on every call; run the eval suite on a schedule, as a canary
OutputsAnswers grow longer, the format changes, refusals increaseTrack output tokens, the stop reasons, the rate of validation failures, and the rate of refusals

The cheapest and most effective detector of drift is a scheduled run of your offline eval suite against the production configuration, every night. If the score moves and you changed nothing, something outside your control has moved. It is the LLM equivalent of a synthetic probe.

promql
# share of answers that ended by hitting the token limit (truncated), per prompt version
sum by (prompt_version) (rate(llm_responses_total{stop_reason="max_tokens"}[1h]))
/
sum by (prompt_version) (rate(llm_responses_total[1h]))

# sampled faithfulness, as an SLI, over 7 days
sum(rate(llm_judge_faithful_sum[7d])) / sum(rate(llm_judge_faithful_count[7d]))

# cost per request, by feature: catches a prompt that has quietly grown
sum by (feature) (rate(llm_cost_total[1h])) / sum by (feature) (rate(llm_requests_total[1h]))

# median output length has risen by more than 30% against last week
histogram_quantile(0.5, sum by (le) (rate(llm_output_tokens_bucket[1d])))
  > 1.3 * histogram_quantile(0.5, sum by (le) (rate(llm_output_tokens_bucket[1d] offset 7d)))

Closing the loop

Observability earns its cost when it changes what you do next. The loop that turns production experience into a better system has six steps, and it should become a weekly routine.

every requestflaggedcausetaggedguidesship; watch the signalProductionrecords + tracesQuality signalsfeedback, judgesRead the tracewhat did it see?Classifyretrieval? prompt?Eval dataset+1 permanent caseFix that layerchunking, prompt, toolEval gatethe whole suite
The loop that turns production into a better system: every request is recorded and traced, quality signals flag the bad ones, each is inspected and classified, added to the eval dataset, fixed at the right layer, and shipped through the gate.
  1. Detect. A thumbs-down, a failed judge, a guard-rail trigger, a drift alert, or a complaint from a person.
  2. Inspect the trace. What did the user ask? What was retrieved? What did the tools return? What did the model see, and what did it say?
  3. Classify the failure: retrieval missed; the prompt was ambiguous; a limitation of the model; a bug in a tool; bad source data; outside the system's scope; an attempt at abuse.
  4. Add it to the eval dataset, as a permanent case, tagged with its failure class and its origin.
  5. Fix the right layer, which the classification tells you, and confirm the fix against the whole eval suite, not only against this case.
  6. Ship it through the gated rollout, and watch the same signal that first alerted you.

The classification step repays the effort. After a month, the tally tells you where to invest. If most of the failures are "retrieval missed", then better prompts will not help, and work on chunking or on hybrid search will. If most are "outside the scope", the product needs to say more clearly what it is for.

Set aside time to read real conversations, a random sample, every week. Dashboards show the failures that you already knew to look for. Reading shows the ones that you did not: the question that everyone asks, and that you never anticipated; the answer that is technically correct and useless; the user who worked around a limitation in a way that reveals what they truly wanted. No metric replaces it.

Tip

Build the dashboard in the layout of the Grafana module. The top row answers "is it healthy, and is it good?": the request rate, the error rate, p95 latency, the cost per request, the rate of positive feedback, and the sampled judge score, each of them split by prompt version. Below that come the breakdowns by feature and by segment, and then the drift panels. Annotate every change of prompt and of model as a deploy, because that is what it is.

Hands-on practice

Instrument an LLM feature end to end

  1. Take the RAG assistant or the agent from the earlier modules. Wrap every model call so that it produces a structured record with the identity, versions, usage, timing, stop reason and outcome fields from this module.
  2. Add OpenTelemetry spans for each step: query rewriting, embedding, search, reranking, generation, and every tool call. Send them to the trace backend from the SRE track, and find a single request as one nested trace.
  3. Write a redaction function for the obvious patterns, such as email addresses, API keys and card numbers, and apply it before storing any content. Decide on, and write down, your sampling and retention policy.
  4. Export metrics: requests, tokens, cost, latency and time to first token, responses by stop reason, and validation failures, all of them labelled with the feature, the prompt version and the model.
  5. Add a thumbs up and down control that records the trace ID. Add one implicit signal that suits your feature, such as "the user rephrased within 60 seconds".
  6. Run the faithfulness judge asynchronously on 5% of traffic, together with every negatively rated answer. Plot the judge score by prompt version.
  7. Ship a deliberately worse version of the prompt to a share of the traffic. Find it on the dashboard by its quality signals, without looking at the code.
  8. Take three real failures. Open the trace of each, classify the cause, add each to the eval dataset with a tag, and fix the commonest class.
Cheat sheet

LLM observability — at a glance

Main things to focus on

  • The main failure mode of an LLM system is a wrong answer delivered with status 200. Ordinary monitoring cannot see it.
  • Four layers: system health, behaviour (traces), quality, and drift. You need all four.
  • Record every model call with its versions, usage, timing, stop reason and outcome. Metrics derive from records, not the reverse.
  • Prompts and responses are highly sensitive. Redact, restrict access, keep them briefly, and sample the content while keeping the metadata.
  • One trace per user request, one span per step. When an answer is wrong, read what the model actually saw.
  • Combine explicit feedback, implicit behavioural signals and sampled asynchronous judges, all labelled by prompt and model version.
  • Run the offline eval suite on a schedule against production, as a canary for drift.
  • Close the loop: detect, inspect the trace, classify, add to the evals, fix the right layer, ship. And read real conversations every week.

Per-call record

trace_id, span_id, timestamp, featureIdentity; joins to logs and to feedback
model, prompt_version, retrieval_versionVersions; essential for every comparison
input_tokens, cached_tokens, output_tokens, costUsage
latency_ms, time_to_first_token_ms, retriesTiming
stop_reasonA natural end, the length limit, a tool call, a refusal
retrieved ids and scores, tool calls and resultsContext; what the model saw
validation result, guard-rail flags, feedback, judge scoreOutcome; filled in as it arrives

Span naming for a RAG agent

assistant.askThe root span for the user's request
rag.rewrite_query / rag.embed_queryPreparation of the query
rag.vector_search / rag.rerankRetrieval, with k and the top score as attributes
llm.generateOne per model call, with the turn number
tool.NAMEOne per tool call, with arguments and status
guardrail.check_outputValidation and safety checks
gen_ai.* attributesThe OpenTelemetry conventions for generative AI; still evolving

Quality signals

thumbs up / down rateExplicit; sparse and biased, but unambiguous
rephrase-within-60s rateImplicit; the first answer did not help
acceptance rate of suggestionsImplicit; ideal for coding and drafting tools
escalation-to-human rateImplicit; ideal for support assistants
sampled judge pass rateAutomated; faithfulness, relevance, tone
validation failure and retry rateGuard rails, as an early warning
refusal rate, truncation rateFrom the stop reasons

Drift detectors

nightly eval run against production configThe score moved, and you changed nothing
distribution of top-k retrieval scoresA stale corpus, or a changed index
share of "not found" answersUsers are asking about things that are not covered
median output tokens against last weekA change in the model's behaviour
language and topic mix of the queriesNew users, new needs
model identifier on every callProves which version answered

Failure classes

retrieval missFix: chunking, hybrid search, reranking, fresher documents
ambiguous or conflicting promptFix: the prompt, the examples
model limitationFix: a tool, a more capable model, or a narrower scope
tool bug or unreadable tool resultFix: the tool, its description, its output
bad source dataFix: the documents themselves
out of scopeFix: the product's messaging, a graceful refusal
abuse or injectionFix: guard rails, privileges, rate limits

Privacy controls

redact before storingKeys, emails, card numbers, identifiers
metadata always, content sampledA tiered policy for logging
short retention for contentLonger for the aggregated metrics
restricted, audited access to raw textNot everyone with dashboard access
deletion by user identifierHonour requests for erasure
pseudonymous user IDsJoin the behaviour without the identity

Common pitfalls

  • Relying on the error rate and latency alone, and learning about bad answers from angry users.
  • Logging full prompts and responses for ever, with no redaction and no limit on who can read them.
  • Recording quality metrics without the prompt and model version, so that no two versions can be compared.
  • Running judges inside the request path, and doubling the latency for every user.
  • Trusting the rate of explicit feedback alone, when only a tiny and unrepresentative share of users click.
  • Fixing each reported failure by hand, and never adding it to the eval dataset.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →