Monitoring & observabilité
Stack self-host, Signoz (OpenTelemetry + ClickHouse), pino pour les logs structurés
Tout self-host, open source, déployé via Dokploy à côté de l'app. Signoz remplace l'ancienne stack Grafana/Loki/Prometheus/GlitchTip dans un seul paquet cohérent. On contrôle nos données et notre budget.
Vue d'ensemble
| Composant | Rôle | Image Docker |
|---|---|---|
| OTel Collector | Reçoit les exports OTLP (traces + metrics + logs), route vers ClickHouse | signoz/signoz-otel-collector:v0.144.2 |
| ClickHouse | Storage columnar des traces, metrics, logs | clickhouse/clickhouse-server:25.5.6 |
| Zookeeper | Coordination ClickHouse (cluster mode) | signoz/zookeeper:3.7.1 |
| Signoz UI | Dashboard, explorer, alertes, http://localhost:8080 | signoz/signoz:v0.119.0 |
Stack embarquée dans le docker-compose.dev.yml derrière le profile observability et dans docker-compose.prod.yml (always on). bun run docker:up lance tout d'un coup.
SDK côté app
Backend (apps/server)
Le SDK OTel est initialisé avant tous les autres imports dans src/index.ts :
import { shutdownOtel, startOtel } from "@/lib/otel";
startOtel(); // ← tout premier call, capture les spans dès le boot
import { app } from "@/app";
// …lib/otel.ts configure NodeSDK avec :
OTLPTraceExportergRPC versOTEL_EXPORTER_OTLP_ENDPOINT(defaulthttp://localhost:4317)OTLPMetricExportergRPC, export periodique toutes les 30 sPgInstrumentation, instrumente automatiquement toutes les queries Drizzle/pgPinoInstrumentation, injectetrace_idetspan_iddans chaque log pino, permet la corrélation logs ↔ traces dans Signoz
Un plugin Elysia (lib/otel-plugin.ts) crée un span HTTP par requête, nom <METHOD> <path>, attrs http.method, http.route, http.status_code, http.duration_ms. Instrumentation manuelle car Bun ne hook pas le module http de Node comme l'auto-instrumentation classique.
Frontend (apps/web)
Next.js 16 a un support natif via src/instrumentation.ts. On utilise @vercel/otel qui wire le SDK en une ligne :
import { registerOTel } from "@vercel/otel";
export function register() {
if (process.env.OTEL_ENABLED === "false") return;
registerOTel({
serviceName: process.env.OTEL_SERVICE_NAME ?? "reciprok-web",
});
}Exporte automatiquement : Server Actions, Route Handlers, fetch calls.
Métriques custom
Les métriques métier sont émises via le SDK OTel (meter reciprok.ai dans apps/server/src/modules/ai/ai.metrics.ts) :
| Métrique | Type | Attributs | Pourquoi |
|---|---|---|---|
reciprok.ai.tokens | counter | model, task, user_id, kind (input/output/cache_read/cache_write) | Suivi consommation tokens Claude par user/modèle |
reciprok.ai.cost_eur | histogram | model, task, user_id | Distribution p50/p95/p99 du coût par appel |
reciprok.ai.tool_calls | counter | tool, model | Quels tools Claude invoque le plus |
Les spans HTTP et DB sont ajoutés automatiquement par les plugins, pas besoin de metrics custom pour reciprok_http_requests_total ou reciprok_db_query_duration_seconds (déjà dans les traces).
Logs
Logger pino
apps/server/src/lib/logger.ts, pino JSON en prod (stdout), pretty-print coloré en dev, silent en test.
import { logger } from "@/lib/logger";
logger.info({ userId, requestId }, "request created");
logger.error({ err, payload }, "failed to parse webhook");Jamais de console.* dans apps/server/src/. Le logger est structuré, Signoz peut filtrer sur les clés.
Corrélation traces ↔ logs
PinoInstrumentation injecte automatiquement dans chaque ligne :
{
"level": 30,
"time": 1713345678,
"msg": "request created",
"userId": "...",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}Dans Signoz, cliquer sur un span ouvre les logs correspondants (même trace_id). Inverse : cliquer sur un log affiche la trace complète.
Alertes (configurées dans Signoz UI)
| Condition | Sévérité |
|---|---|
| Taux d'erreur HTTP 5xx > 5 % sur 5 min | Critical |
Latence p95 sur /api/requests/* > 2 s sur 10 min | Warning |
reciprok.ai.cost_eur cumulé > 200 €/mois | Warning |
reciprok.ai.cost_eur cumulé > 400 €/mois | Critical |
| Postgres connections > 80 % du max | Warning |
Healthcheck GET / KO sur 2 min | Critical |
Canaux : Discord équipe (Critical), email admin (Warning).
Rétention
- Traces : 15 jours
- Metrics : 30 jours
- Logs : 15 jours
Stockage ClickHouse local (disque VPS Hostinger, ~10 GB estimés).
Costs
Stack self-host sur le même VPS que l'app. Coût marginal : 0 €. ClickHouse est très efficace en compression, on tient facilement 30 j de trafic sur 10 GB.
Debug local
bun run docker:uplance toute la stack- UI Signoz sur http://localhost:8080 (compte admin créé au premier login)
- Les services
reciprok-serveretreciprok-webapparaissent dans Services après quelques requêtes - Explorer les traces : Traces → filter by
service.name - Métriques AI : Metrics → chercher
reciprok.ai.*
Voir aussi
operations/environments, où sont déployés les servicesoperations/costs, budget global et plafonds IAdecisions/ai-stack, model routing Sonnet/Haiku et prompt caching