Phase 19 · Lesson 26 of 85

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

An agent harness without observability is a black box that costs money. This lesson hand-rolls a span builder that emits records compliant with 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. The whole thing is stdlib Python and runs offline.

Lesson 26 of 85Build~90 minutesPython (stdlib)

Before this: Lesson 23 of Phase 19 (verification gates), lesson 24 of Phase 19 (sandbox), lesson 25 of Phase 19 (eval harness), lesson 20 of Phase 13 (OpenTelemetry GenAI), lesson 23 of Phase 14 (OTel GenAI conventions)

On this page

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

A coding agent in production produces three classes of artifact every turn: a model call, a tool execution, and a verification gate decision. None of these are useful without structured telemetry.

The first failure mode is the missing trace. Something went wrong on Tuesday but the only record is a 500-line chat log. There is no record of 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 failure mode is the unparseable trace. The harness wrote spans but used its own ad-hoc field names. Nothing in Grafana, Honeycomb, Jaeger, or the local CLI can read them. Whatever tooling exists in the team's stack is wasted because the spans are non-standard.

The third failure mode is the unaggregated metric. You can see one slow tool call in the trace, but you cannot answer "what is the p95 latency of read_file calls over the last hour?" because there are no metrics, only traces.

The OpenTelemetry GenAI semantic conventions exist exactly for 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.

The Concept

Diagram (opens full size)

Every operation in the harness produces a span. A span has a trace id (the whole agent invocation), a span id (this one operation), a name (e.g. gen_ai.chat, gen_ai.tool.execution), attributes that follow the GenAI conventions, a start and end time, and a status.

The GenAI conventions standardise these attribute keys: gen_ai.system (which 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, gen_ai.operation.name, plus tool-specific keys gen_ai.tool.name and gen_ai.tool.call.id.

The exporter writes JSONL. One JSON object per line. This is the simplest possible format that downstream tooling can stream, grep, and import. A real OTel exporter would speak OTLP gRPC; the lesson's JSONL exporter is the offline equivalent and 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, which is the de-facto standard for pull-based metrics.

trace-spans

Architecture

Diagram (opens full size)

The span builder is a small class with a span(name, attrs) method that returns a context manager. The context manager records start time on enter, records end time on exit, attaches an exception if one was raised, and pushes the finalised span to the exporter.

The metrics registry is two dicts. Counters are {(name, frozen_labels): int}. Histograms keep raw samples in a list and serialise to Prometheus histogram buckets at exposition time.

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 span(name, attrs, parent=None) context manager.
  3. JSONLExporter class with export(span) that appends one line.
  4. Counter and Histogram classes plus MetricsRegistry.
  5. prometheus_exposition(registry) that produces text-format output.
  6. wrap_tool_call(name) decorator that emits a span and updates metrics.
  7. Demo: synthesises a complete agent invocation (gen_ai.chat span around tool spans), writes traces.jsonl, prints the Prometheus exposition, exits zero.

The trace id is a 16-byte value (32 hex characters) and the span id is an 8-byte value (16 hex characters), both cut from random uuid4 hex. That matches the W3C Trace Context sizes OTel uses. The exporter never throws; IO errors are surfaced but the harness keeps running.

The histogram has a fixed bucket set for latency in milliseconds: 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, +Inf. It is a trimmed version of the OTel SDK default boundaries, which also include 0, 75, 750, and 7500. Samples are stored as a list; exposition computes per-bucket counts on demand.

Why hand-rolled instead of opentelemetry-sdk

The OTel Python SDK is a real dependency. It is also several thousand lines of code, a background export thread 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 GenAI conventions are still marked Development in the spec, and they have renamed attributes before: gen_ai.usage.prompt_tokens became gen_ai.usage.input_tokens. Pin the semantic-conventions version you emit and check it when you upgrade the SDK.

How this composes with the rest of Track A

Lesson 25 produced the gate chain. Lesson 26 produced the sandbox. Lesson 27 produced 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.

Running it

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

The demo emits a traces.jsonl in the lesson's working dir (cleaned up at the end), then prints a sample of three spans, then prints the Prometheus exposition for the counters and histograms. The tests verify that spans serialise round-trip, that the canonical GenAI attributes are present, that counters increment correctly, and that the histogram exposition contains the expected bucket counts.

Check yourself

Quick quiz

  1. Before reading the lesson: what is the practical benefit of writing your harness's spans in the OTel GenAI attribute shape?

    1. Every OTel-compatible backend can read them without a custom parser.
    2. OTel is a Python-only standard, so the harness gets type hints.
    3. It is required by HIPAA.
    4. OTel spans compress better on disk.
    Show the answer

    A. Every OTel-compatible backend can read them without a custom parser. The conventions are a contract with the wider ecosystem. Honeycomb, Grafana Tempo, Jaeger, and Datadog all parse gen_ai.* attributes out of the box.

  2. An exception is raised inside a SpanBuilder.span() context. What does the lesson's builder record?

    1. status remains UNSET and the exception is silently swallowed.
    2. The span is dropped to avoid polluting the trace.
    3. status=ERROR, status_message includes the exception type and message, and an 'exception' event is appended.
    4. The span is renamed to 'error'.
    Show the answer

    C. status=ERROR, status_message includes the exception type and message, and an 'exception' event is appended. Status ERROR plus an exception event matches the OTel spec. The exception is re-raised so caller code can react.

  3. The Prometheus exposition for a histogram includes _bucket, _sum, and _count lines. Why are bucket counts cumulative (each le bucket includes all smaller buckets)?

    1. Cumulative counts reduce floating-point error.
    2. Cumulative counts let the scraper compute per-bucket counts via subtraction without storing all observations.
    3. Cumulative counts are the only format Grafana supports.
    4. They are not cumulative; the lesson's exposition is non-standard.
    Show the answer

    B. Cumulative counts let the scraper compute per-bucket counts via subtraction without storing all observations. Prometheus' design choice: a backend reads cumulative counts and computes per-bucket differences. This lets the time series be a simple monotonic counter per bucket.

  4. The harness wraps a tool execution in a span. Which attribute keys identify it as a tool call under the GenAI conventions?

    1. operation + tool.name.
    2. agent.harness.tool + agent.harness.call.
    3. gen_ai.system + gen_ai.request.model.
    4. gen_ai.tool.name + gen_ai.tool.call.id.
    Show the answer

    D. gen_ai.tool.name + gen_ai.tool.call.id. gen_ai.tool.name and gen_ai.tool.call.id are the canonical keys for tool executions in the GenAI conventions.

  5. You want to query 'what is the p95 latency of read_file calls'. Which artifact do you query?

    1. The status_message field of the span.
    2. The counter tools_called_total, divided by the time window.
    3. The Prometheus histogram tool_latency_ms{tool="read_file"} scraped over a window.
    4. Each span's duration_ms in traces.jsonl, parsed by a script.
    Show the answer

    C. The Prometheus histogram tool_latency_ms{tool="read_file"} scraped over a window. Histograms are the right tool for percentiles. The counter only counts; the trace contains raw spans but is not pre-aggregated.

  6. A reviewer asks why the lesson hand-rolls instead of importing opentelemetry-sdk. Which answer is correct?

    1. Hand-rolling teaches the wire format; the SDK can drop in later with the same attribute keys.
    2. Hand-rolling is faster at runtime.
    3. The SDK requires a network connection at import time.
    4. The SDK does not support GenAI conventions.
    Show the answer

    A. Hand-rolling teaches the wire format; the SDK can drop in later with the same attribute keys. The conventions are stable across implementations. The hand-rolled version exists for clarity; production wires in the SDK and reuses the same gen_ai.* keys.

Questions people ask

What is OpenTelemetry for LLM apps?

OpenTelemetry is an open standard for traces, metrics and logs. Its GenAI conventions define common fields for AI calls, such as model name, token counts and operation type. Using them lets any compatible tool display and compare AI traces.

What is a trace span?

A span is one timed step in a request, such as a model call or a tool call, with a start time, end time and details. Spans link into a tree that shows the whole run, so you can see which step was slow, failed or cost the most.

Next lessonCapstone Lesson 29: End-to-End Coding Agent on the HarnessPrevious: Capstone Lesson 27: Eval Harness with Fixture Tasks

Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License

Next step

Rather have it built for you?

Reading is optional. Tell me what's stuck and I'll build it into the business you already run.

  • A real conversation, not a pitch
  • You keep your accounts
  • Nothing sends without your yes