Logging Strutturato in Go con slog per l'Osservabilità e l'Alerting
Log JSON interrogabili che si collegano alle tracce.
I log sono un’interfaccia di debugging che puoi utilizzare anche quando il sistema è in fiamme. Il problema è che i log in testo semplice invecchiano male: non appena hai bisogno di filtraggio, aggregazione e alerting, ti trovi a dover analizzare frasi.

Il logging strutturato è l’antidoto. Trasforma ogni riga di log in un piccolo evento con campi stabili, così gli strumenti possono cercare e aggregare in modo affidabile. Per comprendere come i log si colleghino a metriche, dashboard e alerting nel stack più ampio, consulta la Guida all’Osservabilità: Monitoraggio, Metriche, Prometheus & Grafana.
Cos’è il logging strutturato e perché scalabile
Il logging strutturato è un approccio in cui un record non è solo una stringa, ma un messaggio più attributi chiave-valore tipizzati. L’idea è noiosa nel modo migliore: una volta che i log sono leggibili dalle macchine, un incidente smette di essere una gara di grep.
Un confronto rapido:
Testo semplice (prima l’umano, ostile agli strumenti)
fallito il caricamento della carta user=42 amount=19.99 ms=842 err=timeout
Strutturato (prima lo strumento, ma ancora leggibile)
{"msg":"fallito il caricamento della carta","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}
In produzione, aiuta pensare ai log come a un flusso di eventi emesso dal processo, mentre il routing e l’archiviazione risiedono al di fuori dell’applicazione. Questo modello mentale ti spinge a scrivere un evento per riga e a mantenere gli eventi facili da trasferire e ri-elaborare.
Slog in Go come front-end condiviso per il logging
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 il logging strutturato nella libreria standard e formalizza una forma comune per i record di log: ora, livello, messaggio e attributi. Per un riepilogo conciso del linguaggio e dei comandi a supporto di questa guida, consulta il Cheat Sheet per Go.
Le parti chiave del modello sono:
Record
Un record è ciò che è successo. In termini di slog, contiene ora, livello, messaggio e un insieme di attributi. Crei record tramite metodi come Info e Error, o tramite Log quando vuoi specificare il livello esplicitamente.
Attributi
Gli attributi sono le coppie chiave-valore che rendono i log interrogabili. Se registri lo stesso concetto sotto tre chiavi diverse (user, userId, uid), ottieni tre dataset diversi. Chiavi coerenti sono dove si nasconde il vero valore.
Handler
Un handler è il modo in cui i record diventano byte. Il TextHandler integrato scrive l’output in formato chiave=valore, mentre JSONHandler scrive JSON a righe separate. Gli handler sono anche il luogo dove tendono a avvenire la redazione, la rinominazione delle chiavi e il routing dell’output.
Una funzione sottovalutata è che slog può posizionarsi davanti al codice esistente. Quando imposti un logger slog predefinito, le funzioni slog di alto livello lo usano, e il classico pacchetto log può essere reindirizzato verso di esso. Questo rende possibile la migrazione incrementale.
Gruppi
I gruppi risolvono il problema “ogni sottosistema usa id”. Puoi raggruppare un insieme di attributi per una richiesta (request.method, request.path) o namespacare un intero sottosistema con WithGroup in modo che le chiavi non collidano.
Configurazione slog orientata alla produzione
La seguente configurazione raggiunge gli obiettivi soliti.
Gli esempi usano un piccolo pacchetto logx; per sapere dove pacchetti del genere si trovano solitamente in un modulo reale, vedi Struttura del Progetto Go: Pratiche e Pattern.
- un evento JSON per riga
- log scritti su stdout per la raccolta
- metadati del servizio stabili allegati una volta
- logging consapevole del contesto per ID richiesta e traccia
- redazione centralizzata per chiavi sensibili
package logx
import (
"log/slog"
"os"
)
var level slog.LevelVar // default a INFO
func New() *slog.Logger {
opts := &slog.HandlerOptions{
Level: &level, // può essere cambiato a runtime
AddSource: true, // include file e riga quando disponibili
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Redazione centralizzata: coerente e difficile da bypassare per errore.
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 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 del “logging strutturato” sono fallimenti dello schema.
Campi essenziali che continuano a pagare l’affitto
Ogni backend log archivia un timestamp, un livello e un messaggio. Nella pratica, uno schema di applicazione utile aggiunge spesso un piccolo insieme di campi stabili:
- service, env, version
- component (o subsystem)
- event (un nome stabile per la cosa che è accaduta)
- request_id (quando esiste una richiesta)
- trace_id e span_id (quando esiste il tracciamento)
- error (stringa) e error_kind (cestino 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à usi 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 “come abbiamo chiamato questo campo nel servizio B”.
Alta cardinalità e perché i log diventano costosi
L’alta cardinalità significa “troppi valori univoci”. Va bene dentro un payload JSON, ma diventa doloroso quando un backend tratta alcuni campi come etichette indicizzate o chiavi di flusso. Gli ID utente, gli indirizzi IP, i token di richiesta casuali e gli URL completi tendono a esplodere nelle combinazioni.
Il risultato pratico è semplice: mantieni le etichette e le chiavi indicizzate noiose (service, environment, region), e tieni i campi ad alta cardinalità all’interno del payload strutturato per il filtraggio al momento della query.
Correlazione con ID richiesta e tracce
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 a minima attrito
Un ID richiesta è il ponte più semplice tra una richiesta in ingresso e tutto ciò che accade a causa di essa. Funziona spesso anche senza tracciamento distribuito, e rimane utile quando le tracce sono campionate.
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 tracce con W3C Trace Context e OpenTelemetry
W3C Trace Context definisce un modo standard per propagare l’identità della traccia (per HTTP, tramite traceparent e tracestate). OpenTelemetry si basa su questo in modo che gli ID traccia e gli ID span possano essere estratti dal contesto.
Questo esempio di middleware registra sia request_id che gli identificatori di traccia 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 in altri dati. La differenza in un incidente reale non è sottile.
Trasformare i log strutturati in segnali di monitoraggio e alerting
I log sono ottimi per rispondere a “cosa è successo”. L’alerting di solito riguarda “quanto spesso 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. I log strutturati rendono quel conteggio resiliente, perché si basa su un valore di campo piuttosto che su una corrispondenza testile fragile. Quando sei pronto per visualizzare ed esplorare quei segnali, Installa e Usa Grafana su Ubuntu: Guida Completa descrive una configurazione completa di Grafana che puoi puntare ai backend log e metriche comuni.
È anche qui che i livelli di log iniziano a contare. I log di debug sono spesso preziosi, ma sono anche dove si nascondono costi e rumore. Usare un livello dinamico (LevelVar) permette al sistema di rimanere silenzioso di default, consentendo comunque dettagli mirati quando necessario.
Considerazioni finali
Il logging strutturato in Go non è più un dibattito sulle librerie. La parte interessante è se i tuoi record di log sono coerenti, correlabili ed economici da archiviare.
Quando i tuoi log trasportano 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 di Go ha introdotto log/slog in Go 1.21 e ha enfatizzato che i log strutturati usano coppie chiave-valore in modo da poter 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 del record (time, level, message, coppie chiave-valore) e gli handler integrati (TextHandler per chiave=valore e JSONHandler per JSON a righe separate), e documenta l’integrazione 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-traccia 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 circa le 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 campo JSON non indicizzato.