TAI BUI
Back to curriculum
CapstonePython (stdlib)

Capstone Lesson 28: Observability with OTel GenAI Spans and Prometheus Metrics

An agent harness without observability is a black box that costs money. Here you hand-roll a span builder that emits records shaped to the OpenTelemetry GenAI semantic conventions, writes them to a JSON-Lines file one span per line, and exposes counters and histograms in Prometheus text format. It is all stdlib Python and runs offline.

TIME: ~90 minutesPREREQUISITES: Phase 19 · 25 (verification gates), Phase 19 · 26 (sandbox), Phase 19 · 27 (eval harness), Phase 13 · 20 (OpenTelemetry GenAI), Phase 14 · 23 (OTel GenAI conventions)

Learning Objectives

  • Build a span data class shaped to the OpenTelemetry GenAI semantic conventions.
  • Implement a JSONL exporter that writes one self-contained span per line.
  • Build counters and histograms with labels and Prometheus text-format exposition.
  • Wrap any callable in a span context manager that records duration, status, and exceptions.
  • Verify that the emitted spans roundtrip through `json.loads` and match the spec shape.

The Problem

Observability, for an agent harness, means every model call, tool execution, and verification gate decision leaves a structured record you can query later, as traces and as metrics. A coding agent in production produces all three every turn, and none of them are useful without that telemetry. Three failure modes show up when it is missing or badly built.

The first is the missing trace. Something went wrong on Tuesday, and the only record is a 500-line chat log. Nothing says which tool ran, how long it took, how many tokens went into the prompt, or whether the gate refused anything. The agent author has to guess.

The second is the unparseable trace. The harness wrote spans but invented its own field names, so Grafana, Honeycomb, Jaeger, and the local CLI can't read them. Whatever tooling the team already runs goes to waste because the spans are non-standard.

The third is the unaggregated metric. One slow tool call is visible in the trace, but "what is the p95 latency of read_file calls over the last hour?" has no answer, because there are only traces and no metrics.

The OpenTelemetry GenAI semantic conventions exist for exactly this. They define a small set of standard attributes that span emitters across LLM frameworks share. If your harness writes those attributes, every OTel-compatible backend can read them.

Warm-up0/3
1. Your service writes logs with field names it invented. What is the main cost when you later point an existing dashboard or tracing tool at them?Warm-up
2. You need to know how many requests failed each minute over a whole day. Which kind of record fits best?Warm-up
3. A dashboard reports a p95 latency of 400 ms. What does that tell you?Warm-up
Check: The Problem0/3
1. What are the OpenTelemetry GenAI semantic conventions?Define
2. Why does the lesson keep metrics next to traces instead of relying on traces alone?Explain
3. A dashboard shows no token usage for the harness even though spans are being written. The harness stores counts under llm.prompt_tokens. What assumption failed?Analyze

The Concept

Mermaid diagram

A span is the record of one operation in the harness. It carries a trace id (the whole agent invocation), a span id (this one operation), a name such as gen_ai.chat or gen_ai.tool.execution, attributes that follow the GenAI conventions, a start and end time, and a status. Every operation in the harness produces one.

The GenAI conventions standardise the attribute keys: gen_ai.system (the provider, e.g. anthropic, openai), gen_ai.request.model (the model id), gen_ai.request.max_tokens, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.response.model, gen_ai.response.id, and gen_ai.operation.name. Tool spans add gen_ai.tool.name and gen_ai.tool.call.id.

The exporter writes JSONL, one JSON object per line. It is the simplest format downstream tooling can stream, grep, and import. A real OTel exporter would speak OTLP gRPC. The JSONL exporter here is the offline equivalent, and it exits zero on every workstation.

Metrics live next to traces. A counter increments on each tool call: tools_called_total{tool="read_file"}. A histogram records the observed latency: tool_latency_ms{tool="read_file"}. Both serialise into Prometheus text exposition format, the de-facto standard for pull-based metrics.

Check: The Concept0/12
1. In tracing, what is a span?Define
2. What does the trace id identify in an agent harness?Define
3. What does the lesson's JSONL exporter write?Define
4. What is a counter such as tools_called_total{tool="read_file"}?Define
5. What does a histogram such as tool_latency_ms{tool="read_file"} record?Define
6. What is Prometheus text exposition format?Define
7. Which attribute keys mark a span as a tool execution under the GenAI conventions?Define
8. Which of these is a standard GenAI attribute key for a model call span?Define
9. Why does the lesson's exporter write JSONL rather than speak OTLP?Explain
10. Why emit standard gen_ai.* attribute names even from a hand-rolled exporter?Explain
11. Per the lesson, which artifact answers "what is the p95 latency of read_file calls over the last hour?"Analyze
12. You need an hourly count of gate DENY decisions and also the reason behind one specific denial. Which design fits the lesson's approach?Design

Architecture

Mermaid diagram

The span builder is a small class with a span(name, attributes=None, parent=None, kind="INTERNAL") method that returns a context manager. On enter it records the start time. On exit it records the end time, attaches an exception if one was raised, pushes the finalised span to the exporters, and, for spans carrying gen_ai.tool.name, updates the tool counter and latency histogram.

The metrics registry is two dicts. Each counter is a Counter dataclass per name whose values dict is keyed by a sorted label tuple and holds floats. Histograms keep raw samples in a list and turn them into Prometheus histogram buckets at exposition time.

Check: Architecture0/8
1. An exception is raised inside a SpanBuilder.span() block. What does the builder record?Explain
2. Why is a context manager a good shape for wrapping any callable in a span?Explain
3. A tool span is opened with parent=chat_span. Which field links it to the chat span?Apply
4. After the demo's denied shell call, which is true of its span and metrics?Apply
5. A SpanBuilder with a metrics registry runs three spans that each carry gen_ai.tool.name="read_file". What does the registry hold afterwards?Apply
6. tools_called_total{tool="shell"} is 1, while the shell span has status ERROR. A teammate reads the counter as 'shell succeeded once'. What is wrong with that reading?Analyze
7. A gen_ai.tool.execution span appears in traces.jsonl, but tools_called_total has no entry for its tool, although the builder was given a metrics registry. Which cause fits the code?Analyze
8. In a trace viewer, tool spans show up as separate roots in the same trace instead of nested under gen_ai.chat. What is the likely bug?Analyze

What you will build

main.py ships:

  1. GenAISpan dataclass: trace_id, span_id, parent_span_id, name, attributes, start_unix_nano, end_unix_nano, status, status_message, events.
  2. SpanBuilder class with a span(name, attributes=None, parent=None, kind="INTERNAL") context manager.
  3. JSONLExporter class with export(span) that appends one line.
  4. Counter and Histogram classes plus MetricsRegistry.
  5. prometheus_exposition(registry), which produces text-format output.
  6. Metric updates inside SpanBuilder.span: any span carrying gen_ai.tool.name increments tools_called_total and observes tool_latency_ms.
  7. A demo that synthesises a complete agent invocation (a gen_ai.chat span around tool spans), writes traces.jsonl to a temp directory, prints the Prometheus exposition, and exits zero.

Ids come from uuid4: the trace id is 32 hex characters (16 bytes) and the span id is the first 16 hex characters (8 bytes), matching the sizes in OTel's W3C trace context. The exporter has no error handling of its own, so an IO error in export propagates to the caller.

The histogram uses a fixed bucket set, the OTel default for latency in milliseconds: 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, +Inf. Samples are stored as a list, and exposition computes per-bucket counts on demand.

Check: What you will build0/6
1. How does the code generate trace and span ids?Define
2. The histogram keeps raw samples in a list and builds bucket counts only when the exposition is rendered. What does that design imply?Explain
3. With the default latency buckets, a 7 ms tool call is observed. A le bucket counts every sample at or below its bound. Which le buckets count this call?Apply
4. The JSONLExporter's file write raises an OSError inside export(). What does the code as written do?Apply
5. A histogram with buckets (10, 100, 1000) observes 1, 5, 20, 200 and 2000. Each le bucket counts every sample at or below its bound. What are the le=100 count and the sum?Apply
6. A fresh registry observes 7 and 300 in lat_ms{tool="x"} with the default buckets. Which pair of lines appears in the exposition?Apply

Why hand-rolled instead of opentelemetry-sdk

The OTel Python SDK is a real dependency. It is also several thousand lines of code, with multiple processes for the OTLP exporter and a runtime cost that swamps a lesson budget. The hand-rolled version teaches the wire format. In production you wire the same attributes into the real SDK and get the OTLP exporter, batching, and resource detection for free.

The conventions are stable. The wire format emitted here will keep parsing in 2030 because OTel never breaks GenAI attribute names; they only add new ones.

Check: Why hand-rolled instead of opentelemetry-sdk0/2
1. A reviewer asks why the lesson hand-rolls spans instead of importing opentelemetry-sdk. Which answer is correct?Explain
2. You are taking the harness to production and need OTLP export, batching and resource detection, while existing dashboards must keep reading your spans. Which plan fits?Design

How this composes with the rest of Track A

Lesson 25 produced the gate chain, lesson 26 the sandbox, and lesson 27 the eval harness. Lesson 28 makes all three observable. Lesson 29 wraps every step of the end-to-end demo in spans and prints the Prometheus text at the end.

Author's Note

I'd emit the standard GenAI attribute names even from a hand-rolled exporter. The span builder here is small, and the payoff of the standard keys is that any OTel-compatible backend can read the output, where ad-hoc field names leave the spans stranded.

I'd also keep traces and metrics side by side from the start. My bet is that the p95 question is the one you'll want answered first, and a trace of a single slow call can't answer it. The counter and histogram next to the span are what make that question cheap to ask.

Running it

bash
cd phases/19-capstone-projects/28-observability-otel-traces
python3 code/main.py
python3 -m pytest code/tests/ -v

The demo writes traces.jsonl into a directory made by tempfile.mkdtemp (it is not deleted afterwards), then prints all four spans (chat, read_file, run_tests, and a denied shell call), then prints the Prometheus exposition for the counters and histograms. The tests check four things: spans serialise round-trip, the canonical GenAI attributes are present, counters increment correctly, and the histogram exposition contains the expected bucket counts.

Check: Running it0/2
1. Why does the lesson check that exported spans roundtrip through json.loads?Explain
2. In the demo, each span is exported as its block exits. Which of the four spans is exported first?Apply

Code