Un log n’a de valeur que le jour où quelqu’un doit le lire en urgence, à 3h du matin, pour comprendre pourquoi la prod est cassée.
Trop de logs sont souvent écrits pour se rassurer, pas pour aider celui qui les lira plus tard.
Ce guide rassemble ce qui fait qu’un log sert vraiment à quelque chose, plutôt que de juste grossir la facture de stockage.
Les exemples utilisent Go et log/slog, le logger structuré de la bibliothèque standard (depuis Go 1.21), parce que c’est ce que j’ai sous la main au quotidien, mais rien n’est spécifique au langage.
Mon usage a évolué avec le temps, et l’ensemble des erreurs remonté ici sont des erreurs que j’ai moi-même faites par le passé.
Les logs, c’est souvent l’une des choses les plus durs dont on ne parle pas assez souvent.👉 Cet article n’est donc pas un dogme, mais plutôt un retour d’expérience.
🎯 À quoi sert vraiment un log
Un log doit répondre à trois questions, sans que le lecteur ait à deviner :
- Quoi ? 👉 quel événement s’est produit ?
- Où / dans quel contexte ? 👉 quelle requête, quel utilisateur, quel service ?
- Quand et avec quelle gravité ? 👉 un fait normal, un avertissement, une erreur ?
Le test décisif
Un log qu'on ne peut ni chercher (par un identifiant, un statut, une durée) ni corréler avec d'autres lignes de la même requête n'aide personne 👉 si la seule façon d'exploiter un log est de le lire à l'œil dans un terminal, il ne passera pas l'échelle de la production.
🧱 Structurer plutôt que concaténer
Le premier réflexe à corriger : arrêter de construire des messages de log comme des phrases.
// ❌ Message en texte libre : les infos sont "cuites" dans une string
logger.Info(fmt.Sprintf("user %s updated order %s in %dms", userID, orderID, duration))
// ou log.Printf("user %s updated order %s in %dms", userID, orderID, duration)
Ce style de message a longtemps eu du sens : il se lit bien à l’œil.
Le problème arrive dès qu’on veut l’exploiter automatiquement : les données sont « cuites » dans la chaîne de caractères, et il faut une regex fragile pour en ressortir userID, orderID et la durée.
Avec les logs structurés, chaque donnée devient un attribut structuré, indépendant du texte du message. En Go, avec slog, cela donne :
// ✅ Message stable + attributs structurés
logger.Info("order updated","user_id", userID,"order_id", orderID,"duration_ms", duration)
En sortie JSON (slog.NewJSONHandler), ça donne :
{"time":"2026-07-21T10:00:00Z","level":"INFO","msg":"order updated","user_id":"u_42","order_id":"o_198","duration_ms":87}
Chaque champ devient filtrable et agrégeable par l’outil de collecte (Loki, Elasticsearch, CloudWatch Logs Insights, etc.), sans parsing applicatif.
🎚️ Choisir le bon niveau
slog définit quatre niveaux : Debug, Info, Warn, Error.
Le piège classique est de tout mettre en Info ou, à l’inverse, de crier au loup en Error pour un simple événement métier.
| Niveau | Ce qu’il signifie | Exemple |
|---|---|---|
Debug |
Détail utile en développement/investigation, jamais en fonctionnement normal | Valeur intermédiaire d’un calcul, requête SQL exécutée |
Info |
Événement métier ou technique attendu, qui fait partie du déroulement normal | Requête traitée, tâche planifiée exécutée |
Warn |
Anomalie qui ne casse rien, mais qui mérite d’être surveillée | Retry réussi, cache expiré, quota bientôt atteint |
Error |
Échec qui empêche de terminer une opération | Écriture en base échouée, appel downstream en timeout |
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, // Debug filtré en production
}))
⚠️ Réserver
Erroraux échecs qui nécessitent une action : s’il se déclenche en continu, il finit par masquer les vraies urgences.
🧵 Propager le contexte pour pouvoir corréler
Un incident touche rarement une seule ligne de log : il traverse plusieurs fonctions, parfois plusieurs services. Sans un identifiant commun, impossible de relier les lignes entre elles.
func handleOrder(ctx context.Context, logger *slog.Logger, orderID string) error {
// Logger enfant : trace_id / span_id porté par toutes les lignes qui en découlent
reqLogger := logger.With("trace_id", traceIDFromContext(ctx))
reqLogger.InfoContext(ctx, "order processing started", "order_id", orderID)
if err := charge(ctx, orderID); err != nil {
reqLogger.ErrorContext(ctx, "order charge failed",
"order_id", orderID,
"error", err,
)
return fmt.Errorf("charge order %s: %w", orderID, err)
}
reqLogger.InfoContext(ctx, "order processing completed", "order_id", orderID)
return nil
}
Ce qui compte ici :
logger.With(...)fige un attribut (trace_id) une fois, dans un logger dérivé : plus besoin de le répéter à chaque appel.InfoContext/ErrorContext(plutôt queInfo/Error) passent lectxau handler : c’est ce qui permet à un handler personnalisé d’extraire automatiquementtrace_id/span_idducontextsans que chaque appelant y pense.- Toutes les lignes émises pendant le traitement d’une requête partagent le même
trace_id: c’est ce fil qui permet de reconstituer un incident dans l’outil de logs.
Nous verrons dans un autre article comment gérer automatiquement
trace_id/span_idà l’aide d’OpenTelemetry pour tracer les requêtes dans un système distribué.
🔒 Ne jamais logger de données sensibles
Mots de passe, tokens, numéros de carte, etc : une fois dans les logs, ces données vivent potentiellement des mois, répliquées vers plusieurs systèmes de collecte, hors du contrôle de l’application.
// ❌ Le mot de passe atterrit tel quel dans les logs
logger.Info("login attempt", "email", email, "password", password)
slog permet de contrôler explicitement comment une valeur s’affiche, via l’interface slog.LogValuer :
type Credential string
// LogValue est appelée par slog au moment de sérialiser l'attribut :
// la valeur réelle n'est jamais construite ni exposée si elle n'est pas utilisée.
func (p Credential) LogValue() slog.Value {
return slog.StringValue("***********")
}
// ✅ p.LogValue() est utilisée automatiquement par le handler
logger.Info("login attempt", "email", email, "password", Credential(password))
👉 Le principe : identifier les types de données sensibles une bonne fois pour toutes (via un type dédié), plutôt que compter sur chaque développeur pour y penser à chaque appel de log.
Et l'interface fmt.Stringer ?
Rien n'empêche d'ajouter en plus un String() string sur Credential : les deux méthodes coexistent sans conflit, et ça couvre un chemin que LogValue() ne protège pas (un fmt.Println, un %v/%s glissé dans un fmt.Errorf, bref tout affichage qui ne passe pas par slog).
⚠️ Elle ne remplace pas LogValue() pour autant : encoding/json (donc JSONHandler) ignore complètement fmt.Stringer, il ne respecte que json.Marshaler/encoding.TextMarshaler. Sans LogValue(), la valeur réelle finirait sérialisée telle quelle en JSON, String() ou pas.
📢 Logger une erreur une seule fois
Une erreur qui remonte à travers plusieurs couches d’appel est souvent logguée à chaque étape : trois lignes de logs pour un seul échec, difficiles à distinguer de trois échecs différents.
// ❌ La même erreur est logguée trois fois en remontant la pile d'appel
func repository() error {
if err := db.Exec(query); err != nil {
logger.Error("db exec failed", "error", err)
return err
}
return nil
}
func service() error {
if err := repository(); err != nil {
logger.Error("service failed", "error", err) // doublon
return err
}
return nil
}
La règle : une erreur se propage avec %w (pour garder la chaîne de causalité), et se loggue à un seul endroit : la frontière qui ne la propage plus (un handler HTTP, un main, un consumer de message).
// ✅ Propagation silencieuse, avec contexte ajouté à chaque niveau
func repository() error {
if err := db.Exec(query); err != nil {
return fmt.Errorf("exec query: %w", err)
}
return nil
}
func service() error {
if err := repository(); err != nil {
return fmt.Errorf("service processing: %w", err)
}
return nil
}
// À la frontière : un seul log, avec la chaîne d'erreur complète
func httpHandler(w http.ResponseWriter, r *http.Request) {
if err := service(); err != nil {
logger.ErrorContext(r.Context(), "request failed", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
🗂️ Message stable, attributs variables
Un même type d’événement doit toujours produire le même message, quelle que soit la donnée.
C’est ce qui permet de compter, filtrer et grouper des occurrences dans un outil de log.
// ❌ Le message change à chaque appel : impossible de grouper les occurrences
logger.Info(fmt.Sprintf("processed order %s in %dms", orderID, duration))
// ✅ Message constant, la variation est dans les attributs
logger.Info("order processed", "order_id", orderID, "duration_ms", duration)
Avec la seconde forme, une requête du type « compter les order processed dont duration_ms > 500 » devient triviale.
Avec la première, il faut d’abord réapprendre à parser le message.
📤 Où écrire les logs
En production, une application ne devrait pas décider où finissent ses logs, juste les émettre.
// ✅ stdout/stderr : la plateforme (conteneur, systemd, orchestrateur) collecte
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
C’est le principe 12-factor : écrire sur stdout/stderr en flux non bufferisé, et laisser l’environnement d’exécution (Docker, Kubernetes, systemd) router ces flux vers le bon système de collecte. Gérer soi-même des fichiers de logs applicatifs (rotation, rétention, chemins) ajoute une responsabilité que l’application n’a pas à porter.
⚡ Le coût des logs
Logger n’est pas gratuit : sérialisation, écriture, I/O. Deux pièges reviennent souvent.
1. Construire l’attribut avant de savoir s’il sera utilisé
// ❌ Le calcul coûteux est fait même si le niveau Debug est désactivé
logger.Debug("payload received", "body", expensiveDump(payload))
expensiveDump(payload) s’exécute avant l’appel à Debug, que le niveau Debug soit actif ou non, parce que Go évalue tous les arguments d’un appel de fonction. Si le calcul est coûteux, il faut le protéger explicitement :
// ✅ Le calcul coûteux n'a lieu que si Debug est effectivement actif
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("payload received", "body", expensiveDump(payload))
}
2. Logger dans une boucle chaude
// ❌ Une ligne de log par itération, sur un lot de 10 000 éléments
for _, item := range items {
logger.Info("processing item", "item_id", item.ID)
}
Le volume produit noie l’information utile et peut représenter un coût d’ingestion non négligeable chez le fournisseur de logs. Préférer une ligne récapitulative, ou un échantillonnage :
// ✅ Une ligne récapitulative pour tout le lot
logger.Info("batch processed", "item_count", len(items), "duration_ms", duration)
📊 Tableau récapitulatif
| Mauvaise pratique | Bonne pratique |
|---|---|
| Message texte libre avec données concaténées | Message stable + attributs structurés |
Tout en Info (ou tout en Error) |
Niveau choisi selon la gravité réelle |
| Aucun identifiant commun entre les lignes | request_id/trace_id/span_id propagé via un logger dérivé |
| Mots de passe, tokens, PII en clair | Redaction explicite via slog.LogValuer |
| La même erreur logguée à chaque couche | Propagation avec %w, un seul log à la frontière |
| Message qui varie selon la donnée | Message constant, variation dans les attributs |
| Fichiers de logs gérés par l’application | Écriture sur stdout/stderr, collecte externe |
Calcul coûteux systématique pour un log Debug |
Calcul protégé par logger.Enabled(...) |
📝 En résumé
- Structurer — des attributs typés, pas des messages concaténés
- Calibrer le niveau —
Debug/Info/Warn/Errorselon la gravité réelle, pas par habitude - Corréler — un identifiant commun (
trace_id) propagé via un logger dérivé (With) et lecontext - Protéger — jamais de secret ni de donnée personnelle en clair, via une redaction explicite
- Ne pas dupliquer — propager l’erreur, logguer une seule fois à la frontière
- Garder un message stable — pour pouvoir compter et grouper les occurrences
- Laisser la plateforme collecter —
stdout/stderr, pas de gestion de fichiers applicative - Mesurer le coût — protéger les calculs coûteux, éviter le log par itération sur un gros volume
👉 Un bon log n’est pas celui qui en dit le plus, c’est celui qui permet de répondre à une question précise sans relire tout le code autour.
✅ Ecrire des logs n’est pas facile, mais le plus important est de les améliorer au fil du temps.

