My App

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 sortants

Setup

Chaque app l'importe au tout début du bootstrap :

apps/server/src/index.ts
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 :

packages/observability/src/instrumentations/elysia.ts
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 :

packages/api/src/modules/cardex/cardex.service.ts
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

MetricDescriptionLabels
bell.check_in.completedCheck-ins confirmésorg_id
bell.check_in.duration_msDélai invited → arrivedorg_id
bell.bookings.createdBookings crééesorg_id, type (restaurant/room-service/laundry/spa)
bell.ai.messages.sentMessages AI envoyésorg_id, model
bell.ai.tool_calls.totalTool calls AItool, org_id
bell.ai.cost_usdCoût cumuléorg_id
bell.emails.sentEmails envoyéstemplate, verdict (reacher)
bell.pms.sync.duration_msDurée full syncprovider, org_id
bell.pms.sync.errorsErrors de syncprovider, org_id, error_type
bell.payments.captured_totalTotal captures en EURorg_id
bell.jobs.queue_sizeTaille des queuesqueue

Logs structurés

Pino + transport OpenTelemetry :

packages/observability/src/logger.ts
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 possible
  • warn — anomalie non bloquante (retry en cours, fallback actif)
  • info — event métier (check-in confirmé, commande créée) — parcimonieux
  • debug — 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 :

AlerteConditionCanalSévérité
API error rate high> 5 % 5xx sur 5 minSlack #alerts + email on-callcritical
BullMQ queue backing up> 100 jobs waiting > 5 minSlackwarning
Worker downheartbeat miss > 2 minSlack + emailcritical
Postgres connections> 90 % du poolSlackwarning
Mews sync fails3 échecs consécutifs par orgSlack + email adminwarning
Stripe webhook fail> 0 fail signatureSlack + emailcritical
Email bounce rate> 5 % sur 1 hourSlackwarning
AI cost spikeorg > $50/jourSlack + email adminwarning
Disk fill > 80 %Postgres / ClickHouseSlack + emailcritical

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"

  1. Ouvrir Signoz dashboard Overview → graph queue_size
  2. Identifier la queue qui spike (mews-sync / pms-bridge / email / stripe-webhook)
  3. Consulter les logs du worker associé : apps/worker logs filter queue = ...
  4. Si Mews down : attendre (retries exponentiels, 3 tentatives)
  5. Si SMTP lent : vérifier quota Gmail Workspace
  6. Si bug : ajouter un test qui reproduit, fix, déploy

Incident : "Guest coincé sur /auth/waiting"

  1. Vérifier le check_in_status du guest en DB
  2. Si completed : rien à faire, en attente staff
  3. Si arrived : le middleware Next.js ne devrait pas bloquer → check cookies navigateur, refresh
  4. Si NULL : linkGuestToUser a échoué — vérifier que l'email guest existe dans le cardex

Incident : "Sync Mews échoue"

  1. Signoz dashboard PMS → latence + errors Mews
  2. integration_sync_log : filtrer par error IS NOT NULL derniers 24h
  3. Si 401/403 : credentials Mews expirés, contacter l'hôtel pour renouveler
  4. Si 429 : rate limit, réduire la concurrency BullMQ
  5. Si 5xx Mews : 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/observability pour 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 patterns logger.*({ 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

On this page