Je metrics zeggen dat iets traag is. Je logs zeggen dat er errors waren. Prima. Beantwoord dan dit: welke request faalde nou eigenlijk, waar kwam die latency vandaan, en welke service in de keten vrat die timeout op? Metrics en logs halen daar allebei hun schouders bij op.

Ik liep tegen die muur aan toen een checkout flow onder load begon te timeouten. Tien services in het pad, elk van ze groen op zijn eigen dashboard, en geen enkele manier om één gedoemde request te volgen van voordeur tot fout. Precies dat gat vult distributed tracing op. Het volgt één request terwijl die door je services beweegt en toont je precies wat er gebeurde en waar het vastliep.

Het hele punt van deze blog is begrijpen wat je draait in plaats van gokken. Tracing is het stuk dat “ik denk dat het de payment service is” verandert in “hier is de span, hier is de trage query”. Laat me het opbouwen vanaf niets.

De observability driehoek

flowchart TD
    subgraph observability["Complete Observability"]
        M["Metrics<br/>(Prometheus/Thanos)<br/>WAT gebeurt er"]
        L["Logs<br/>(Loki)<br/>WAAROM gebeurde het"]
        T["Traces<br/>(Tempo)<br/>WAAR gebeurde het"]
    end

    M <--> L
    L <--> T
    T <--> M

    G["Grafana"] --> M
    G --> L
    G --> T
  • Metrics beantwoorden: “Wat is de error rate? Wat is de latency?”
  • Logs beantwoorden: “Welke error message? Wat was de context?”
  • Traces beantwoorden: “Welke service? Welke call? Wat was het pad?”

Elk apart laat je half blind achter. Hang alle drie in dezelfde Grafana en je kunt daadwerkelijk redeneren over wat je cluster deed, niet over wat je hoopt dat het deed.

Wat is een trace?

Een trace is een boom van spans die het werk voor één request representeren:

flowchart LR
    subgraph trace["Trace: order-12345"]
        A["API Gateway<br/>250ms"] --> B["Order Service<br/>180ms"]
        B --> C["Inventory Check<br/>45ms"]
        B --> D["Payment Service<br/>120ms"]
        D --> E["Bank API<br/>95ms"]
        B --> F["Notification<br/>15ms"]
    end

Elk blok is een span. Spans hebben:

  • Name: Welke operatie (bijv. “HTTP GET /orders”)
  • Duration: Hoe lang het duurde
  • Parent: Welke span initieerde deze
  • Attributes: Key-value metadata (user_id, order_id, etc.)
  • Status: Success/error

De trace ID is de draad die elke span van dezelfde request aan elkaar rijgt, hoeveel services die ook kruiste.

Waarom Tempo?

Ik draai Grafana Tempo om een paar redenen die aansluiten op hoe de rest van mijn stack al werkt:

  1. Kosteneffectief - Object storage backend, geen indexering
  2. Simpel - Geen complexe cluster management
  3. Schaalbaar - Verwerkt enorme trace volumes
  4. Geïntegreerd - Native Grafana support, links naar metrics/logs

De grote ontwerpkeuze is dat Tempo alleen trace IDs indexeert, dezelfde truc die Loki uithaalt voor logs. Het indexeert geen spans of attributes. Dat houdt storage goedkoop, maar er hangt een echte prijs aan: je querydt op trace ID, dus je kunt niet zomaar zoeken naar “alle traces met user_id=123”.

Hoe vind je dan überhaupt een trace ID? Via de andere twee hoeken van de driehoek. Metrics wijzen je het slechte tijdvenster aan, logs geven je een trace ID, en je springt Tempo in om het hele pad te zien. Klinkt als gedoe, maar in de praktijk is die sprong één klik in Grafana zodra je het hebt aangesloten.

Architectuur

flowchart TD
    subgraph apps["Applicaties"]
        A1["Service A<br/>(instrumented)"]
        A2["Service B<br/>(instrumented)"]
        A3["Service C<br/>(instrumented)"]
    end

    subgraph collector["OpenTelemetry"]
        OC["OTel Collector"]
    end

    A1 -->|"OTLP"| OC
    A2 -->|"OTLP"| OC
    A3 -->|"OTLP"| OC

    OC -->|"traces"| T["Tempo"]
    OC -->|"metrics"| P["Prometheus"]
    OC -->|"logs"| L["Loki"]

    T --> OS["Object Storage"]
    T --> G["Grafana"]
    P --> G
    L --> G

Je applicaties dragen OpenTelemetry SDKs die spans uitsturen. De OTel Collector zit ertussen en ontvangt telemetry, verwerkt het, en waaiert het uit naar de juiste backends. Tempo parkeert de traces in object storage. Grafana is waar je er daadwerkelijk naar kijkt en correleert tegen metrics en logs.

Laat me dat stuk voor stuk opbouwen, te beginnen met het kleinste dat werkt.

Tempo installeren

Kleinste werkende Tempo, 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

Basis single-binary deployment, 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

Dit is genoeg om traces te gaan ontvangen. Het is ook genoeg om ze allemaal kwijt te raken als het volume van de pod sneuvelt, prima voor een eerste blik en onacceptabel voor iets waar je om geeft. De volgende laag wisselt lokale storage dus in voor object storage en schaalt de componenten uit.

# 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 Tempo naar mijn eigen MinIO in plaats van een hosted bucket, zodat de trace data op hardware blijft die ik bezit. Dezelfde redenering als de rest van de stack.

OpenTelemetry Collector installeren

De OTel Collector is de pipeline waar elk stukje telemetry doorheen stroomt voordat het 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

Collector configuratie:

# 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]

Applicaties instrumenteren

Tempo en de collector zijn nutteloos totdat je apps daadwerkelijk spans uitsturen. Er zijn twee manieren naar binnen, en je gebruikt waarschijnlijk allebei.

Auto-instrumentatie (easy mode)

Voor veel talen haakt OpenTelemetry het framework voor je in 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 DB calls gratis. Wat het niet kan zien is je business logic, dus als je een span om “verwerk deze order” wilt met de order ID eraan, grijp je naar de handmatige API.

Handmatige instrumentatie (meer controle)

Voor custom spans en de attributes waar je echt om geeft:

// 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 zit de valkuil die de meeste eerste pogingen sloopt. Een trace blijft alleen één trace als de trace ID met de request meereist van de ene service naar de volgende. Laat je die context op een hop vallen, dan krijg je twee losse traces in plaats van één, zonder enig idee dat ze bij elkaar hoorden.

HTTP headers (automatisch met instrumentatie):

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

gRPC metadata (ook automatisch met instrumentatie)

De auto-instrumentatie regelt propagation voor je. Maak 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 de hele driehoek laat renderen. Met de juiste data source config kun je in Grafana van een trace direct naar de bijbehorende logs en metrics springen zonder ergens een trace ID te kopiëren.

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

Traces 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

Trace naar logs

Met tracesToLogs ingesteld linkt een span direct naar de logs die eruit voortkwamen:

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

Die ene klik is de beloning voor het aan elkaar knopen van de data sources. Geen grep, geen gok welke pod wat logde.

Trace naar metrics

De omgekeerde richting werkt ook. Vanaf een trage trace pivot je naar de latency histogrammen en error rates voor die service, zodat je kunt zien of je naar één pechrequest staart of naar een echte trend.

Service graph

Tempo kan een service dependency graph rechtstreeks uit je traces bouwen:

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

Dit creëert metrics zoals:

  • traces_service_graph_request_total
  • traces_service_graph_request_failed_total
  • traces_service_graph_request_server_seconds

Grafana rendert dat als een interactieve map van traffic flow en error rates tussen services, afgeleid van echte requests in plaats van een architectuurdiagram dat niemand bijwerkte.

Sampling strategieën

Zodra echt verkeer dit raakt, is het volgende probleem volume. Elke losse trace opslaan op productieschaal wordt snel duur, dus je sampled. Er zijn twee smaken, en het verschil doet ertoe.

Head sampling (bij collectie)

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

Doodsimpel, en met één vervelende fout: de beslissing valt voordat de request klaar is, dus een muntworp kan precies de trace weggooien die een error gaf. De interessante traces zijn juist de traces die je het liefst wilt houden.

Tail sampling (na collectie)

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. Houd elke error, houd alles dat traag is, gooi een net van 5% over de saaie successen. Dit is de versie die ik echt draai, want de traces die je tijdens een incident wilt zijn precies degene die head sampling vaak weggooit.

Mijn productie setup

Als ik de lagen bij elkaar trek, is dit ruwweg wat ik draai. Object storage op MinIO, tail sampling die errors en trage requests beschermt, en de metrics generator aan zodat ik de service graph gratis krijg:

# 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

Belangrijke keuzes:

  • 72u retentie - Genoeg om recente issues te debuggen
  • Tail sampling - Bewaar alle errors en trage traces
  • 5% general sampling - Beheerbaar volume
  • Service graph - Visuele dependency map

Drie dagen retentie klinkt kort totdat je merkt dat bijna elke trace die je opzoekt uit het afgelopen uur komt. Overleeft een issue langer dan dat, dan duikt het op in metrics, en metrics zijn goedkoop om eindeloos te bewaren.

Debuggen met traces

Terug naar die timeoutende checkout van bovenaan. Zo leest het hele ding met tracing op zijn plek:

  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

Elk van die stappen was eerst giswerk. Nu is het een ketting van kliks, en het gokken is weg.

Waarom dit ertoe doet

Microservices zijn geweldig om werk over teams te verdelen en ellendig om te debuggen. Eén user request kan tien services raken, en als die breekt tonen logs je alleen de errortekst terwijl metrics je alleen het symptoom tonen. De causaliteit, het echte pad dat de request nam, leeft in de traces.

Stapel Prometheus en Thanos voor metrics, Loki voor logs, en Tempo voor traces, en je ziet alle drie in één Grafana, gecorreleerd, draaiend op hardware die je zelf bezit. Dat laatste is het stuk waar ik het meest om geef: geen vendor ziet je request paden, geen SaaS-rekening schaalt mee met je trace volume, en als het om 2 uur ’s nachts breekt kun je de doos echt openmaken en begrijpen waarom.

Dat is het verschil tussen “works on my machine” en een span die naar een trage query wijst. De een is een schouderophaal, de ander een antwoord.