Strukturiertes Logging in Go mit slog für Observability und Alerting

Abfragbare JSON-Logs, die mit Traces verknüpft sind.

Inhaltsverzeichnis

Logs sind eine Debugging-Schnittstelle, die Sie noch verwenden können, wenn das System im Brand ist. Das Problem ist, dass reine Text-Logs schlecht altern: Sobald Sie Filterung, Aggregation und Alarmierung benötigen, beginnen Sie, Sätze zu parsen.

Arbeitsplatz mit Laptop und Go-Maskottchen für besseres Logging

Strukturiertes Logging ist das Gegenmittel. Es verwandelt jede Log-Zeile in ein kleines Ereignis mit stabilen Feldern, sodass Tools zuverlässig suchen und aggregieren können. Für Informationen darüber, wie Logs mit Metriken, Dashboards und Alarmierung im weiteren Stack verbunden sind, siehe den Beobachtbarkeit: Monitoring, Metriken, Prometheus & Grafana Guide.

Was strukturiertes Logging ist und warum es skaliert

Strukturiertes Logging ist ein Logging-Ansatz, bei dem ein Datensatz nicht nur eine Zeichenkette ist, sondern eine Nachricht plus typisierte Schlüssel-Wert-Attribute. Die Idee ist auf die beste Art langweilig: Sobald Logs maschinenlesbar sind, ist ein Vorfall kein Grep-Wettbewerb mehr.

Ein kurzer Vergleich:

Reiner Text (menschengerecht, toolfeindlich)

failed to charge card user=42 amount=19.99 ms=842 err=timeout

Strukturiert (toolorientiert, dennoch lesbar)

{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}

In der Produktion hilft es, Logs als einen vom Prozess ausgestrahlten Ereignisstrom zu betrachten, während Routing und Speicherung außerhalb der Anwendung stattfinden. Dieses mentale Modell drängt Sie dazu, ein Ereignis pro Zeile zu schreiben und Ereignisse einfach zu versenden und neu zu verarbeiten.

Slog in Go als gemeinsame Logging-Frontend

Go hatte seit jeher das klassische log-Paket, aber moderne Dienste benötigen Level und Felder. Das log/slog-Paket (Go 1.21 und höher) bringt strukturiertes Logging in die Standardbibliothek und formalisiert eine gemeinsame Struktur für Log- Datensätze: Zeit, Level, Nachricht und Attribute. Für eine kompakte Sprach- und Befehlsübersicht neben diesem Leitfaden siehe die Go Cheat Sheet.

Die wichtigsten Teile des Modells sind:

Record

Ein Record ist das, was passiert ist. In Slog-Begriffen enthält er Zeit, Level, Nachricht und eine Reihe von Attributen. Sie erstellen Records über Methoden wie Info und Error, oder über Log, wenn Sie das Level explizit angeben möchten.

Attribute

Attribute sind die Schlüssel-Wert-Paare, die Logs abfragbar machen. Wenn Sie dasselbe Konzept unter drei verschiedenen Schlüsseln loggen (user, userId, uid), erhalten Sie drei verschiedene Datensätze. Konsistente Schlüssel sind dort, wo der eigentliche Wert verborgen liegt.

Handler

Ein Handler ist die Art und Weise, wie Records in Bytes umgewandelt werden. Der eingebaute TextHandler schreibt Schlüssel=Wert-Ausgabe, während JSONHandler zeilengetrenntes JSON schreibt. Handler sind auch der Ort, an dem Redaktion, Umbenennung von Schlüsseln und Ausgabe-Routing stattfinden.

Eine unterschätzte Funktion ist, dass Slog vor bestehendem Code sitzen kann. Wenn Sie einen Standard-Slog-Logger festlegen, verwenden oberste Slog-Funktionen diesen, und das klassische log-Paket kann ebenfalls dorthin umgeleitet werden. Das ermöglicht eine inkrementelle Migration.

Gruppen

Gruppen lösen das Problem „jedes Subsystem verwendet id“. Sie können eine Reihe von Attributen für eine Anfrage gruppieren (request.method, request.path) oder ein gesamtes Subsystem mit WithGroup benennen, damit Schlüssel nicht kollidieren.

Ein produktionsreifes Slog-Setup

Das folgende Setup erreicht die üblichen Ziele. Die Beispiele verwenden ein kleines logx-Paket; für Informationen darüber, wo solche Pakete normalerweise in einem echten Modul zu finden sind, siehe Go-Projektstruktur: Praktiken & Muster.

  • ein JSON-Ereignis pro Zeile
  • Logs werden zur Sammlung auf stdout geschrieben
  • stabile Service-Metadaten werden einmalig angehängt
  • kontextbewusstes Logging für Request- und Trace-IDs
  • zentrale Redaktion für sensible Schlüssel
package logx

import (
	"log/slog"
	"os"
)

var level slog.LevelVar // standardmäßig INFO

func New() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     &level, // kann zur Laufzeit geändert werden
		AddSource: true,   // Datei und Zeile einschließen, wenn verfügbar
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// Zentrale Redaktion: konsistent und schwer unbeabsichtigt zu umgehen.
			switch a.Key {
			case "password", "token", "authorization", "api_key":
				return slog.String(a.Key, "[redacted]")
			}
			return a
		},
	}

	h := slog.NewJSONHandler(os.Stdout, opts)

	return slog.New(h).With(
		"service", os.Getenv("SERVICE_NAME"),
		"env", os.Getenv("ENV"),
		"version", os.Getenv("VERSION"),
	)
}

func SetLevel(l slog.Level) { level.Set(l) }

Ein kleines Detail mit großen Konsequenzen: Der eingebaute JSON-Handler verwendet Standard- Schlüssel (time, level, msg, source). Wenn Ihr Log-Backend ein anderes Schema erwartet, ist ReplaceAttr das Ventil, das es Ihnen ermöglicht, Schlüssel zu normalisieren, ohne Aufrufstellen neu schreiben zu müssen.

Schema ist wichtiger als der Logger

Die meisten „strukturierten Logging“-Fehler sind Schema-Fehler.

Wesentliche Felder, die sich lohnen

Jedes Log-Backend speichert einen Zeitstempel, ein Level und eine Nachricht. In der Praxis fügt ein nützliches Anwendungsschema oft eine kleine Reihe stabiler Felder hinzu:

  • service, env, version
  • component (oder subsystem)
  • event (ein stabiler Name für das, was passiert ist)
  • request_id (wenn eine Anfrage existiert)
  • trace_id und span_id (wenn Tracing existiert)
  • error (Zeichenkette) und error_kind (stabiler Bucket)

Beachten Sie das Muster: Diese Felder beantworten operative Fragen, nicht die Neugier von Entwicklern.

Semantische Konventionen sind ein billiger Konsistenz-Trick

Wenn Sie bereits OpenTelemetry verwenden, bieten seine semantischen Konventionen einen Standard- Wortschatz für Attribute über Telemetrie-Signale hinweg. Selbst wenn Sie keine Logs über OpenTelemetry exportieren, reduziert die Übernahme von Attributnamen die „Was haben wir dieses Feld in Service B genannt“-Steuer.

Hohe Kardinalität und warum Logs teuer werden

Hohe Kardinalität bedeutet „zu viele eindeutige Werte“. Das ist innerhalb einer JSON- Payload in Ordnung, wird aber schmerzhaft, wenn ein Backend einige Felder als indizierte Labels oder Stream-Schlüssel behandelt. Benutzer-IDs, IP-Adressen, zufällige Request-Token und vollständige URLs neigen dazu, Kombinationen zu explodieren.

Das praktische Ergebnis ist einfach: Halten Sie Labels und Index-Schlüssel langweilig (Service, Umgebung, Region) und halten Sie hochkardinalitätige Felder innerhalb der strukturierten Payload für die Filterung zur Abfragezeit.

Korrelation mit Request-IDs und Traces

Korrelation ist der Punkt, an dem Logs aufhören, nur Text zu sein, und beginnen, sich wie Telemetrie zu verhalten.

Request-ID als Korrelationsschlüssel mit geringstem Aufwand

Eine Request-ID ist die einfachste Brücke zwischen einer eingehenden Anfrage und allem, was aufgrund davon passiert. Sie funktioniert oft auch ohne verteiltes Tracing, und sie ist immer noch nützlich, wenn Traces gesampelt werden. Für das vollständige Bild darüber, wie Request-IDs und andere Metadaten im Kontext gespeichert und abgerufen werden sollen — einschließlich typisierter Schlüssel-Muster und Middleware-Beispielen — siehe Go context.Context richtig machen.

Ein häufiges Muster ist, einen pro-Anfrage-Logger an den Kontext anzuhängen:

package logx

import (
	"context"
	"log/slog"
)

type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
	return context.WithValue(ctx, ctxKey{}, l)
}

func FromContext(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
		return l
	}
	return slog.Default()
}

Trace-Korrelation mit W3C Trace Context und OpenTelemetry

W3C Trace Context definiert eine Standardmethode zur Weitergabe der Trace-Identität (für HTTP über traceparent und tracestate). OpenTelemetry baut darauf auf, sodass Trace-IDs und Span-IDs aus dem Kontext extrahiert werden können.

Dieses Middleware-Beispiel loggt sowohl request_id als auch Trace-Identifikatoren, wenn verfügbar:

package middleware

import (
	"crypto/rand"
	"encoding/hex"
	"net/http"

	"go.opentelemetry.io/otel/trace"
	"log/slog"

	"example.com/project/logx"
)

func requestID() string {
	var b [16]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func WithRequestLogger(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rid := r.Header.Get("X-Request-Id")
			if rid == "" {
				rid = requestID()
			}

			l := base.With(
				"request_id", rid,
				"method", r.Method,
				"path", r.URL.Path,
			)

			if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
				l = l.With(
					"trace_id", sc.TraceID().String(),
					"span_id", sc.SpanID().String(),
				)
			}

			ctx := logx.WithLogger(r.Context(), l)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Sobald Korrelationsfelder existieren, wird die Log-Zeile zu einem Index in andere Daten. Der Unterschied bei einem live Vorfall ist nicht subtil.

Strukturierte Logs in Monitoring- und Alarmierungssignale umwandeln

Logs sind großartig darin, die Frage „was ist passiert“ zu beantworten. Alarmierung betrifft meist „wie oft und wie schlimm“.

Ein praktischer Ansatz ist, bestimmte Log-Ereignisse als Zähler zu behandeln:

  • event=payment_failed
  • event=db_timeout
  • event=cache_miss

Viele Plattformen können logbasierte Metriken ableiten, indem sie übereinstimmende Datensätze über ein Fenster zählen. Strukturierte Logs machen diese Zählung widerstandsfähig, da sie auf einem Feldwert basieren, anstatt auf einer brüchigen Textübereinstimmung. Wenn Sie bereit sind, diese Signale zu visualisieren und zu erforschen, führt Installieren und Verwenden von Grafana auf Ubuntu: Kompleter Leitfaden durch ein vollständiges Grafana-Setup, das Sie auf gängige Log- und Metrik-Backends ausrichten können.

Hier beginnen auch Log-Levels an Bedeutung zu gewinnen. Debug-Logs sind oft wertvoll, aber hier verbergen sich auch Kosten und Rauschen. Die Verwendung eines dynamischen Levels (LevelVar) lässt das System standardmäßig ruhig, ermöglicht aber bei Bedarf gezielte Details.

Schlussgedanken

Strukturiertes Logging in Go ist kein Bibliotheksstreit mehr. Der interessante Teil ist, ob Ihre Log-Datensätze konsistent, korrelierbar und kostengünstig zu speichern sind.

Wenn Ihre Logs stabile Felder wie event, request_id und trace_id tragen, hören sie auf, „Zeichenketten zu sein, die jemand geschrieben hat“, und beginnen, ein Datensatz zu sein, mit dem Sie arbeiten können.

Anmerkungen

Das Go-Team führte log/slog in Go 1.21 ein und betonte, dass strukturierte Logs Schlüssel-Wert-Paare verwenden, sodass sie zuverlässig geparst, gefiltert, gesucht und analysiert werden können, und erwähnte auch die Motivation, einen gemeinsamen Framework über das Ökosystem hinweg bereitzustellen.

Die Dokumentation des log/slog-Pakets definiert das Record-Modell (Zeit, Level, Nachricht, Schlüssel-Wert-Paare) und die eingebaute Handler (TextHandler für Schlüssel=Wert und JSONHandler für zeilengetrenntes JSON) und dokumentiert die SetDefault-Integration mit dem klassischen log-Paket.

Für verteilte Korrelation standardisiert die W3C Trace Context-Spezifikation die Weitergabe von traceparent und tracestate, und OpenTelemetry spezifiziert, dass sein SpanContext dem W3C Trace Context entspricht und TraceId und SpanId verfügbar macht, was die Log-Trace-Korrelation straightforward macht, wenn ein Span vorhanden ist.

Für Log-Speicherkosten und -leistung empfiehlt die Grafana Loki-Dokumentation stark begrenzte, statische Labels und warnt vor hochkardinalitätigen Labels, die zu viele Streams und einen riesigen Index erzeugen, was direkt relevant ist bei der Entscheidung, was ein Label wird und was als nicht indiziertes JSON-Feld bleibt.

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.