Strukturerad loggning i Go med slog för observabilitet och avisering

Frågbara JSON-loggar som kopplar ihop spår.

Sidinnehåll

Loggar är ett felsökningsgränssnitt som du fortfarande kan använda när systemet är i brand. Problemet är att vanliga textloggar åldras dåligt: så fort du behöver filtrering, aggregering och avisering börjar du parsas meningar.

arbetsplats med laptop och Go-maskotar för bättre loggning

Strukturerad loggning är motmedlet. Den omvandlar varje loggrad till en liten händelse med stabila fält, så verktyg kan söka och aggregera på ett pålitligt sätt. För information om hur loggar kopplas ihop med mätvärden, instrumentpaneler och aviseringar i den bredare stacken, se Observability: Monitoring, Metrics, Prometheus & Grafana Guide.

Vad strukturerad loggning är och varför den skalerar

Strukturerad loggning är loggning där en post inte bara är en sträng, utan ett meddelande plus typade nyckel-värde-attribut. Idén är tråkig på det bästa sättet: när loggar är maskinläsbara, slutar en incident vara en grep-tävling.

En snabb jämförelse:

Ren text (mänskligt först, verktygsvänligt)

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

Strukturerad (verktyg först, fortfarande läsbar)

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

I produktion är det bra att tänka på loggar som en händelseström som emitteras av processen, medan routing och lagring finns utanför applikationen. Denna mentala modell driver dig mot att skriva en händelse per rad och hålla händelser enkla att skicka och bearbeta om.

Slog i Go som en gemensam loggningsfrontend

Go har haft den klassiska log-paketet sedan evigheten, men moderna tjänster behöver nivåer och fält. Paketet log/slog (Go 1.21 och senare) tar strukturerad loggning in i standardbiblioteket och formaliserar en gemensam form för loggposter: tid, nivå, meddelande och attribut. För en kompakt språk- och kommandoreferens vid sidan av denna guide, se Go Cheatsheet.

De nyckelkomponenterna i modellen är:

Post

En post är vad som hände. I slog-termer innehåller den tid, nivå, meddelande och en uppsättning attribut. Du skapar poster via metoder som Info och Error, eller via Log när du vill ange nivån explicit.

Attribut

Attribut är nyckel-värde-par som gör loggar frågba. Om du loggar samma koncept under tre olika nycklar (user, userId, uid), får du tre olika dataset. Konsistenta nycklar är där det verkliga värdet döljer sig.

Handläggare

En handläggare är hur poster blir till byte. Den inbyggda TextHandler skriver nyckel=värde-utdata, medan JSONHandler skriver radavgränsad JSON. Handläggare är också där redigering, nyckelbyten och utdata-rutning brukar ske.

En undervärderad funktion är att slog kan sitta framför befintlig kod. När du ställer in en standard slog-loggare, använder toppnivåens slog-funktioner den, och det klassiska log-paketet kan omdirigeras till den också. Det gör inkrementell migration möjlig.

Grupper

Grupper löser problemet “varje subsystem använder id”. Du kan gruppera en uppsättning attribut för en begäran (request.method, request.path) eller namnrymda ett helt subsystem med WithGroup så att nycklar inte kolliderar.

En produktionsformad slog-uppsättning

Följande uppsättning träffar de vanliga målen. Exemplen använder ett litet logx-paket; för var paket som det brukar bo i en riktig modul, se Go Project Structure: Practices & Patterns.

  • en JSON-händelse per rad
  • loggar skrivna till stdout för insamling
  • stabil tjänstmetadata bifogad en gång
  • kontextmedveten loggning för begäran och spår-ID:n
  • central redigering för känsliga nycklar
package logx

import (
	"log/slog"
	"os"
)

var level slog.LevelVar // standard till INFO

func New() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     &level, // kan ändras vid körning
		AddSource: true,   // inkludera fil och rad när tillgänglig
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// Centraliserad redigering: konsistent och svår att kringgå av misstag.
			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) }

En liten detalj med stora konsekvenser: den inbyggda JSON-handläggaren använder standardnycklar (time, level, msg, source). När din loggbackend förväntar sig en annan schema, är ReplaceAttr tryckavlastningsventilen som låter dig normalisera nycklar utan att skriva om anropssiter.

Schema betyder mer än loggaren

De flesta “strukturerade loggnings”-fiaskon är schema-fiaskon.

Viktiga fält som fortsätter betala hyran

Varje loggbackend kommer att lagra ett tidsstämpel, nivå och meddelande. I praktiken lägger en användbar applikationsschema ofta till en liten uppsättning stabila fält:

  • service, env, version
  • component (eller subsystem)
  • event (ett stabilt namn för det som hände)
  • request_id (när en begäran finns)
  • trace_id och span_id (när spårning finns)
  • error (sträng) och error_kind (stabil behållare)

Lägg märke till mönstret: dessa fält svarar på operativa frågor, inte utvecklar nyfikenhet.

Semantiska konventioner är en billig konsistenshack

Om du redan använder OpenTelemetry, dess semantiska konventioner ger en standardordförståelse för attribut över telemetrisignaler. Även om du inte exporterar loggar via OpenTelemetry, minskar låning av attributnamn skatten “vad kallade vi detta fält i tjänst B”.

Hög kardinalitet och varför loggar blir dyra

Hög kardinalitet betyder “för många unika värden”. Det är bra inuti en JSON-belastning, men det blir smärtsamt när en backend behandlar vissa fält som indexerade etiketter eller strömningsnycklar. Användar-ID:n, IP-adresser, slumpmässiga begäranstoken och fulla URL:er tenderar att explodera kombinationer.

Den praktiska utkomsten är enkel: håll etiketter och indexnycklar tråkiga (tjänst, miljö, region), och håll fält med hög kardinalitet inuti den strukturerade belastningen för filtrering vid frågetid.

Korrelation med begäran-ID:n och spår

Korrelation är punkten där loggar slutar vara bara text och börjar bete sig som telemetri.

Begäran-ID som den lägsta friktionskorrelationsnyckeln

Ett begäran-ID är den enklaste bron mellan en inkommande begäran och allt som händer på grund av det. Det tenderar att fungera även utan distribuerad spårning, och det är fortfarande användbart när spår är samplade. För den fulla bilden av hur begäran-ID:n och annan metadata bör lagras i och hämtas från kontext — inklusive typade nyckelmönster och mellantjänstexempel — se Go context.Context Done Right.

Ett vanligt mönster är att bifoga en per-begäran loggare till kontexten:

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

Spårkorrelation med W3C Trace Context och OpenTelemetry

W3C Trace Context definierar ett standard sätt att sprida spåridentitet (för HTTP, via traceparent och tracestate). OpenTelemetry bygger på det så att spår-ID:n och span-ID:n kan extraheras från kontexten.

Detta mellantjänstexempel loggar både request_id och spåridentifierare när tillgängliga:

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

När korrelationsfält finns, blir loggraden ett index i andra data. Skillnaden vid en live incident är inte subtil.

Att omvandla strukturerade loggar till övervakning och aviseringssignaler

Loggar är bra på att svara på “vad hände”. Avisering handlar oftast om “hur ofta och hur illa”.

En praktisk approach är att behandla vissa logghändelser som räknare:

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

Många plattformar kan härleda loggbaserade mätvärden genom att räkna matchande poster över ett fönster. Strukturerade loggar gör den räkningen motståndskraftig, eftersom den är baserad på ett fältvärde snarare än en spröd textmatch. När du är redo att visualisera och utforska dessa signaler, Install and Use Grafana on Ubuntu: Complete Guide går igenom en full Grafana-uppsättning du kan peka på vanliga logg- och mätvärdesbackends.

Detta är också där loggnivåer börjar betydelse. Debug-loggar är ofta värdefulla, men de är också där kostnad och brus döljer sig. Att använda en dynamisk nivå (LevelVar) låter systemet vara tyst som standard, medan det fortfarande tillåter målad detalj när det behövs.

Avslutande tankar

Strukturerad loggning i Go är inte längre ett biblioteksdiskussion. Den intressanta delen är om dina loggposter är konsistenta, korrelerbara och prisvärda att lagra.

När dina loggar bär stabila fält som event, request_id och trace_id, slutar de vara “strängar någon skrev” och börjar vara ett dataset du kan operera.

Noter

Go-teamet introducerade log/slog i Go 1.21 och betonade att strukturerade loggar använder nyckel-värde-par så att de kan parsas, filtreras, sökas och analyseras pålitligt, och noterade också motivationen att tillhandahålla ett gemensamt ramverk delat över ekosystemet.

log/slog-paketets dokumentation definierar postmodellen (tid, nivå, meddelande, nyckel-värde-par) och de inbyggda handläggarna (TextHandler för nyckel=värde och JSONHandler för radavgränsad JSON), och dokumenterar SetDefault-integration med det klassiska log-paketet.

För distribuerad korrelation standardiserar W3C Trace Context-specifikationen traceparent och tracestate-propagering, och OpenTelemetry specificerar att dess SpanContext följt W3C Trace Context och exponerar TraceId och SpanId, vilket gör logg-spår-korrelation enkel när en span finns.

För logglagring kostnad och prestanda, rekommenderar Grafana Loki-dokumentationen starkt begränsade, statiska etiketter och varnar för höga kardinalitetsetiketter som skapar för många strömmar och en enorm index, vilket är direkt relevant när man bestämmer vad som blir en etikett vs vad som stannar som ett oindexerat JSON-fält.

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.