Observability

You can't fix what you can't see. Tracing LLM apps span by span (prompt, response, tokens, latency, cost), what to capture, tools like Langfuse/LangSmith/Phoenix over OpenTelemetry, cost/latency dashboards, and connecting production traces back to online evaluation.

~3hobservabilitymonitoringllmops
Learning objectives
  • Explain why LLM apps need observability that ordinary logging doesn't provide.
  • Model a request as a trace of nested spans (retrieval, LLM calls, tool calls) and know what to capture.
  • Compute and aggregate cost and latency per model/endpoint.
  • Place Langfuse / LangSmith / Phoenix / OpenTelemetry on the map and connect traces to online eval.
Note

For JavaScript developers: a "trace" is like a distributed-tracing waterfall (think browser devtools' network panel, or OpenTelemetry) but for an AI request — each LLM/tool/retrieval step is a span with timing and metadata. The code wraps calls to record start/end, inputs/outputs, and token counts.

Why LLM apps need observability

A traditional app is deterministic: same input, same output, and a stack trace tells you what broke. LLM apps are the opposite — non-deterministic, multi-step (retrieve → maybe several tool calls → generate), and they fail silently and semantically: no exception, just a subtly wrong answer. When a user reports "the agent gave a bad answer," you need to see exactly what happened: what was retrieved, the exact prompt sent, which tools ran with what arguments, the raw response, and the tokens/latency/cost of each. That's observability — and without it you're debugging blind.

Traces and spans

The core abstraction: a trace = one full request end to end; spans = the individual steps inside it, nested to show structure. A RAG agent request might be:

Trace: "answer user question"                      (2.3s, €0.004, 1,850 tokens total)
├─ Span: retrieve            (0.2s)  query -> 4 chunks
├─ Span: LLM call [agent]    (0.9s, 1,100 tok)  decides to call a tool
├─ Span: tool: get_weather   (0.1s)  Paris -> "rainy"
└─ Span: LLM call [final]    (1.1s, 750 tok)  grounded answer

You capture this by wrapping each step. Manually, a span records inputs, outputs, timing, and token usage:

import time, contextlib

@contextlib.contextmanager                             # makes a `with`-usable span (like a try/finally scope)
def span(trace: list, name: str, **meta):
    start = time.perf_counter()                        # start the clock
    record = {"name": name, "meta": meta}              # what this step is
    try:
        yield record                                   # the wrapped work runs here; it can add to `record`
    finally:
        record["latency_s"] = time.perf_counter() - start   # always capture duration, even on error
        trace.append(record)                           # add this span to the trace

def traced_llm(trace, prompt):
    with span(trace, "llm_call", model="gpt-4o-mini") as s:
        resp = client.chat.completions.create(model="gpt-4o-mini",
            messages=[{"role":"user","content":prompt}])
        s["prompt"] = prompt                                    # capture INPUT
        s["output"] = resp.choices[0].message.content          # capture OUTPUT
        s["tokens"] = resp.usage.total_tokens                  # capture token usage (for cost)
        return s["output"]
Tip

Exercise 1 has you build this span/traced_llm wrapper and inspect a captured span — prompt, output, tokens, latency — the atom of every observability tool.

What to capture

For every LLM span, log at minimum:

  • Inputs & outputs — the exact prompt (post-templating) and raw response. This is what you'll actually read when debugging.
  • Model & params — model id, temperature, etc. (a silent model swap is a common regression cause).
  • Tokens — prompt + completion counts → drives cost.
  • Latency — per span, so you can find the slow step.
  • Metadata — user/session/trace id, app version, feature tag — for filtering and slicing.
  • Errors & retries — what failed and how it recovered.
Watch out

Traces contain prompts and outputs — i.e. user data, often sensitive. Scrub/redact PII before logging, control access to your tracing backend, and set retention limits. Observability is a data-governance surface, not just an ops tool (Security & Governance track).

Nested spans for multi-step agents

The value compounds for agents: a nested trace shows the whole decision path, so you can see which step went wrong — the retriever fetched junk, or the model ignored good context, or a tool errored:

def traced_agent(question):
    trace = []                                          # one trace per request
    with span(trace, "agent_request", question=question):
        with span(trace, "retrieve") as s:
            s["chunks"] = retrieve(question)            # record what retrieval returned
        answer = traced_llm(trace, build_prompt(question, s["chunks"]))   # nested LLM span
    return answer, trace                                # return the answer AND its full trace
# Reading the trace: bad answer + junk in the retrieve span = retriever bug; good chunks + bad answer = generator bug.

This is the check-retrieval-first diagnostic from RAG, now backed by data instead of guesswork — the trace shows you which stage failed.

Tip

Exercise 2 has you trace a two-step agent and use the nested spans to localize a deliberately broken step (bad retrieval vs. bad generation).

Cost & latency

Tokens convert to money via per-model pricing; aggregating across traces gives you the dashboards that keep an app affordable and fast:

PRICE = {"gpt-4o-mini": {"in": 0.15/1e6, "out": 0.60/1e6}}   # $ per token (illustrative; check current pricing)

def span_cost(model, prompt_tokens, completion_tokens):
    p = PRICE[model]
    return prompt_tokens * p["in"] + completion_tokens * p["out"]   # cost of one LLM call

# Aggregate across many traces to answer the questions that actually matter operationally:
#   • cost per request (and per user/feature)   • p50/p95 latency, and WHICH span dominates it
#   • token trends over time                     • error rate per model/endpoint

These are the metrics a dashboard (Langfuse, Grafana) surfaces: a prompt change that doubles token usage, a p95 latency spike traced to one slow tool, a cost-per-user creeping past your margin — all invisible without observability, obvious with it.

Tip

Exercise 3 has you compute per-call cost from token usage and aggregate cost + p95 latency across a batch of traces — the core of a cost/latency dashboard.

Tools & connecting to eval

You rarely hand-roll all this. Purpose-built LLM observability platforms capture traces, cost, and latency and add dashboards, and most auto-instrument LangChain/LangGraph/LlamaIndex:

  • Langfuse — open-source, self-hostable tracing + evals + prompt management (a common default).
  • LangSmith — LangChain's hosted tracing/eval platform.
  • Phoenix (Arize) — open-source tracing + eval, strong on RAG.
  • OpenLLMetry / OpenTelemetry — vendor-neutral instrumentation you can export to any backend.

The real payoff is the loop: traces feed online evaluation (Eval Design topic). Sample production traces, run your judges/metrics on them, and you catch quality drift the offline golden set never anticipated — closing the loop from "we shipped it" to "we know it's still good."

Tip

Exercise 4 has you take a captured trace and walk the debugging path — read the spans to diagnose a bad answer — the skill every observability tool exists to enable.

Pitfalls

  • No tracing until something breaks. Instrument from day one; you can't retro-debug a request you didn't capture.
  • Logging outputs but not inputs. Without the exact prompt/context you can't reproduce or explain a bad answer.
  • Ignoring cost/latency until the bill or the SLA hurts. Track tokens and p95 continuously.
  • Leaking PII into traces. Prompts/outputs are user data — redact, restrict access, set retention.
  • Traces you never look at. Observability only pays off if you read traces and feed them into online eval.

Recap

  • LLM apps are non-deterministic, multi-step, and fail silently, so ordinary logging isn't enough — you need tracing.
  • A trace is one request; spans are its nested steps. Capture inputs/outputs, model/params, tokens, latency, metadata, errors per span.
  • Aggregate tokens→cost and per-span latency into dashboards; watch cost-per-request and p95.
  • Langfuse/LangSmith/Phoenix (over OpenTelemetry) auto-instrument frameworks; the key payoff is feeding production traces into online eval to catch drift.
  • Treat traces as sensitive data — redact PII, restrict access, set retention.
  • Worked examples map to the exercises: a span wrapper (Ex 1), nested agent trace (Ex 2), cost/latency aggregation (Ex 3), and trace-driven debugging (Ex 4).

Observability — Exercises

Reuse your chat client and RAG pipeline. These build a tiny tracer by hand so the abstractions behind Langfuse/LangSmith are concrete; in production you'd use one of those instead.


Exercise 1 — A span wrapper

Build a span context manager and a traced_llm that records prompt, output, tokens, and latency. Make one call and print the captured span.

Reference solution
import time, contextlib

@contextlib.contextmanager
def span(trace, name, **meta):
    start = time.perf_counter(); rec = {"name": name, "meta": meta}
    try:
        yield rec
    finally:
        rec["latency_s"] = round(time.perf_counter() - start, 3); trace.append(rec)

def traced_llm(trace, prompt):
    with span(trace, "llm_call", model="gpt-4o-mini") as s:
        r = client.chat.completions.create(model="gpt-4o-mini",
            messages=[{"role":"user","content":prompt}])
        s["prompt"] = prompt
        s["output"] = r.choices[0].message.content
        s["tokens"] = r.usage.total_tokens
        return s["output"]

trace = []
traced_llm(trace, "Say hi in one word.")
print(trace[0])   # {'name':'llm_call','meta':{'model':...},'latency_s':...,'prompt':...,'output':...,'tokens':...}

That single span — inputs, outputs, tokens, latency — is the atom every observability tool records. The finally ensures latency is captured even if the call raises.


Exercise 2 — Trace a two-step agent and localize a bug

Trace retrieve → generate with nested spans. Deliberately break retrieval (return junk). Use the trace to decide whether a bad answer is a retriever or generator failure — from the data, not by guessing.

Reference solution
def traced_agent(question):
    trace = []
    with span(trace, "agent_request", question=question):
        with span(trace, "retrieve") as s:
            s["chunks"] = broken_retrieve(question)   # returns irrelevant chunks
        answer = traced_llm(trace, build_prompt(question, s["chunks"]))
    return answer, trace

answer, trace = traced_agent("What does the Pro plan cost?")
retrieve_span = next(s for s in trace if s["name"] == "retrieve")
print("retrieved:", retrieve_span["chunks"])   # junk -> retriever bug, confirmed from the trace

Read the retrieve span: if the answer-bearing chunk is absent, it's a retriever bug (fix chunking/k/embeddings); if good chunks are present but the answer is wrong, it's a generator bug. This is 'check retrieval first' (RAG track) backed by trace data instead of guesswork.


Exercise 3 — Cost & latency aggregation

Given token counts across a batch of traces, compute per-call cost and aggregate total cost + p95 latency.

Reference solution
import statistics
PRICE = {"gpt-4o-mini": {"in": 0.15/1e6, "out": 0.60/1e6}}   # illustrative $/token

def cost(model, pt, ct):
    p = PRICE[model]; return pt*p["in"] + ct*p["out"]

calls = [("gpt-4o-mini", 900, 200), ("gpt-4o-mini", 1200, 350), ("gpt-4o-mini", 700, 150)]
costs = [cost(*c) for c in calls]
lat = [1.1, 2.4, 0.8]
print("total $:", round(sum(costs), 5))
print("p95 latency:", statistics.quantiles(lat, n=20)[-1])   # ~95th percentile

These are the numbers a dashboard tracks: cost per request (and per user/feature), and which span drives p95. A prompt change that doubles tokens, or one slow tool spiking p95, is invisible without this and obvious with it.


Exercise 4 — Debug from a trace

Given the trace below (as a dict), diagnose the failure and name the fix.

{"spans": [
  {"name": "retrieve", "chunks": ["The Pro plan is €49/month."]},
  {"name": "llm_call", "prompt": "...Context: The Pro plan is €49/month... Q: Pro plan price?",
   "output": "The Pro plan costs €59/month.", "tokens": 320}
]}
Reference solution

The retrieve span contains the correct chunk ("€49/month"), and the prompt clearly includes it — so retrieval succeeded. Yet the output says "€59/month", contradicting the provided context. That's a generator / faithfulness failure (intrinsic hallucination), not a retrieval failure.

Fix: tighten the grounding + abstention instruction, drop temperature to 0, and/or add a verification pass (Hallucination & Reliability topic) — not chunking or k. The trace localizes the bug to the generation step because it shows good context in and a contradicting answer out. That localization is the entire reason to trace.