Lesson 5 - Guided Project: Instrumenting Docent

Welcome to the Guided Project

This is the capstone for Observability & Tracing, and it is where the three pieces this module taught finally snap together on one app. Over the last lessons you built them separately: a structured log record (Lesson 2) that turns a model call into a queryable row of prompt, response, model, tokens, latency, and cost; a Tracer and Span (Lesson 3) that follow a single request through retrieval and generation so you can see where the time went; and cost, latency, and token accounting (Lesson 4) with the percentiles that reveal the slow tail an average hides. Each was useful alone. In this project you assemble them into one thin instrumentation layer and wrap it around Docent, so that every request Docent serves leaves behind three things you can inspect: a trace, a log record, and — once you aggregate a batch — a dashboard.

The deliverable is a working instrumented_docent(question) plus the mini observability dashboard you compute over a batch of real runs. We keep the live Claude calls small and honest about run-to-run latency variation, then do all the aggregation deterministically over the fixed collected batch, so the dashboard numbers reproduce exactly. The point of the project is the last stage: turning that dashboard from a wall of numbers into a decision — which request was slowest, where the tokens and cost concentrate, and what you would optimize first. That is the whole arc of the module in one script: from raw model calls to an inspectable, costed, traced system.

By the end of this project, you will be able to:

  • Assemble a reusable instrumentation layer — a Tracer/Span, a structured log record, and an illustrative cost helper — into one instrumented_docent(question) that traces retrieve and generate, logs a record per request, and returns (answer, trace, log_record)
  • Run instrumented Docent live over a batch of golden questions and collect every trace and log record, then read one full real trace and one real log record
  • Aggregate the collected batch into a mini dashboard — total requests, total and mean tokens, total and mean cost, mean/p50/p95 latency, and error rate — and confirm the numbers reproduce on a re-run
  • Use the dashboard and traces to answer real operational questions: find the slowest request from its trace, see where cost concentrates, and decide what to optimize

Stage 1: The Instrumentation Layer

An observability layer needs three primitives, and you already built each one in this module. The first is a Tracer and Span: the Tracer mints a short trace_id for a request and hands out Span context managers, each of which times a stage and holds a bag of attributes. The second is a structured log record — a flat dict, one per request, carrying everything you would ever want to query later: the model, token counts, latency, cost, status, and the trace_id that links the row back to its trace. The third is a cost helper: an illustrative per-token price that turns input and output token counts into dollars.

We assemble them into instrumented_docent(question). It opens a retrieve span around the keyword retrieval (recording which doc ids came back), opens a generate span around the live claude-haiku-4-5 call (recording the token usage the API returns), and wraps the whole thing in a try/except so a failed call becomes a logged status: "error" rather than a crash. It returns three things — the answer, the trace, and the log_record — so the caller gets the reply and the full observability trail for it.

import warnings; warnings.filterwarnings("ignore")
import time, uuid, contextlib, statistics, json, anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

DOCS = [
    {"id": "auth", "title": "Authentication",
     "text": "Meridian authenticates every request with an API key sent in the Authorization header as a bearer token. "
             "Create keys in the dashboard under Settings > API Keys. Keys are either test or live mode; test keys only "
             "touch sandbox data. A key is shown once at creation and cannot be recovered, only rotated."},
    {"id": "ratelimits", "title": "Rate Limits",
     "text": "The Free plan allows 60 requests per minute. The Pro plan allows 600 requests per minute. Exceeding the "
             "limit returns HTTP 429 with a Retry-After header giving the seconds to wait. Rate limits are applied per "
             "API key, not per account."},
    {"id": "plans", "title": "Plans and Pricing",
     "text": "Meridian has three plans. Free costs 0 dollars per month and includes 1 GB of storage. Pro costs 25 dollars "
             "per month and includes 50 GB of storage. Scale costs 199 dollars per month and includes 500 GB of storage. "
             "All plans include unlimited read replicas."},
    {"id": "regions", "title": "Regions",
     "text": "Databases can be created in three regions: us-east, eu-west, and ap-south. The region is fixed at creation "
             "time and cannot be changed later; to move data to another region you must export and re-import into a new "
             "database. Cross-region read replicas are available on the Scale plan only."},
    {"id": "errors", "title": "Error Codes",
     "text": "Meridian uses standard HTTP status codes. 400 means a malformed request, 401 means a missing or invalid API "
             "key, 403 means the key is valid but lacks permission, 404 means the resource does not exist, and 429 means "
             "the rate limit was exceeded. 5xx codes indicate a server-side error and should be retried with backoff."},
    {"id": "backups", "title": "Backups",
     "text": "Automatic daily backups are retained for 7 days on the Free plan, 30 days on Pro, and 90 days on Scale. "
             "Backups run at 03:00 UTC. Point-in-time recovery to any second in the retention window is available on the "
             "Scale plan. Restoring a backup creates a new database and never overwrites the original."},
    {"id": "sdks", "title": "SDKs",
     "text": "Official SDKs are available for Python, JavaScript, and Go. Install the Python SDK with 'pip install meridian'. "
             "The SDK reads the MERIDIAN_API_KEY environment variable by default. All SDKs retry idempotent requests up to "
             "3 times on 5xx errors with exponential backoff."},
    {"id": "deletion", "title": "Deleting Data",
     "text": "Deleting a database is immediate and irreversible once the 24-hour grace period ends. During the grace period "
             "the database is soft-deleted and can be restored from the dashboard. After 24 hours the data and its backups "
             "are permanently purged. Deletion requires a live-mode key with admin permission."},
]

def retrieve(question, docs, k=2):
    q = set(question.lower().split())
    scored = sorted(docs, key=lambda d: len(q & set((d["title"] + " " + d["text"]).lower().split())), reverse=True)
    return scored[:k]

# --- primitive 1: Tracer + Span (from Lesson 3) ---
class Span:
    def __init__(self, name):
        self.name, self.attributes = name, {}
        self.start = self.end = None
    @property
    def duration_ms(self):
        return round((self.end - self.start) * 1000, 1)

class Tracer:
    def __init__(self):
        self.trace_id = uuid.uuid4().hex[:12]
        self.spans = []
    @contextlib.contextmanager
    def span(self, name):
        s = Span(name); s.start = time.perf_counter(); self.spans.append(s)
        try:
            yield s
        finally:
            s.end = time.perf_counter()

# --- primitive 2: cost helper (from Lesson 4) ---
# Illustrative teaching price, NOT real Anthropic pricing -- dollars per million tokens.
PRICE_PER_MTOK = {"input": 0.80, "output": 4.00}
def cost_usd(input_tokens, output_tokens):
    return (input_tokens * PRICE_PER_MTOK["input"] + output_tokens * PRICE_PER_MTOK["output"]) / 1e6

# --- assemble: instrumented Docent -> (answer, trace, log_record) ---
def instrumented_docent(question, docs, model="claude-haiku-4-5"):
    tracer = Tracer()
    t0 = time.perf_counter()
    status, err, answer = "ok", None, ""
    in_tok = out_tok = 0
    try:
        with tracer.span("retrieve") as sp:
            hits = retrieve(question, docs)
            sp.attributes.update(k=len(hits), doc_ids=[d["id"] for d in hits])
        with tracer.span("generate") as sp:
            ctx = "\n\n".join(f"[{d['id']}] {d['title']}\n{d['text']}" for d in hits)
            prompt = ("Answer the question using ONLY the context. If the context does not contain the answer, say "
                      "\"I don't have that in the docs.\" Be concise.\n\n"
                      f"Context:\n{ctx}\n\nQuestion: {question}")
            msg = client.messages.create(model=model, max_tokens=200,
                                         messages=[{"role": "user", "content": prompt}])
            answer = msg.content[0].text
            in_tok, out_tok = msg.usage.input_tokens, msg.usage.output_tokens
            sp.attributes.update(model=model, input_tokens=in_tok, output_tokens=out_tok)
    except Exception as e:
        status, err = "error", type(e).__name__
    latency_ms = round((time.perf_counter() - t0) * 1000, 1)
    log_record = {
        "trace_id": tracer.trace_id, "model": model, "question": question,
        "status": status, "error": err,
        "input_tokens": in_tok, "output_tokens": out_tok, "total_tokens": in_tok + out_tok,
        "latency_ms": latency_ms, "cost_usd": round(cost_usd(in_tok, out_tok), 8),
    }
    return answer, tracer, log_record

# smoke test: one request, look at all three artifacts
answer, trace, record = instrumented_docent("What does a 401 error mean?", DOCS)
print("answer:", answer)
print("trace_id:", trace.trace_id)
for s in trace.spans:
    print(f"  span={s.name:9} {s.duration_ms:8.1f}ms  {s.attributes}")
print(json.dumps(record, indent=2))
answer: A 401 error means a missing or invalid API key.
trace_id: fc581c9f0896
  span=retrieve       0.1ms  {'k': 2, 'doc_ids': ['errors', 'auth']}
  span=generate    1381.1ms  {'model': 'claude-haiku-4-5', 'input_tokens': 213, 'output_tokens': 16}
{
  "trace_id": "fc581c9f0896",
  "model": "claude-haiku-4-5",
  "question": "What does a 401 error mean?",
  "status": "ok",
  "error": null,
  "input_tokens": 213,
  "output_tokens": 16,
  "total_tokens": 229,
  "latency_ms": 1381.1,
  "cost_usd": 0.0002344
}

One call, three artifacts, all linked by the same trace_id (fc581c9f0896). The trace shows the shape every LLM request has: retrieve is essentially free (0.1 ms of local set arithmetic) while generate is the whole cost — 1381 ms and every token. The log record is the flat row you would ship to a datastore and query later. Notice that the cost is computed from the real token usage the API returned (213 in, 16 out), not an estimate — the layer measures, it doesn’t guess. And notice what is not in any artifact: the API key. The layer never reads or logs it; anthropic.Anthropic() picks it up from the environment, and nothing in the record or trace could ever leak it.

Instrument at the boundaries, not inside the model

Every span here wraps a boundary you control — the retrieval function, the API call — never the model’s internals, which you can’t see. That is the whole art of instrumenting an LLM app: you can’t trace what happens inside claude-haiku-4-5, but you can time the call, record the tokens it reports, and price the result. The generate span’s duration_ms includes network round-trip and queueing, not just compute, which is exactly what you want — it is the latency your user feels. Wrapping the boundary rather than guessing at the interior is what makes the trace honest, and it is why the token attributes come from msg.usage (the provider’s own count) rather than a local tokenizer estimate.


Stage 2: Run a Batch

One request proves the layer works; a batch is where observability earns its keep, because you can’t aggregate a single call. We run instrumented Docent live over seven golden questions — spanning rate limits, plans, regions, backups, SDKs, and the out-of-scope MongoDB probe — and collect, for each, a small run bundle of answer, trace, and record. Collecting everything once, up front, is deliberate: the live calls vary run to run, so we freeze their results here and let every later stage read from the frozen batch. That is what will make the dashboard reproducible even though the model isn’t.

After the batch, we print two things in full: one complete trace (both spans, their durations, and the token attributes) and one complete log record. Read the trace as a timeline of a single request; read the record as the row that request becomes in your logs.

QUESTIONS = [
    "What HTTP status code does Meridian return when you exceed the rate limit?",
    "How many requests per minute does the Pro plan allow?",
    "How much does the Pro plan cost per month?",
    "Can you change a database's region after it is created?",
    "How long are backups retained on the Scale plan?",
    "Which environment variable does the Python SDK read for the API key?",
    "Does Meridian support MongoDB-style aggregation pipelines?",
]

runs = []
for q in QUESTIONS:
    answer, trace, record = instrumented_docent(q, DOCS)
    runs.append({"answer": answer, "trace": trace, "record": record})

print(f"{'status':7}{'latency':>10}{'tokens':>8}{'cost':>11}  question")
for r in (x["record"] for x in runs):
    print(f"{r['status']:7}{r['latency_ms']:>8.1f}ms{r['total_tokens']:>8}"
          f"{'  $'+format(r['cost_usd'],'.6f')}  {r['question'][:40]}")

demo = runs[0]["trace"]
print("\n--- one full trace ---")
print("trace_id:", demo.trace_id)
for s in demo.spans:
    print(f"  span={s.name:9} {s.duration_ms:8.1f}ms  attrs={s.attributes}")
print("\n--- one full log record ---")
print(json.dumps(runs[0]["record"], indent=2))
status    latency  tokens       cost  question
ok       1295.9ms     237  $0.000247  What HTTP status code does Meridian retu
ok        969.7ms     214  $0.000216  How many requests per minute does the Pr
ok        980.4ms     212  $0.000211  How much does the Pro plan cost per mont
ok        925.0ms     250  $0.000334  Can you change a database's region after
ok        856.0ms     238  $0.000248  How long are backups retained on the Sca
ok        736.8ms     248  $0.000269  Which environment variable does the Pyth
ok       1088.0ms     229  $0.000222  Does Meridian support MongoDB-style aggr

--- one full trace ---
trace_id: a36bd094fdab
  span=retrieve       0.1ms  attrs={'k': 2, 'doc_ids': ['errors', 'ratelimits']}
  span=generate    1295.8ms  attrs={'model': 'claude-haiku-4-5', 'input_tokens': 219, 'output_tokens': 18}

--- one full log record ---
{
  "trace_id": "a36bd094fdab",
  "model": "claude-haiku-4-5",
  "question": "What HTTP status code does Meridian return when you exceed the rate limit?",
  "status": "ok",
  "error": null,
  "input_tokens": 219,
  "output_tokens": 18,
  "total_tokens": 237,
  "latency_ms": 1295.9,
  "cost_usd": 0.0002472
}

Seven requests, seven traces, seven records — the raw material for a dashboard. Already the batch table tells a story a single call couldn’t: latency ranges from about 737 ms to about 1296 ms, a nearly 2x spread across identical-looking requests, while tokens sit in a tight band (212–250) because every prompt carries two retrieved docs of similar length. The generate span in the full trace is 1295.8 ms out of a 1295.9 ms request — retrieval is a rounding error, so all of Docent’s latency and cost live in the model call. That single fact will shape every optimization decision in Stage 4.

Because these are live calls, your numbers will differ. Latency shifts with network and load, and even the token counts wobble a little because the model’s answer length varies run to run (a one-word “429” versus a full sentence). That is expected and honest — the aggregation over a fixed batch is what we make reproducible next, not the live calls that fill it.


Stage 3: Aggregate Into a Mini Dashboard

A pile of records isn’t observability until you can summarize it. The dashboard function collapses the batch into the numbers an on-call engineer actually watches: volume (total requests, error rate), cost (total and mean tokens, total and mean dollars), and latency (mean, p50, p95). The percentiles matter more than the mean here — recall from Lesson 4 that an average latency hides the slow tail, and p95 is the number your slowest one-in-twenty users feels.

To make the error rate non-zero and realistic, we inject one failed call: a request pointed at a bad model id. The try/except in instrumented_docent catches it, logs status: "error" with zero tokens, and returns a record like any other — which is the point. A failure isn’t a crash; it’s a data point. We append it to the batch, then aggregate. Crucially, we run dashboard twice over the same frozen records and confirm the two results are identical — the aggregation touches no model, so it reproduces byte-for-byte.

# inject one genuinely failed call (bad model id) -> caught, logged as status "error"
answer, trace, fail = instrumented_docent("Ping test on a bad model id", DOCS, model="claude-nonexistent-x")
runs.append({"answer": answer, "trace": trace, "record": fail})
print("injected:", fail["status"], fail["error"], "tokens =", fail["total_tokens"])

def dashboard(records):
    ok = [r for r in records if r["status"] == "ok"]
    lat = sorted(r["latency_ms"] for r in ok)
    def pct(p):
        if not lat:
            return 0.0
        return lat[min(len(lat) - 1, int(round((p / 100) * (len(lat) - 1))))]
    n = len(records)
    n_err = sum(1 for r in records if r["status"] == "error")
    return {
        "requests":        n,
        "errors":          n_err,
        "error_rate":      round(n_err / n, 3),
        "total_tokens":    sum(r["total_tokens"] for r in records),
        "mean_tokens":     round(statistics.mean([r["total_tokens"] for r in ok]), 1),
        "total_cost_usd":  round(sum(r["cost_usd"] for r in records), 6),
        "mean_cost_usd":   round(statistics.mean([r["cost_usd"] for r in ok]), 8),
        "mean_latency_ms": round(statistics.mean(lat), 1),
        "p50_latency_ms":  pct(50),
        "p95_latency_ms":  pct(95),
    }

records = [x["record"] for x in runs]
d1 = dashboard(records)
d2 = dashboard(records)   # deterministic: same frozen batch -> identical numbers

print("\n=== DOCENT OBSERVABILITY DASHBOARD ===")
for k, v in d1.items():
    print(f"  {k:16}: {v}")
print("reproducible?", d1 == d2)
injected: error NotFoundError tokens = 0

=== DOCENT OBSERVABILITY DASHBOARD ===
  requests        : 8
  errors          : 1
  error_rate      : 0.125
  total_tokens    : 1628
  mean_tokens     : 232.6
  total_cost_usd  : 0.001747
  mean_cost_usd   : 0.0002496
  mean_latency_ms : 978.8
  p50_latency_ms  : 969.7
  p95_latency_ms  : 1295.9
reproducible? True

There is the mini dashboard, and reproducible? True is the load-bearing line: the same frozen batch produces identical numbers on every re-run, because dashboard only counts, sums, and sorts records that were already collected. That is the discipline the whole module rests on — keep the live randomness in Stage 2, make everything downstream deterministic. Read the numbers as a triage screen. Error rate 0.125 (one failed request in eight) is the first thing you’d check, and note it’s computed over all requests while token, cost, and latency stats deliberately exclude the failed one — a failed call has no tokens and no meaningful latency, so folding it into the mean would poison those numbers. Total cost is about $0.0017 for the whole batch on the illustrative price, mean about $0.00025 per request. And the latency line is the interesting one: mean 978.8 ms and p50 969.7 ms sit close together, but p95 jumps to 1295.9 ms — a clear slow tail that the average alone would have smoothed over.

A failed call is a record, not an exception

The single most important design choice in this layer is that instrumented_docent never lets an error escape — it catches it and returns a normal log record with status: "error" and zero tokens. This is why the dashboard can report an error rate at all: errors are counted, not crashed on. In production this is the difference between “5% of requests are failing and we can see it on a graph” and “the service throws and we find out from angry users.” Notice the deliberate split in the aggregation: error_rate is measured over every request, but mean_tokens, mean_cost, and the latency percentiles are computed over the successful subset only, because a failed call’s zeros aren’t real measurements of anything. Deciding what a failure contributes to each metric is a real observability judgment call, not an afterthought.

The instrumented Docent pipeline. On the left, a golden question flows into an instrumented_docent function that wraps two timed spans: a retrieve span doing keyword overlap in about 0.1 milliseconds, and a generate span making the claude-haiku-4-5 call where tokens and latency concentrate. The function emits three linked artifacts on the right, all sharing one trace id. First, a trace showing the two spans as duration bars, with generate at 1295.8 milliseconds dwarfing retrieve at 0.1 milliseconds, plus token attributes. Second, a structured log record: a flat dict with trace_id, model, question, status ok, input and output tokens, total tokens, latency, and an illustrative cost. Third, a mini dashboard aggregated over the batch: 8 requests, error rate 0.125, total and mean tokens 1628 and 232.6, total illustrative cost 0.001747 dollars, mean latency 978.8 milliseconds, and p50/p95 latency 969.7 and 1295.9 milliseconds, with a note that the average hides the slow tail the p95 exposes. A caption notes the API key is never logged; the code reads it from the environment.

Stage 4: Use It

A dashboard you only glance at is decoration. The payoff of instrumentation is that it answers specific operational questions — the ones you’d ask at 2 a.m. when something is slow or expensive. We ask three, all answered directly from the frozen batch, no new model calls needed.

Which request was slowest, and where did the time go? We find the successful record with the highest latency and open its trace to see the span breakdown. Where do tokens and cost concentrate? We sort the successful records by cost and look at the top few. What would you optimize? The answer falls out of the first two.

ok_runs = [x for x in runs if x["record"]["status"] == "ok"]

# 1. slowest request -> open its trace
slow = max(ok_runs, key=lambda x: x["record"]["latency_ms"])
print(f"slowest: {slow['record']['latency_ms']}ms  trace {slow['record']['trace_id']}")
print(f"  q: {slow['record']['question']}")
for s in slow["trace"].spans:
    share = s.duration_ms / slow["record"]["latency_ms"]
    print(f"   span={s.name:9} {s.duration_ms:8.1f}ms  ({share:.0%} of request)")

# 2. where cost concentrates
top = sorted(ok_runs, key=lambda x: x["record"]["cost_usd"], reverse=True)[:3]
total = sum(x["record"]["cost_usd"] for x in ok_runs)
print("\ntop cost drivers:")
for x in top:
    r = x["record"]
    print(f"   ${r['cost_usd']:.6f}  {r['output_tokens']:>3} out-tok  {r['question'][:40]}")
print(f"top-3 share of spend: {sum(x['record']['cost_usd'] for x in top) / total:.0%}")
slowest: 1295.9ms  trace a36bd094fdab
  q: What HTTP status code does Meridian return when you exceed the rate limit?
   span=retrieve       0.1ms  (0% of request)
   span=generate    1295.8ms  (100% of request)

top cost drivers:
   $0.000334   42 out-tok  Can you change a database's region after
   $0.000269   22 out-tok  Which environment variable does the Pyth
   $0.000248   18 out-tok  How long are backups retained on the Sca
top-3 share of spend: 49%

This is the moment the whole module pays off: raw calls became an inspectable, costed, traced system, and now it answers questions. The slowest request’s trace is unambiguous — generate is 100% of the latency, retrieve is 0%. Whatever you do to make Docent faster, it will not be about retrieval; the lever is the model call (a smaller max_tokens, streaming so the user sees the first token sooner, or caching repeated prompts). The cost breakdown points the same way from a different angle: the three priciest requests account for about half of all spend, and they’re the ones with the most output tokens (the region question alone generated 42 output tokens versus 7–18 for the terse ones). Because output tokens are priced 5x input in our illustrative table, output length is the cost driver — the way to spend less is to prompt for shorter answers, not to shrink the docs you retrieve.

Two honesty notes. First, which request is slowest will change every run — latency is live, so on your machine the region or SDK question might top the list instead. That’s fine; the method is what’s stable — find the max, open its trace, read the span shares. Second, the cost and token numbers move a little run to run with answer length, but over any fixed collected batch the ranking and the aggregation are exact. The instrumentation didn’t make Docent better; it made Docent legible — and you can’t improve what you can’t see.


Practice Exercises

Exercise 1: Add a token-budget attribute and flag over-budget requests

Right now the layer records tokens but never judges them. Extend instrumented_docent so the generate span also records a boolean attribute over_budget, true when output_tokens exceeds a threshold you pick (say 30). Then, after the batch, print every request whose trace has an over-budget generate span. Confirm the region question — the longest answer in our run — is the one that trips it.

Hint

You don’t need to change the return signature — just add one line inside the generate span: sp.attributes["over_budget"] = out_tok > 30. To find the flagged requests afterward, iterate runs, look at each trace’s generate span (next(s for s in x["trace"].spans if s.name == "generate")), and check its attributes.get("over_budget"). A per-span flag like this is how real tracing systems surface anomalies: you tag the span at capture time, then filter on the tag later. It’s cheaper and more reliable than re-deriving the condition from the raw record downstream, because the judgment travels with the span.

Exercise 2: Add p99 and explain why it needs more data

The dashboard reports p50 and p95. Add a p99 latency line using the same pct helper. Then run the batch with only 7 successful requests and look at what p99 returns — you’ll find it equals the maximum. Explain why p99 is meaningless at this sample size, and estimate how many requests you’d need before p99 is a stable, trustworthy number.

Hint

pct(99) with 7 samples computes round(0.99 * 6) = 6, the last index — so p99 is literally just max(lat). A percentile can only be as granular as your data: with n samples the smallest meaningful gap between percentiles is roughly 100/n percent, so 7 samples can’t distinguish p95 from p99 at all. As a rule of thumb you want at least 100 / (100 - p) samples for a percentile p to have any resolution — about 20 for p95, 100 for p99 — and several times that before it stops jumping around run to run. This is why observability dashboards aggregate over thousands of requests and time windows, not a handful: the tail statistics that matter most are exactly the ones that need the most data.

Exercise 3: Group the dashboard by outcome status

A single global dashboard blends successes and failures. Write a variant that computes the dashboard per status — one summary for ok requests, one for error — so you can see, for instance, that errors have zero cost and near-zero latency while successes carry all the tokens. Print both. Then argue why grouping (or “slicing”) a dashboard is often more useful than a single blended number.

Hint

Group first, then reuse: build by_status = {} and append each record to by_status.setdefault(r["status"], []), then call your existing dashboard(...) on each group — though note the ok-only stats inside dashboard will now be redundant for the error group, so a simpler per-group summary (count, total tokens, total cost) reads cleaner. The deeper point is that a blended average is a liar’s tool: “mean latency 978 ms” hides that failures returned in milliseconds and dragged the number down. Slicing by a dimension you care about — status, model, question type, customer tier — is how you turn one misleading number into several honest ones, and it’s the natural next step from this project into production monitoring.


Summary

You built a real observability layer and wrapped it around Docent, combining everything Module 7 taught into one working system. You assembled the instrumentation layer — a Tracer and Span for timing, a structured log record for querying, and an illustrative cost helper for pricing — into instrumented_docent(question), which traces the retrieve and generate stages, logs one record per request, and returns (answer, trace, log_record) while never touching the API key. You ran it live on claude-haiku-4-5 over seven golden questions, collected every trace and record, and read one full trace and one full log record. You aggregated the batch into a mini dashboard — 8 requests, error rate 0.125 after injecting one failed call, 1628 total tokens, about $0.0017 total illustrative cost, mean latency 978.8 ms, p50 969.7 ms, p95 1295.9 ms — and confirmed it reproduces exactly (reproducible? True). And you used it: the slowest request’s trace showed generate was 100% of latency, and the top three requests drove roughly half of all spend, all on output tokens — telling you exactly where to optimize.

Key Concepts

  • Instrumentation layer — a thin wrapper of Tracer/Span, a structured log record, and a cost helper, assembled so every request emits a trace, a record, and (in aggregate) a dashboard; you build it once and every call inherits it.
  • The three linked artifacts — trace, log record, and dashboard all share a trace_id; the trace shows where time went inside one request, the record is the queryable row, the dashboard is the batch summary.
  • Collect-then-aggregate — freeze the live, varying batch once, then compute every metric deterministically over the frozen records so the dashboard reproduces (reproducible? True); keep the randomness in one clearly-labeled place.
  • Percentiles over the mean — p50 and p95 expose the slow tail an average smooths away; percentiles need enough samples to be meaningful, which is why real dashboards aggregate over thousands of requests.
  • A failure is a data point — catching errors into status: "error" records is what makes an error rate possible; deciding what a failed call contributes to each metric (counted for error rate, excluded from token/latency means) is a real observability judgment.

Why This Matters

Everything before this module judged Docent against a dataset; this project makes Docent legible in production, which is a different and harder thing. A live LLM app that you can’t trace, log, or cost is a black box that works until it doesn’t, and when it slows down or runs up a bill you have nothing to look at. The layer you built turns every request into evidence — a trace that pinpoints where the time went, a record you can query, and a dashboard whose p95 and error rate you can alert on — and it does so cheaply, at the boundaries you control, without ever leaking a secret. That is the exact capability that separates a demo from a service: you can answer “which request was slow, where did the cost go, and what do I fix?” with data instead of a guess. Every production LLM system needs this instrumentation, and every optimization you make to one — smaller responses, streaming, caching, a cheaper model on easy queries — is a change you can only justify, and only verify, because you measured it first.


Continue Building Your Skills

You just built the layer that turns a working LLM app into an operable one. It wraps Docent’s retrieve and generate stages in timed spans, emits a structured record for every request, prices each call, and rolls a batch up into a dashboard with the percentiles and error rate an on-call engineer actually watches — and it does all of it without ever reading or logging the API key. You proved the aggregation reproduces exactly over a frozen batch, then used it to find that the model call is 100% of Docent’s latency and output length is its main cost driver: two optimization decisions you could only make because you measured them. Carry the instinct forward — when you ship an LLM feature, instrument it before you tune it, because the trace, the cost, and the p95 are what tell you whether your change helped or just felt faster. In Module 8, this instrumentation stops being something you inspect after the fact and becomes something that watches in real time: monitoring that alerts on these metrics and guardrails that act on them before a bad response reaches a user.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com