LGTM Stack : l'observabilité complète de mon homelab
Après des mois à construire mon homelab à l’aveugle, j’ai décidé de mettre en place une observabilité digne d’un environnement de production. Le genre de setup qu’on trouve chez Scaleway, Datadog, ou dans n’importe quelle équipe SRE sérieuse.
Le résultat : une stack LGTM complète avec un dashboard Golden Signals qui me donne une vue instantanée de la santé de toute mon infra.
C’est quoi la stack LGTM ?
LGTM, c’est l’acronyme de l’écosystème Grafana Labs :
L → Loki (Logs) "Le grep distribué pour vos logs"
G → Grafana (Visualisation) "Le cerveau qui affiche tout"
T → Tempo (Traces) "Le parcours complet d'une requête"
M → Mimir (Métriques) "Prometheus à l'échelle" (on utilise Prometheus directement)
C’est l’alternative open-source à Datadog, New Relic, ou Splunk. La grosse différence : tout est self-hosted, les données restent chez moi, et ça coûte 0 euros.
Pourquoi ces 3 piliers ?
L’observabilité repose sur 3 signaux complémentaires :
┌──────────────────────────────────────────────────────────────┐
│ OBSERVABILITÉ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MÉTRIQUES│ │ LOGS │ │ TRACES │ │
│ │ │ │ │ │ │ │
│ │ "QUOI" │ │ "POURQUOI│ │ "OÙ" │ │
│ │ │ │ │ │ │ │
│ │ Combien │ │ Qu'est-ce│ │ Quel │ │
│ │ de req/s?│ │ qui s'est│ │ chemin │ │
│ │ Quelle │ │ passé? │ │ prend la │ │
│ │ latence? │ │ Quelle │ │ requête? │ │
│ │ % erreur?│ │ erreur? │ │ Où est │ │
│ │ │ │ │ │ le goulot│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┴──────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ GRAFANA │ │
│ │ (Dashboard) │ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
Un exemple concret :
- Les métriques me disent : “le taux d’erreur vient de passer à 45%”
- Les logs me disent : “erreur 500 : connection refused to Redis”
- Les traces me disent : “la requête
POST /api/v1/ai/chatattend 30s sur l’appel Redis avant de timeout”
Sans les 3 ensemble, on est aveugle.
Architecture déployée
Voici ce qu’on a déployé dans le cluster :
┌─────────────────────────────────────────────────────────┐
│ Namespace: monitoring │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Prometheus │ │ Grafana │ │ Tempo │ │
│ │ (métriques) │ │ (visu) │ │ (traces) │ │
│ │ :9090 │ │ :3000 │ │ :4317 │ │
│ └──────┬──────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ┌──────┴──────┐ │ │ │
│ │ Alertmanager│ │ │ │
│ │ :9093 │ │ │ │
│ └─────────────┘ │ │ │
│ │ │ │
│ ┌─────────────┐ │ │ │
│ │ Loki ├───────┘ │ │
│ │ (logs) │ │ │
│ │ :3100 │ │ │
│ └─────────────┘ │ │
└───────────────────────────────────────┘ │
│
┌──────────────────────────────────────────────────────────┤
│ Namespace: console │
│ │
│ ┌─────────────────────┐ │
│ │ console-api (Go) │ │
│ │ │ │
│ │ GET /metrics ─────┼──── Prometheus scrape (pull) │
│ │ OTel traces ─────┼──── Tempo (gRPC push) │
│ │ stdout logs ─────┼──── Loki (via DaemonSet) │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────┘
1. Prometheus : les métriques
Comment ça marche
Prometheus fonctionne en mode pull : il va chercher les métriques à intervalle régulier (toutes les 15 secondes) en faisant un GET /metrics sur chaque target.
C’est l’inverse de Datadog/StatsD qui fonctionnent en push (l’app envoie les métriques). L’avantage du pull :
- Prometheus contrôle la fréquence → pas de surcharge
- Si une app est down, Prometheus le détecte (scrape échoue)
- On peut
curl /metricspour debugger directement
Les types de métriques
Prometheus a 4 types fondamentaux :
Counter - ne fait que monter (comme un compteur kilométrique)
tom_lab_console_http_requests_total{method="GET", path="/api/v1/apps", status="2xx"} 42
Pour obtenir un débit, on utilise rate() : “combien de requêtes par seconde ?”
Gauge - monte et descend (comme un thermomètre)
go_goroutines 20
tom_lab_console_active_sse_connections 3
Histogram - répartition des valeurs dans des tranches (buckets)
tom_lab_console_http_request_duration_seconds_bucket{le="0.005"} 12 # 12 requêtes en <5ms
tom_lab_console_http_request_duration_seconds_bucket{le="0.01"} 18 # 18 requêtes en <10ms
tom_lab_console_http_request_duration_seconds_bucket{le="0.025"} 25 # 25 requêtes en <25ms
tom_lab_console_http_request_duration_seconds_bucket{le="+Inf"} 30 # 30 requêtes au total
tom_lab_console_http_request_duration_seconds_sum 4.5 # somme des durées
tom_lab_console_http_request_duration_seconds_count 30 # nombre total
Grâce aux buckets, on peut calculer des percentiles : “99% des requêtes prennent moins de Xms”
Summary - similaire à l’histogram mais calcule les quantiles côté client (moins flexible, rarement utilisé)
Le code Go pour les métriques
Voici comment j’ai instrumenté le backend. J’utilise un registre custom au lieu du registre global par défaut :
// metrics.go - Création du registre et des métriques
func New(cfg Config) (Collector, error) {
// Registre custom (pas le global)
registry := prometheus.NewRegistry()
registry.MustRegister(collectors.NewGoCollector())
registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
c := &collector{registry: registry}
// Counter : nombre total de requêtes HTTP
c.httpRequestsTotal = promauto.With(registry).NewCounterVec(
prometheus.CounterOpts{
Namespace: "tom_lab",
Subsystem: "console",
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"}, // Labels
)
// Histogram : durée des requêtes HTTP
c.httpRequestDuration = promauto.With(registry).NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "tom_lab",
Subsystem: "console",
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets, // 5ms, 10ms, 25ms, 50ms...
},
[]string{"method", "path"},
)
return c, nil
}
Le middleware HTTP enregistre chaque requête automatiquement :
// middleware.go - Intercepte chaque requête pour mesurer
func HTTPMiddleware(collector Collector) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrapper pour capturer le status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
collector.RecordHTTPRequest(r.Method, r.URL.Path, wrapped.statusCode, duration)
})
}
}
Le bug du registre (et comment je l’ai trouvé)
Premier problème : après avoir tout déployé, le dashboard Grafana affichait “No data” partout. Les métriques existaient dans le code, mais Prometheus ne les voyait pas.
Le diagnostic :
# Prometheus scrape OK, target "up"
$ kubectl exec -n monitoring svc/prometheus -- wget -qO- \
'http://localhost:9090/api/v1/targets' | jq '.data.activeTargets[] | select(.labels.job=="console-api")'
# → state: "up", lastScrape: "2s ago"
# Mais les métriques custom n'existent pas
$ curl http://console-api:8080/metrics | grep tom_lab
# → rien !
# Seulement les métriques Go runtime
$ curl http://console-api:8080/metrics | head -5
# → go_gc_duration_seconds, go_goroutines, etc.
Le problème : Dans main.go, le handler /metrics utilisait promhttp.Handler() qui sert le registre global par défaut de Prometheus. Mais toutes mes métriques custom étaient enregistrées dans un registre custom (prometheus.NewRegistry()) via promauto.With(registry).
Deux registres, zéro communication :
Registre GLOBAL (défaut) Registre CUSTOM (le mien)
├── go_goroutines ├── tom_lab_console_http_requests_total
├── go_gc_* ├── tom_lab_console_http_request_duration_seconds
├── process_* ├── tom_lab_console_deployments_total
│ ├── tom_lab_console_ai_requests_total
│ └── ...
│
GET /metrics → promhttp.Handler()
→ sert le registre GLOBAL
→ métriques custom invisibles !
Le fix en 3 lignes :
// AVANT (main.go) - sert le mauvais registre
r.Handle("/metrics", promhttp.Handler())
// APRÈS - sert le bon registre via l'interface Collector
r.Handle("/metrics", metricsCollector.Handler())
// La méthode Handler() dans metrics.go
func (c *collector) Handler() http.Handler {
return promhttp.HandlerFor(c.registry, promhttp.HandlerOpts{})
}
J’ai aussi ajouté collectors.NewGoCollector() et NewProcessCollector() au registre custom pour ne pas perdre les métriques runtime Go.
Après le fix et redeploy :
$ curl http://console-api:8080/metrics | grep tom_lab | head -5
tom_lab_console_http_requests_total{method="GET",path="/api/v1/apps",status="2xx"} 1
tom_lab_console_http_requests_total{method="POST",path="/api/v1/auth/login",status="2xx"} 1
tom_lab_console_http_request_duration_seconds_bucket{method="GET",path="/api/v1/cluster/nodes",le="0.025"} 2
...
ServiceMonitor pour Prometheus Operator
Pour que Prometheus découvre automatiquement notre API, on utilise un ServiceMonitor (CRD de Prometheus Operator) :
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: console-api
namespace: monitoring
labels:
release: kube-prometheus-stack # Label que Prometheus Operator surveille
spec:
namespaceSelector:
matchNames: ["console"]
selector:
matchLabels:
app: console-api
endpoints:
- port: http
path: /metrics
interval: 15s
Prometheus Operator détecte ce ServiceMonitor, génère la config de scrape, et Prometheus commence à collecter nos métriques automatiquement.
2. OpenTelemetry : le tracing distribué
Pourquoi du tracing ?
Les métriques te disent “la latence est élevée”. Les logs te disent “il y a des erreurs”. Mais où exactement est le problème dans le parcours d’une requête ?
Le tracing répond à cette question en suivant chaque requête de bout en bout :
Requête: POST /api/v1/ai/chat
│
├── [Span 1] HTTP Handler (2.5s)
│ ├── [Span 2] Auth Middleware - ValidateToken (0.2ms)
│ ├── [Span 3] Redis GET conversation (1.2ms)
│ ├── [Span 4] Claude API Call (2.3s) ← le goulot !
│ └── [Span 5] Redis SET response (0.8ms)
│
Total: 2.5s
D’un coup d’œil, on voit que c’est l’appel à l’API Claude qui prend 95% du temps. Sans tracing, on aurait fouillé les logs pendant des heures.
Les concepts OTel
Trace : le parcours complet d’une requête à travers le système Span : une opération unitaire (un appel HTTP, une query Redis, un traitement) Span Context : l’identifiant unique (trace_id + span_id) qui lie les spans entre eux
Trace ID: abc123def456
│
├── Span ID: 001 (parent: none) → HTTP POST /api/v1/ai/chat
│ ├── Span ID: 002 (parent: 001) → Auth Middleware
│ ├── Span ID: 003 (parent: 001) → Redis GET
│ ├── Span ID: 004 (parent: 001) → Claude API
│ └── Span ID: 005 (parent: 001) → Redis SET
La propagation de contexte
En microservices, une requête traverse plusieurs services. Pour garder la trace, on propage le contexte via les headers HTTP (standard W3C TraceContext) :
Service A → Service B
Headers:
traceparent: 00-abc123def456-001-01
tracestate: ...
Chaque service lit le header, crée ses propres spans en rattachant au trace_id parent, et propage le contexte au service suivant.
Implémentation dans notre Go backend
L’initialisation du tracing :
// tracing.go - Pipeline OpenTelemetry
func Init(ctx context.Context, serviceName, tempoEndpoint string) (func(context.Context) error, error) {
// Connexion gRPC vers Tempo
conn, _ := grpc.NewClient(tempoEndpoint,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
// Exporter OTLP (OpenTelemetry Protocol)
exporter, _ := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
// Resource attributes (qui suis-je ?)
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName), // "tom-lab-console"
semconv.ServiceVersion("1.0.0"),
),
resource.WithHost(),
resource.WithProcess(),
)
// TracerProvider avec batch export
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter, sdktrace.WithBatchTimeout(5*time.Second)),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
// Définir comme TracerProvider global
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, // W3C standard
propagation.Baggage{},
))
return tp.Shutdown, nil
}
L’instrumentation est automatique grâce au middleware otelmux :
// main.go - Un seul middleware pour instrumenter toutes les routes
r := mux.NewRouter()
r.Use(otelmux.Middleware("tom-lab-console")) // ← Auto-trace chaque requête HTTP
Chaque requête HTTP génère automatiquement un span avec :
- Méthode HTTP, path, status code
- Durée
- Attributs du service (nom, version, host)
Le bug du schéma URL
À la première tentative, le tracing plantait au démarrage :
ERROR: conflicting Schema URL:
https://opentelemetry.io/schemas/1.39.0
and
https://opentelemetry.io/schemas/1.21.0
Le problème : resource.Merge(resource.Default(), customResource) essayait de fusionner deux resources avec des versions de schéma différentes. La resource par défaut utilisait le schéma OTel v1.39.0 (dernière version du SDK), mais semconv/v1.21.0 définissait le schéma v1.21.0.
Le fix : utiliser resource.New() au lieu de resource.Merge() pour éviter le conflit :
// AVANT - conflit de schéma
res, _ := resource.Merge(
resource.Default(), // ← schéma v1.39.0
resource.NewWithAttributes(semconv.SchemaURL, ...) // ← schéma v1.21.0
)
// APRÈS - pas de merge, pas de conflit
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
),
resource.WithHost(),
resource.WithProcess(),
)
Tempo : le backend de traces
Tempo est le backend Grafana Labs pour stocker les traces. Il est optimisé pour :
- Ingestion massive (OTLP gRPC)
- Stockage efficient (object storage ou local)
- Intégration native avec Grafana (clic sur un trace_id dans les logs → vue trace)
Déploiement simple dans le cluster :
apiVersion: apps/v1
kind: Deployment
metadata:
name: tempo
namespace: monitoring
spec:
template:
spec:
containers:
- name: tempo
image: grafana/tempo:latest
ports:
- containerPort: 4317 # OTLP gRPC (réception traces)
- containerPort: 3200 # HTTP API (query par Grafana)
3. Loki : les logs
Collecte des logs
Loki collecte les logs de tous les pods du cluster via un DaemonSet (un agent sur chaque nœud) qui lit les fichiers de log des containers.
Ce qui est génial avec Loki, c’est qu’on peut corréler logs et traces. Si notre application Go écrit le trace ID dans les logs :
logger.Info("request processed",
zap.String("trace_id", span.SpanContext().TraceID().String()),
zap.String("path", r.URL.Path),
zap.Int("status", statusCode),
)
Alors dans Grafana, on peut cliquer sur un log et sauter directement à la trace correspondante dans Tempo.
LogQL : le langage de requête
Comme PromQL pour les métriques, Loki a LogQL pour les logs :
# Tous les logs du console-api
{namespace="console", app="console-api"}
# Seulement les erreurs
{namespace="console"} |= "error" | json | level="error"
# Taux d'erreurs par seconde
count_over_time({namespace="console"} |= "error" [5m]) / count_over_time({namespace="console"} [5m])
4. Le Dashboard Golden Signals
C’est quoi les Golden Signals ?
Les 4 Golden Signals viennent du livre “Site Reliability Engineering” de Google. Ce sont les 4 métriques essentielles pour monitorer n’importe quel service :
| Signal | Question | Métrique |
|---|---|---|
| Traffic | Combien de demande je reçois ? | Requêtes par seconde |
| Latency | Combien de temps pour répondre ? | p50, p95, p99 de durée |
| Errors | Combien de requêtes échouent ? | Taux d’erreur 5xx (%) |
| Saturation | Mon service est-il saturé ? | Goroutines, CPU, mémoire |
Si ces 4 signaux sont verts, le service va bien. Si un seul est rouge, il y a un problème.
Les requêtes PromQL du dashboard
Traffic - Request Rate (req/s)
sum(rate(tom_lab_console_http_requests_total[5m]))
rate() calcule le taux de changement par seconde du counter sur une fenêtre de 5 minutes.
Traffic par endpoint
sum by (path) (rate(tom_lab_console_http_requests_total[5m]))
Le by (path) découpe le résultat par endpoint pour voir lequel reçoit le plus de trafic.
Latency - p50, p95, p99
histogram_quantile(0.99, sum(rate(tom_lab_console_http_request_duration_seconds_bucket[5m])) by (le))
histogram_quantile() utilise les buckets de l’histogram pour calculer : “99% des requêtes prennent moins de X secondes”.
Errors - Taux d’erreur (%)
100 * sum(rate(tom_lab_console_http_requests_total{status="5xx"}[5m]))
/
sum(rate(tom_lab_console_http_requests_total[5m]))
Division : requêtes 5xx / total des requêtes, multiplié par 100 pour avoir un pourcentage.
Saturation - Goroutines
go_goroutines{job="console-api"}
Les goroutines Go sont un excellent indicateur de saturation. Normalement ~20-50. Si ça monte à 5000+, il y a des requêtes qui s’accumulent.
Résultat dans Grafana
Le dashboard “Golden Signals - Tom Lab Console” avec 4 rangées :
┌─────────────────────────────────────────────────────────┐
│ TRAFFIC │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Request Rate │ │ By Endpoint │ │
│ │ 0.04 req/s │ │ /apps: 40% │ │
│ │ ___/── │ │ /auth: 30% │ │
│ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ LATENCY │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ p50/p95 │ │ p99 Latency │ │
│ │ p50: 12ms │ │ 2.3s │ │
│ │ p95: 450ms │ │ (login) │ │
│ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ ERRORS │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Error Rate │ │ By Status │ │
│ │ 0.0% │ │ 2xx: 85% │ │
│ │ (healthy) │ │ 4xx: 15% │ │
│ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ SATURATION │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Goroutines │ │ Concurrent │ │
│ │ 20 │ │ Deploys: 0 │ │
│ │ (normal) │ │ SSE: 0 │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
5. L’interface Collector en Go
Le pattern Interface + Implémentation
Un point d’architecture important : on ne manipule jamais les métriques Prometheus directement dans le code métier. On passe par une interface :
type Collector interface {
RecordHTTPRequest(method, path string, status int, duration time.Duration)
RecordK8sOperation(operation string, success bool, duration time.Duration)
RecordDeployment(chart string, success bool, duration time.Duration)
RecordAIRequest(action string, duration time.Duration)
RecordAITokens(action string, tokens int)
SetActiveConnections(count int)
SetConcurrentDeployments(count int)
RecordRedisOperation(operation string, success bool, duration time.Duration)
RecordNPMOperation(operation string, success bool, duration time.Duration)
Handler() http.Handler
Flush() error
}
Avec deux implémentations :
collector - la vraie implémentation avec Prometheus
func (c *collector) RecordHTTPRequest(method, path string, status int, duration time.Duration) {
c.httpRequestsTotal.WithLabelValues(method, path, statusToString(status)).Inc()
c.httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
}
noopCollector - ne fait rien (quand les métriques sont désactivées)
func (n *noopCollector) RecordHTTPRequest(method, path string, status int, duration time.Duration) {}
Pourquoi ce pattern ?
- Tests : dans les tests unitaires, on injecte
noopCollectorpour ne pas avoir besoin de Prometheus - Configuration : on peut désactiver les métriques avec
METRICS_ENABLED=false - Découplage : le code métier ne connaît pas Prometheus, juste l’interface
Collector - Extensibilité : on pourrait ajouter une implémentation Datadog sans changer le code métier
C’est le principe SOLID D (Dependency Inversion) : dépendre d’abstractions, pas d’implémentations.
6. En production : comment j’utilise tout ça
Scénario 1 : Debug d’un problème de latence
1. Dashboard Golden Signals → p99 latency spike à 30s
2. Panel "Latency par endpoint" → /api/v1/ai/chat est le coupable
3. Grafana → Explore → Tempo → chercher traces avec duration > 10s
4. Trace trouvée → le span "Claude API Call" prend 28s
5. → Problème côté API Claude, pas côté nous
Scénario 2 : Erreurs après un déploiement
1. Dashboard Golden Signals → Error rate passe à 15%
2. Panel "Errors par status" → 401 Unauthorized en masse
3. Grafana → Explore → Loki → {namespace="console"} |= "401"
4. Logs → "invalid token" sur toutes les requêtes API
5. → Le déploiement a régénéré les secrets JWT (pas en env var)
6. Fix : patcher le secret K8s avec JWT_SECRET stable
Scénario 3 : Saturation mémoire
1. Dashboard Golden Signals → go_goroutines monte à 2000
2. Panel "Active SSE Connections" → 200 (normalement < 10)
3. Grafana → Explore → Loki → {namespace="console"} |= "SSE"
4. Logs → connections SSE qui ne se ferment jamais
5. → Bug dans le frontend qui ne close pas les EventSource
Ce que j’ai appris
1. Le registre Prometheus, c’est important. J’ai perdu 2 heures à debugger pourquoi mes métriques n’apparaissaient pas. Le registre custom vs défaut, c’est un piège classique en Go.
2. OTel c’est puissant mais complexe. Le setup initial demande de comprendre les concepts (TracerProvider, Exporter, Resource, Propagator, Sampler). Une fois en place, c’est magique.
3. Les 3 piliers ensemble valent 10x chacun seul. Métriques seules = on sait QUOI. Logs seuls = on sait POURQUOI. Traces seules = on sait OÙ. Les 3 ensemble = on sait TOUT.
4. Les Golden Signals suffisent pour 95% des cas. Pas besoin de 50 dashboards. 4 métriques bien choisies couvrent l’essentiel.
5. L’observabilité doit être là dès le jour 1. J’aurais dû mettre ça en place bien plus tôt. Chaque incident qu’on debug sans observabilité, c’est du temps perdu.
Prochaine étape : ajouter des alertes automatiques (Alertmanager + Slack/Telegram) pour être prévenu quand un Golden Signal dépasse un seuil. Plus besoin de regarder le dashboard en permanence.
Mais ça, c’est pour un prochain article…