Gestructureerd loggen in Go met slog voor observabiliteit en alarmering

Queryerbare JSON-logboeken die verbinding maken met traces.

Inhoud

Logs zijn een debuginterface die je nog kunt gebruiken als het systeem in brand staat. Het probleem is dat platte tekstlogs snel verouderen: zodra je filtering, aggregatie en alerting nodig hebt, begin je met het parsen van zinnen.

werkplek met laptop en Go mascottes voor beter loggen

Gestructureerd loggen is het tegengif. Het zet elke logregel om in een klein evenement met stabiele velden, zodat tools betrouwbaar kunnen zoeken en aggregeren. Zie voor hoe logs aansluiten op metrics, dashboards en alerting in de bredere stack de Observability: Monitoring, Metrics, Prometheus & Grafana Guide.

Wat gestructureerd loggen is en waarom het schaalbaar is

Gestructureerd loggen is loggen waarbij een record niet slechts een string is, maar een bericht plus getypte sleutel-waarde attributen. Het idee is op de beste manier saai: zodra logs machine-leesbaar zijn, stopt een incident met een grep-wedstrijd te zijn.

Een snelle vergelijking:

Platte tekst (mens-georiënteerd, tool-vijandig)

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

Gestructureerd (tool-georiënteerd, nog steeds leesbaar)

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

In productie helpt het om te denken aan logs als een evenementsstroom die door het proces wordt gegenereerd, terwijl routing en opslag buiten de applicatie plaatsvinden. Dat mentale model drijft je ertoe om één evenement per regel te schrijven en evenementen eenvoudig te houden voor verzending en herverwerking.

Slog in Go als gedeeld loggingfront-end

Go heeft al lang het klassieke log-pakket, maar moderne services hebben niveaus en velden nodig. Het log/slog-pakket (Go 1.21 en nieuwer) brengt gestructureerd loggen naar de standaardbibliotheek en formaliseert een gemeenschappelijke structuur voor log records: tijd, niveau, bericht en attributen. Voor een beknopte taal- en commando-opfrisser naast deze gids, zie de Go Cheatsheet.

De belangrijkste onderdelen van het model zijn:

Record

Een record is wat er is gebeurd. In slog-termen bevat het tijd, niveau, bericht en een set attributen. Je maakt records aan via methoden zoals Info en Error, of via Log als je het niveau expliciet wilt opgeven.

Attributen

Attributen zijn de sleutel-waarde paren die logs zoekbaar maken. Als je hetzelfde concept logt onder drie verschillende sleutels (user, userId, uid), krijg je drie verschillende datasets. De echte waarde zit in consistente sleutels.

Handler

Een handler bepaalt hoe records worden omgezet in bytes. De ingebouwde TextHandler schrijft key=value-uitvoer, terwijl JSONHandler JSON schrijft met lijn-scheiding. Handlers zijn ook de plek waar redactie, hernoemen van sleutels en uitvoerrouting meestal plaatsvinden.

Een onderschatte functie is dat slog voor bestaande code kan staan. Wanneer je een standaard slog-logger instelt, gebruiken top-level slog-functies deze, en het klassieke log-pakket kan ook naar deze worden omgeleid. Dit maakt incrementele migratie mogelijk.

Groepen

Groepen lossen het probleem op dat “elke subsystem id gebruikt”. Je kunt een set attributen groeperen voor een verzoek (request.method, request.path) of een heel subsystem namenruimen met WithGroup zodat sleutels niet botsen.

Een productieklaar slog-opzet

De volgende opzet raakt de gebruikelijke doelen. De voorbeelden gebruiken een klein logx-pakket; zie voor waar dergelijke pakketten meestal in een echte module staan, Go Project Structure: Practices & Patterns.

  • één JSON-evenement per regel
  • logs geschreven naar stdout voor verzameling
  • stabiele servicemetadata éénmaal toegevoegd
  • contextbewust loggen voor verzoek- en trace-IDs
  • centrale redactie voor gevoelige sleutels
package logx

import (
	"log/slog"
	"os"
)

var level slog.LevelVar // standaard op INFO

func New() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     &level, // kan tijdens runtime worden gewijzigd
		AddSource: true,   // inclusief bestand en regel indien beschikbaar
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// Centraliseerde redactie: consistent en moeilijk per ongeluk te omzeilen.
			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) }

Een klein detail met grote gevolgen: de ingebouwde JSON-handler gebruikt standaard sleutels (time, level, msg, source). Wanneer je logbackend een ander schema verwacht, is ReplaceAttr de veiligheidsklep die je in staat stelt om sleutels te normaliseren zonder aanroeplocaties te herschrijven.

Schema is belangrijker dan de logger

De meeste “gestructureerde logging”-mislukkingen zijn schemamislukkingen.

Essentiële velden die hun waarde blijven bewijzen

Elke logbackend zal een tijdstempel, niveau en bericht opslaan. In de praktijk voegt een nuttig applicatieschema vaak een kleine set stabiele velden toe:

  • service, env, version
  • component (of subsystem)
  • event (een stabiele naam voor het gebeurde)
  • request_id (wanneer er een verzoek bestaat)
  • trace_id en span_id (wanneer tracing aanwezig is)
  • error (string) en error_kind (stabiele bucket)

Merk het patroon op: deze velden beantwoorden operationele vragen, niet de nieuwsgierigheid van ontwikkelaars.

Semantische conventies zijn een goedkope consistentiehack

Als je al OpenTelemetry gebruikt, bieden de semantische conventies daarvan een standaard vocabulaire voor attributen over telemetriesignalen heen. Zelfs als je logs niet via OpenTelemetry exporteert, vermindert het lenen van attribuutnamen de “wat noemden we dit veld in service B”-last.

Hoge cardinaliteit en waarom logs duur worden

Hoge cardinaliteit betekent “te veel unieke waarden”. Dat is prima binnen een JSON payload, maar het wordt pijnlijk wanneer een backend sommige velden behandelt als geïndexeerde labels of stroomsleutels. Gebruikers-ID’s, IP-adressen, willekeurige verzoektokens en volledige URL’s hebben de neiging om combinaties te laten exploderen.

Het praktische resultaat is eenvoudig: houd labels en indexsleutels saai (service, omgeving, regio), en houd velden met hoge cardinaliteit binnen de gestructureerde payload voor filtering op query-tijd.

Correlatie met verzoek-ID’s en traces

Correlatie is het punt waarop logs stoppen met alleen tekst te zijn en beginnen te gedragen als telemetrie.

Verzoek-ID als de correlatiesleutel met de laagste wrijving

Een verzoek-ID is de eenvoudigste brug tussen een binnenkomend verzoek en alles wat erdoor gebeurt. Het werkt vaak zelfs zonder gedistribueerde tracing, en het is nog steeds nuttig wanneer traces worden bemonsterd. Zie voor het volledige beeld van hoe verzoek-ID’s en andere metadata moeten worden opgeslagen in en opgehaald uit context — inclusief getypte sleutelpatronen en middleware-voorbeelden — Go context.Context Done Right.

Een veelvoorkomend patroon is het koppelen van een per-verzoek logger aan de context:

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()
}

Tracecorrelatie met W3C Trace Context en OpenTelemetry

W3C Trace Context definieert een standaard manier om trace-identiteit te propageren (voor HTTP, via traceparent en tracestate). OpenTelemetry bouwt daarop voort zodat trace-ID’s en span-ID’s kunnen worden geëxtraheerd uit de context.

Dit middleware-voorbeeld logt zowel request_id als trace-identificatiegegevens wanneer beschikbaar:

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))
		})
	}
}

Zodra correlatievelden bestaan, wordt de logregel een index naar andere data. Het verschil bij een live incident is niet subtiel.

Gestructureerde logs omzetten in monitoring- en alertingsignalen

Logs zijn uitstekend in het beantwoorden van “wat er is gebeurd”. Alerting gaat meestal over “hoe vaak en hoe ernstig”.

Een praktische aanpak is het behandelen van bepaalde logevenementen als counters:

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

Veel platforms kunnen log-gebaseerde metrics afleiden door het tellen van overeenkomende records over een raam. Gestructureerde logs maken die telling robuust, omdat deze gebaseerd is op een veldwaarde in plaats van een kwetsbare tekstmatch. Wanneer je klaar bent om die signalen te visualiseren en te verkennen, Install and Use Grafana on Ubuntu: Complete Guide behandelt een volledige Grafana-opzet die je kunt richten op veelvoorkomende log- en metricsbackends.

Hier beginnen logniveaus ook belangrijk te worden. Debuglogs zijn vaak waardevol, maar ze zijn ook de plek waar kosten en ruis verstopt zitten. Door een dynamisch niveau (LevelVar) te gebruiken, blijft het systeem standaard stil, terwijl er toch gericht detail mogelijk is wanneer nodig.

Slotgedachten

Gestructureerd loggen in Go is geen debat over bibliotheken meer. Het interessante deel is of je logrecords consistent, correleerbaar en betaalbaar opslaan.

Wanneer je logs stabiele velden dragen zoals event, request_id en trace_id, stoppen ze met “strings die iemand heeft geschreven” te zijn en worden ze een dataset die je kunt beheren.

Opmerkingen

Het Go-team introduceerde log/slog in Go 1.21 en benadrukte dat gestructureerde logs sleutel-waarde paren gebruiken zodat ze betrouwbaar kunnen worden geparsed, gefilterd, doorzocht en geanalyseerd, en noteerde ook de motivatie om een gemeenschappelijk framework te bieden dat door het ecosysteem wordt gedeeld.

De documentatie van het log/slog-pakket definieert het recordmodel (tijd, niveau, bericht, sleutel-waarde paren) en de ingebouwde handlers (TextHandler voor key=value en JSONHandler voor JSON met lijn-scheiding), en documenteert SetDefault-integratie met het klassieke log-pakket.

Voor gedistribueerde correlatie standardiseert de W3C Trace Context-specificatie traceparent en tracestate-propagatie, en specificeert OpenTelemetry dat zijn SpanContext conform is aan W3C Trace Context en TraceId en SpanId blootlegt, wat log-trace-correlatie eenvoudig maakt wanneer een span aanwezig is.

Voor logopslagkosten en -prestaties beveelt Grafana Loki-documentatie sterk aan om begrensde, statische labels te gebruiken en waarschuwt voor labels met hoge cardinaliteit die te veel streams en een enorme index creëren, wat direct relevant is bij het beslissen wat een label wordt versus wat een ongeïndexeerd JSON-veld blijft.

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.