Sie haben eine Landschaft aus mehreren Dutzend Services, die über Jahre gewachsen ist. Jedes Team hat sein eigenes Logging, ein paar Dashboards, vielleicht irgendwo einen Zipkin-Server, den keiner mehr pflegt. Wenn ein Request quer durch fünf Services läuft und irgendwo hängen bleibt, sucht jemand in fünf verschiedenen Log-Aggregatoren nach einer Correlation-ID, die es in drei davon gar nicht gibt. Genau an diesem Punkt fing das Projekt an, um das es hier geht: 40 Services, kein einheitliches Tracing, und ein Team, das wusste, dass es OpenTelemetry braucht — aber nicht, wo es anfangen soll.
Die kurze Antwort vorweg: Ein OpenTelemetry-Rollout startet nicht mit Dashboards, sondern mit Auto-Instrumentierung und mit der Entscheidung, was Sie überhaupt speichern.
Ausgangslage: gewachsen, heterogen, ohne roten Faden
Der Kunde betreibt rund 40 Services, der Großteil in Java (Spring Boot, teils noch auf einer alten 2.x-Linie), dazu ein paar Node-Dienste und zwei Python-Batchjobs am Rand. Deployment über Kubernetes, ungefähr 30 Nodes. Metriken laufen bereits über Prometheus, Logs landen zentral. Traces gab es faktisch nicht — bis auf zwei Services, die vor Jahren mal manuell mit Jaeger-Clients instrumentiert wurden und deren SDK-Version niemand mehr anfassen wollte.
Das Ziel des Kunden war klar formuliert und ungefähr so realistisch wie üblich: „Wir wollen sehen, wo unsere Requests Zeit verlieren." Das ist eine gute Anforderung. Sie sagt nur nichts darüber, wo man anfängt, wenn man 40 Dienste hat und ein begrenztes Budget im Trace-Backend.
Warum der OpenTelemetry-Rollout nicht beim Backend anfängt
Der naheliegende Reflex bei so einem Vorhaben ist, ein Backend auszuwählen, ein schönes Dashboard hinzustellen und dann Team für Team zu instrumentieren. Das ist der Weg, auf dem Projekte teuer werden, bevor sie nützlich sind.
Traces sind hochvolumig. Ein einzelner Request durch fünf Services erzeugt schnell zwanzig oder mehr Spans — eine Span ist die einzelne Operation innerhalb eines Traces, etwa ein HTTP-Aufruf oder eine Datenbankabfrage. Bei ein paar tausend Requests pro Sekunde über die gesamte Landschaft reden wir über eine Menge Zeitreihen und Spans, die irgendwo hin müssen. Wer das ohne Sampling ins Backend kippt, zahlt entweder für Speicher, den er nie liest, oder das Backend kippt unter der Last.
Deshalb war die erste inhaltliche Entscheidung im Projekt nicht die Frage nach dem Tool, sondern die Frage, wie viel wir behalten und wovon. Trace-Sampling — die Regel, welcher Anteil der Traces tatsächlich gespeichert wird — legt man am besten fest, bevor der erste Service Daten schickt. Sonst explodieren die Kosten im Backend, und man baut die Bremse nachträglich in einen fahrenden Zug ein.
Auto-Instrumentierung zuerst, weil sie ohne Codeänderung skaliert
Bei 40 Services ist manuelle Instrumentierung als Startpunkt keine Option. Sie würde bedeuten, in jedem Repository das SDK einzubauen, Spans von Hand zu setzen und das über Wochen mit einzelnen Teams abzustimmen. Das dauert zu lange, um Momentum zu erzeugen.
Für die Java-Dienste haben wir mit dem OpenTelemetry Java Agent gearbeitet. Der Agent hängt sich als JVM-Agent an den Prozess und instrumentiert gängige Bibliotheken — Servlet-Container, JDBC, HTTP-Clients, Messaging — ohne dass Sie eine Zeile Anwendungscode ändern. Getestet haben wir das Setup mit dem OpenTelemetry Java Agent 2.10, einem OpenTelemetry Collector 0.115 und Grafana Tempo 2.6 als Backend.
Der Agent kommt als Umgebungsvariablen-gesteuertes Artefakt ins Container-Image oder wird per Init-Container beigelegt. Die minimale Konfiguration für einen Service sieht so aus:
# Im Container, vor dem java -jar Aufruf:
export JAVA_TOOL_OPTIONS="-javaagent:/otel/opentelemetry-javaagent.jar"
export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="service.namespace=shop,deployment.environment=prod"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector.observability:4317"
export OTEL_EXPORTER_OTLP_PROTOCOL="grpc"
export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.1"
Wichtig sind hier drei Dinge. OTEL_SERVICE_NAME ist der Name, unter dem der Dienst im Backend auftaucht — der wird später noch relevant. service.namespace und deployment.environment als Resource-Attribute sorgen dafür, dass Sie prod von staging trennen und Services gruppieren können. Und der Sampler: parentbased_traceidratio mit Argument 0.1 bedeutet, dass ein Service, der einen Trace startet, in 10 Prozent der Fälle behält — und dass ein Service, der einen bereits gesampelten Trace fortsetzt, die Entscheidung des Eltern-Spans übernimmt. Das ist entscheidend, damit ein Trace nicht mitten drin abbricht, weil ein Downstream-Service anders würfelt.
Sampling: erst grob head-based, dann gezielt
Head-based Sampling — die Entscheidung fällt am Anfang des Traces — hat einen Nachteil: Sie wissen zum Zeitpunkt der Entscheidung noch nicht, ob der Request in einem Fehler endet. Genau die Traces mit Fehlern und hoher Latenz will man aber sehen. Deshalb wurde die 10-Prozent-Regel am SDK nur als erster, billiger Filter gesetzt, um die Grundlast zu deckeln.
Die eigentliche Intelligenz kam in den Collector, über den Tail-Sampling-Processor. Tail-based Sampling — die Entscheidung fällt, nachdem der komplette Trace eingesammelt wurde — erlaubt, gezielt alle fehlerhaften und langsamen Traces zu behalten und vom Rest nur eine Stichprobe:
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow-requests
type: latency
latency:
threshold_ms: 800
- name: baseline-sample
type: probabilistic
probabilistic:
sampling_percentage: 10
exporters:
otlp/tempo:
endpoint: tempo-distributor.observability:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlp/tempo]
Der Haken an Tail-based Sampling, den man kennen muss: Der Collector muss alle Spans eines Traces zwischenpuffern, bis er entscheiden kann. Das kostet Speicher und funktioniert nur sauber, wenn alle Spans eines Traces beim selben Collector-Prozess ankommen. In einer horizontal skalierten Collector-Flotte braucht man dafür eine Schicht, die nach trace_id routet — sonst sieht jeder Collector nur einen Teil des Traces und entscheidet falsch. Wir haben das über eine zweistufige Collector-Topologie gelöst: Agent-Collector sammeln pro Node ein, eine kleine Gateway-Schicht mit loadbalancing Exporter verteilt nach trace_id an die Sampling-Collector. Das ist der Teil, den man beim ersten Rollout gern unterschätzt.
flowchart LR
A[Service + Java Agent] -->|OTLP head 10%| B[Collector Agent pro Node]
B -->|loadbalancing nach trace_id| C[Collector Gateway]
C -->|tail_sampling| D[Tempo]
Warum die Entwickler die Traces zunächst ignoriert haben
Nach zwei Sprints war die halbe Landschaft instrumentiert, Traces liefen ins Backend, die Sampling-Kosten waren im Griff. Und dann passierte in der Retro etwas, das wir in ähnlicher Form in mehreren Projekten gesehen haben: Die Entwickler nutzten die Traces nicht.
Nicht, weil sie unbrauchbar gewesen wären. Sondern weil die Span-Namen kryptisch waren. Die Auto-Instrumentierung benennt Spans generisch — ein HTTP-Server-Span heißt dann etwa GET oder, wenn man Glück hat, GET /api/v1/{id}, ein JDBC-Span heißt SELECT shopdb. Wenn ein Entwickler einen Trace öffnet und eine Liste aus zwanzig Zeilen GET, SELECT, HTTP POST sieht, ohne fachlichen Bezug, dann schließt er das Fenster wieder und geht zurück ins Log.
Das ist das eigentliche Lernstück aus diesem Projekt, und es ist keins über Technik: Observability scheitert selten an der Technik, meistens an der Benennung. Sie können die sauberste Pipeline bauen — wenn niemand im Trace erkennt, was fachlich passiert, wird das Werkzeug nicht benutzt.
Eine Naming-Konvention, die man durchhalten kann
Die Lösung war unspektakulär und genau deshalb wirksam. Wir haben eine kurze Konvention definiert und sie da durchgesetzt, wo sie am meisten bringt: an den Einstiegspunkten, die ein Entwickler zuerst sieht.
Erstens: Service-Namen fachlich und einheitlich. Kein svc-2, kein spring-app, sondern checkout-api, payment-worker, inventory-sync. Das kostet nur die Disziplin, OTEL_SERVICE_NAME sauber zu setzen.
Zweitens: Server-Spans mit dem Route-Template statt der aufgelösten URL. Der Java Agent macht das für Spring meist automatisch, sodass aus tausend Varianten mit unterschiedlicher ID eine Span GET /orders/{orderId} wird — das ist zugleich der wichtigste Hebel gegen eine Cardinality-Explosion, also gegen ausufernd viele unterscheidbare Span-Namen, die das Backend aufblähen.
Manuelle Spans nur an den fachlich wichtigen Stellen
Der dritte Baustein sind manuelle Spans mit sprechendem Namen an den fachlich wichtigen Stellen. Das ist die einzige Stelle, an der wir Code angefasst haben, und wir haben sie bewusst klein gehalten:
import io.opentelemetry.instrumentation.annotations.WithSpan;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
public class OrderService {
@WithSpan("order.validate")
public ValidationResult validate(@SpanAttribute("order.id") String orderId) {
// fachliche Logik
}
}
Die @WithSpan-Annotation aus opentelemetry-instrumentation-annotations erzeugt eine Span mit dem angegebenen Namen, @SpanAttribute hängt einen fachlichen Wert an. Damit steht im Trace order.validate statt einer anonymen Methodensignatur. Ein Entwickler, der einen langsamen Checkout untersucht, sieht auf einen Blick, ob die Validierung oder die Zahlung die Zeit frisst.
Seit dieser Konvention stieg die Nutzung messbar — im Sinne von: Teams öffneten Tempo von sich aus, um Incidents zu untersuchen, statt zuerst ins Log zu springen. Harte Vorher-nachher-Zahlen zur Adoption haben wir bewusst nicht erhoben, weil sie leicht schöngerechnet sind. Das qualitative Signal war eindeutig genug.
Was sich nach den ersten Wochen geändert hat
Nach den ersten Wochen stand eine Pipeline, die den Großteil der Java-Services abdeckte, die Backend-Last durch zweistufiges Sampling in einem kalkulierbaren Rahmen hielt und deren Traces fachlich lesbar waren. Die 10-Prozent-Grundrate plus das gezielte Behalten von Fehlern und langsamen Requests hat das Volumen im Backend gegenüber einer ungefilterten Variante deutlich reduziert — konkret um mehr als eine Größenordnung, ohne die diagnostisch wertvollen Traces zu verlieren.
Wichtiger als die Zahl: Die Reihenfolge stimmte. Erst Auto-Instrumentierung für Breite, dann Sampling für Kostenkontrolle, dann Benennung für Nutzbarkeit. Dashboards kamen zuletzt — und sie waren dann einfach, weil die Daten sauber benannt in Tempo lagen.
Grenzen dieses Vorgehens
Auto-Instrumentierung deckt gängige Bibliotheken ab, nicht Ihre Geschäftslogik. Alles, was innerhalb eines Services zwischen zwei Framework-Aufrufen passiert, bleibt eine Blackbox, bis Sie manuell Spans setzen. Wer glaubt, mit dem Agent allein die volle Sicht zu bekommen, wird enttäuscht.
Head-based Sampling mit fester Ratio ist grob. Bei sehr niedrigem Traffic auf einem selten genutzten Service sehen Sie mit 10 Prozent unter Umständen tagelang gar keinen Trace. Für solche Dienste lohnt eine höhere oder eine per-Service unterschiedliche Rate — was die Konfiguration verkompliziert.
Tail-based Sampling ist der teuerste Teil im Betrieb. Es braucht Speicher, es braucht die trace_id-basierte Verteilung, und es hat eine Latenz durch decision_wait. In kleineren Landschaften mit überschaubarem Volumen kann head-based Sampling allein völlig ausreichen; dann sparen Sie sich die Gateway-Schicht. Man sollte Tail-based Sampling nicht aus Prinzip einführen, sondern wenn das Volumen es erzwingt.
Und für Sprachen jenseits von Java ist der Komfort geringer. Node lässt sich noch gut auto-instrumentieren, bei anderen Runtimes wird mehr Handarbeit fällig. Ein einheitlicher Rollout heißt nicht überall gleicher Aufwand.
Das Fazit: Die Reihenfolge entscheidet, nicht das Dashboard
Ein Tracing-Rollout beginnt nicht mit dem hübschen Dashboard, sondern mit zwei nüchternen Entscheidungen: was Sie automatisch instrumentieren und was Sie behalten. Und er steht und fällt mit der Benennung — die beste Pipeline nützt nichts, wenn niemand im Trace erkennt, was fachlich passiert.
Wir begleiten solche Rollouts von der ersten Sampling-Entscheidung bis zu dem Punkt, an dem die Traces im Alltag der Teams ankommen. Wenn Ihre Landschaft gewachsen ist und ein einheitliches Tracing fehlt, ist der strukturierte Start meist der schwierigere Teil als die Technik selbst.