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
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:
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
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:
- Selecteer Tempo data source
- Kies “Search” tab
- Filter op service name, duration, status
- 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:
- Open een trace
- Klik een span
- Klik “Logs for this span”
- 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_totaltraces_service_graph_request_failed_totaltraces_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:
- 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
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.
