Link metrics to traces (exemplars)¶
An exemplar attaches a trace id to a single metric sample, not to 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 rather than competing with it.