Gestructureerd loggen in Go met slog voor observabiliteit en alerting

Opvraagbare JSON-logboeken die koppelen aan traces.

Inhoud

Logs zijn een debug-interface die je nog kunt gebruiken als het systeem in brand staat. Het probleem is dat gewone tekstenlogs slecht verouderen: zodra je filtering, aggregatie en alarmering nodig hebt, begin je met het parsen van zinnen.

werkplek met laptop en Go-maskottes voor beter loggen

Gestructureerd loggen is het tegenmiddel. Het transformeert elke logregel in een klein gebeurtenis met stabiele velden, zodat tools betrouwbaar kunnen zoeken en aggregeren. Voor informatie over hoe logs zich verhouden tot metrieken, dashboards en alarmering in de bredere stack, zie de Gids voor Observability: Monitoring, Metrieken, Prometheus & Grafana.

Wat gestructureerd loggen is en waarom het schaalbaar is

Gestructureerd loggen is loggen waarbij een record niet alleen een string is, maar een bericht plus getypeerde sleutel-waarde-attributen. Het idee is op zijn best saai: zodra logs machineleesbaar zijn, stopt een incident met een grep-wedstrijd.

Een snelle vergelijking:

Gewone tekst (mens-georiënteerd, vijandig voor tools)

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

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

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

In productie helpt het om logs te zien als een gebeurtenisstroom die door het proces wordt gegenereerd, waarbij routing en opslag buiten de applicatie plaatsvinden. Dit mentaal model drijft je ertoe om één gebeurtenis per regel te schrijven en gebeurtenissen eenvoudig te houden voor verzending en herverwerking.

Slog in Go als gedeelde logging-interface

Go heeft het klassieke log-pakket al sinds de beginjaren, maar moderne services hebben niveaus en velden nodig. Het log/slog-pakket (Go 1.21 en hoger) brengt gestructureerd loggen in de standaardbibliotheek en formaliseert een gemeenschappelijke vorm voor logrecords: tijd, niveau, bericht en attributen. Voor een compacte 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 querybaar maken. Als je hetzelfde concept onder drie verschillende sleutels logt (user, userId, uid), krijg je drie verschillende datasets. Consistente sleutels zijn waar de echte waarde schuilt.

Handler

Een handler is hoe records bytes worden. De ingebouwde TextHandler schrijft key=value-uitvoer, terwijl JSONHandler lijngewijs JSON schrijft. Handlers zijn ook waar redactie, hernoemen van sleutels en output-routing meestal gebeuren.

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 kan het klassieke log-pakket ook naar deze worden omgeleid. Dit maakt incrementele migratie mogelijk.

Groepen

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

Een productiegereed slog-opzet

De volgende opzet raakt de gebruikelijke doelen. De voorbeelden gebruiken een klein logx-pakket; voor waar pakketten als dat meestal in een echt module wonen, zie Go Projectstructuur: Praktijken & Patronen.

  • één JSON-gebeurtenis per regel
  • logs geschreven naar stdout voor verzameling
  • stabiele service-metadata één keer vastgehecht
  • context-gevoelig loggen voor verzoeken 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 veranderd
		AddSource: true,   // inclusief bestand en regel als beschikbaar
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// Gecentraliseerde 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 log-backend een ander schema verwacht, is ReplaceAttr de drukontlastingsklep die je toelaat om sleutels te normaliseren zonder aanroeplocaties te herschrijven.

Schema telt meer dan de logger

De meeste “gestructureerd loggen”-mislukkingen zijn schema-mislukkingen.

Essentiële velden die hun huur betalen

Elke log-backend slaat een tijdstempel, niveau en bericht op. In de praktijk voegt een nuttig applicatieschema vaak een kleine set stabiele velden toe:

  • service, env, version
  • component (of subsysteem)
  • event (een stabiele naam voor wat er gebeurde)
  • request_id (als er een verzoek bestaat)
  • trace_id en span_id (als tracing bestaat)
  • 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 consistentie-hack

Als je al OpenTelemetry gebruikt, biedt diens semantische conventies een standaard vocabulaire voor attributen over telemetie-signalen. Zelfs als je geen logs via OpenTelemetry exporteert, verlaagt het lenen van attribuutnamen de “hoe noemden we dit veld in service B”-belasting.

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 als geïndexeerde labels of stream-sleutels behandelt. 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 verzoeks-ID’s en traces

Correlatie is het punt waar logs ophouden slechts tekst te zijn en beginnen te gedragen als telemetrie.

Request ID als de correlatiesleutel met de laagste wrijving

Een Request ID is de eenvoudigste brug tussen een binnenkomend verzoek en alles wat daaruit voortkomt. Het werkt vaak zelfs zonder gedistribueerde tracing en blijft nuttig wanneer traces worden bemonsterd.

Een veelvoorkomend patroon is het vasthechten van een logger per verzoek 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()
}

Trace-correlatie 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 uit de context kunnen worden geëxtraheerd.

Dit middleware-voorbeeld logt zowel request_id als trace-identificatoren als ze beschikbaar zijn:

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 in een live incident is niet subtiel.

Het omzetten van gestructureerde logs in monitoring- en alarmeringssignalen

Logs zijn geweldig om “wat is er gebeurd” te beantwoorden. Alarmering gaat meestal over “hoe vaak en hoe erg”.

Een praktische aanpak is om bepaalde loggebeurtenissen als tellers te behandelen:

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

Veel platformen kunnen op logs gebaseerde metrieken afleiden door overeenkomende records over een venster te tellen. Gestructureerde logs maken die telling robuust, omdat het gebaseerd is op een veldwaarde in plaats van een broze tekstmatch. Wanneer je klaar bent om die signalen te visualiseren en te verkennen, leidt Install en Gebruik Grafana op Ubuntu: Complete Gids je door een volledige Grafana-opzet die je op gangemeene log- en metrieck-backends kunt richten.

Dit is ook waar logniveaus beginnen te tellen. Debug-logs zijn vaak waardevol, maar het is ook waar kosten en ruis schuilen. Het gebruik van een dynamisch niveau (LevelVar) zorgt ervoor dat het systeem standaard stil blijft, terwijl het toch gerichte details toelaat wanneer nodig.

Afsluitende gedachten

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

Wanneer je logs stabiele velden dragen zoals event, request_id en trace_id, stoppen ze met “strings die iemand schreef” te zijn en beginnen ze een dataset te zijn waarmee je kunt opereren.

Opmerkingen

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

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

Voor gedistribueerde correlatie standaardiseert 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, waardoor log-trace-correlatie eenvoudig is wanneer een span aanwezig is.

Voor logopslagkosten en -prestaties, beveelt de Grafana Loki-documentatie sterk aan om gebonden, statische labels te gebruiken en waarschuwt tegen 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.