Skip to content

Link metrics to traces (exemplars)

An exemplar attaches a trace id to a single metric sample — not a label — so a latency spike on a Prometheus/Grafana dashboard links straight to the trace that produced it. You get the drill-down with zero cardinality cost, because the trace id never becomes a label.

transport-metrics records exemplars on every duration histogram that observes through the shared Metrics.Observe path — http_request_duration_seconds, grpc_server_handling_seconds, operation_duration_seconds, db_query_duration_seconds and queue_processing_seconds — whenever an exemplar source is configured. queue_wait_seconds is the one exception: ObserveWait takes no context, so there is no trace id to read.

Enable it

The trace-id source is pluggable so the core needs no tracing dependency. Use the opt-in metrics/otel subpackage to read the current OpenTelemetry span:

import (
    metrics "gitlab.com/phpboyscout/go/transport-metrics"
    metricsotel "gitlab.com/phpboyscout/go/transport-metrics/otel"
)

m, err := metrics.New(
    metrics.WithExemplarSource(metricsotel.ExemplarSource()),
)

Now every duration observation made on a context carrying a span gets a trace_id exemplar. metricsotel.ExemplarSource() reads only the span context — it starts no spans and needs no tracer provider — so it is safe whether or not tracing is configured; it yields nothing when the context carries no span.

It checks for the presence of a trace id, not for the sampling decision, so an unsampled span still produces an exemplar. That exemplar points at a trace your backend most likely never stored, so following it from a dashboard will dead-end. If that matters, wrap the source and drop unsampled spans yourself:

otelSource := metricsotel.ExemplarSource()

metrics.WithExemplarSource(func(ctx context.Context) (string, bool) {
    if !trace.SpanContextFromContext(ctx).IsSampled() {
        return "", false
    }
    return otelSource(ctx)
})

A custom source

Not on OpenTelemetry? Supply your own — anything that pulls a trace id from the request context:

metrics.WithExemplarSource(func(ctx context.Context) (string, bool) {
    if id := myTraceID(ctx); id != "" {
        return id, true
    }
    return "", false
})

Scraping exemplars

Exemplars ride on the OpenMetrics exposition format. The handler offers it, but only serves it when the client asks — point a scraper that supports exemplars (Prometheus with --enable-feature=exemplar-storage, or an OTLP-scraping agent) at /metrics, and wire Grafana's exemplar-to-trace link on the trace_id label.

Why curl shows no exemplars

curl sends no Accept header, so it gets the Prometheus text format back — which has nowhere to put an exemplar. The samples are being recorded; the format is hiding them. Ask for OpenMetrics explicitly:

curl -s -H 'Accept: application/openmetrics-text; version=1.0.0' \
  localhost:8080/metrics | grep trace_id
http_request_duration_seconds_bucket{method="GET",route="GET /x",status="200",le="0.005"} 1 # {trace_id="0102030405060708090a0b0c0d0e0f10"} 2.2e-07 1.7856969705221822e+09

The exemplar is the part after the #: the trace id, the observed value, and the timestamp. If that comes back empty, the source is not yielding a trace id — check there is a span on the context at the point the observation is made, not just somewhere upstream.

The bridge to OTel, not a conflict

Exemplars are the deliberate meeting point between this Prometheus (pull) module and the OTel (push) go/observability modules: your metrics stay in Prometheus, your traces stay in your tracing backend, and an exemplar stitches a sample to a trace across the two. It complements the OTel metrics story — it does not compete with it.