Vai al contenuto
·32 min di lettura

Observability in Node.js: log, metriche e trace senza la complessità

Logging strutturato con Pino, metriche con Prometheus, distributed tracing con OpenTelemetry. Lo stack di observability che uso per le app Node.js in produzione, da zero a visibilità completa.

Condividi:X / TwitterLinkedIn

Pensavo che observability significasse "aggiungi qualche console.log e controllali quando qualcosa si rompe." Ha funzionato finché non ha funzionato più. Il punto di rottura è stato un incidente in produzione dove la nostra API restituiva 200 ma i dati erano stali. Nessun errore nei log. Nessuna eccezione. Solo risposte silenziosamente sbagliate perché una cache downstream era diventata stale e nessuno se n'era accorto per quattro ore.

È allora che ho imparato la differenza tra monitoraggio e observability. Il monitoraggio ti dice che qualcosa non va. L'observability ti dice perché non va. E il divario tra i due è dove vivono gli incidenti di produzione.

Questo è lo stack di observability su cui mi sono assestato per i servizi Node.js dopo aver provato la maggior parte delle alternative. Non è il setup più sofisticato del mondo, ma cattura i problemi prima che gli utenti li notino, e quando qualcosa sfugge, posso diagnosticarlo in minuti invece che ore.

I tre pilastri, e perché ti servono tutti#

Tutti parlano dei "tre pilastri dell'observability" — log, metriche e trace. Ciò che nessuno ti dice è che ogni pilastro risponde a una domanda fondamentalmente diversa, e ti servono tutti e tre perché nessun singolo pilastro può rispondere a ogni domanda.

I log rispondono a: Cosa è successo?

Una riga di log dice "alle 14:23:07, l'utente 4821 ha richiesto /api/orders e ha ottenuto un 500 perché la connessione al database è andata in timeout." È una narrazione. Ti racconta la storia di un evento specifico.

Le metriche rispondono a: Quanto sta accadendo?

Una metrica dice "negli ultimi 5 minuti, il tempo di risposta p99 era di 2,3 secondi e il tasso di errore era del 4,7%." Sono dati aggregati. Ti dicono della salute del sistema nel suo complesso, non di una singola richiesta.

I trace rispondono a: Dove è andato il tempo?

Un trace dice "questa richiesta ha speso 12ms nel middleware Express, 3ms nel parsing del body, 847ms in attesa di PostgreSQL, e 2ms nella serializzazione della risposta." È una cascata. Ti dice esattamente dove si trova il collo di bottiglia, attraverso i confini dei servizi.

Ecco l'implicazione pratica: quando il tuo pager suona alle 3 di notte, la sequenza è quasi sempre la stessa.

  1. Le metriche ti dicono che qualcosa non va (picco nel tasso di errore, aumento della latenza)
  2. I log ti dicono cosa sta succedendo (messaggi di errore specifici, endpoint interessati)
  3. I trace ti dicono perché (quale servizio downstream o query del database è il collo di bottiglia)

Se hai solo i log, saprai cosa si è rotto ma non quanto è grave. Se hai solo le metriche, saprai quanto è grave ma non cosa lo causa. Se hai solo i trace, avrai bellissime cascate ma nessun modo di sapere quando guardarle.

Costruiamo ciascuno.

Logging strutturato con Pino#

Perché console.log non basta#

Lo so. Hai usato console.log in produzione ed è "a posto." Lascia che ti mostri perché non lo è.

typescript
// Quello che scrivi
console.log("User login failed", email, error.message);
 
// Quello che finisce nel tuo file di log
// User login failed john@example.com ECONNREFUSED
 
// Ora prova a:
// 1. Cercare tutti i login falliti nell'ultima ora
// 2. Contare i fallimenti per utente
// 3. Filtrare solo gli errori ECONNREFUSED
// 4. Correlare questo con la richiesta che l'ha innescato
// Buona fortuna. È una stringa non strutturata. Stai facendo grep nel testo.

Il logging strutturato significa che ogni voce di log è un oggetto JSON con campi consistenti. Invece di una stringa leggibile dagli umani che è ostile alle macchine, ottieni un oggetto leggibile dalle macchine che è anche leggibile dagli umani (con gli strumenti giusti).

typescript
// Come appare il logging strutturato
{
  "level": 50,
  "time": 1709312587000,
  "msg": "User login failed",
  "email": "john@example.com",
  "error": "ECONNREFUSED",
  "requestId": "req-abc-123",
  "route": "POST /api/auth/login",
  "responseTime": 1247,
  "pid": 12345
}

Ora puoi interrogarlo. level >= 50 AND msg = "User login failed" AND time > now() - 1h ti dà esattamente ciò di cui hai bisogno.

Pino vs Winston#

Ho usato entrambi ampiamente. Ecco la versione breve:

Winston è più popolare, più flessibile, ha più transport, ed è significativamente più lento. Incoraggia anche cattive pratiche — il sistema di "format" rende troppo facile creare log non strutturati e formattati che sembrano belli in sviluppo ma sono imparsabili in produzione.

Pino è più veloce (5-10x nei benchmark), opinionato sull'output JSON, e segue la filosofia Unix: fai una cosa bene (scrivi JSON su stdout) e lascia che altri strumenti gestiscano il resto (transport, formattazione, aggregazione).

Uso Pino. La differenza di prestazioni conta quando stai loggando migliaia di richieste al secondo, e l'approccio opinionato significa che ogni sviluppatore nel team produce log consistenti.

Setup base di Pino#

typescript
// src/lib/logger.ts
import pino from "pino";
 
const isProduction = process.env.NODE_ENV === "production";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  // In produzione, solo JSON su stdout. PM2/container runtime gestisce il resto.
  // In sviluppo, usa pino-pretty per output leggibile.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Campi standard su ogni riga di log
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Serializza correttamente gli oggetti Error
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Redigi i campi sensibili
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

L'opzione redact è critica. Senza, prima o poi loggerai una password o una chiave API. Non è questione di se, ma di quando. Qualche sviluppatore aggiungerà logger.info({ body: req.body }, "incoming request") e improvvisamente stai loggando numeri di carte di credito. La redazione è la tua rete di sicurezza.

Livelli di log: usali correttamente#

typescript
// FATAL (60) - Il processo sta per crashare. Sveglia qualcuno.
logger.fatal({ err }, "Errore irrecuperabile nella connessione al database");
 
// ERROR (50) - Qualcosa è fallito che non doveva. Investiga presto.
logger.error({ err, userId, orderId }, "Elaborazione pagamento fallita");
 
// WARN (40) - Qualcosa di inaspettato ma gestito. Tieni d'occhio.
logger.warn({ retryCount: 3, service: "email" }, "Limite di retry in avvicinamento");
 
// INFO (30) - Operazioni normali degne di nota. Il log "cosa è successo".
logger.info({ userId, action: "login" }, "Utente autenticato");
 
// DEBUG (20) - Informazioni dettagliate per il debugging. Mai in produzione.
logger.debug({ query, params }, "Query al database in esecuzione");
 
// TRACE (10) - Estremamente dettagliato. Solo quando sei disperato.
logger.trace({ headers: req.headers }, "Header richiesta in arrivo");

La regola: se stai decidendo tra INFO e DEBUG, è DEBUG. Se stai decidendo tra WARN e ERROR, chiediti: "Vorrei essere allertato per questo alle 3 di notte?" Se sì, ERROR. Se no, WARN.

Child logger e contesto della richiesta#

Qui è dove Pino brilla davvero. Un child logger eredita tutta la configurazione del genitore ma aggiunge campi di contesto extra.

typescript
// Ogni log da questo child logger includerà userId e sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("L'utente ha visualizzato la dashboard");
// L'output include userId e sessionId automaticamente
 
userLogger.info({ page: "/settings" }, "L'utente ha navigato");
// L'output include userId, sessionId E page

Per i server HTTP, vuoi un child logger per richiesta così ogni riga di log nel ciclo di vita di quella richiesta include l'ID della richiesta:

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // Attacca un child logger alla richiesta
  req.log = logger.child({
    requestId,
    method: req.method,
    url: req.originalUrl,
    userAgent: req.headers["user-agent"],
    ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
        || req.socket.remoteAddress,
  });
 
  // Imposta l'header request ID sulla risposta per la correlazione
  res.setHeader("x-request-id", requestId);
 
  req.log.info("Richiesta ricevuta");
 
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
    const logMethod = res.statusCode >= 500 ? "error"
                    : res.statusCode >= 400 ? "warn"
                    : "info";
 
    req.log[logMethod]({
      statusCode: res.statusCode,
      duration,
      contentLength: res.getHeader("content-length"),
    }, "Richiesta completata");
  });
 
  next();
}

AsyncLocalStorage per la propagazione automatica del contesto#

L'approccio con il child logger funziona, ma richiede di passare req.log attraverso ogni chiamata di funzione. Diventa tedioso. AsyncLocalStorage risolve questo — fornisce uno store di contesto che segue il flusso di esecuzione asincrono senza passaggio esplicito.

typescript
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
 
interface RequestContext {
  requestId: string;
  logger: Logger;
  userId?: string;
  startTime: number;
}
 
export const asyncContext = new AsyncLocalStorage<RequestContext>();
 
// Ottieni il logger contestuale da qualsiasi punto nel call stack
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  return asyncContext.getStore()?.requestId;
}
typescript
// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function asyncContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const requestLogger = logger.child({ requestId });
 
  const context = {
    requestId,
    logger: requestLogger,
    startTime: performance.now(),
  };
 
  asyncContext.run(context, () => {
    res.setHeader("x-request-id", requestId);
    next();
  });
}

Ora qualsiasi funzione, ovunque nel call stack, può ottenere il logger con scope sulla richiesta:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Ha automaticamente requestId allegato!
 
  log.info({ orderId }, "Elaborazione ordine");
 
  const items = await fetchOrderItems(orderId);
  log.debug({ itemCount: items.length }, "Elementi ordine recuperati");
 
  const total = calculateTotal(items);
  log.info({ orderId, total }, "Ordine elaborato con successo");
 
  return { orderId, total, items };
}
 
// Non serve passare il logger come parametro. Funziona e basta.

Aggregazione dei log: dove vanno i log?#

In sviluppo, i log vanno su stdout e pino-pretty li rende leggibili. In produzione, è più sfumato.

Il percorso PM2#

Se stai eseguendo su un VPS con PM2 (che ho trattato nel mio post sul setup VPS), PM2 cattura stdout automaticamente:

bash
# Visualizza i log in tempo reale
pm2 logs api --lines 100
 
# I log sono memorizzati in ~/.pm2/logs/
# api-out.log  — stdout (i tuoi log JSON)
# api-error.log — stderr (eccezioni non catturate, stack trace)

La rotazione dei log integrata in PM2 previene problemi di spazio su disco:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Invio dei log a Loki o Elasticsearch#

Per qualsiasi cosa oltre un singolo server, hai bisogno di aggregazione centralizzata dei log. Le due opzioni principali:

Grafana Loki — Il "Prometheus per i log." Leggero, indicizza solo le label (non il testo completo), funziona splendidamente con Grafana. La mia raccomandazione per la maggior parte dei team.

Elasticsearch — Ricerca full-text sui log. Più potente, più esigente di risorse, più overhead operativo. Usa questo se hai genuinamente bisogno della ricerca full-text su milioni di righe di log.

Per Loki, il setup più semplice usa Promtail per inviare i log:

yaml
# /etc/promtail/config.yml
server:
  http_listen_port: 9080
 
positions:
  filename: /tmp/positions.yaml
 
clients:
  - url: http://loki:3100/loki/api/v1/push
 
scrape_configs:
  - job_name: node-api
    static_configs:
      - targets:
          - localhost
        labels:
          job: node-api
          environment: production
          __path__: /home/deploy/.pm2/logs/api-out.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            msg: msg
            service: service
      - labels:
          level:
          service:
      - timestamp:
          source: time
          format: UnixMs

Il formato NDJSON#

Pino produce Newline Delimited JSON (NDJSON) di default — un oggetto JSON per riga, separato da \n. Questo è importante perché:

  1. Ogni strumento di aggregazione log lo capisce
  2. È streamabile (puoi processare i log riga per riga senza bufferizzare l'intero file)
  3. Gli strumenti Unix standard funzionano su di esso: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Non configurare mai Pino per produrre JSON pretty-printed e multi-riga in produzione. Romperai ogni strumento nella pipeline.

typescript
// SBAGLIATO in produzione — JSON multi-riga rompe il processamento basato sulle righe
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// GIUSTO in produzione — NDJSON, un oggetto per riga
{"level":30,"time":1709312587000,"msg":"Request completed"}

Metriche con Prometheus#

I log ti dicono cosa è successo. Le metriche ti dicono come sta performando il sistema. La differenza è come la differenza tra leggere ogni transazione sull'estratto conto bancario e guardare il saldo del conto.

I quattro tipi di metriche#

Prometheus ha quattro tipi di metriche. Capire quale usare quando ti salverà dagli errori più comuni.

Counter — Un valore che può solo crescere. Conteggio richieste, conteggio errori, byte processati. Si resetta a zero al riavvio.

typescript
// "Quante richieste abbiamo servito?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Numero totale di richieste HTTP",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Un valore che può salire e scendere. Connessioni correnti, dimensione della coda, temperatura, uso heap.

typescript
// "Quante connessioni sono attive adesso?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Numero di connessioni attualmente attive",
});

Histogram — Osserva valori e li conta in bucket configurabili. Durata della richiesta, dimensione della risposta. Così ottieni i percentili (p50, p95, p99).

typescript
// "Quanto tempo impiegano le richieste?" con bucket a 10ms, 50ms, 100ms, ecc.
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Durata delle richieste HTTP in secondi",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

Summary — Simile all'Histogram ma calcola i quantili lato client. Usa Histogram invece a meno che tu non abbia un motivo specifico per non farlo. I Summary non possono essere aggregati tra istanze.

Setup completo di prom-client#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Crea un registry personalizzato per evitare di inquinare quello globale
export const metricsRegistry = new Registry();
 
// Raccogli le metriche predefinite di Node.js:
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
  // Raccogli ogni 10 secondi
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- Metriche HTTP ---
 
export const httpRequestsTotal = new Counter({
  name: "nodeapp_http_requests_total",
  help: "Numero totale di richieste HTTP ricevute",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [metricsRegistry],
});
 
export const httpRequestDuration = new Histogram({
  name: "nodeapp_http_request_duration_seconds",
  help: "Durata delle richieste HTTP in secondi",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [metricsRegistry],
});
 
export const httpRequestSizeBytes = new Histogram({
  name: "nodeapp_http_request_size_bytes",
  help: "Dimensione dei body delle richieste HTTP in byte",
  labelNames: ["method", "route"] as const,
  buckets: [100, 1000, 10000, 100000, 1000000],
  registers: [metricsRegistry],
});
 
// --- Metriche di business ---
 
export const ordersProcessed = new Counter({
  name: "nodeapp_orders_processed_total",
  help: "Numero totale di ordini elaborati",
  labelNames: ["status"] as const, // "success", "failed", "refunded"
  registers: [metricsRegistry],
});
 
export const activeWebSocketConnections = new Gauge({
  name: "nodeapp_active_websocket_connections",
  help: "Numero di connessioni WebSocket attive",
  registers: [metricsRegistry],
});
 
export const externalApiDuration = new Histogram({
  name: "nodeapp_external_api_duration_seconds",
  help: "Durata delle chiamate API esterne",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
  registers: [metricsRegistry],
});
 
export const dbQueryDuration = new Histogram({
  name: "nodeapp_db_query_duration_seconds",
  help: "Durata delle query al database",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [metricsRegistry],
});

Il middleware delle metriche#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normalizza le route per evitare l'esplosione della cardinalità
// /api/users/123 → /api/users/:id
// Senza questo, Prometheus creerà una nuova serie temporale per ogni ID utente
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Sostituisci i segmenti dinamici comuni
  return route
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
    .replace(/\/\d+/g, "/:id")
    .replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Non tracciare le metriche per l'endpoint delle metriche stesso
  if (req.path === "/metrics") {
    return next();
  }
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = normalizeRoute(req);
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

L'endpoint /metrics#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Protezione con basic auth — non esporre le metriche pubblicamente
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (!authHeader || authHeader !== expected) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Errore nella raccolta delle metriche");
  }
});
 
export default router;

Le metriche di business personalizzate sono il vero potere#

Le metriche predefinite di Node.js (dimensione heap, lag dell'event loop, durata GC) sono il minimo sindacale. Ti dicono della salute del runtime. Ma le metriche di business ti dicono della salute dell'applicazione.

typescript
// Nel tuo servizio ordini
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Cronometra la chiamata al provider di pagamento
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Un picco in ordersProcessed{status="failed"} ti dice qualcosa che nessuna quantità di metriche CPU ti dirà mai.

Cardinalità delle label: il killer silenzioso#

Una parola di cautela. Ogni combinazione unica di valori di label crea una nuova serie temporale. Se aggiungi una label userId al tuo contatore di richieste HTTP, e hai 100.000 utenti, hai appena creato 100.000+ serie temporali. Prometheus si bloccherà.

Regole per le label:

  • Solo bassa cardinalità: metodo HTTP (7 valori), codice di stato (5 categorie), route (decine, non migliaia)
  • Non usare mai ID utente, ID richiesta, indirizzi IP o timestamp come valori di label
  • Se non sei sicuro, non aggiungere la label. Puoi sempre aggiungerla dopo, ma rimuoverla richiede di cambiare dashboard e alert

Dashboard Grafana#

Prometheus memorizza i dati. Grafana li visualizza. Ecco i pannelli che metto sulla dashboard di ogni servizio Node.js.

La dashboard essenziale#

1. Tasso di richieste (richieste/secondo)

promql
rate(nodeapp_http_requests_total[5m])

Mostra il pattern di traffico. Utile per individuare picchi o cali improvvisi.

2. Tasso di errore (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

Il singolo numero più importante. Se va sopra l'1%, qualcosa non va.

3. Latenza p50 / p95 / p99

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 ti dice l'esperienza tipica. p99 ti dice l'esperienza peggiore. Se p99 è 10x il p50, hai un problema di latenza di coda.

4. Lag dell'event loop

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Se va sopra i 100ms, il tuo event loop è bloccato. Probabilmente un'operazione sincrona in un percorso asincrono.

5. Uso heap

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Osserva un trend costantemente crescente — è un memory leak. I picchi durante il GC sono normali.

6. Handle attivi

promql
nodeapp_nodejs_active_handles_total

File descriptor aperti, socket, timer. Un numero in crescita continua significa che stai perdendo handle — probabilmente non chiudendo connessioni al database o risposte HTTP.

Dashboard Grafana come codice#

Puoi versionare le tue dashboard usando la funzionalità di provisioning di Grafana:

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Esporta il JSON della tua dashboard da Grafana, committalo nel tuo repository, e la tua dashboard sopravviverà alle reinstallazioni di Grafana. Questo non è opzionale per la produzione — è lo stesso principio dell'infrastructure as code.

Distributed tracing con OpenTelemetry#

Il tracing è il pilastro che la maggior parte dei team adotta per ultimo, e quello che avrebbero voluto adottare per primo. Quando hai più servizi che comunicano tra loro (anche se è solo "server API + database + Redis + API esterna"), il tracing ti mostra il quadro completo del percorso di una richiesta.

Cos'è un trace?#

Un trace è un albero di span. Ogni span rappresenta un'unità di lavoro — una richiesta HTTP, una query al database, una chiamata di funzione. Gli span hanno un tempo di inizio, un tempo di fine, uno stato e degli attributi. Sono collegati tra loro da un trace ID che viene propagato attraverso i confini dei servizi.

Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│   ├── [Auth Service] validate-token (12ms)
│   ├── [Order Service] create-order (230ms)
│   │   ├── [PostgreSQL] INSERT INTO orders (15ms)
│   │   ├── [Redis] SET order:cache (2ms)
│   │   └── [Payment Service] charge (200ms)
│   │       ├── [Stripe API] POST /v1/charges (180ms)
│   │       └── [PostgreSQL] UPDATE orders SET status (8ms)
│   └── [Email Service] send-confirmation (async, 45ms)

Un solo sguardo ti dice: la richiesta da 250ms ha speso 180ms in attesa di Stripe. È lì che ottimizzi.

Setup di OpenTelemetry#

OpenTelemetry (OTel) è lo standard. Ha sostituito il panorama frammentato di client Jaeger, client Zipkin e SDK specifici per vendor con un'unica API neutrale rispetto ai vendor.

typescript
// src/instrumentation.ts
// Questo file DEVE essere caricato prima di qualsiasi altro import.
// In Node.js, usa il flag --require o --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Invia trace al tuo collector (Jaeger, Tempo, ecc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Opzionalmente invia metriche tramite OTel
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentation: crea automaticamente span per
  // richieste HTTP, route Express, query PostgreSQL, comandi Redis,
  // lookup DNS e altro
  instrumentations: [
    getNodeAutoInstrumentations({
      // Disabilita le instrumentation rumorose
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configura quelle specifiche
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Shutdown grazioso
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK arrestato con successo"),
    (err) => console.error("Errore nell'arresto dell'OTel SDK", err)
  );
});

Avvia la tua app con:

bash
node --import ./src/instrumentation.ts ./src/server.ts

Tutto qui. Con zero modifiche al codice della tua applicazione, ora hai trace per ogni richiesta HTTP, ogni query al database, ogni comando Redis.

Creazione manuale di span#

L'auto-instrumentation copre le chiamate infrastrutturali, ma a volte vuoi tracciare la logica di business:

typescript
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string): Promise<Order> {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // Questo span diventa il genitore di qualsiasi query DB
      // o chiamata HTTP auto-instrumentata dentro queste funzioni
      const order = await fetchOrder(orderId);
      span.setAttribute("order.total", order.total);
      span.setAttribute("order.item_count", order.items.length);
 
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateInventory(order);
          validationSpan.setAttribute("validation.passed", result.valid);
          if (!result.valid) {
            validationSpan.setStatus({
              code: SpanStatusCode.ERROR,
              message: `Validazione fallita: ${result.reason}`,
            });
          }
          validationSpan.end();
          return result;
        }
      );
 
      if (!validationResult.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "Validazione ordine fallita",
        });
        throw new Error(validationResult.reason);
      }
 
      const payment = await processPayment(order);
      span.setAttribute("payment.id", payment.id);
 
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Propagazione del contesto dei trace#

La magia del distributed tracing è che il trace ID segue la richiesta attraverso i servizi. Quando il Servizio A chiama il Servizio B, il contesto del trace viene automaticamente iniettato negli header HTTP (header traceparent secondo lo standard W3C Trace Context).

L'auto-instrumentation gestisce questo per le chiamate HTTP in uscita. Ma se stai usando una coda di messaggi, devi propagare manualmente:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Quando pubblichi un messaggio
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Inietta il contesto del trace corrente nel carrier
  propagation.inject(context.active(), carrier);
 
  // Invia sia il payload che il contesto del trace
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Quando consumi un messaggio
function consumeEvent(message: QueueMessage) {
  // Estrai il contesto del trace dal messaggio
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Esegui l'handler dentro il contesto estratto
  // Ora qualsiasi span creato qui sarà figlio del trace originale
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Dove inviare i trace#

Jaeger — La classica opzione open-source. Buona UI, facile da eseguire in locale con Docker. Storage a lungo termine limitato.

Grafana Tempo — Se stai già usando Grafana e Loki, Tempo è la scelta naturale per i trace. Usa object storage (S3/GCS) per una retention a lungo termine economica.

Grafana Cloud / Datadog / Honeycomb — Se non vuoi gestire l'infrastruttura. Più costosi, meno overhead operativo.

Per lo sviluppo locale, Jaeger in Docker è perfetto:

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # UI Jaeger
      - "4318:4318"     # Ricevitore OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Endpoint di health check#

Gli health check sono la forma più semplice di observability e la prima cosa che dovresti implementare. Rispondono a una domanda: "Questo servizio è in grado di servire richieste adesso?"

Tre tipi di health check#

/health — Salute generale. Il processo è in esecuzione e responsivo?

/ready — Prontezza. Questo servizio può gestire traffico? (Si è connesso al database? Ha caricato la sua configurazione? Ha riscaldato la sua cache?)

/live — Vivacità. Il processo è vivo e non è in deadlock? (Può rispondere a una semplice richiesta entro un timeout?)

La distinzione conta per Kubernetes, dove le liveness probe riavviano i container bloccati e le readiness probe rimuovono i container dal load balancer durante lo startup o i fallimenti delle dipendenze.

typescript
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
 
const router = Router();
 
interface HealthCheckResult {
  status: "ok" | "degraded" | "error";
  checks: Record<
    string,
    {
      status: "ok" | "error";
      latency?: number;
      message?: string;
    }
  >;
  uptime: number;
  timestamp: string;
  version: string;
}
 
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await pool.query("SELECT 1");
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await redis.ping();
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
export function createHealthRoutes(pool: Pool, redis: Redis) {
  // Liveness — controlla solo se il processo può rispondere
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Readiness — controlla tutte le dipendenze
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Health completo — stato dettagliato per dashboard e debugging
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Connessione fallita" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Connessione fallita" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Restituisci 200 per ok/degraded (il servizio può ancora gestire del traffico)
    // Restituisci 503 per error (il servizio dovrebbe essere rimosso dalla rotazione)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Configurazione delle probe Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Riavvia dopo 3 fallimenti consecutivi (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Rimuovi dal LB dopo 2 fallimenti (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Concedi fino a 150s per lo startup

Un errore comune: rendere la liveness probe troppo aggressiva. Se la tua liveness probe controlla il database, e il database è temporaneamente giù, Kubernetes riavvierà il tuo container. Ma riavviare non risolverà il database. Ora hai un crash loop oltre a un'interruzione del database. Mantieni le liveness probe semplici — dovrebbero rilevare solo processi in deadlock o bloccati.

Error tracking con Sentry#

I log catturano gli errori che ti aspettavi. Sentry cattura quelli che non ti aspettavi.

La differenza è importante. Aggiungi blocchi try/catch intorno al codice che sai potrebbe fallire. Ma i bug che contano di più sono quelli nel codice che pensavi fosse sicuro. Promise rejection non gestite, errori di tipo da risposte API inaspettate, accesso a null su optional chain che non erano abbastanza opzionali.

Setup di Sentry per Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Campiona il 10% delle transazioni per il monitoraggio delle prestazioni
    // (100% in sviluppo)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profila il 100% delle transazioni campionate
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filtra gli errori rumorosi
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Non inviare errori dallo sviluppo
    enabled: process.env.NODE_ENV === "production",
 
    // Filtra i non-problemi noti
    ignoreErrors: [
      // Le disconnessioni client non sono bug
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bot che inviano spazzatura
      "SyntaxError: Unexpected token",
    ],
 
    // Rimuovi PII prima dell'invio
    beforeSend(event) {
      // Rimuovi indirizzi IP
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Rimuovi parametri query sensibili
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Error handler Express con Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// Il request handler di Sentry deve venire per primo
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Il tracing handler di Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Il tuo error handler personalizzato viene per ultimo
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Aggiungi contesto personalizzato all'evento Sentry
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // Non inviare email o username a Sentry
      });
    }
 
    // Aggiungi breadcrumb per il debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Logga l'errore con contesto completo
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Errore non gestito nel request handler"
  );
 
  // Invia una risposta di errore generica
  // Non esporre mai i dettagli dell'errore al client in produzione
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source map#

Senza source map, Sentry ti mostra stack trace minificati/transpilati. Inutili. Carica le source map durante la build:

bash
# Nella tua pipeline CI/CD
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Oppure configuralo nel tuo bundler:

typescript
// vite.config.ts (o equivalente)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Necessario per Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

Il costo delle promise rejection non gestite#

Da Node.js 15, le promise rejection non gestite crashano il processo di default. Questo è positivo — ti costringe a gestire gli errori. Ma hai bisogno di una rete di sicurezza:

typescript
// src/server.ts — vicino all'inizio del tuo entry point
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Promise rejection non gestita — crash");
  Sentry.captureException(reason);
 
  // Fluscia gli eventi Sentry prima di crashare
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Eccezione non catturata — crash");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

La parte importante: Sentry.flush() prima di process.exit(). Senza, l'evento di errore potrebbe non arrivare a Sentry prima che il processo muoia.

Alerting: gli alert che contano davvero#

Avere 200 metriche Prometheus e zero alert è solo monitoraggio vanitoso. Avere 50 alert che scattano ogni giorno è alert fatigue — inizierai a ignorarli, e poi ti perderai quello che conta.

L'obiettivo è un piccolo numero di alert ad alto segnale che significano "qualcosa è genuinamente sbagliato e un umano deve guardarci."

Configurazione di Prometheus AlertManager#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

Gli alert che mi svegliano davvero#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Alto tasso di errore — qualcosa è rotto
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Tasso di errore sopra l'1% per 5 minuti"
          description: "{{ $value | humanizePercentage }} delle richieste restituisce 5xx"
 
      # Risposte lente — gli utenti soffrono
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Latenza p99 sopra 1 secondo per 5 minuti"
          description: "Latenza p99 è {{ $value | humanizeDuration }}"
 
      # Memory leak — OOM in arrivo
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Uso heap sopra l'80% per 10 minuti"
          description: "Uso heap al {{ $value | humanizePercentage }}"
 
      # Il processo è giù
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "API Node.js è giù"
 
  - name: node-api-warnings
    rules:
      # L'event loop sta rallentando
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Lag event loop sopra 100ms"
          description: "Lag event loop p99 è {{ $value | humanizeDuration }}"
 
      # Il traffico è calato significativamente — possibile problema di routing
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffico calato di più del 50% rispetto a 1 ora fa"
 
      # Le query al database stanno rallentando
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Tempo query database p99 sopra 500ms"
          description: "Query {{ $labels.operation }} lente: {{ $value | humanizeDuration }}"
 
      # L'API esterna sta fallendo
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "API esterna {{ $labels.service }} fallisce >10%"

Nota la clausola for su ogni alert. Senza, un singolo picco innesca un alert. Un for di 5 minuti significa che la condizione deve essere vera per 5 minuti continui. Questo elimina il rumore da blip momentanei.

Igiene degli alert#

Ogni alert deve superare questo test:

  1. È azionabile? Se nessuno può fare niente, non allertare. Loggalo, mettilo nella dashboard, ma non svegliare qualcuno.
  2. Richiede intervento umano? Se si auto-risolve (come un breve blip di rete), la clausola for dovrebbe filtrarlo.
  3. È scattato negli ultimi 30 giorni? Se no, potrebbe essere mal configurato o la soglia è sbagliata. Revisionalo.
  4. Quando scatta, alla gente importa? Se il team lo ignora regolarmente, rimuovilo o aggiusta la soglia.

Faccio un audit dei miei alert trimestralmente. Ogni alert ha uno di tre esiti: mantieni, aggiusta la soglia, o elimina.

Mettere tutto insieme: l'applicazione Express#

Ecco come tutti i pezzi si incastrano in un'applicazione reale:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Inizializza Sentry per primo — prima di altri import
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- L'ordine dei middleware conta ---
 
// 1. Request handler Sentry (deve essere primo)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Contesto asincrono (crea contesto con scope sulla richiesta)
app.use(asyncContextMiddleware);
 
// 3. Logging delle richieste
app.use(requestLogger);
 
// 4. Raccolta metriche
app.use(metricsMiddleware);
 
// 5. Parsing del body
app.use(express.json({ limit: "1mb" }));
 
// --- Route ---
 
// Health check (nessuna auth necessaria)
app.use(createHealthRoutes(pool, redis));
 
// Metriche (protette con basic auth)
app.use(metricsRouter);
 
// Route API
app.use("/api", apiRouter);
 
// --- Gestione errori ---
 
// Error handler Sentry (deve essere prima dell'error handler personalizzato)
app.use(Sentry.Handlers.errorHandler());
 
// Error handler personalizzato (deve essere ultimo)
app.use(errorHandler);
 
// --- Avvio ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server avviato"
  );
});
 
// Shutdown grazioso
async function shutdown(signal: string) {
  logger.info({ signal }, "Segnale di shutdown ricevuto");
 
  // Smetti di accettare nuove connessioni
  // Processa le richieste in corso (Express lo fa automaticamente)
 
  // Chiudi il pool del database
  await pool.end().catch((err) => {
    logger.error({ err }, "Errore nella chiusura del pool del database");
  });
 
  // Chiudi la connessione Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Errore nella chiusura della connessione Redis");
  });
 
  // Fluscia Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown completato");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Lo stack minimo vitale#

Tutto quanto sopra è lo stack "completo". Non ti serve tutto dal primo giorno. Ecco come scalare la tua observability man mano che il tuo progetto cresce.

Fase 1: progetto personale / sviluppatore singolo#

Ti servono tre cose:

  1. Log strutturati su console — Usa Pino, produci JSON su stdout. Anche se li stai solo leggendo con pm2 logs, i log JSON sono cercabili e parsabili.

  2. Un endpoint /health — Ci vogliono 5 minuti per implementarlo, ti salva quando stai debuggando "ma sta anche girando?"

  3. Piano gratuito di Sentry — Cattura gli errori che non avevi previsto. Il piano gratuito ti dà 5.000 eventi/mese, che sono più che sufficienti per un progetto personale.

typescript
// Questo è il setup minimale. Meno di 50 righe. Nessuna scusa.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Errore non gestito");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server avviato sulla porta 3000"));

Fase 2: progetto in crescita / team piccolo#

Aggiungi:

  1. Metriche Prometheus + Grafana — Quando "sembra lento" non basta più e hai bisogno di dati. Inizia con tasso di richieste, tasso di errore e latenza p99.

  2. Aggregazione dei log — Quando fare ssh nel server e grep nei file non scala più. Loki + Promtail se stai già usando Grafana.

  3. Alert base — Tasso di errore > 1%, p99 > 1s, servizio giù. Tre alert. Tutto qui.

Fase 3: servizio in produzione / servizi multipli#

Aggiungi:

  1. Distributed tracing con OpenTelemetry — Quando "l'API è lenta" diventa "quale dei 5 servizi che chiama è lento?" L'auto-instrumentation OTel ti dà l'80% del valore con zero modifiche al codice.

  2. Dashboard come codice — Versiona le tue dashboard Grafana. Te ne ringrazierai quando dovrai ricrearle.

  3. Alerting strutturato — AlertManager con routing appropriato, escalation e regole di silenzio.

  4. Metriche di business — Ordini/secondo, tasso di conversione, profondità della coda. Le metriche a cui il tuo team di prodotto tiene.

Cosa saltare#

  • Vendor APM con prezzo per host — A scala, il costo è assurdo. L'open source (Prometheus + Grafana + Tempo + Loki) ti dà il 95% delle funzionalità.
  • Livelli di log sotto INFO in produzione — Genererai terabyte di log DEBUG e pagherai per lo storage. Usa DEBUG solo quando stai investigando attivamente problemi, poi disattivalo.
  • Metriche personalizzate per tutto — Inizia con il metodo RED (Rate, Errors, Duration) per ogni servizio. Aggiungi metriche personalizzate solo quando hai una domanda specifica a cui rispondere.
  • Sampling dei trace complesso — Inizia con un tasso di campionamento semplice (10% in produzione). Il sampling adattivo è un'ottimizzazione prematura per la maggior parte dei team.

Considerazioni finali#

L'observability non è un prodotto che compri o uno strumento che installi. È una pratica. È la differenza tra operare il tuo servizio e sperare che il tuo servizio si operi da solo.

Lo stack che ho descritto qui — Pino per i log, Prometheus per le metriche, OpenTelemetry per i trace, Sentry per gli errori, Grafana per la visualizzazione, AlertManager per gli alert — non è il setup più semplice possibile. Ma ogni pezzo si guadagna il suo posto rispondendo a una domanda che gli altri pezzi non possono.

Inizia con log strutturati e un endpoint health. Aggiungi metriche quando hai bisogno di sapere "quanto è grave." Aggiungi trace quando hai bisogno di sapere "dove va il tempo." Ogni strato si costruisce sull'ultimo, e nessuno di essi richiede di riscrivere la tua applicazione.

Il momento migliore per aggiungere l'observability era prima del tuo ultimo incidente in produzione. Il secondo momento migliore è adesso.

Articoli correlati