Logging strutturato in Go con slog per osservabilità e allerta
Log JSON interrogabili che si collegano alle tracce.
I log rappresentano un’interfaccia di debugging utilizzabile anche quando il sistema è in fiamme. Il problema è che i log in testo puro si deteriorano rapidamente: non appena è necessario filtrare, aggregare e configurare allarmi, si inizia a analizzare le frasi.

La registrazione strutturata (structured logging) è l’antidoto. Trasforma ogni riga di log in un piccolo evento con campi stabili, consentendo agli strumenti di cercare e aggregare in modo affidabile. Per capire come i log si connettono a metriche, dashboard e allarmi nell’infrastruttura più ampia, consulta la Guida all’Osservabilità: Monitoraggio, Metriche, Prometheus & Grafana.
Cos’è la registrazione strutturata e perché scala bene
La registrazione strutturata è un tipo di logging in cui un record non è solo una stringa, ma un messaggio più attributi tipizzati key-value. L’idea è noiosa nel migliore dei modi: una volta che i log sono leggibili dalle macchine, un incidente smette di essere una gara di grep.
Un rapido confronto:
Testo puro (priorità all’utente umano, ostile agli strumenti)
failed to charge card user=42 amount=19.99 ms=842 err=timeout
Strutturato (priorità agli strumenti, comunque leggibile)
{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}
In produzione, è utile considerare i log come uno stream di eventi emessi dal processo, mentre instradamento e archiviazione risiedono al di fuori dell’applicazione. Questo modello mentale spinge verso la scrittura di un evento per riga e il mantenimento di eventi facili da trasmettere e ri-elaborare.
Slog in Go come interfaccia di logging condivisa
Go ha avuto il pacchetto log classico fin dall’inizio, ma i servizi moderni hanno bisogno di livelli e campi. Il pacchetto log/slog (Go 1.21 e successive) porta la registrazione strutturata nella libreria standard e formalizza una forma comune per i record di log: ora, livello, messaggio e attributi. Per un rapido ripasso della sintassi del linguaggio e dei comandi affiancato a questa guida, consulta il Cheat Sheet Go.
Le parti chiave del modello sono:
Record
Un record è ciò che è accaduto. In termini di slog, contiene ora, livello, messaggio e un insieme di attributi. Si creano record tramite metodi come Info ed Error, o tramite Log quando si vuole fornire il livello esplicitamente.
Attributi
Gli attributi sono le coppie chiave-valore che rendono i log interrogabili. Se si registrano lo stesso concetto sotto tre chiavi diverse (user, userId, uid), si ottengono tre dataset diversi. Il vero valore si nasconde nelle chiavi coerenti.
Handler
Uno handler è il modo in cui i record diventano byte. Il TextHandler integrato scrive output key=value, mentre JSONHandler scrive JSON delimitato da righe. Gli handler sono anche il luogo in cui avviene solitamente la redazione (censura), la rinomina delle chiavi e l’instradamento dell’output.
Una funzione sottovalutata è che slog può porsi davanti al codice esistente. Quando si imposta un logger slog predefinito, le funzioni slog di livello superiore lo utilizzano, e il classico pacchetto log può essere reindirizzato ad esso. Ciò rende possibile una migrazione incrementale.
Gruppi
I gruppi risolvono il problema “ogni sottosistema usa id”. Si può raggruppare un insieme di attributi per una richiesta (request.method, request.path) o spaziare nomi un’intera sottosistema con WithGroup in modo che le chiavi non collidano.
Una configurazione slog pronta per la produzione
La seguente configurazione soddisfa gli obiettivi usuali.
Gli esempi utilizzano un piccolo pacchetto logx; per capire dove pacchetti simili si trovano solitamente in un modulo reale, consulta Struttura dei Progetti Go: Pratiche & Pattern.
- un evento JSON per riga
- log scritti su stdout per la raccolta
- metadati del servizio stabili allegati una volta sola
- logging consapevole del contesto per ID di richiesta e trace
- redazione centralizzata per chiavi sensibili
package logx
import (
"log/slog"
"os"
)
var level slog.LevelVar // predefinito a INFO
func New() *slog.Logger {
opts := &slog.HandlerOptions{
Level: &level, // può essere modificato a runtime
AddSource: true, // include file e riga quando disponibile
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Redazione centralizzata: coerente e difficile da aggirare accidentalmente.
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) }
Un piccolo dettaglio con grandi conseguenze: l’handler JSON integrato usa chiavi standard (time, level, msg, source). Quando il tuo backend di log si aspetta uno schema diverso, ReplaceAttr è la valvola di sfogo che ti permette di normalizzare le chiavi senza riscrivere i siti di chiamata.
Lo schema conta più del logger
La maggior parte dei fallimenti nella “registrazione strutturata” sono fallimenti dello schema.
Campi essenziali che continuano a pagare il loro affitto
Ogni backend di log memorizzerà un timestamp, un livello e un messaggio. Nella pratica, uno schema dell’applicazione utile aggiunge spesso un piccolo insieme di campi stabili:
- service, env, version
- component (o subsystem)
- event (un nome stabile per l’evento accaduto)
- request_id (quando esiste una richiesta)
- trace_id e span_id (quando esiste il tracing)
- error (stringa) e error_kind (bucket stabile)
Nota il pattern: questi campi rispondono a domande operative, non alla curiosità degli sviluppatori.
Le convenzioni semantiche sono un trucco economico per la coerenza
Se già utilizzi OpenTelemetry, le sue convenzioni semantiche forniscono un vocabolario standard per gli attributi attraverso i segnali di telemetria. Anche se non esporti i log tramite OpenTelemetry, prendere in prestito i nomi degli attributi riduce la tassa del “come abbiamo chiamato questo campo nel servizio B”.
Alta cardinalità e perché i log diventano costosi
Alta cardinalità significa “troppi valori unici”. Va bene all’interno di un payload JSON, ma diventa doloroso quando un backend tratta alcuni campi come etichette indici o chiavi di stream. Gli ID utente, gli indirizzi IP, i token di richiesta casuali e gli URL completi tendono a far esplodere le combinazioni.
Il risultato pratico è semplice: mantieni etichette e chiavi di indicizzazione noiose (servizio, ambiente, regione), e mantieni i campi ad alta cardinalità all’interno del payload strutturato per il filtraggio al momento della query.
Correlazione con ID di richiesta e trace
La correlazione è il punto in cui i log smettono di essere solo testo e iniziano a comportarsi come telemetria.
Request ID come chiave di correlazione con il minor attrito
Un request ID è il ponte più semplice tra una richiesta in arrivo e tutto ciò che accade a causa di essa. Tende a funzionare anche senza tracing distribuito, ed è comunque utile quando le trace sono campionate. Per l’immagine completa di come gli ID di richiesta e altri metadati dovrebbero essere memorizzati e recuperati dal contesto — inclusi pattern di chiavi tipizzate ed esempi di middleware — consulta Go context.Context Fatto Bene.
Un pattern comune è allegare un logger per richiesta al contesto:
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()
}
Correlazione delle trace con W3C Trace Context e OpenTelemetry
W3C Trace Context definisce un modo standard per propagare l’identità della trace (per HTTP, tramite traceparent e tracestate). OpenTelemetry si basa su questo in modo che gli ID delle trace e gli ID degli span possano essere estratti dal contesto.
Questo esempio di middleware registra sia request_id che gli identificatori delle trace quando disponibili:
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))
})
}
}
Una volta che i campi di correlazione esistono, la riga di log diventa un indice verso altri dati. La differenza durante un incidente live non è sottile.
Trasformare i log strutturati in segnali di monitoraggio e allarmi
I log sono ottimi nel rispondere a “cosa è successo”. Gli allarmi riguardano solitamente “quante volte e quanto grave”.
Un approccio pratico è trattare certi eventi di log come contatori:
- event=payment_failed
- event=db_timeout
- event=cache_miss
Molte piattaforme possono derivare metriche basate sui log contando i record corrispondenti in una finestra temporale. I log strutturati rendono quel conteggio resiliente, perché si basa su un valore di campo piuttosto che su una corrispondenza di testo fragile. Quando sei pronto per visualizzare ed esplorare quei segnali, Installa e Usa Grafana su Ubuntu: Guida Completa passa attraverso una configurazione completa di Grafana che puoi puntare verso backend comuni per log e metriche.
Questo è anche dove i livelli di log iniziano a contare. I log di debug sono spesso preziosi, ma sono anche dove si nascondono costi e rumore. L’uso di un livello dinamico (LevelVar) permette al sistema di rimanere silente per impostazione predefinita, consentendo comunque dettagli mirati quando necessario.
Pensieri finali
La registrazione strutturata in Go non è più una questione di librerie. La parte interessante è se i tuoi record di log sono coerenti, correlabili ed economici da archiviare.
Quando i tuoi log contengono campi stabili come event, request_id e trace_id, smettono di essere “stringhe scritte da qualcuno” e iniziano a essere un dataset su cui puoi operare.
Note
Il team Go ha introdotto log/slog in Go 1.21 ed ha enfatizzato che i log strutturati usano coppie chiave-valore in modo che possano essere analizzati, filtrati, cercati e analizzati in modo affidabile, e ha anche notato la motivazione di fornire un framework comune condiviso in tutto l’ecosistema.
La documentazione del pacchetto log/slog definisce il modello di record (ora, livello, messaggio, coppie chiave-valore) e gli handler integrati (TextHandler per key=value e JSONHandler per JSON delimitato da righe), e documenta l’integrazione di SetDefault con il pacchetto log classico.
Per la correlazione distribuita, la specifica W3C Trace Context standardizza la propagazione di traceparent e tracestate, e OpenTelemetry specifica che il suo SpanContext è conforme a W3C Trace Context ed espone TraceId e SpanId, rendendo la correlazione log-trace diretta quando è presente uno span.
Per il costo e le prestazioni dell’archiviazione dei log, la documentazione di Grafana Loki raccomanda fortemente etichette limitate e statiche e avverte sulle etichette ad alta cardinalità che creano troppi stream e un indice enorme, il che è direttamente rilevante quando si decide cosa diventa un’etichetta e cosa rimane come un campo JSON non indicizzato.