📍 Article principal : Observabilité applicative en 2026 : OpenTelemetry, traces distribuées et stack LGTM — pour le contexte conceptuel et l’architecture d’ensemble.
Pourquoi instrumenter Go avec OpenTelemetry
Go est devenu un langage de choix pour les microservices à fort débit. Sa concision et sa performance native poussent à mettre en production des services qui traitent des dizaines de milliers de requêtes par seconde sur des ressources modestes. Cette densité opérationnelle rend l’observabilité indispensable : sans traces distribuées, un incident sur un service Go au cœur d’une chaîne devient un cauchemar à diagnostiquer.
OpenTelemetry pour Go diffère de Node et Python sur un point structurel : il n’y a pas de mécanisme d’auto-instrumentation par patching à la volée — Go est un langage compilé, on ne patche pas un binaire au lancement. À la place, l’écosystème fournit des bibliothèques d’instrumentation (otelhttp, otelgrpc, otelsql) que l’on câble explicitement dans le code. Le résultat est plus verbeux mais aussi plus contrôlé : on sait exactement ce qui est instrumenté et ce qui ne l’est pas.
Ce tutoriel construit un service HTTP Go minimal avec son TracerProvider et son MeterProvider configurés, ses routes instrumentées via otelhttp, et un span manuel pour une opération métier. À la fin, on a une chaîne complète qui exporte traces et métriques en OTLP gRPC vers un Collector local.
Prérequis
- Go 1.22 ou plus récent
- Un Collector OpenTelemetry local en écoute sur
127.0.0.1:4317 - Notions de base sur
net/httpet les modules Go - Temps estimé : 40 à 50 minutes
Étape 1 — Initialiser le module et installer les dépendances
On commence par créer un module Go et ajouter les paquets OpenTelemetry nécessaires. Trois familles à distinguer : l’API et le SDK (go.opentelemetry.io/otel et ...otel/sdk) qui sont stables en v1.x, les exporters OTLP gRPC pour les trois signaux, et les bibliothèques d’instrumentation contrib (otelhttp, otelgrpc) qui suivent leur propre cycle de versions.
mkdir otel-go-demo && cd otel-go-demo
go mod init otel-go-demo
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/sdk/metric \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc \
go.opentelemetry.io/otel/semconv/v1.27.0 \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
L’installation peut prendre une minute parce que go.opentelemetry.io/otel tire un graphe de dépendances assez large. Une fois terminée, le fichier go.sum est généré et toutes les versions sont figées. Si l’on veut bloquer une version précise (utile pour la reproductibilité), passer @v1.43.0 derrière le module dans la commande go get.
Étape 2 — Configurer le TracerProvider et le MeterProvider
L’API Go OTel exige une configuration explicite des providers. Le TracerProvider orchestre les traces : il combine une Resource qui décrit le service, un ou plusieurs SpanProcessor qui buffrent et exportent les spans, et un sampler qui décide ce qui est conservé. Le MeterProvider joue le même rôle pour les métriques avec un Reader qui lit l’état des instruments à intervalle régulier.
// otel.go
package main
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
)
func setupOTel(ctx context.Context) (func(context.Context) error, error) {
res, err := resource.Merge(resource.Default(),
resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName("otel-go-demo"),
semconv.ServiceVersion("1.0.0"),
))
if err != nil { return nil, err }
traceExp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithInsecure())
if err != nil { return nil, err }
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithBatcher(traceExp),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
metricExp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithInsecure())
if err != nil { return nil, err }
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
sdkmetric.WithInterval(15*time.Second))),
)
otel.SetMeterProvider(mp)
shutdown := func(ctx context.Context) error {
if err := tp.Shutdown(ctx); err != nil { return err }
return mp.Shutdown(ctx)
}
return shutdown, nil
}
Le pattern est verbeux mais instructif : chaque provider est construit avec sa Resource commune (qui identifie le service), son exporter, et sa configuration de processing. Le WithBatcher pour les traces utilise un BatchSpanProcessor qui groupe et envoie périodiquement, comportement adapté à la production. Le NewPeriodicReader pour les métriques exporte toutes les 15 secondes. Le propagator composite assure la compatibilité W3C Trace Context et Baggage pour la propagation inter-services.
La fonction shutdown retournée doit être appelée en fin de programme pour vider les buffers. C’est le seul moyen de garantir que les derniers spans et la dernière fenêtre de métriques sont bien exportés avant que le processus ne meure.
Étape 3 — Écrire l’application HTTP
Le service expose deux routes via net/http. La nuance importante avec Go est qu’on enveloppe le handler avec otelhttp.NewHandler qui produit automatiquement le span racine de la requête entrante avec les attributs sémantiques HTTP standard. Sans cette enveloppe, aucune trace n’est créée pour les routes — la conséquence directe de l’absence d’auto-instrumentation transparente en Go.
// main.go
package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"message": "hello"})
}
func jokeHandler(w http.ResponseWriter, r *http.Request) {
client := http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
Timeout: 5 * time.Second,
}
req, _ := http.NewRequestWithContext(r.Context(),
http.MethodGet, "https://icanhazdadjoke.com/", nil)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil { http.Error(w, err.Error(), 502); return }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}
func main() {
ctx := context.Background()
shutdown, err := setupOTel(ctx)
if err != nil { log.Fatal(err) }
mux := http.NewServeMux()
mux.Handle("/hello", otelhttp.NewHandler(http.HandlerFunc(helloHandler), "hello"))
mux.Handle("/joke", otelhttp.NewHandler(http.HandlerFunc(jokeHandler), "joke"))
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() { _ = srv.ListenAndServe() }()
// arrêt propre
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctxShut, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctxShut)
_ = shutdown(ctxShut)
}
Deux points méritent attention. Le client HTTP sortant utilise otelhttp.NewTransport qui propage le contexte de trace via l'en-tête traceparent. Sans cette enveloppe, l'appel à icanhazdadjoke serait visible dans Tempo seulement si l'API distante était elle-même instrumentée — autrement dit, on ne verrait pas la latence de l'appel sortant comme un span enfant. Le bloc d'arrêt propre garantit que le serveur termine les requêtes en cours et que shutdown vide les buffers OTel avant de rendre la main.
Étape 4 — Lancer et observer le trafic
On compile et on lance le binaire avec les variables d'environnement OTel pour pointer le bon endpoint Collector.
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
export OTEL_SERVICE_NAME=otel-go-demo
go run .
On déclenche du trafic dans un autre terminal :
curl http://127.0.0.1:8080/hello
curl http://127.0.0.1:8080/joke
Si tout est correctement câblé, le Collector reçoit un span pour /hello et deux spans pour /joke (un pour la requête entrante, un pour la requête sortante vers icanhazdadjoke). Le traceparent est propagé dans les en-têtes sortants — visible avec OTEL_LOG_LEVEL=debug ou en interceptant le trafic avec un proxy de debug.
Étape 5 — Ajouter un span manuel pour une opération métier
L'instrumentation HTTP couvre l'infrastructure mais pas la logique métier. Pour un calcul de scoring qu'on veut visualiser dans la trace comme un span dédié, on l'enveloppe explicitement. L'API Go pour les spans est concise et idiomatique.
// scoring.go
package main
import (
"context"
"math/rand/v2"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("otel-go-demo")
func scoreUser(ctx context.Context, userID string) (int, error) {
ctx, span := tracer.Start(ctx, "score.compute")
defer span.End()
span.SetAttributes(attribute.String("user.id", userID))
value := rand.IntN(1001)
span.SetAttributes(attribute.Int("score.value", value))
span.SetStatus(codes.Ok, "")
return value, nil
}
Le pattern tracer.Start + defer span.End() est l'idiome Go canonique : il garantit la fermeture du span même en cas de panique ou de retour anticipé. Les attributs typés (attribute.String, attribute.Int) sont préférés aux types arbitraires — ils permettent au backend de filtrer et d'agréger correctement.
On câble la fonction dans une nouvelle route :
// dans main.go
mux.Handle("/score/", otelhttp.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Path[len("/score/"):]
score, _ := scoreUser(r.Context(), userID)
json.NewEncoder(w).Encode(map[string]any{"user": userID, "score": score})
}), "score"))
Le contexte r.Context() contient le span racine HTTP. Quand scoreUser ouvre son propre span à partir de ce contexte, l'imbrication parent-enfant est automatique et la trace résultante affiche les deux niveaux dans Tempo.
Étape 6 — Émettre une métrique applicative
L'API metrics Go suit la même logique que les autres SDK : on récupère un Meter nommé du module, on crée des instruments typés (Counter, UpDownCounter, Histogram), et on les met à jour aux endroits pertinents. Les méthodes sont contextuelles : on passe le context.Context à Add ou Record, ce qui permet au SDK d'attacher les bons attributs de contexte (baggage, exemplars).
// scoring.go (extension)
import (
"go.opentelemetry.io/otel/metric"
)
var meter = otel.Meter("otel-go-demo")
var (
scoreCounter metric.Int64Counter
scoreHistogram metric.Int64Histogram
)
func init() {
scoreCounter, _ = meter.Int64Counter("app.score.computed",
metric.WithDescription("Number of scores computed"))
scoreHistogram, _ = meter.Int64Histogram("app.score.value",
metric.WithDescription("Distribution of computed score values"))
}
func scoreUser(ctx context.Context, userID string) (int, error) {
ctx, span := tracer.Start(ctx, "score.compute")
defer span.End()
span.SetAttributes(attribute.String("user.id", userID))
value := rand.IntN(1001)
scoreCounter.Add(ctx, 1)
scoreHistogram.Record(ctx, int64(value))
span.SetAttributes(attribute.Int("score.value", value))
return value, nil
}
Les instruments sont initialisés dans init() pour être prêts au moment où la première requête arrive. Le passage du contexte à Add et Record est non négligeable : c'est lui qui permet au SDK d'attacher des exemplars aux métriques, ce qui rend possible le saut depuis un point d'histogramme dans Grafana vers la trace correspondante. Cette corrélation métriques-traces est l'un des bénéfices subtils mais puissants de l'écosystème OTel.
Étape 7 — Vérifier la chaîne
On lance du trafic et on observe le Collector :
for i in $(seq 1 100); do
curl -s "http://127.0.0.1:8080/score/$i" > /dev/null
done
Cent appels génèrent suffisamment de signal pour observer dans Tempo l'arbre des spans score → score.compute, et dans Mimir la métrique app_score_computed_total qui s'incrémente par lots de 15 secondes. Si rien n'arrive, vérifier l'écoute du Collector sur 4317 et la cohérence du protocole gRPC. Le tutoriel Configurer un OpenTelemetry Collector couvre la mise en place côté infrastructure.
Erreurs fréquentes
Oublier d'envelopper le client HTTP sortant
Sans otelhttp.NewTransport, les appels sortants ne propagent pas le traceparent et n'apparaissent pas comme spans enfants. C'est la forme la plus subtile d'instrumentation incomplète : on a des traces, mais elles s'arrêtent à la frontière de chaque service au lieu de tisser un arbre complet.
Ne pas appeler shutdown() à l'arrêt
Le BatchSpanProcessor garde des spans en mémoire entre deux exports. Si le processus meurt sans shutdown(), ces spans sont perdus. Pour un job batch qui démarre, traite et s'arrête, c'est la cause classique des « traces qui n'apparaissent pas dans Tempo ». Toujours câbler le shutdown via signal.Notify et un defer propre.
Confondre v1.27 et anciennes versions du paquet semconv
Le paquet semconv évolue version par version. Mélanger des constantes semconv/v1.21.0 dans un projet qui pointe ailleurs peut produire des conventions inconsistantes côté backend. Choisir une version (la plus récente alignée avec ce que le backend supporte) et s'y tenir. La version utilisée doit aussi correspondre à la SchemaURL passée à la Resource.
Mettre les SDK logs en bêta en production critique
Au moment où ce texte est écrit, le SDK logs Go (go.opentelemetry.io/otel/sdk/log) n'est pas encore stabilisé selon les engagements du projet pour les langages non-stable, alors que les traces et métriques le sont. Pour les logs côté Go, l'approche éprouvée reste de logger via slog ou zap structurés en JSON, puis de laisser le Collector router ces logs vers Loki via un receiver de fichier ou stdin. La voie OTLP logs natif Go gagne en maturité mais demande de suivre les changelog de près.
Activer le sampling head-based à 1 %
Réflexe par mimétisme avec d'autres outils. En OTel, c'est dommageable parce qu'on perd 99 % des traces, y compris en erreur. Garder 100 % côté SDK et basculer le sampling tail-based dans le Collector quand le volume justifie l'effort de réduction.
Tutoriels associés
- Instrumenter Node.js avec OpenTelemetry SDK
- Instrumenter Python avec OpenTelemetry SDK
- Configurer un OpenTelemetry Collector
- 🔝 Retour à l'article principal — Observabilité applicative et stack LGTM
Ressources et références officielles
- OpenTelemetry Go — documentation : opentelemetry.io/docs/languages/go
- Dépôt principal : github.com/open-telemetry/opentelemetry-go
- Paquet
otelsur pkg.go.dev : pkg.go.dev/go.opentelemetry.io/otel - SDK trace : pkg.go.dev/go.opentelemetry.io/otel/sdk
- Instrumentations contrib : github.com/open-telemetry/opentelemetry-go-contrib
otelhttp: pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp- Conventions sémantiques : opentelemetry.io/docs/specs/semconv
- W3C Trace Context : w3.org/TR/trace-context
FAQ
Pourquoi pas d'auto-instrumentation comme Node ou Python ?
Go est un binaire compilé, on ne patche pas un binaire au runtime sans recompilation. L'instrumentation se fait par bibliothèques contrib explicitement câblées (otelhttp, otelgrpc, otelsql, etc.). Un projet expérimental d'auto-instrumentation eBPF existe (opentelemetry-go-instrumentation) mais reste hors du périmètre stable.
Quel est l'état de stabilité ?
Les paquets traces et métriques (otel, otel/trace, otel/metric, otel/sdk/trace, otel/sdk/metric) sont stables en v1.x. Les paquets logs (otel/log, otel/sdk/log) progressent vers la stabilité avec les engagements correspondants à venir.
Faut-il utiliser net/http ou un router tiers ?
Les deux fonctionnent. otelhttp instrumente n'importe quel http.Handler. Pour des routers comme chi ou gin, des middlewares dédiés existent dans opentelemetry-go-contrib. Choisir celui qui correspond à l'écosystème déjà en place.
Le surcoût en performance est-il sensible ?
Pour un service Go typique, l'overhead mesuré est de l'ordre de 1 à 3 % de CPU et 50 à 150 µs de latence ajoutée par requête, dominé par la sérialisation des spans en protobuf. À très haut débit, on peut activer le sampling tail-based dans le Collector pour réduire le coût d'export sans changer le code.
Comment instrumenter du code de bibliothèque qu'on ne maîtrise pas ?
Si une dépendance critique n'a pas d'instrumentation contrib, deux options : ouvrir un span manuel autour de chaque appel à cette dépendance dans le code applicatif (verbeux mais contrôlé), ou écrire un wrapper qui ajoute le span. La deuxième approche est plus propre quand l'API se prête à l'enveloppement.