· · 11 min leestijd Intermediate

Distributed tracing met Tempo en OpenTelemetry

Implementeer distributed tracing in Kubernetes met Grafana Tempo en OpenTelemetry. Volg requests door services en debug latency problemen.

Een gloeiende draad van een knop door zes racks, zij volgt hem tot de knoop, een wezen verdwaald in logboeken

TL;DR Tempo is waar m’n traces heen gaan. De OpenTelemetry Collector ontvangt spans van elke service, tail sampling houdt elke error en elke trage request vast, en Tempo schrijft de rest naar MinIO met drie dagen retentie. Vanaf een span in Grafana klik ik door naar de Loki-regels met dezelfde trace ID. M’n productie-values staan onderaan.

Je metrics zeggen dat iets traag is. Je logs zeggen dat er errors waren. Prima. Welke request faalde, waar kwam die latency vandaan, en welke service in de keten vrat de timeout op? Geen van beide kan je dat vertellen.

De eerste keer dat ik hier tegenaan liep, was een checkout flow onder load gaan timeouten. Tien services in het pad, stuk voor stuk groen op hun eigen dashboard, en geen manier om één request te volgen van de voordeur tot de plek waar hij stierf. Ik verdacht de payment service, omdat het voelde als de payment service.

Dat gat dicht distributed tracing. Het volgt één request door elke service die hij raakt en laat zien waar hij bleef hangen. Ik wil begrijpen wat ik draai in plaats van ernaar te gokken, en tracing is wat “ik denk dat het de payment service is” verandert in “hier is de span, hier is de trage query”. Dus hier is de opstelling, één Helm chart tegelijk, eindigend met de values die ik zelf draai.

Metrics, logs en traces

Complete Observability

Metrics
(Prometheus/Thanos)
WAT gebeurt er

Logs
(Loki)
WAAROM gebeurde het

Traces
(Tempo)
WAAR gebeurde het

Grafana

Metrics vertellen je de error rate en de latency. Logs vertellen je de errormelding en wat er omheen gebeurde. Traces vertellen je welke service, welke call, en welk pad de request nam.

Elk van de drie op zichzelf laat je half blind. Zet alle drie in dezelfde Grafana en je volgt een request van het alert tot de query die het veroorzaakte.

Een trace is een boom van spans

Een trace is een boom van spans, één boom per request:

Trace: order-12345

API Gateway
250ms

Order Service
180ms

Inventory Check
45ms

Payment Service
120ms

Bank API
95ms

Notification
15ms

Elk blok is een span. Een span heeft een naam, zoals HTTP GET /orders, een duur, een parent span, een status van success of error, en de attributes die je eraan hangt, meestal user_id en order_id.

De trace ID is wat de boom bij elkaar houdt. Elke span van dezelfde request draagt hem mee, door hoeveel services die request ook ging.

Waarom ik Tempo draai

Ik draai Grafana Tempo omdat het past bij hoe de rest van m’n stack al werkt. Het schrijft naar object storage en houdt geen index bij behalve de trace ID, dus het blijft goedkoop bij hoog volume en er is geen cluster om op te passen. En Grafana ziet het als native data source, en dat is wat de klik van een span naar een logregel straks mogelijk maakt.

Die indexkeuze wil je snappen voordat je iets installeert. Tempo indexeert trace IDs en verder niks, dezelfde truc die Loki uithaalt voor logs. Geen index op spans, geen index op attributes. Storage blijft goedkoop, en de prijs is dat je niet zomaar kunt vragen om “elke trace met user_id=123”.

Hoe vind je dan een trace ID? Via de andere twee. Metrics wijzen je de slechte vijf minuten aan, een logregel draagt de trace ID, en je springt Tempo in voor het hele pad. Klinkt als gedoe. Zodra de data sources aan elkaar geknoopt zijn is het één klik in Grafana, en dat knopen staat verderop.

Hoe de stukken in elkaar passen

OpenTelemetry

Applicaties

OTLP

OTLP

OTLP

traces

metrics

logs

Service A
(instrumented)

Service B
(instrumented)

Service C
(instrumented)

OTel Collector

Tempo

Prometheus

Loki

Object Storage

Grafana

Je applicaties dragen een OpenTelemetry SDK die spans uitstuurt. De OTel Collector zit ertussen, ontvangt de telemetry, verwerkt die en waaiert hem uit naar de juiste backend. Tempo parkeert de traces in object storage. Grafana is waar je ernaar kijkt, naast de metrics en logs van dezelfde request.

Twee Helm charts, dan de instrumentatie.

Tempo installeren

De kleinste Tempo die traces ontvangt, via Helm :

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install tempo grafana/tempo \
  --namespace monitoring \
  --values tempo-values.yaml

Eén binary met traces op een lokaal volume:

# tempo-values.yaml
tempo:
  storage:
    trace:
      backend: local
      local:
        path: /var/tempo/traces

  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

persistence:
  enabled: true
  size: 50Gi

Genoeg om traces te gaan ontvangen, en genoeg om ze allemaal kwijt te raken als het volume van de pod sneuvelt. Gebruik dit voor een eerste blik. Voordat je er tijdens een incident op leunt, wisselt de volgende values-file de lokale schijf in voor object storage en knipt Tempo op in z’n componenten.

# tempo-values.yaml
tempo:
  storage:
    trace:
      backend: s3
      s3:
        bucket: tempo-traces
        endpoint: minio.storage:9000
        access_key: ${MINIO_ACCESS_KEY}
        secret_key: ${MINIO_SECRET_KEY}
        insecure: true

  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

  # Retentie
  compactor:
    compaction:
      block_retention: 48h

# Distributed mode voor schaal
distributor:
  replicas: 2
ingester:
  replicas: 3
querier:
  replicas: 2
compactor:
  replicas: 1

Ik wijs hem naar m’n eigen MinIO en niet naar een hosted bucket, zodat de trace data op hardware blijft die van mij is. Zelfde redenering als de rest van de stack.

De OpenTelemetry Collector installeren

Elke span, metric en logregel gaat door de OTel Collector voordat hij ergens landt:

helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts

helm install otel-collector open-telemetry/opentelemetry-collector \
  --namespace monitoring \
  --values otel-collector-values.yaml

De config van de collector:

# otel-collector-values.yaml
mode: deployment
replicaCount: 2

config:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

  processors:
    batch:
      timeout: 1s
      send_batch_size: 1024

    # Voeg Kubernetes metadata toe
    k8sattributes:
      auth_type: serviceAccount
      extract:
        metadata:
          - k8s.namespace.name
          - k8s.pod.name
          - k8s.deployment.name

    # Sample om volume te reduceren
    probabilistic_sampler:
      sampling_percentage: 10

  exporters:
    otlp/tempo:
      endpoint: tempo.monitoring:4317
      tls:
        insecure: true

    prometheus:
      endpoint: 0.0.0.0:8889
      namespace: otel

  service:
    pipelines:
      traces:
        receivers: [otlp]
        processors: [k8sattributes, batch]
        exporters: [otlp/tempo]

      metrics:
        receivers: [otlp]
        processors: [batch]
        exporters: [prometheus]

Je applicaties instrumenteren

Tempo en de collector doen niks zolang je apps geen spans uitsturen. Er zijn twee manieren naar binnen, en waarschijnlijk gebruik je ze allebei.

Auto-instrumentatie

Voor veel talen haakt OpenTelemetry zelf in het framework en schrijf je nul tracing code. Hier begin ik bij elke service die ik niet zelf geschreven heb.

Java:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          image: my-java-app:latest
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-javaagent:/otel/opentelemetry-javaagent.jar"
            - name: OTEL_SERVICE_NAME
              value: "order-service"
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: "http://otel-collector.monitoring:4317"
          volumeMounts:
            - name: otel-agent
              mountPath: /otel
      initContainers:
        - name: otel-agent
          image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest
          command: [cp, /javaagent.jar, /otel/opentelemetry-javaagent.jar]
          volumeMounts:
            - name: otel-agent
              mountPath: /otel
      volumes:
        - name: otel-agent
          emptyDir: {}

Python:

FROM python:3.11
RUN pip install opentelemetry-distro opentelemetry-exporter-otlp
RUN opentelemetry-bootstrap -a install
CMD ["opentelemetry-instrument", "python", "app.py"]

Node.js:

// tracing.js - require dit eerst
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://otel-collector:4317',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
  serviceName: process.env.OTEL_SERVICE_NAME || 'my-service',
});

sdk.start();

Auto-instrumentatie geeft je HTTP, gRPC en database calls cadeau. Je business logic ziet het niet. Wil je een span om “verwerk deze order” met de order ID eraan, dan schrijf je die span zelf.

Handmatige instrumentatie

Voor je eigen spans en de attributes waar je op gaat zoeken:

// Go voorbeeld
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
)

func ProcessOrder(ctx context.Context, orderID string) error {
    tracer := otel.Tracer("order-service")
    ctx, span := tracer.Start(ctx, "process-order")
    defer span.End()

    // Voeg attributes toe
    span.SetAttributes(
        attribute.String("order.id", orderID),
        attribute.String("order.type", "standard"),
    )

    // Maak child span voor sub-operatie
    ctx, childSpan := tracer.Start(ctx, "validate-inventory")
    err := validateInventory(ctx, orderID)
    childSpan.End()

    if err != nil {
        span.RecordError(err)
        return err
    }

    return nil
}

Context propagation

Hier sneuvelen de meeste eerste pogingen. Een trace blijft alleen één trace als de trace ID met de request meereist naar de volgende service. Laat je hem op één hop vallen, dan heb je twee traces die niks van elkaar weten.

HTTP headers (automatisch met instrumentatie):

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
tracestate: vendor=value

gRPC metadata (ook automatisch met instrumentatie)

Auto-instrumentatie regelt dit voor je. Bouw je HTTP calls met de hand, dan inject en extract je de context zelf:

// Inject context in uitgaande request
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

// Extract context uit inkomende request
ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))

Grafana-integratie

Dit is het deel dat tracing de moeite waard maakt. Met de juiste data source config springt Grafana van een trace naar de bijbehorende logs en metrics zonder dat jij ergens een trace ID kopieert.

Voeg Tempo toe als data source:

apiVersion: v1
kind: ConfigMap
metadata:
  name: grafana-datasources
data:
  tempo.yaml: |
    apiVersion: 1
    datasources:
      - name: Tempo
        type: tempo
        url: http://tempo.monitoring:3100
        access: proxy
        jsonData:
          tracesToLogs:
            datasourceUid: loki
            tags: ['app', 'namespace']
          tracesToMetrics:
            datasourceUid: prometheus
            tags: ['service.name']
          serviceMap:
            datasourceUid: prometheus
          nodeGraph:
            enabled: true
          search:
            hide: false
          lokiSearch:
            datasourceUid: loki

Een trace vinden

In Grafana Explore:

  1. Selecteer Tempo data source
  2. Kies “Search” tab
  3. Filter op service name, duration, status
  4. Klik een trace om de waterfall te zien

Van een trace naar z’n logs

Met tracesToLogs ingesteld linkt een span naar de logregels die hij produceerde:

  1. Open een trace
  2. Klik een span
  3. Klik “Logs for this span”
  4. Zie Loki logs met dezelfde trace ID

Die klik is wat het aan elkaar knopen van de data sources je oplevert. Geen grep over pods, geen gok welke pod de error logde.

Van een trace naar z’n metrics

Andersom werkt ook. Vanaf een trage trace pivot je naar het latency-histogram en de error rate van die service, en dan zie je of je naar één pechrequest kijkt of naar een trend.

Service graph

Tempo kan een service dependency graph bouwen uit de traces zelf:

# Enable metrics generator in Tempo
tempo:
  metricsGenerator:
    enabled: true
    remoteWriteUrl: http://prometheus.monitoring:9090/api/v1/write

Dat levert metrics op zoals:

  • traces_service_graph_request_total
  • traces_service_graph_request_failed_total
  • traces_service_graph_request_server_seconds

Grafana rendert die als een kaart van verkeer en error rates tussen services, opgebouwd uit echte requests en niet uit een architectuurdiagram dat niemand bijhield.

Sampling

Zodra er echt verkeer doorheen gaat is volume het volgende probleem. Elke trace bewaren op productieschaal wordt snel duur, dus je sampled. Dat kan op twee manieren, en het verschil doet ertoe.

Head sampling

# OTel Collector
processors:
  probabilistic_sampler:
    sampling_percentage: 10  # Houd 10% van traces

Doodsimpel, met één fout. De beslissing valt voordat de request klaar is, dus een muntworp kan precies de trace weggooien die een error gaf. De traces die je tijdens een incident wilt hebben zijn de traces die dit weggooit.

Tail sampling

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      # Altijd errors bewaren
      - name: errors
        type: status_code
        status_code:
          status_codes: [ERROR]

      # Altijd trage traces bewaren
      - name: slow
        type: latency
        latency:
          threshold_ms: 1000

      # Sample 5% van de rest
      - name: probabilistic
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

Tail sampling wacht tot de request klaar is en beslist dan. Elke error bewaard, alles wat traag is bewaard, een net van 5% over de saaie successen. Dit draai ik, want de trace die ik tijdens een incident zoek is precies het soort dat head sampling weggooit.

Wat ik in productie draai

Dit is ruwweg wat ik draai. Object storage op MinIO, tail sampling die errors en trage requests bewaart, en de metrics generator aan zodat de service graph erbij komt:

# Tempo met object storage
tempo:
  storage:
    trace:
      backend: s3
      s3:
        bucket: tempo-traces
        endpoint: minio.storage:9000
  compactor:
    compaction:
      block_retention: 72h  # 3 dagen traces
  metricsGenerator:
    enabled: true
    remoteWriteUrl: http://prometheus:9090/api/v1/write

# OTel Collector met tail sampling
otel-collector:
  config:
    processors:
      tail_sampling:
        policies:
          - name: errors
            type: status_code
            status_codes: [ERROR]
          - name: slow
            type: latency
            threshold_ms: 500
          - name: sample-rest
            type: probabilistic
            sampling_percentage: 5

Die 5% houdt het volume beheersbaar, en de metrics generator geeft me de dependency map zonder extra werk. Retentie is 72 uur, en dat klinkt kort totdat je merkt dat bijna elke trace die je opzoekt uit het afgelopen uur komt. Wat langer dan drie dagen aanhoudt zie je in de metrics, en metrics kun je goedkoop eindeloos bewaren.

Debuggen met een trace

Terug naar de checkout die stond te timeouten. Met tracing erbij leest hetzelfde probleem zo:

  1. Alert vuurt: Hoge latency op checkout service
  2. Check metrics: P99 latency piekte om 14:32
  3. Vind traces: Zoek Tempo voor checkout-service, duration > 1s, tijdsbereik 14:30-14:35
  4. Analyseer trace: Zie dat payment-service call 4.2s duurde
  5. Duik in span: Zie db.statement attribute die trage query toont
  6. Check logs: Spring naar Loki logs voor die span, zie connection pool uitputting
  7. Fix: Vergroot connection pool size

Stap 3 tot en met 6 waren vroeger giswerk. Nu zijn het kliks in Grafana.

Waarom dit ertoe doet

Microservices verdelen werk mooi over teams en maken debuggen ellendig. Eén request raakt tien services, en als hij breekt laten de logs je de errortekst zien en de metrics het symptoom. Het pad dat de request nam, het ding dat je vertelt welke service je moet openen, zit in de traces.

Zet Prometheus en Thanos voor metrics, Loki voor logs en Tempo voor traces in één Grafana, gecorreleerd, op hardware die van jou is. Dat laatste is waar ik het meest om geef. Geen vendor ziet m’n request-paden, geen SaaS-rekening groeit mee met m’n trace-volume, en als een request faalt kan ik de doos openmaken en lezen wat ermee gebeurd is.

De volgende keer dat iets timeout, zoek ik in Tempo op de service, sorteer op duur, open de langste trace en lees welke span de tijd opvrat. Dan klik ik door naar z’n logs.