Monitoring (Signoz + OpenTelemetry)
Instrumentation des apps, dashboards, alertes, runbooks d'incidents
Signoz self-hosté + OpenTelemetry natif dans toutes les apps. Corrélation traces ↔ logs ↔ metrics dans une seule UI (cf. ADR-08).
Stack
Package @bell/observability
Centralisé dans packages/observability/ :
packages/observability/src/
├── index.ts ← export initOtel(), logger, meter
├── tracer.ts ← BatchSpanProcessor + OTLPExporter setup
├── logger.ts ← pino + pino-opentelemetry-transport
├── meter.ts ← MeterProvider + business metrics
├── hash-email.ts ← helper anti-PII
└── instrumentations/
├── elysia.ts ← middleware tracing
├── drizzle.ts ← wrap queries en spans
├── bullmq.ts ← spans pour jobs
└── fetch.ts ← spans pour appels HTTP sortantsSetup
Chaque app l'importe au tout début du bootstrap :
import { initOtel } from "@bell/observability";
// AVANT tout autre import qui doit être instrumenté
await initOtel({
serviceName: "bell-server",
serviceVersion: process.env.APP_VERSION ?? "dev",
environment: process.env.DEPLOYMENT_ENV ?? "development",
});
import { app } from "@bell/api";
app.listen(3000);Instrumentation automatique
Elysia routes
Plugin Elysia qui crée un span par request :
export const otelPlugin = new Elysia({ name: "otel" })
.onRequest(({ request }) => {
const span = tracer.startSpan(`HTTP ${request.method}`, {
attributes: {
"http.method": request.method,
"http.url": request.url,
"http.route": new URL(request.url).pathname,
},
});
// ... attach to context
})
.onError(({ error }) => {
const span = getActiveSpan();
span?.recordException(error);
span?.setStatus({ code: SpanStatusCode.ERROR });
})
.onAfterResponse(({ set }) => {
const span = getActiveSpan();
span?.setAttribute("http.status_code", set.status ?? 200);
span?.end();
});Attributs automatiques : méthode HTTP, route, status code, user ID (si authentifié), organization ID.
Drizzle queries
Wrap chaque query en span :
export const drizzleWithOtel = new Proxy(db, {
get(target, prop) {
const method = target[prop];
if (typeof method === "function") {
return (...args: unknown[]) => {
const span = tracer.startSpan(`db.${String(prop)}`, {
attributes: { "db.system": "postgresql" },
});
try {
return method.apply(target, args);
} finally {
span.end();
}
};
}
return method;
},
});BullMQ jobs
Span par job :
worker.on("active", (job) => {
const span = tracer.startSpan(`job.${job.name}`, {
attributes: { "job.id": job.id, "job.queue": job.queueName, "job.attempt": job.attemptsMade },
});
jobSpans.set(job.id, span);
});
worker.on("completed", (job) => jobSpans.get(job.id)?.end());
worker.on("failed", (job, err) => {
const span = jobSpans.get(job.id);
span?.recordException(err);
span?.end();
});Fetch sortants
Auto-instrument via @opentelemetry/instrumentation-fetch. Chaque appel Mews / Stripe / Reacher apparaît comme un span enfant.
Business metrics
En plus des RED metrics techniques auto-générées, on trace des métriques métier :
import { meter } from "@bell/observability";
const checkInCounter = meter.createCounter("bell.check_in.completed", {
description: "Nombre de check-ins confirmés",
});
const checkInLatency = meter.createHistogram("bell.check_in.duration_ms", {
description: "Durée entre invited et arrived",
});
export async function confirmGuestArrival(opts: { ... }) {
const start = Date.now();
// ... logique
checkInCounter.add(1, { organizationId: opts.organizationId });
if (guest.checkInInvitedAt) {
checkInLatency.record(Date.now() - guest.checkInInvitedAt.getTime(), {
organizationId: opts.organizationId,
});
}
}Liste des métriques business
| Metric | Description | Labels |
|---|---|---|
bell.check_in.completed | Check-ins confirmés | org_id |
bell.check_in.duration_ms | Délai invited → arrived | org_id |
bell.bookings.created | Bookings créées | org_id, type (restaurant/room-service/laundry/spa) |
bell.ai.messages.sent | Messages AI envoyés | org_id, model |
bell.ai.tool_calls.total | Tool calls AI | tool, org_id |
bell.ai.cost_usd | Coût cumulé | org_id |
bell.emails.sent | Emails envoyés | template, verdict (reacher) |
bell.pms.sync.duration_ms | Durée full sync | provider, org_id |
bell.pms.sync.errors | Errors de sync | provider, org_id, error_type |
bell.payments.captured_total | Total captures en EUR | org_id |
bell.jobs.queue_size | Taille des queues | queue |
Logs structurés
Pino + transport OpenTelemetry :
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
formatters: {
level: (label) => ({ level: label }),
},
transport: process.env.NODE_ENV === "production"
? { target: "pino-opentelemetry-transport" }
: { target: "pino-pretty", options: { colorize: true } },
});Convention :
// Context structuré EN PREMIER, message ensuite
logger.info({ guestId, organizationId }, "Guest arrived");
// PAS de PII — hash les emails, ne log pas les passwords/tokens
logger.info({ userHash: hashEmail(user.email) }, "Login attempt");Niveaux :
error— quelque chose a cassé, intervention humaine possiblewarn— anomalie non bloquante (retry en cours, fallback actif)info— event métier (check-in confirmé, commande créée) — parcimonieuxdebug— dev uniquement, désactivé en prod
Chaque log porte automatiquement le trace_id courant → click dans Signoz → trace complète.
Dashboards de base
4 dashboards provisionnés au jour 1, versionnés en JSON dans packages/observability/dashboards/ et appliqués via l'API Signoz à chaque deploy.
1. Overview
- Requêtes/sec globales (par app)
- Taux d'erreur global (barre empilée 2xx/4xx/5xx)
- Durée p95 globale
- BullMQ queue sizes (4 queues)
- Active SSE connections
2. API par module
- Breakdown par route Elysia
- p50/p95/p99 par route
- Error rate par route
- Slowest endpoints top 10
3. PMS integrations
- Latence Mews p95 par endpoint
- Full sync duration par org (barre)
- Sync errors par org
- Bridge retries + DLQ count
- Webhook ingestion rate
4. Business
- Check-ins/jour par hôtel (stacked area)
- Revenue Stripe capturé par hôtel
- Commandes par service (donut)
- AI cost par hôtel (log scale)
- Satisfaction % (gauge)
Alertes critiques
Configurées au jour 1 via Alertmanager Signoz :
| Alerte | Condition | Canal | Sévérité |
|---|---|---|---|
| API error rate high | > 5 % 5xx sur 5 min | Slack #alerts + email on-call | critical |
| BullMQ queue backing up | > 100 jobs waiting > 5 min | Slack | warning |
| Worker down | heartbeat miss > 2 min | Slack + email | critical |
| Postgres connections | > 90 % du pool | Slack | warning |
| Mews sync fails | 3 échecs consécutifs par org | Slack + email admin | warning |
| Stripe webhook fail | > 0 fail signature | Slack + email | critical |
| Email bounce rate | > 5 % sur 1 hour | Slack | warning |
| AI cost spike | org > $50/jour | Slack + email admin | warning |
| Disk fill > 80 % | Postgres / ClickHouse | Slack + email | critical |
Canal principal : Slack #bell-alerts.
On-call : rotation hebdo HOAIY (tag Slack → notif push), email fallback si pas d'ack en 10 min.
Runbooks
Plus formalisé dans operations/runbooks/*.mdx (futur). Pour l'instant, les incidents courants sont listés ici.
Incident : "Queue BullMQ empilée"
- Ouvrir Signoz dashboard Overview → graph
queue_size - Identifier la queue qui spike (mews-sync / pms-bridge / email / stripe-webhook)
- Consulter les logs du worker associé :
apps/workerlogs filterqueue = ... - Si Mews down : attendre (retries exponentiels, 3 tentatives)
- Si SMTP lent : vérifier quota Gmail Workspace
- Si bug : ajouter un test qui reproduit, fix, déploy
Incident : "Guest coincé sur /auth/waiting"
- Vérifier le
check_in_statusdu guest en DB - Si
completed: rien à faire, en attente staff - Si
arrived: le middleware Next.js ne devrait pas bloquer → check cookies navigateur, refresh - Si NULL :
linkGuestToUsera échoué — vérifier que l'email guest existe dans le cardex
Incident : "Sync Mews échoue"
- Signoz dashboard PMS → latence + errors Mews
integration_sync_log: filtrer parerror IS NOT NULLderniers 24h- Si
401/403: credentials Mews expirés, contacter l'hôtel pour renouveler - Si
429: rate limit, réduire la concurrency BullMQ - Si
5xxMews : Mews down, attendre + vérifier mews status
Frontend (PWA + Dashboard)
Instrumentation client optionnelle via @opentelemetry/sdk-trace-web :
- Spans pour les navigations page (fetch), les appels API (eden), les erreurs non-catchées
- Envoyés vers le même OTLP collector (via CORS)
- Surtout utile pour tracer les latences ressenties par le guest
Décision : oui on instrumente mais sampling 10 % pour ne pas exploser ClickHouse. Config via @opentelemetry/sdk-trace-web + TraceSampler.
RGPD
- Traces et logs ne contiennent jamais email complet, téléphone, adresse
- Fonction
hashEmail()dans@bell/observabilitypour les cas où on a besoin d'identifier un user sans PII - Linter custom
no-pii-in-logsà écrire (Biome plugin ou règle ESLint) — détecte les patternslogger.*({ email: ... })
Rétention
- Traces : 7 jours (débug incidents récents)
- Logs : 30 jours
- Metrics : 90 jours (tendances, capacity planning)
Configurables via ClickHouse TTL policies, réglables sans redeploy.
Prod deploy
Signoz tourne sur un VPS dédié (Proxmox), pas sur le même hôte que Postgres/Redis pour isoler les SLA.
Ressources recommandées :
- 8 GB RAM
- 200 GB SSD (ClickHouse mange)
- 2 vCPU
Backup ClickHouse : snapshot hebdo sur R2 (mais les data d'observabilité sont considérées comme éphémères — on n'a pas besoin de les restaurer si on les perd).
Lien
- ADR-08 monitoring Signoz
- Concurrency — BullMQ et ses métriques
- Environments — Docker config Signoz