Ein gewachsenes System mit PHP im Kern und ein paar Go-Diensten daneben hat meistens beides: zu viele Signale und zu wenig Antwort. Es gibt Logdateien, es gibt eine Anbieteroberfläche, es gibt Metriken aus drei Quellen, und trotzdem dauert die Antwort auf „warum war das gestern um 14 Uhr langsam" einen halben Tag.
Das liegt selten an der Menge der Daten. Es liegt daran, dass sie nicht zusammenhängen: Der Logeintrag aus PHP und die Metrik aus dem Go-Dienst beschreiben dieselbe Anfrage und wissen nichts voneinander.
OpenTelemetry löst genau dieses Problem, und es löst es an der richtigen Stelle. Dieser Artikel beschreibt eine Einführung, die sich in Wochen rechnet statt in Quartalen, mit den Stellen, an denen es in einem PHP-Go-Stack tatsächlich klemmt.
Warum OpenTelemetry und nicht der Anbieter
Jeder Anbieter liefert eigene Bibliotheken, und sie sind bequemer als der Standard. Der Preis steht nicht auf der Rechnung, sondern im Code: Die Instrumentierung ist die teuerste und am schwersten reversible Hälfte der Beobachtbarkeit, und sie gehört nicht dem Anbieter.
Mit OpenTelemetry liegt sie einmal im eigenen Code, und die Entscheidung über das Ziel wird eine Frage der Konfiguration. Ein Wechsel von einem Anbieter zu einem anderen oder zu einem eigenen Stapel ist dann kein Umbau, sondern ein Endpunkt und ein Neustart des Sammlers.
Das ist kein theoretisches Argument. Der häufigste Grund, über Beobachtbarkeit erneut nachzudenken, ist eine Rechnung, die über ein Jahr um das Dreifache gewachsen ist. Wer dann feststellt, dass ein Wechsel sechs Monate Instrumentierung bedeutet, wechselt nicht, sondern verhandelt aus einer schlechten Position.
Wenn noch gar nichts gemessen wird, reichen für den Anfang drei Zahlen: Drei Kennzahlen, bevor Sie eine Zeile refaktorieren.
Traces zuerst, und nur ein Pfad
Die verbreitete Reihenfolge ist Metriken, dann Logs, dann irgendwann Traces. Sie ist falsch herum, wenn die offene Frage lautet „wo geht die Zeit hin".
Metriken sagen, dass etwas langsam ist. Das weiß man schon, sonst würde man nicht schauen. Ein Trace sagt, wo, und zwar für eine einzelne Anfrage über alle beteiligten Dienste hinweg. Das ist die Antwort, für die sonst ein halber Tag draufgeht.
Genauso wichtig ist der Zuschnitt des ersten Schritts: ein Pfad, nicht das ganze System. Der Bestellvorgang, der Anmeldevorgang, die Suche. Ein Pfad reicht vom Einstieg bis in die Datenbank und über jeden Dienst, der daran beteiligt ist, und er ist in einer Woche fertig. Eine flächendeckende Instrumentierung ist in einem Quartal halb fertig, und die Hälfte, die fehlt, ist immer die, die man gerade braucht.
PHP: die Eigenheit, die niemand erwähnt
In PHP gibt es eine automatische Instrumentierung über die Erweiterung, und sie deckt Symfony, Laravel, Doctrine, PDO und die üblichen Clients ab. Der Einstieg ist deshalb kurz:
pecl install opentelemetry
composer require \
open-telemetry/sdk \
open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-symfonyDie Eigenheit liegt nicht in der Einrichtung, sondern im Ausliefern. PHP beendet den Prozess am Ende der Anfrage; es gibt keinen Hintergrundprozess, der Spans gebündelt wegschickt. Wer direkt an den Anbieter sendet, hängt deshalb jede Anfrage an eine Netzwerkverbindung, die er nicht kontrolliert.
Die Lösung ist ein Sammler auf demselben Host, der über einen Unix-Socket oder über localhost angesprochen wird. Das Senden ist dann eine Sache von Mikrosekunden, und die Bündelung, die Wiederholung bei Fehlern und das Stichprobenverfahren passieren außerhalb der Anfrage:
# Nicht an den Anbieter, sondern an den Sammler nebenan.
# Alles Weitere ist dessen Aufgabe, nicht die des PHP-Prozesses.
OTEL_SERVICE_NAME=shop-web
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_TRACES_SAMPLER=parentbased_always_on
OTEL_PHP_AUTOLOAD_ENABLED=trueDer zweite Punkt betrifft die Kosten: Die automatische Instrumentierung erzeugt in einem gewachsenen System schnell dreißig Spans pro Anfrage, und die meisten davon sind Datenbankzugriffe, die alle gleich aussehen. Das ist nicht nur teuer, es ist auch unlesbar. Die Abfragen einer Schleife gehören zu einem Span zusammengefasst, und das ist Handarbeit an genau einer Stelle.
Go: der Kontext ist die ganze Arbeit
In Go gibt es keine automatische Instrumentierung, und das ist weniger schlimm, als es klingt. Die Middleware für HTTP und die Wrapper für Datenbank und Client sind fertig; was bleibt, ist das Durchreichen des Kontexts.
Genau daran scheitern die meisten ersten Versuche. Ein Trace reißt immer an derselben Stelle: dort, wo jemand den Kontext nicht weitergegeben hat.
func (s *Dienst) Bestellen(ctx context.Context, b Bestellung) error {
ctx, span := s.tracer.Start(ctx, "bestellung.anlegen")
defer span.End()
// Fachliche Merkmale ja, Kennungen einzelner Nutzer nein:
// jede eindeutige Auspraegung kostet in der Auswertung Geld.
span.SetAttributes(
attribute.String("bestellung.kanal", b.Kanal),
attribute.Int("bestellung.positionen", len(b.Positionen)),
)
if err := s.repo.Speichern(ctx, b); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "speichern fehlgeschlagen")
return err
}
// Hier reisst der Trace in fast jedem ersten Versuch:
// eine Goroutine ohne Kontext ist ein zweiter, wurzelloser Baum.
go s.benachrichtigen(context.WithoutCancel(ctx), b)
return nil
}context.WithoutCancel ist dabei der Punkt, an dem sich zwei richtige Absichten treffen: Die Goroutine soll weiterlaufen, wenn die Anfrage endet, aber sie soll im selben Trace stehen. Ohne diese Unterscheidung bekommt man entweder abgebrochene Arbeit oder einen abgerissenen Trace.
Die Naht zwischen PHP und Go
Der eigentliche Gewinn entsteht erst, wenn beide Seiten in demselben Baum stehen. Über HTTP passiert das von selbst, weil beide Bibliotheken denselben Kopf setzen und lesen. Über eine Warteschlange passiert es nicht, und das ist die Stelle, an der die meisten Einführungen auf halber Strecke stehen bleiben.
Ein Auftrag, den PHP einstellt und ein Go-Dienst abarbeitet, trägt den Kontext nicht mit. Er muss ihn als Feld mitbekommen:
// Beim Einstellen: den aktuellen Kontext in die Nutzlast schreiben.
// Das Format ist der W3C-Standard, beide Seiten verstehen ihn.
$traeger = [];
TraceContextPropagator::getInstance()->inject($traeger);
$this->warteschlange->einstellen('rechnung.erzeugen', [
'bestellung_id' => $bestellung->id,
'trace' => $traeger,
]);Auf der Go-Seite wird derselbe Träger wieder eingelesen, bevor der Span beginnt. Danach hängt die Rechnungserzeugung im Trace der Bestellung, und die Frage „warum hat der Kunde seine Rechnung erst zwei Stunden später bekommen" ist eine Ansicht statt einer Untersuchung.
Ein Hinweis, der eine Woche spart: Der Trace einer Warteschlange sieht absichtlich anders aus als der einer Anfrage. Die Verarbeitung beginnt später und dauert länger als die Anfrage, die sie ausgelöst hat. Wer das als Fehler liest, baut den falschen Zusammenhang.
Wie Aufträge überhaupt in eine Warteschlange kommen und was dabei zu beachten ist, steht in Von Cronjobs zu Queues.
Die Logdateien, die schon da sind
Der Teil, den viele Einführungen überspringen, weil er nach Aufräumen aussieht: Ein Altsystem hat bereits Logdateien, und sie enthalten oft genau die Zeile, die man braucht. Sie ist nur nicht auffindbar, weil nichts sie mit der Anfrage verbindet.
Dafür muss die Logpipeline nicht ersetzt werden. Es reicht, die Trace-Kennung in jede Zeile zu schreiben, und das ist ein Prozessor im Logger, keine Änderung an den Aufrufstellen:
// Ein Monolog-Prozessor, einmal registriert. Danach traegt jede
// Zeile, die irgendwo im System entsteht, ihre Trace-Kennung.
$logger->pushProcessor(function (LogRecord $satz): LogRecord {
$span = Span::getCurrent()->getContext();
if ($span->isValid()) {
$satz->extra['trace_id'] = $span->getTraceId();
$satz->extra['span_id'] = $span->getSpanId();
}
return $satz;
});Der Gewinn ist unverhältnismäßig zum Aufwand. Aus „such mal in den Logs von gestern" wird eine Filterung auf eine Kennung, die im Trace danebensteht, und zwar über alle Dienste hinweg, ohne dass eine einzige Logzeile umgeschrieben wurde.
Kardinalität, Stichproben, Rechnung
Beobachtbarkeit wird nicht durch die Anzahl der Anfragen teuer, sondern durch zwei Entscheidungen, die früh fallen und spät auffallen.
Merkmale mit vielen Ausprägungen. Eine Nutzerkennung, eine Bestellnummer oder eine Sitzungskennung als Merkmal an einer Metrik erzeugt eine eigene Zeitreihe je Wert. Das ist der übliche Weg, aus einer Rechnung von zweihundert Euro eine von viertausend zu machen. An einem Span sind solche Werte in Ordnung, an einer Metrik nie.
Alles behalten. Ein Stichprobenverfahren bei einem Prozent ist billig und unbrauchbar, wenn der Fehlerfall bei einem Promille liegt. Die Antwort darauf ist kein höherer Prozentsatz, sondern eine Entscheidung nach dem Ende des Traces, im Sammler:
# Entschieden wird, wenn der Trace fertig ist: alles Auffaellige
# vollstaendig, vom Rest ein Zehntel.
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: fehler
type: status_code
status_code: { status_codes: [ERROR] }
- name: langsam
type: latency
latency: { threshold_ms: 1000 }
- name: rest
type: probabilistic
probabilistic: { sampling_percentage: 10 }Diese fünfzehn Zeilen sind regelmäßig der Unterschied zwischen einer Beobachtbarkeit, die im nächsten Sparlauf gestrichen wird, und einer, die bleibt.
Woran man merkt, dass es reicht
Eine Einführung dieser Art ist nicht fertig, wenn alles instrumentiert ist, sondern wenn eine bestimmte Frage ihre Antwortzeit verliert.
Der Prüfstein ist konkret: Jemand nennt eine Uhrzeit und eine Bestellnummer, und es dauert weniger als fünf Minuten zu sagen, wo die Zeit geblieben ist und welcher Dienst sie gebraucht hat. Ist das erreicht, wird der nächste Pfad instrumentiert, und zwar der mit den nächsthäufigsten Fragen, nicht der technisch interessanteste.
Was dabei nicht entsteht, ist ein Projekt mit Enddatum. Beobachtbarkeit wächst mit dem System und schrumpft mit ihm, und die einzige Regel, die das am Leben hält, ist dieselbe wie bei den Zwischenspeichern: Was niemand ansieht, wird abgeschaltet, nicht gepflegt. Der Begriff dahinter steht im Glossar, das Vorgehen als Leistung auf einer eigenen Seite.
Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.

