Observabilité d'une API Go : logs, métriques et traces reliés par un trace_id

Sommaire (8 parties)

Mille requêtes partent vers une petite API de factures écrite en Go. Une API est un programme qui répond à d'autres programmes par le web. Chaque demande qu'elle reçoit s'appelle une requête, par exemple « donne-moi la facture 1 ». Le programme qui envoie la requête s'appelle le client.

Sur ces mille requêtes, environ une sur vingt échoue exprès, et une autre sur vingt est ralentie exprès de 700 ms. C'est un banc d'essai. Il pose la question de tous les jours en production, c'est-à-dire sur le vrai service, face aux vrais clients. Quelles requêtes ont échoué, et où les lentes ont-elles perdu leur temps ?

Trois outils répondent, et chacun donne un morceau de la réponse :

  • les logs, des lignes que le programme écrit à chaque événement, disent ce qui s'est passé ;
  • les métriques, des compteurs mis à jour à chaque requête, disent combien de requêtes, combien d'erreurs et à quelle vitesse ;
  • les traces disent où le temps passe. Une trace est le trajet complet d'une requête, découpé en étapes chronométrées.

Trois morceaux séparés ne mènent pas loin. Une ligne de log doit conduire à la trace de la même requête. Un pic sur un graphique doit conduire aux traces lentes de ce moment-là. Le fil qui relie les trois est un numéro, le trace_id : l'identifiant unique de chaque trace. Le schéma ci-dessous montre ce fil.

Une ligne de log et un losange sur le graphique des durées portent le même trace_id, qui ouvre la même trace dans Jaeger Ligne de log "status":500, "trace_id":"4bf9…" Graphique des durées requête lente même trace_id même trace_id Trace dans Jaeger GET /invoices/{id} demo.delay store.Get
Schéma fait pour l'article. La ligne de log et le losange du graphique portent le même trace_id, qui ouvre la même trace. La trace montre trois étapes : la requête entière, le retard ajouté exprès (demo.delay), puis la lecture de la facture (store.Get).

Pour produire et ranger ces trois signaux, le dépôt fait tourner cinq programmes côte à côte :

  • l'API de factures, en Go, qui écrit ses logs et tient ses compteurs ;
  • Prometheus, un serveur qui vient lire les compteurs de l'API toutes les cinq secondes et les garde ;
  • le Collector, un relais qui reçoit les étapes des traces, trie les traces et transmet celles qu'il garde ;
  • Jaeger, un serveur qui stocke les traces et les affiche comme une frise chronologique ;
  • Grafana, l'outil qui dessine les tableaux de bord à partir de Prometheus et ouvre les traces de Jaeger.

L'ensemble se lance d'une seule commande, docker compose up, et s'appelle la pile. Les traces sont produites avec OpenTelemetry, un standard ouvert pour créer traces, métriques et logs, avec une bibliothèque par langage. Le Collector fait partie de ce standard.

Voici comment je m'y prends dans un petit dépôt public : le code source, publié sur GitHub. Le dépôt s'appuie sur trois livres, et chacun a des erreurs, rassemblées en fin d'article :

  • Mastering Go, 4e édition, de Mihalis Tsoukalos (Packt, 2024) ;
  • Modern REST API Development in Go, de Jesús Espino (Packt, 2025) ;
  • Cloud-Native Observability with OpenTelemetry, d'Alex Boten (Packt, 2022).
$ git clone https://github.com/ohugonnot/go-api-observability
$ cd go-api-observability && ./scripts/e2e.sh

ohugonnot/go-api-observability

Un clone, une commande, et tout se vérifie seul. Il faut seulement Docker, curl et jq. La commande lance le test de bout en bout : il démarre toute la pile, envoie mille requêtes et vérifie que les signaux se répondent.

  • VérifiéChaque ligne de log d'une requête porte le numéro de sa trace, sans le nom ni l'e-mail de la personne facturée. Test : TestLogsCarryTraceIDAndNoPersonalData.
  • VérifiéLes compteurs ont une courbe par route, pas une par facture. Test : TestRequestCounterUsesRoutePattern. Le test de bout en bout refuse aussi tout autre nom de route.
  • VérifiéUn client ne peut ni imposer son numéro de trace ni cacher une de ses erreurs 500. Tests : TestIncomingTraceparentBecomesALink, TestUnsampledTraceparentCannotHideA500.
  • VérifiéLe test de bout en bout exige que toutes les traces d'erreur et toutes les traces lentes soient gardées. Dernier passage : 58 sur 58, 64 sur 64, et 78 % de l'ensemble des traces jetées. Il tourne aussi sur GitHub à chaque envoi de code.
  • VérifiéChaque losange du graphique des durées ouvre une trace qui existe (4 sur 4 au dernier passage). La page des compteurs répond 404 aux clients de l'API.
  • VérifiéLes tests voient la casse : 54 modifications volontaires du code Go font chacune échouer un test. Même chose pour 18 modifications des règles d'alerte, qui préviennent quand les erreurs montent. Le script qui les applique est local et non publié.
  • Non vérifiéLa tenue en charge réelle et le passage à plusieurs instances de l'API. Un Collector en haute disponibilité, ou un vrai stockage pour Jaeger : ici, tout reste en mémoire.
  • Non vérifiéLe clic sur un losange dans l'interface de Grafana. Le lien vers la trace n'est vérifié que par les API de Grafana et de Prometheus.

slog en JSON : chaque ligne de log porte le trace_id

Commençons par les logs. Depuis la version 1.21, Go fournit le paquet log/slog. Ce paquet écrit des logs structurés : chaque ligne est un objet JSON, un format texte fait de champs nommés, comme "status":500. Un outil peut alors filtrer sur un champ au lieu de chercher dans du texte.

Une trace se découpe en étapes, appelées spans. Le trajet entier est la trace, chaque étape du trajet est un span. Les spans forment un arbre : le span de la requête est le parent, celui de la lecture de la facture est son enfant. Chaque span a son numéro, le span_id, et tous les spans d'une trace partagent le même trace_id.

En Go, une requête transporte avec elle un contexte (context.Context) : un sac de valeurs qui la suit de fonction en fonction. OpenTelemetry range le span en cours dans ce sac.

Le dépôt emballe le formateur JSON de slog dans un petit type, traceHandler. Avant d'écrire une ligne, sa méthode Handle cherche un span dans le contexte. S'il y en a un, elle ajoute deux champs, trace_id et span_id, puis passe la ligne au formateur JSON habituel :

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
	if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
		r.AddAttrs(
			slog.String("trace_id", sc.TraceID().String()),
			slog.String("span_id", sc.SpanID().String()),
		)
	}
	return h.Handler.Handle(ctx, r)
}

Fichier complet : logging.go, lignes 19 à 27

La condition sc.IsValid() compte : une ligne écrite hors de toute requête n'a pas de span, et ne reçoit donc pas de champ vide. La dernière ligne, h.Handler.Handle(ctx, r), rend la main au formateur JSON d'origine.

Le piège pratique est ailleurs : les champs n'arrivent que si le code passe le contexte. Il faut écrire log.InfoContext(ctx, …) et non log.Info(…), sinon traceHandler ne trouve aucun span.

Métriques Prometheus : un label par route, jamais par URL

Passons aux métriques. L'API publie ses mesures en texte à une adresse, /metrics, et Prometheus vient les lire toutes les cinq secondes. Le dépôt tient deux mesures par requête :

  • un compteur du nombre de requêtes, avec leur code de réponse, où se lisent le débit et le taux d'erreur ;
  • un histogramme des durées, qui range chaque requête dans une tranche, de 0,5 ms à 5 s.

Ces trois questions, débit, erreurs et durée, forment la méthode dite RED, pour Rate, Errors, Duration.

Chaque mesure porte des labels : des étiquettes comme la méthode, la route et le code de réponse. Chaque combinaison de labels crée une courbe à part, que Prometheus appelle une série. Par exemple :

  • method="GET", route des factures, code 200 : une série ;
  • même méthode, même route, code 404 : une deuxième série ;
  • method="POST", route de création, code 201 : une troisième.

Le piège arrive quand un label prend trop de valeurs. Avec l'URL complète en label, chaque facture a sa série : /invoices/1, /invoices/2, et ainsi de suite. Un million de factures donnent un million de séries pour un seul compteur. Ce nombre de valeurs différentes s'appelle la cardinalité. Prometheus garde chaque série en mémoire : sans borne, sa mémoire grossit avec la cardinalité.

Modern REST API Development in Go décrit très bien ce piège en leçon 64 (les livres sont découpés en leçons numérotées). Jesús Espino écrit que « the abuse of labels can lead to inefficiencies » : un label mal choisi coûte cher. Son exemple est un label par identifiant de requête, qui mènerait vite à des millions de séries.

Deux leçons plus loin, son code de mesure, prévu pour toutes les routes, prend pourtant r.URL.Path comme label (leçon 66). r.URL.Path est le chemin exact de l'URL, numéro compris. Or son API a des routes comme GET /lists/{id}, où {id} est la place du numéro de liste. Chaque liste crée donc sa série : exactement le piège que le livre décrit.

Le remède est le motif de route : l'URL avec la place du numéro, et non le numéro lui-même. Depuis Go 1.23, le routeur standard, le code qui choisit quelle fonction répond à quelle URL, écrit ce motif dans le champ r.Pattern. Pour une facture, le motif vaut GET /invoices/{id}, quel que soit le numéro.

La fonction ci-dessous en tire le label de route. Si aucun motif n'a reconnu la requête, elle renvoie unmatched. Sinon, strings.Cut coupe le motif au premier espace et garde le chemin, sans la méthode :

func routeLabel(r *http.Request) string {
	if r.Pattern == "" {
		return "unmatched" // 404/405: bounded, whatever the URL
	}
	if _, path, ok := strings.Cut(r.Pattern, " "); ok {
		return path // "GET /invoices/{id}" -> "/invoices/{id}"
	}
	return r.Pattern
}

Fichier complet : metrics.go, lignes 137 à 145

Les commentaires du code le disent en anglais : unmatched couvre les réponses 404 et 405, quelle que soit l'URL. Un client peut envoyer n'importe quelle URL ; sans cette branche, chaque URL inventée créerait sa série.

Le test suivant envoie quatre requêtes : deux sur la facture 1, une sur la facture 42 qui n'existe pas, une sur une URL inconnue. Il écrit ensuite les séries attendues, au format texte de /metrics, et les compare aux compteurs réels :

	app.do("GET", "/invoices/1", "")
	app.do("GET", "/invoices/1", "")
	app.do("GET", "/invoices/42", "") // 404
	app.do("GET", "/does/not/exist", "")

	want := `
# HELP http_requests_total HTTP requests by method, route pattern and status code.
# TYPE http_requests_total counter
http_requests_total{code="200",method="GET",route="/invoices/{id}"} 2
http_requests_total{code="404",method="GET",route="/invoices/{id}"} 1
http_requests_total{code="404",method="GET",route="unmatched"} 1
`
	if err := testutil.GatherAndCompare(app.reg, strings.NewReader(want), "http_requests_total"); err != nil {
		t.Error(err)
	}

Fichier complet : metrics_test.go, lignes 21 à 35

Les lignes # HELP et # TYPE décrivent le compteur. Les trois lignes suivantes sont les trois séries, avec leur valeur à la fin. Aucune ne contient un numéro de facture. Remettre r.URL.Path à la place du motif fait échouer ce test : c'est l'une des 54 modifications volontaires de l'encart.

Métriques runtime : les mesures que Go fournit déjà

Les compteurs de requêtes disent ce que fait l'API. Reste ce que fait le programme Go lui-même. Le moteur d'exécution de Go, le runtime, gère la mémoire et le ramasse-miettes, qui libère la mémoire inutilisée. Il publie ses propres mesures dans le paquet runtime/metrics.

La bibliothèque Prometheus pour Go sait les publier sans copie à la main. La fonction ci-dessous crée le registre, la liste des mesures que /metrics publiera, et y inscrit deux collecteurs :

func newRegistry() *prometheus.Registry {
	reg := prometheus.NewRegistry()
	reg.MustRegister(
		collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
		collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(
			collectors.MetricsGC, collectors.MetricsMemory, collectors.MetricsScheduler,
		)),
	)
	return reg
}

Fichier complet : metrics.go, lignes 47 à 56

Le premier collecteur mesure le processus vu du système : processeur, mémoire, fichiers ouverts. Le second lit runtime/metrics. L'option WithGoCollectorRuntimeMetrics choisit trois familles : le ramasse-miettes, la mémoire et l'ordonnanceur, la partie du runtime qui répartit le travail entre les cœurs. Ces séries arrivent sur le même /metrics que les compteurs de requêtes.

OpenTelemetry : une trace suit la requête d'un bout à l'autre

Place aux traces. Le dépôt utilise OpenTelemetry pour elles, et pour elles seules. Le trace_id que slog écrit plus haut vient déjà de là : il est lu dans le span qu'OpenTelemetry a rangé dans le contexte.

OpenTelemetry a aussi son propre système de logs, que le dépôt n'utilise pas. En octobre 2026, la documentation officielle classe les logs Go en « Release candidate » : une version presque finale, pas encore déclarée stable. Les traces et les métriques, elles, sont « Stable ». Le dépôt garde donc slog pour les logs.

OpenTelemetry coupe sa bibliothèque en deux moitiés. La première, qu'il appelle API (rien à voir avec notre API de factures), sert à créer des spans dans le code. Créer ces spans s'appelle instrumenter le code. La seconde moitié, le SDK, décide quels spans garder et où les envoyer. On peut voir la première comme une prise murale, et le SDK comme l'appareil qu'on y branche.

Cette séparation, Cloud-Native Observability with OpenTelemetry en fait un principe, en leçon 14. « A user who instruments their code by using the API and does not configure the SDK will not see any telemetry produced by design. » La telemetry, ou télémétrie, désigne toutes les données d'observation : logs, métriques et traces. Du code instrumenté sans SDK branché tourne normalement, mais rien n'en sort, et c'est voulu. Une bibliothèque peut donc créer ses spans sans imposer d'outil à ceux qui l'utilisent.

Le dépôt suit cette séparation. Parmi les fichiers de l'application, seul tracing.go importe le SDK, c'est-à-dire le charge comme dépendance. La couche de stockage n'utilise que l'API. Sa fonction Get commence par ouvrir un span enfant :

func (s *Store) Get(ctx context.Context, id string) (Invoice, error) {
	_, span := s.tracer.Start(ctx, "store.Get", trace.WithAttributes(attribute.String("invoice.id", id)))
	defer span.End()

Fichier complet : store.go, lignes 54 à 56

s.tracer.Start(ctx, …) reçoit le contexte de la requête, et c'est lui qui fait du span store.Get un enfant. Sans ce contexte, le span démarrerait une trace à part. La ligne defer span.End() ferme le span à la sortie de la fonction, ce qui fixe sa durée. Le premier résultat de Start, un nouveau contexte, est ignoré (_) : aucune étape plus fine ne suit.

Passer le numéro de trace d'un service à l'autre

Le span de la requête elle-même vient d'un emballage fourni par OpenTelemetry, otelhttp, placé autour de toutes les routes. Une vraie application compte souvent plusieurs services : des programmes séparés qui s'appellent entre eux. Une trace qui passe d'un service à l'autre doit garder son numéro.

Le service appelant écrit ce numéro dans un en-tête HTTP, une ligne de texte envoyée avant le contenu de la requête. L'en-tête s'appelle traceparent. Le W3C, l'organisme qui fixe les standards du web, le définit dans sa recommandation Trace Context. Un exemple tiré de cette recommandation : 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01.

Cet en-tête se lit en quatre morceaux séparés par des tirets. Ils sont écrits en hexadécimal, avec les chiffres de 0 à 9 et les lettres de a à f :

  • 00, la version du format ;
  • 32 caractères, le trace_id ;
  • 16 caractères, le span_id de l'appelant, qui deviendra le parent ;
  • 01, un drapeau, c'est-à-dire un interrupteur : 01 veut dire « l'appelant enregistre cette trace », 00 le contraire.

Cloud-Native Observability with OpenTelemetry insiste sur ce passage de relais, en leçon 25. « Failing to propagate the trace ID across the system means that requests will be broken into multiple traces ». Un service qui oublie l'en-tête coupe donc la trace en morceaux séparés.

Tail sampling : garder les traces utiles et jeter les autres

Garder toutes les traces coûte cher : chaque requête produit plusieurs spans à stocker. On n'en garde donc qu'une partie, et ce tri s'appelle l'échantillonnage. La décision peut se prendre à deux moments, en tête ou en queue de la trace :

  • le head sampling (« en tête ») décide au tout début, au premier span, au hasard : peu coûteux, mais aveugle ;
  • le tail sampling (« en queue ») décide à la fin, sur la trace entière : il garde chaque erreur, mais attend tout en mémoire.

Cloud-Native Observability with OpenTelemetry résume le défaut du premier, en leçon 174. « An inherent problem with head sampling is that the decision is made before all the information is available. » On choisit avant de savoir si la requête va échouer. Le même livre estime qu'en production, moins de 10 % des données suffit souvent à représenter l'ensemble (leçon 179). Ce repère date de 2022, et ce n'est pas une règle.

Dans le dépôt, le tail sampling se fait dans le Collector. Le Collector reçoit tous les spans et attend cinq secondes que chaque trace soit complète. Il applique ensuite trois règles, et une trace est gardée dès qu'une règle dit oui :

    policies:
      - name: errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: slow
        type: latency
        latency:
          threshold_ms: 500
      - name: ten-percent-of-the-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

Fichier complet : otel-collector/config.yaml, lignes 22 à 34

Les trois règles se lisent ainsi :

  • errors garde les traces dont un span est marqué en erreur, ce que fait otelhttp pour chaque réponse 500 ;
  • slow garde les traces de 500 ms et plus ;
  • ten-percent-of-the-rest garde 10 % de toutes les autres, au hasard.

Le calcul attendu : 5 % d'erreurs et 5 % de lentes, soit 10 % de traces gardées d'office. S'y ajoutent 10 % des 90 % restants, soit 9 %. Au total, environ 19 % gardées et 81 % jetées.

Le test de bout en bout mesure le résultat. Au dernier passage, Jaeger contenait 58 traces d'erreur sur 58 et 64 traces lentes sur 64. Aucune trace d'erreur ni aucune trace lente n'a donc été perdue. Le Collector avait jeté 78 % des traces, assez près des 81 % attendus pour un tirage au hasard.

Ce test n'a pas toujours mesuré la bonne chose. Sa première version comptait les traces dans Jaeger avant que le Collector ait fini de trancher. Elle trouvait parfois 43 traces d'erreur sur 53, et passait quand même, car elle n'exigeait que 80 %. La version actuelle attend que les 1 000 traces soient décidées, puis exige toutes les erreurs et toutes les lentes.

Un piège à connaître : mieux vaut ne pas combiner un head sampling sous 100 % avec le tail sampling. Le head sampling jette des traces avant que le Collector sache qu'elles ont échoué. Dans le dépôt, l'API garde donc 100 % des traces, et seul le Collector trie.

Du pic sur le graphique à la trace lente

Reste le losange du schéma d'ouverture. Prometheus sait attacher à une mesure de durée le trace_id de la requête mesurée. Cette mesure annotée s'appelle un exemplar, et Grafana l'affiche comme un losange sur la courbe des durées. Grafana sait ouvrir dans Jaeger la trace qui porte ce numéro.

Le code ne pose un exemplar que sur les requêtes que le Collector garde à coup sûr. Le seuil des lentes est donc écrit une seconde fois, côté Go. Le commentaire, en anglais, renvoie à la règle slow du Collector :

// slowThreshold must match the "slow" latency policy (threshold_ms: 500) in
// otel-collector/config.yaml: such traces are always kept by the Collector.
const slowThreshold = 500 * time.Millisecond

Fichier complet : metrics.go, lignes 66 à 68

slowThreshold vaut 500 ms, comme le threshold_ms: 500 de la règle slow plus haut : c'est le même seuil, écrit deux fois. Le code ci-dessous l'utilise au moment d'enregistrer la durée :

	obs := m.duration.WithLabelValues(method, route)
	sc := trace.SpanContextFromContext(r.Context())
	// Exemplar only for requests the Collector always keeps (5xx or slow):
	// a fast 200 would mostly link to a trace dropped by tail sampling.
	if eo, ok := obs.(prometheus.ExemplarObserver); ok && sc.IsSampled() &&
		(status >= 500 || elapsed >= slowThreshold) {
		eo.ObserveWithExemplar(elapsed.Seconds(), prometheus.Labels{"trace_id": sc.TraceID().String()})
	} else {
		obs.Observe(elapsed.Seconds())
	}

Fichier complet : metrics.go, lignes 114 à 123

Le if pose deux questions. La première, obs.(prometheus.ExemplarObserver), vérifie que l'histogramme accepte les exemplars. La seconde exige une trace enregistrée, puis une erreur 500 ou plus, ou une durée d'au moins slowThreshold. Si tout est vrai, ObserveWithExemplar enregistre la durée avec le trace_id. Sinon, Observe l'enregistre seule.

Pourquoi pas un exemplar sur chaque requête ? Les commentaires du code répondent : une requête rapide et réussie n'a qu'une chance sur dix d'être gardée. Son exemplar mènerait le plus souvent à une trace jetée.

Ce qui casse : une mise à jour de Jaeger

Le dépôt fige Jaeger en version 2.20.0 : il demande cette version précise, et pas la plus récente. La raison mérite d'être racontée. Les notes de la version 2.21.0 annoncent : « remove v1 http endpoints the ui no longer calls ». Jaeger a donc retiré d'anciennes adresses HTTP que sa propre interface n'appelait plus.

Grafana 13.2 lit Jaeger par ces anciennes adresses. Avec Jaeger 2.22.0, l'adresse /api/services répond 404, et Grafana déclare Jaeger injoignable (constaté le 28 octobre 2026). Un robot de mises à jour, Dependabot, a proposé ce passage à 2.22.0 sur le dépôt. Les tests que GitHub relance à chaque proposition ont échoué, à l'étape « jaeger not ready » du test de bout en bout.

Les tests du code Go seuls n'auraient rien vu : chaque pièce marche seule. Seul le test de bout en bout, qui fait parler les pièces entre elles, voit la panne.

Ce que les trois livres ne font pas : protéger ce qu'on observe

Observer une API ajoute des risques : une adresse de plus, des données recopiées, un en-tête de plus à croire. Les trois livres en traitent peu. Le dépôt ajoute quatre protections.

Servir /metrics sur un port interne

Un serveur écoute sur un port, un numéro qui sert de porte d'entrée sur la machine : l'API répond sur le port 8080. /metrics révèle la version de Go, la mémoire et la liste des routes. Rien de tout cela ne regarde les clients.

Modern REST API Development in Go montre, en leçon 64, que /metrics s'ajoute partout où l'API répond. « Wherever you expose your API, you can expose your metrics using the promhttp.Handler. » Dans son code, /metrics est servi sur le même port que l'API.

Mastering Go laisse ouverts les ports de sa pile de démonstration (application, Prometheus, Grafana). Le livre se contente d'une mise en garde, en leçon 134. Les joindre depuis une autre machine « is not very secure and should be blocked by a firewall ». Un firewall, ou pare-feu, est un filtre qui décide ce qui peut entrer sur la machine.

Le dépôt ouvre deux serveurs, avec deux ports. Le premier sert l'API à tous. Le second ne sert que /metrics :

	reg := newRegistry()
	public := newServer(cfg.Addr, newHandler(log, tp, reg, cfg.DemoFaults))
	// /metrics lives on a separate, internal listener: it is not part of the
	// public API, it reveals internals, and scraping must not depend on the
	// public port's routing, auth or rate limits.
	internal := newServer(cfg.MetricsAddr, metricsHandler(reg))

Fichier complet : main.go, lignes 84 à 89

Le commentaire du code donne trois raisons. /metrics ne fait pas partie de l'API publique, et il révèle l'intérieur du programme. Enfin, sa lecture ne doit pas dépendre des règles du port public. Les deux serveurs partagent le même registre, reg, mais pas la même adresse. Par défaut, le second écoute sur 127.0.0.1:9091. L'adresse 127.0.0.1 désigne la machine elle-même : personne d'autre ne peut la joindre. Le test de bout en bout vérifie que /metrics répond 404 sur le port public.

Borner chaque label

Le motif de route borne déjà le label de route. Reste la méthode HTTP, que le client choisit librement. Une requête envoyée avec une méthode inventée, comme BREW, reçoit le label OTHER : c'est le test TestUnknownMethodIsBounded. Chaque label dont un client choisit la valeur doit ainsi avoir une liste fermée de valeurs.

Garder les données personnelles hors des logs et des traces

Une facture contient le nom et l'e-mail de la personne facturée. Ce sont des données personnelles, et leur place n'est ni dans un log ni dans une trace. Cloud-Native Observability with OpenTelemetry le dit sans détour, en leçon 55, à propos des PII, les données qui identifient une personne. « Whenever possible, save yourself the trouble and remove all PII from the telemetry. » En français : quand c'est possible, retirer toutes les données personnelles de la télémétrie, et s'éviter ainsi des ennuis.

Modern REST API Development in Go fait l'inverse : un de ses logs écrit l'adresse IP et l'utilisateur, sans un mot sur leur nature. Ma fiche de Programmation système en Go d'Alex Rios cite ce qu'on n'écrit jamais dans un log : mots de passe, données personnelles.

Dans le dépôt, la vraie défense est de ne pas loguer ces champs. Le paquet slog cherche une méthode LogValue sur toute valeur qu'on lui passe, et l'appelle à la place d'afficher la valeur brute. Celle de Invoice ne garde que trois champs :

func (i Invoice) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("id", i.ID),
		slog.Int64("amount_cents", i.AmountCents),
		slog.String("currency", i.Currency),
	)
}

Fichier complet : store.go, lignes 24 à 30

Ni Customer ni Email n'y figurent. Le test TestLogsCarryTraceIDAndNoPersonalData crée une facture au nom d'Alice et vérifie que ni « Alice » ni son e-mail n'apparaissent dans les logs.

Un filet de sécurité masque en plus les champs de log dont le nom ressemble à email, password ou token. Ce filet ne voit que le nom des champs : un secret glissé dans un message d'erreur passe quand même.

Ne pas croire le traceparent d'un client

Sur le port public, l'en-tête traceparent vient de n'importe qui. Le croire ouvre deux failles.

  • Un client pourrait reprendre le trace_id d'une autre requête. Nos spans iraient se ranger dans une trace qui n'est pas la leur, et les logs de deux requêtes porteraient le même numéro.
  • Un client pourrait envoyer le drapeau 00, « non enregistré ». Ses erreurs 500 disparaîtraient alors des traces et des exemplars.

La parade tient en une option de otelhttp, la dernière de ce bloc :

	return otelhttp.NewHandler(instrument(mux, m, log), "http.server",
		otelhttp.WithTracerProvider(tp),
		otelhttp.WithPropagators(propagation.TraceContext{}), // W3C traceparent
		// Public port: an incoming traceparent is untrusted. Each request starts
		// a new trace (sampled by our own policy) and only links to the caller's.
		// (WithPublicEndpoint is gone from otelhttp v0.72; this is its equivalent.)
		otelhttp.WithPublicEndpointFn(func(*http.Request) bool { return true }),
	)

Fichier complet : server.go, lignes 55 à 62

WithPublicEndpointFn reçoit une petite fonction, appelée pour chaque requête, qui répond « ce point d'entrée est public ». Ici, elle répond toujours true. Le commentaire précise que l'ancienne option, WithPublicEndpoint, n'existe plus dans otelhttp v0.72, et que celle-ci la remplace.

Chaque requête ouvre alors une nouvelle trace, échantillonnée selon nos règles. La trace du client n'est gardée que comme lien : une référence vers elle, pas un parent. Entre services internes qui se font confiance, on veut au contraire suivre l'en-tête, pour garder une seule trace d'un service à l'autre.

Conclusion

Les erreurs des trois livres tiennent chacune dans un chapitre. Un label mal choisi, ou un code qui contredit son propre texte, se repère en relisant ce chapitre. La liste complète est dans les sources, juste en dessous.

Les pannes les plus difficiles à voir sont ailleurs, dans les coutures entre les signaux. Le seuil de 500 ms est écrit deux fois, dans le code Go et dans le Collector. Si les deux valeurs divergent, chaque signal reste juste, et seul le passage de l'un à l'autre casse, sans bruit. Le premier test de bout en bout du dépôt est tombé dans une couture du même genre. Il comptait les traces avant que le Collector ait fini de trier. Seul un test qui suit un trace_id d'un bout à l'autre de la pile, au bon moment, voit ce genre de panne.

Les livres derrière cet article

  • Mastering Go, 4e édition Mihalis Tsoukalos, Packt 2024

    Repris : la lecture de runtime/metrics et l'exposition à Prometheus (leçons 130 à 133). Repris aussi : tester /metrics avec curl avant de brancher Prometheus (leçon 134).

    À corriger : le texte annonce la mesure /memory/classes/total:bytes, toute la mémoire que Go s'est réservée. Le code lit /memory/classes/heap/free:bytes, la mémoire libre pas encore rendue au système (leçon 133). Les deux ne mesurent pas la même chose. La boucle de démonstration force le ramasse-miettes avant chaque lecture, pour faire bouger la valeur. La pile Docker met le mot de passe Grafana en clair et publie ses ports.

  • Modern REST API Development in Go Jesús Espino, Packt 2025

    Repris : slog en JSON et les compteurs en _total (leçons 63 et 64). Repris aussi : la mise en garde sur les labels (leçon 64).

    À corriger : r.URL.Path en label, malgré sa propre mise en garde (leçon 66). Ses logs slog n'ont ni contexte ni trace_id. Le livre annonce « we will use OTel for traces », puis son code utilise OpenTracing, l'ancêtre d'OpenTelemetry, et l'ancienne bibliothèque de Jaeger (leçon 66). Les deux sont archivées sur GitHub. L'échantillonnage est fixé à 100 % sans explication. L'IP et l'utilisateur partent dans les logs.

  • Cloud-Native Observability with OpenTelemetry Alex Boten, Packt 2022

    Repris : la séparation entre API et SDK (leçon 14) et la propagation du contexte (leçon 25). Repris aussi : des labels aux valeurs « finite and knowable » (leçon 29) et les données personnelles (leçon 55). Repris enfin : head et tail sampling (leçons 172 à 179).

    À corriger : les exemples sont en Python et datent de 2021-2022. Les logs reposaient sur une version expérimentale, le livre le dit (leçon 88). Les exemplars étaient encore en développement (leçon 28). Les traces partent vers Jaeger dans l'ancien format propre à Jaeger. Jaeger reçoit aujourd'hui directement le format d'OpenTelemetry, comme dans le dépôt.

Aucun des trois n'entre dans ma sélection des meilleurs livres pour apprendre Go : ce sont des livres de spécialité. Le type traceHandler fonctionne grâce aux interfaces implicites de Go, expliquées dans ma fiche de Learning Go. Les middlewares, ces fonctions qui emballent chaque requête, ont leur propre article : l'ordre des middlewares Go en production. Un autre moyen de borner ce qu'un client envoie est dans le limiteur de débit par IP.

Lus sur ExpertEdge (liens affiliés) Les trois livres font partie de l'abonnement ExpertEdge de Packt. Avec le code ODILON50, l'abonnement annuel est à -50 % : Mastering Go, Modern REST API Development in Go et Cloud-Native Observability with OpenTelemetry. Je touche une commission si tu t'abonnes avec ce code. Mon avis sur les livres n'en dépend pas.

Commentaires (0)