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:
- Kosteneffectief - Object storage backend, geen indexering
- Simpel - Geen complexe cluster management
- Schaalbaar - Verwerkt enorme trace volumes
- 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:
- Selecteer Tempo data source
- Kies “Search” tab
- Filter op service name, duration, status
- Klik een trace om de waterfall te zien
Trace naar logs
Met tracesToLogs ingesteld linkt een span direct naar de logs die eruit voortkwamen:
- Open een trace
- Klik een span
- Klik “Logs for this span”
- 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_totaltraces_service_graph_request_failed_totaltraces_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:
- Alert vuurt: Hoge latency op checkout service
- Check metrics: P99 latency piekte om 14:32
- Vind traces: Zoek Tempo voor checkout-service, duration > 1s, tijdsbereik 14:30-14:35
- Analyseer trace: Zie dat payment-service call 4.2s duurde
- Duik in span: Zie
db.statementattribute die trage query toont - Check logs: Spring naar Loki logs voor die span, zie connection pool uitputting
- 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.
