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 Error aux é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 que Info/Error) passent le ctx au handler : c’est ce qui permet à un handler personnalisé d’extraire automatiquement trace_id/span_id du context sans 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 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 niveauDebug/Info/Warn/Error selon la gravité réelle, pas par habitude
  • Corréler — un identifiant commun (trace_id) propagé via un logger dérivé (With) et le context
  • 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 collecterstdout/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.