Linking Metrics to Traces with Exemplars

An alert tells you the tail got slower. A trace tells you why one request was slow. An exemplar is the only thing that connects the two — a trace ID recorded alongside a measurement, so the tail bucket in a histogram becomes clickable. This page covers emitting them from Python, the two things that silently prevent them, and how to verify one arrived. It builds on correlating logs, traces and metrics, part of the distributed tracing and OpenTelemetry in Python section.

A bucket with an exemplar, and the same bucket without one The same latency histogram shown twice. Without exemplars the tail bucket is a number: forty-one requests took longer than two seconds in this interval, and the only way to find one of them is to guess a time window and search the traces for something slow, which works when the rate is low and fails exactly when it matters. With exemplars, the same bucket carries a trace identifier captured at the moment one of those measurements was recorded, so a click on the bucket opens a request that genuinely took that long — the right service, the right minute, the right request, with no searching. The note underneath states the constraint that makes this possible at all: the exemplar is captured when the value is recorded, so it can never be added later, and a measurement recorded outside a span carries no exemplar and produces no error to say so. the same tail bucket, with and without an exemplar without le="+Inf" → 41 requests …and that is the whole story the metric can tell to find one: guess a time window, search for something slow, hope with trace_id = 4bf92f3577b34da6a3ce929d0e0e4736 · 4.021 s one request that actually took that long — the right one, no searching the constraint that makes this the one join you cannot add later the exemplar is captured when the value is recorded — a measurement taken outside a span carries none, and nothing reports it so the record() call site, not the configuration, is where this usually goes wrong
Without exemplars, the tail bucket is a count and finding one of its requests is a search. With them it is a link.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0" \
            "prometheus-client>=0.20.0,<1.0.0"
export OTEL_METRICS_EXEMPLAR_FILTER=trace_based
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1

Implementation

Step 1 — Set the exemplar filter. trace_based records an exemplar only when the active span is sampled, which keeps every link pointing at a trace the backend actually received.

export OTEL_METRICS_EXEMPLAR_FILTER=trace_based    # the default, and the right choice

The alternatives are worth knowing so you recognise them in someone else's configuration: always_on records exemplars regardless of sampling, producing links to traces that were never exported; always_off disables them entirely.

Step 2 — Record the measurement inside the span. This is where exemplars are actually lost. The context manager must still be open when record() runs.

import time
from opentelemetry import trace, metrics

tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)
latency = meter.create_histogram("http.server.request.duration", unit="s")

def handle(request):
    with tracer.start_as_current_span("GET /orders/{id}") as span:
        span.set_attribute("http.route", "/orders/{id}")
        started = time.perf_counter()
        try:
            return do_work(request)
        finally:
            latency.record(                          # still inside the with-block
                time.perf_counter() - started,
                attributes={"http.route": "/orders/{id}"},
            )

Move that latency.record() one line down — outside the with — and everything still works, no error appears, and every exemplar silently disappears. That is the single most common cause of "we enabled exemplars and got none".

Step 3 — Keep the identity in the exemplar, not in the labels. This is what exemplars are for: a histogram must stay low-cardinality, and the exemplar carries the specific request without adding a series.

# wrong: one series per user, forever
latency.record(elapsed, attributes={"http.route": route, "user_id": user_id})

# right: the route is a label; the request is an exemplar, attached automatically
latency.record(elapsed, attributes={"http.route": route})

The cardinality argument in full is in controlling label cardinality in Prometheus; exemplars are the escape hatch that makes the strict rule livable.

Three ways to get no exemplars, none of which raise Three causes of missing exemplars, each with its check. First, the measurement was recorded outside the span: the record call sits after the context manager closed, so there is no active span context to capture, and the SDK records the value normally with no exemplar and no complaint — the check is to read the call site and confirm the record call is inside the with-block. Second, the exemplar filter is set to always_off, either explicitly or by an environment variable inherited from somewhere else — the check is to print the resolved configuration at startup. Third, the trace was simply not sampled: with a ten percent sampling rate and the trace_based filter, ninety percent of measurements correctly produce no exemplar, so a low-traffic route may go a whole interval without one — the check is to compare the exemplar rate against the sampling rate before concluding anything is broken. The ordering matters because the first cause accounts for most reports and is the cheapest to check. no exemplars — check in this order 1 · recorded outside the span the record() call sits after the with-block closed — no active context to capture, no error, value recorded normally check: read the call site — is record() inside the with? 2 · the filter is always_off set explicitly, or inherited from a base image or a shared environment file nobody remembers writing check: print OTEL_METRICS_EXEMPLAR_FILTER at startup 3 · the trace was not sampled at 10% sampling, 90% of measurements correctly produce nothing — a quiet route can go a whole interval without one check: compare the exemplar rate against the sampling rate before calling it a bug
Only the third of these is working as intended. The first two produce identical symptoms and the first accounts for most reports.

Step 4 — Expose them. On the OTLP path, exemplars travel inside the metric point and need no extra configuration. On the Prometheus path, the scrape must negotiate OpenMetrics and the server must have exemplar storage enabled.

# prometheus_client, on a histogram observation
from prometheus_client import Histogram
from opentelemetry import trace

LATENCY = Histogram("http_request_duration_seconds", "Latency", ["route"])

span_ctx = trace.get_current_span().get_span_context()
LATENCY.labels(route="/orders/{id}").observe(
    elapsed,
    exemplar={"trace_id": format(span_ctx.trace_id, "032x")} if span_ctx.is_valid else None,
)
# prometheus.yml — the storage side
storage:
  exemplars:
    max_exemplars: 100000

Step 5 — Reconcile sampling with drillability. At 1% sampling, 99 of every 100 measurements produce no exemplar. For the routes you most need to investigate, raise the sampling rate rather than the exemplar filter — the filter only decides whether to record a link to a trace that may not exist.

Sampling rate decides how many exemplars you actually get The tail bucket of a latency histogram at three sampling rates, over one collection interval. At full sampling, every measurement in the bucket carries a trace link, so any of them can be opened; this is the development experience and it is why exemplars feel reliable until they reach production. At ten percent, roughly one in ten measurements carries a link, which is still comfortable for a busy route where the tail bucket receives dozens of measurements per interval. At one percent, a quiet route whose tail bucket receives only a handful of measurements per interval may go several intervals with no exemplar at all — the metric still shows the latency correctly, but there is nothing to click. The conclusion drawn is that drillability is a function of traffic multiplied by sampling rate, so a rarely-hit but important route benefits from a higher sampling rate rather than from any exemplar setting. the tail bucket, one interval, three sampling rates 100% every measurement is clickable — the development experience 10% comfortable on a busy route, thin on a quiet one 1% the latency is still correct — there is just nothing to click drillability = traffic × sampling rate so a rare but important route wants a higher sampling rate — no exemplar setting can create a link to a trace that was discarded
Full sampling in development is why exemplars feel reliable. At one percent on a quiet route, the metric is right and there is nothing behind it.

Configuration options

Option Where Default Recommended
OTEL_METRICS_EXEMPLAR_FILTER env trace_based trace_based
record() placement code inside the span's with block
Metric attributes code conventions only; no request identity
Instrument type code histogram or counter; gauges carry none
Prometheus format scrape text OpenMetrics negotiation required
max_exemplars Prometheus 0 (off) 100 000
Sampling rate env 1.0 raise it for routes you must drill into

Verification

On a Prometheus scrape, the exemplar is a trailing comment on the bucket line:

curl -s -H 'Accept: application/openmetrics-text' localhost:8000/metrics | grep -A1 'le="5.0"'

Expected Output:

http_request_duration_seconds_bucket{route="/orders/{id}",le="5.0"} 41.0 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 4.021 1754146743.0

The part after the # is the exemplar: the trace ID, the measured value, and the timestamp. Without the Accept header the server returns the older text format and the comment is absent — which looks exactly like exemplars not working.

On the OTLP path, read the Collector's debug exporter:

Expected Output:

Histogram #0
  -> route: Str(/orders/{id})
  Count: 41
  Sum: 63.882
  ExemplarValue: 4.021
  ExemplarTraceID: 4bf92f3577b34da6a3ce929d0e0e4736
  ExemplarSpanID: 00f067aa0ba902b7

Then close the loop: take that trace ID to the tracing backend and confirm it opens a request whose duration matches the exemplar value. A mismatch means the measurement is being recorded against a different span than the one you think.

Common mistakes

Exemplars appear in staging and not in production

Error signature: the same code produces exemplars locally and none in production. Root cause: production samples at a low rate and the trace_based filter correctly skips unsampled spans. Remediation: compare the exemplar rate with the sampling rate. If drilling into a specific route matters, raise sampling for that route rather than changing the filter.

The bucket line has no trailing comment

Error signature: the Prometheus scrape looks normal and contains no exemplars. Root cause: the scrape negotiated the classic text format, which cannot carry them, or exemplar storage is off. Remediation: confirm the Accept header negotiates OpenMetrics and that storage.exemplars.max_exemplars is set.

Every exemplar points at a trace that does not exist

Error signature: clicking an exemplar opens an empty trace view. Root cause: the filter is always_on, so exemplars were recorded for spans that sampling discarded. Remediation: set trace_based, which is also the default.

Which metrics deserve exemplars

Not every instrument benefits, and adding them indiscriminately produces storage cost without investigative value. Three properties identify the ones worth it.

The metric aggregates something that varies per request. Latency histograms are the canonical case: the aggregate hides individual requests, and finding one is exactly what an exemplar provides. A gauge reporting queue depth does not have this property — there is no individual request behind the value — and correspondingly most implementations do not support exemplars on gauges at all.

Someone will want to drill in from an alert. If the metric appears in an alert or on an incident dashboard, the first question after "it moved" is "show me one". If it appears only in a capacity report, nobody will ever click it.

The tail is where the interest is. Exemplars are most valuable on the buckets that contain the unusual cases, which is another way of saying they are most valuable on distributions with a meaningful tail. A metric whose values are tightly clustered has an uninteresting exemplar in every bucket.

Instrument Exemplars? Why
Request latency histogram yes the primary case: aggregate to individual
Downstream call latency yes which call was slow, in which request
Queue wait time yes a long wait belongs to a specific request
Error counter yes one example of the failure
Queue depth gauge no no individual request behind the value
Resident memory gauge no a process-level number, not a request one
Build info no not a measurement at all

Reading an exemplar responsibly

An exemplar is one example, chosen by whichever measurement happened to be recorded when the storage had room for it. It is not a representative sample, and treating it as one leads to a specific mistake: concluding that because the exemplar for the tail bucket shows a slow database call, all the requests in that bucket were slow for the same reason.

The disciplined reading is that an exemplar proves at least one request in that bucket looked like this. Confirming the pattern means opening several — most backends keep more than one over a window — or, better, using the exemplar to form a hypothesis and then testing it against the aggregate: if the theory is a slow database, the database call duration metric should show the same shape at the same time.

That is a small discipline and it prevents the failure mode where an incident is diagnosed from a single trace that turns out to be unrepresentative.

The Prometheus specifics

Two details decide whether exemplars work end to end on the Prometheus path, and both live outside the application.

The scrape must negotiate the OpenMetrics format, because the classic text format has no syntax for an exemplar. Prometheus does this automatically when the target advertises support, but a scrape configured with an explicit Accept header, or a proxy that rewrites it, silently downgrades — and the symptom is missing exemplars with everything else working normally.

And the server needs exemplar storage enabled, with a size limit that bounds how many it retains. That storage is separate from the series storage and is small, but it is off by default in some configurations, which is the second place to look when the exposition clearly contains exemplars and the queries return none.

storage:
  exemplars:
    max_exemplars: 100000

On the OTLP path neither applies: exemplars travel inside the metric point and the backend either supports them or does not, with no negotiation to get wrong.

Frequently Asked Questions

Why do I have no exemplars at all?

Three usual causes, in order of likelihood. The measurement is recorded outside the span — a finally block after the context manager exits is the classic case. The exemplar filter is set to always_off. Or the trace is not sampled, and with the trace_based filter an unsampled span produces no exemplar by design. Check them in that order; the first accounts for most of it.

Do exemplars increase cardinality?

No, and that is the point. An exemplar is attached to an existing bucket rather than creating a new series, so a histogram with four route values still has four series regardless of how many exemplars it carries. Storage keeps a small bounded number per bucket per interval, typically one. That is what makes exemplars the right place for request identity that would be ruinous as a label.

Does Prometheus support exemplars?

Yes, on the OpenMetrics exposition format and with the exemplar storage feature enabled, and only on histogram buckets and counters. The scrape must negotiate OpenMetrics rather than the older text format, and the exemplar appears as a trailing comment on the bucket line. If a bucket line has no trailing hash comment after a scrape, the negotiation or the feature flag is the thing to check.

How many exemplars should a bucket keep?

One per bucket per collection interval is the usual default and is enough. Exemplars are for finding an example, not a sample: you want one request that landed in the tail bucket, not a statistically representative set. Keeping more multiplies storage for very little added investigative value.