{"id":28109,"date":"2026-09-02T08:00:00","date_gmt":"2026-09-02T06:00:00","guid":{"rendered":"https:\/\/lets-scale.it\/?p=28109"},"modified":"2026-09-16T13:31:33","modified_gmt":"2026-09-16T11:31:33","slug":"opentelemetry-tracing-grafana-tempo-incident-to-request","status":"publish","type":"post","link":"https:\/\/lets-scale.it\/en\/opentelemetry-tracing-grafana-tempo-incident-to-request\/","title":{"rendered":"From Incident to the Triggering Request with OpenTelemetry Tracing"},"content":{"rendered":"<p>The alert reports elevated response times in checkout, the dashboard confirms it, and then the hunt begins. Filter logs by time window, guess hostnames, copy IDs. Metrics and logs are there \u2014 what&#8217;s missing is the path from the incident to the one request that triggered it. Building exactly that path is what OpenTelemetry tracing with Grafana Tempo does, provided four connection points are configured correctly. This article shows those four points, the Grafana configuration behind them, and the click path that ultimately leads from a latency alert to the log line that reveals the cause.<\/p>\n<h2>Starting point: metrics and logs run, the path to the request is missing<\/h2>\n<p>The picture is the same in most of the environments we take over. Metrics sit in Mimir or Prometheus, logs in Loki, alerting works. During an incident the on-call engineer sees the latency alert, opens three dashboards, and searches Loki with a ten-minute window for anomalies. That search often ends at a plausible explanation rather than at the cause, because the proof is missing that this specific request triggered the alert.<\/p>\n<p>Tracing is usually there too, but disconnected. Some of the services are instrumented with OpenTelemetry, Tempo or an older Jaeger runs somewhere, and traces can only be found if someone knows a trace ID. No log carries the trace ID, no metric points to a trace, the service graph is empty.<\/p>\n<p>The goal we set in these projects is concrete. From any alert, the on-call engineer reaches the triggering request and its log lines in three clicks. No ID is searched for, none is copied.<\/p>\n<h2>Why the path from incident to request breaks at four points<\/h2>\n<p>The chain from incident to request has four connection points, and it can break at any one of them. First, the metric the alert sits on must point to a trace. That is what exemplars do \u2014 measurement points in a metric that carry a trace ID. They originate in the metrics generator, the Tempo component that computes metrics from spans.<\/p>\n<p>Second, the trace must be complete. Context propagation \u2014 passing the trace ID from call to call \u2014 must work across HTTP, gRPC, messaging, and thread pools. Otherwise the trace ends at the producer, and the cause in the consumer stays invisible. Third, the trace must exist at all. Tail sampling \u2014 selecting the traces to store based on the complete trace \u2014 keeps errors and slow requests in full and discards the rest proportionally.<\/p>\n<p>Fourth, the logs must carry the trace ID, and their labels must match the attributes of the spans, otherwise the jump from a span into the logs goes nowhere. A common case: the application writes the ID as <code>traceId<\/code>, the regex in Grafana looks for <code>trace_id=<\/code>, and no one notices because Loki produces no error for it. None of the four points is a product feature; each is a configuration detail. That is why &quot;we have tracing&quot; so often still means &quot;we&#8217;re searching.&quot;<\/p>\n<h2>What OpenTelemetry tracing with Grafana Tempo needs for this<\/h2>\n<p>The data flow is unspectacular. The applications send spans via OTLP to a collector agent per node, which distributes them by trace ID to a collector gateway. There, <code>tail_sampling<\/code> decides what gets stored, and the export goes to the Tempo distributor. Tempo stores the blocks in object storage, and the metrics generator writes span metrics and service-graph metrics to Mimir via remote write. The examples apply to OpenTelemetry Collector Contrib 0.128, Grafana Tempo 2.8, Loki 3.5, and Grafana 12.1.<\/p>\n<p>On the collector side, two settings are decisive. The propagator \u2014 the component that writes and reads the context in headers \u2014 is set to <code>tracecontext,baggage<\/code>, so that all services speak the same format. And the sampling rules in the gateway keep <code>status_code: ERROR<\/code> and latencies above a threshold in full. What this pipeline looks like is described in the article on the rollout across 40 services in this series.<\/p>\n<p>On the Tempo side, the metrics generator must run with the <code>service-graphs<\/code> and <code>span-metrics<\/code> processors and set <code>send_exemplars: true<\/code> on remote write. Without that line there are metrics but no exemplars, and the first click of the path is missing. Mimir must also accept exemplars: the limit <code>max_global_exemplars_per_user<\/code> defaults to zero and must be raised per tenant. The metrics generator is a distinct Tempo component, to which the distributor delivers the spans in parallel to the ingesters; it builds counters and histograms from them and needs its own memory for that, bounded by <code>max_active_series<\/code> in the overrides.<\/p>\n<h2>The Grafana configuration that connects incident and request<\/h2>\n<p>The actual connection is made in Grafana, in the settings of the three data sources. We store them as a provisioning file, so that they are versioned and don&#8217;t live in an administrator&#8217;s click path. The structure of the keys follows the <a href=\"https:\/\/grafana.com\/docs\/grafana\/latest\/datasources\/tempo\/configure-tempo-data-source\/provision\/\">provisioning documentation for the Tempo data source<\/a>:<\/p>\n<pre><code class=\"language-yaml\"># Grafana 12.1, provisioning\/datasources\/observability.yaml, simplified\napiVersion: 1\ndatasources:\n  - name: Mimir\n    type: prometheus\n    uid: mimir\n    url: http:\/\/mimir-query-frontend.metrics.svc:8080\/prometheus\n    jsonData:\n      exemplarTraceIdDestinations:\n        - name: traceID\n          datasourceUid: tempo\n\n  - name: Loki\n    type: loki\n    uid: loki\n    url: http:\/\/loki-gateway.logs.svc:3100\n    jsonData:\n      derivedFields:\n        - name: trace_id\n          matcherRegex: &quot;trace_id=(\\\\w+)&quot;\n          datasourceUid: tempo\n          url: &quot;$${__value.raw}&quot;\n\n  - name: Tempo\n    type: tempo\n    uid: tempo\n    url: http:\/\/tempo-query-frontend.tracing.svc:3200\n    jsonData:\n      tracesToLogsV2:\n        datasourceUid: loki\n        spanStartTimeShift: &quot;-5m&quot;\n        spanEndTimeShift: &quot;5m&quot;\n        tags:\n          - key: service.name\n            value: service_name\n        filterByTraceID: true\n        filterBySpanID: false\n      tracesToMetrics:\n        datasourceUid: mimir\n        spanStartTimeShift: &quot;-5m&quot;\n        spanEndTimeShift: &quot;5m&quot;\n        tags:\n          - key: service.name\n            value: service\n        queries:\n          - name: Fehler je Minute\n            query: sum by (service) (increase(traces_spanmetrics_calls_total{$$__tags, status_code=&quot;STATUS_CODE_ERROR&quot;}[1m]))\n      serviceMap:\n        datasourceUid: mimir\n      nodeGraph:\n        enabled: true<\/code><\/pre>\n<p>The three blocks each carry one connection point. <code>exemplarTraceIdDestinations<\/code> turns the exemplar points in every Mimir panel into links to Tempo; <code>name<\/code> must be the label under which the exemplars carry the trace ID \u2014 with Tempo&#8217;s metrics generator that is <code>traceID<\/code>. <code>derivedFields<\/code> in Loki recognizes <code>trace_id=<\/code> in the log line and links the ID to Tempo. <code>tracesToLogsV2<\/code> forms the return path: the span&#8217;s resource attribute <code>service.name<\/code> becomes the Loki label <code>service_name<\/code>, and <code>filterByTraceID<\/code> restricts to lines with that trace ID. The five-minute window in both directions catches logs that were written with a delay.<\/p>\n<p><code>tracesToMetrics<\/code> delivers the matching RED metric from any span \u2014 rate, errors, and duration per service; <code>$__tags<\/code> assembles a label filter from the mapped attributes. The double dollar sign is not a typo: in provisioning files <code>$$<\/code> stands for a literal <code>$<\/code>, otherwise Grafana tries to substitute an environment variable. <code>serviceMap<\/code> and <code>nodeGraph<\/code> draw the dependencies between services from the service-graph metrics, with error rates on the edges. During an incident, the service with the red edge is the first suspect, before anyone has even opened a trace.<\/p>\n<h2>The path in three clicks, verified against a real incident<\/h2>\n<p>The path begins at the alert rule on the latency from the span metrics. The rule sits in Mimir on the same query as the panel, with a threshold per service, so that alert and exemplar refer to the same time series. In Prometheus&#8217;s expression browser or in Mimir, a query answers which service is currently breaching the 95th percentile:<\/p>\n<pre><code class=\"language-promql\">histogram_quantile(0.95, sum by (le, service) (increase(traces_spanmetrics_latency_bucket[1m])))<\/code><\/pre>\n<p>First click: in the panel the exemplars sit as points above the curve; the point at the outlier opens the corresponding trace in Tempo. Second click: in the trace, the span with <code>status = error<\/code> or the longest duration is highlighted. The &quot;Logs for this span&quot; link leads via <code>tracesToLogsV2<\/code> to the Loki lines of that trace ID. Third click: the log line names the cause, for example the timeout against the database. From there, back in the trace, TraceQL \u2014 Tempo&#8217;s query language \u2014 answers whether it is an isolated case:<\/p>\n<pre><code class=\"language-traceql\">{ resource.service.name = &quot;checkout-api&quot; &amp;&amp; status = error } &gt;&gt; { span.db.system = &quot;postgresql&quot; &amp;&amp; duration &gt; 2s }<\/code><\/pre>\n<p>The query returns all failed checkout traces in which, below them, a database span ran longer than two seconds. The <code>&gt;&gt;<\/code> operator requires the right span to be a descendant of the left. We do not quote numbers on time saved, because they depend on the environment and don&#8217;t transfer. Qualitatively the difference is unambiguous: the on-call engineer no longer searches for IDs, and the question &quot;isolated case or pattern&quot; is a query rather than an hour of reading logs.<\/p>\n<h2>Where this path reaches its limits<\/h2>\n<p>Exemplars only point to traces that exist. Because the metrics generator sits in Tempo \u2014 that is, behind tail sampling \u2014 exemplars are created only for stored traces. Whoever computes span metrics in the collector instead, before sampling, gets exemplars on traces that were never stored. The first click then ends in &quot;trace not found.&quot; The order of the components is not a matter of taste here.<\/p>\n<p>The jump into the logs presupposes that the application writes the trace ID at all. The OpenTelemetry Java Agent puts <code>trace_id<\/code> and <code>span_id<\/code> into the logging context, but the log format has to emit them. With other languages and with legacy components without OpenTelemetry, the return path stays empty. And the label mapping in <code>tracesToLogsV2<\/code> must match the Loki configuration: if the label there is <code>app<\/code> instead of <code>service_name<\/code>, the link returns nothing, without showing an error.<\/p>\n<p>Finally, RED metrics from sampled spans are skewed. Whoever keeps errors in full and samples the rest proportionally sees error rates in the service graph that are higher than the actual ones. For the click path this is irrelevant; for capacity planning it is not \u2014 for that, the metrics from the application itself remain the right source. Anyone who needs the error rate for an SLO from span metrics must know the sampling proportion and correct the numbers accordingly.<\/p>\n<h2>The takeaway: the click from incident to request is configuration<\/h2>\n<p>Getting from an incident to the triggering request is not a tool feature but the result of four correctly set connection points. These are exemplars from the metrics generator, complete traces through context propagation, tail sampling that keeps errors, and logs with a trace ID. The provisioning file above is the part that is missing in most environments.<\/p>\n<p>We implement these four points in every tracing rollout and validate them against a real click path, from the alert to the log line. Because we tie our compensation to your goals, missed targets have a tangible cost on our side. If you want to know at which of the four points your click path breaks today, an afternoon with your provisioning file and a real alert is the fastest way to find out.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How OpenTelemetry tracing with Grafana Tempo traces an incident back to the request that triggered it: exemplars, trace-to-logs, and the Grafana configuration.<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[27,1],"tags":[],"class_list":["post-28109","post","type-post","status-publish","format-standard","hentry","category-it-monitoring-en","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28109","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/comments?post=28109"}],"version-history":[{"count":4,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28109\/revisions"}],"predecessor-version":[{"id":28150,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28109\/revisions\/28150"}],"wp:attachment":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/media?parent=28109"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/categories?post=28109"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/tags?post=28109"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}