Un console.log “temporaneo” per capire perché una richiesta fallisce in produzione produce una stringa piatta: senza timestamp, senza livello, senza contesto. Se il container si riavvia, quei log spariscono. Con più istanze, l’unica opzione è saltare da un docker logs all’altro cercando la riga giusta. È l’approccio più rapido per iniziare, ma il primo a diventare inutile quando il sistema cresce.

Questo articolo copre il passaggio da console.log a un sistema di logging strutturato e centralizzato in tre step incrementali, ognuno motivato dai limiti del precedente.

👉 Codice completo: github.com/monte97/otel-demo (moduli 01 e 02)

git clone https://github.com/monte97/otel-demo
cd otel-demo

Perché console.log non basta

Un servizio Express con tre endpoint e console.log produce output come questo:

Health check request received
Processing purchase...
Purchase completed successfully
[INFO] User mario is checking out with amount 29.99

Quattro stringhe piatte. Il confronto con un approccio strutturato:

Caratteristicaconsole.logPino (strutturato)Pino + OTel + Loki
FormatoStringa liberaJSON strutturatoJSON strutturato
TimestampAssenteEpoch ms (ISO 8601 configurabile)ISO 8601 automatico
LivelliNessunoerror/warn/info/debugerror/warn/info/debug
Filtraggiogrep manualeCampi JSONLogQL su Grafana
PersistenzaCiclo di vita containerCiclo di vita containerLoki (persistente)
Multi-istanzadocker logs per ognunadocker logs per ognunaQuery centralizzata

In sintesi: formato, persistenza e centralizzazione mancano tutti. Le sezioni successive affrontano ciascun limite in ordine.


Da stringhe piatte a JSON filtrabili

Il primo step non richiede infrastruttura, solo aggiungere una libreria dedicata al logging. In questi esempi useremo Pino.

npm install pino

Configurazione base

// logger.js
const pino = require('pino');
const logger = pino({
    level: 'info',
    timestamp: pino.stdTimeFunctions.isoTime,
    formatters: {
        level(label) { return { level: label }; }
    }
});

Due configurazioni rilevanti:

  • pino.stdTimeFunctions.isoTime - aggiunge il campo "time" in formato ISO 8601
  • formatters.level - converte il livello da numerico (30) a stringa ("info")

Sostituzione di console.log

// Prima
console.log(`[INFO] User ${user} is checking out with amount ${amount}`);

// Dopo
logger.info({ userId: user, amount, action: 'checkout' }, 'Checkout started');

L’output diventa un oggetto JSON con campi separati:

{
    "level": "info",
    "time": "2026-02-10T14:23:01.456Z",
    "userId": "mario",
    "amount": 29.99,
    "action": "checkout",
    "msg": "Checkout started"
}

Child logger per contesto HTTP

Per le richieste HTTP, un middleware crea un child logger che aggiunge automaticamente contesto a ogni log della request:

const { randomUUID } = require('node:crypto');

app.use((req, res, next) => {
    req.logger = logger.child({
        requestId: randomUUID(),
        method: req.method,
        url: req.url
    });
    next();
});

Ogni chiamata a req.logger.info(...) include automaticamente requestId, method e url. Dato un requestId, è possibile ricostruire l’intera sequenza di log di una singola richiesta.

Nota: Pino supporta anche la scrittura su file con pino.destination('./logs/service.log'), ma con container questa soluzione richiede la gestione di volumi e rotazione file. Il logging su file non risolve il problema della centralizzazione tra istanze.

Limiti

I log sono strutturati e performanti (benchmark Pino), ma restano locali al container. Un restart li cancella e con più istanze, serve comunque accedere a ciascuna separatamente.


Log persistenti con modifiche minime

L’aggiunta di OpenTelemetry rende i log persistenti e centralizzati con modifiche minime al codice applicativo: un file di instrumentazione e una proprietà transport nel logger.

Dipendenze

Oltre a Pino (già installato nello step precedente), servono l’SDK OpenTelemetry e il transport per collegare Pino al Collector:

npm install @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions \
  pino-opentelemetry-transport

File di instrumentazione

// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
    resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'shop-service' }),
    instrumentations: [getNodeAutoInstrumentations()]
});

sdk.start();

Avvio

# Prima
node index.js

# Dopo
node --require ./instrumentation.js index.js

Il flag --require carica l’SDK prima del codice applicativo. L’SDK abilita @opentelemetry/instrumentation-pino (incluso in auto-instrumentations-node), che inietta automaticamente trace_id e span_id nei log Pino, collegando log e trace.

Per inviare i log al Collector via OTLP, si configura pino-opentelemetry-transport come transport Pino. Il transport opera in un worker thread separato e gestisce autonomamente l’invio dei log al Collector, senza dipendere dal LoggerProvider dell’SDK. Aggiornare logger.js:

// logger.js (con OpenTelemetry)
const pino = require('pino');
const logger = pino({
    level: 'info',
    timestamp: pino.stdTimeFunctions.isoTime,
    formatters: {
        level(label) { return { level: label }; }
    },
    transport: {
        target: 'pino-opentelemetry-transport'
    }
});

La proprietà transport redirige l’output di Pino verso l’SDK OpenTelemetry invece che verso stdout. I logger.info() esistenti restano invariati.

L’instrumentazione è reversibile: rimuovendo il --require, il servizio torna al comportamento originale.

Nota: OTLPLogExporter() senza argomenti usa http://localhost:4318 come endpoint. Questo funziona quando il servizio Node.js gira sull’host. Se il servizio è containerizzato nello stesso Docker Compose, l’endpoint deve puntare al nome del servizio: http://otel-collector:4318. In quel caso, impostare la variabile d’ambiente OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318.


Tre servizi Docker per chiudere il cerchio

Il flusso dei dati segue questa pipeline:

App (Pino) → OTel SDK → Collector (:4318) → Loki (:3100) → Grafana (:3000)

Docker Compose

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.145.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./support/otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4318:4318"  # OTLP HTTP

  loki:
    image: grafana/loki:3.6.5
    ports:
      - "3100:3100"
    volumes:
      - loki-data:/loki  # persistenza log tra restart

  grafana:
    image: grafana/grafana:12.3.2
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true

volumes:
  loki-data:

Nota: Questa configurazione Grafana è esclusivamente per sviluppo locale. GF_AUTH_ANONYMOUS_ORG_ROLE=Admin consente l’accesso senza login con privilegi amministrativi e GF_AUTH_DISABLE_LOGIN_FORM=true disabilita completamente il form di login. Non usare queste impostazioni in ambienti accessibili dall’esterno. Per la produzione, consultare la documentazione sull’autenticazione di Grafana.

Nota: Dopo il primo avvio, è necessario aggiungere manualmente Loki come data source in Grafana (URL: http://loki:3100). In alternativa, è possibile automatizzare questo passaggio con i file di provisioning di Grafana.

Configurazione del Collector

# support/otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:

exporters:
  otlphttp/loki:
    endpoint: "http://loki:3100/otlp"
    tls:
      insecure: true

service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/loki]

Il Collector riceve i log via OTLP HTTP, li raggruppa in batch e li inoltra a Loki tramite l’endpoint OTLP nativo (disponibile da Loki 3.x). Il Collector funge da punto di controllo: è possibile aggiungere backend aggiuntivi, filtrare log sensibili o applicare sampling modificando solo questa configurazione.

Avviare lo stack

# Avvia Collector, Loki e Grafana in background
docker compose up -d

# Verifica che i container siano attivi
docker ps

Una volta che i tre container risultano healthy o running, avviare il servizio Node.js con l’instrumentazione:

node --require ./instrumentation.js index.js

Filtrare per livello, utente e azione

Dopo aver avviato l’infrastruttura con docker compose up e generato qualche richiesta, Grafana è disponibile su http://localhost:3000.

In Explore, selezionando Loki come data source, le query LogQL seguono questa struttura:

{service_name="shop-service"}

Esempi di query più specifiche:

ObiettivoQuery LogQL
Tutti i log del servizio{service_name="shop-service"}
Solo errori{service_name="shop-service"} | json | level="error"
Checkout di un utente{service_name="shop-service"} | json | userId="mario" | action="checkout"
Log di una request specifica{service_name="shop-service"} | json | requestId="<uuid>"

La pipeline | json estrae i campi JSON come label filtrabili. I filtri multipli (| campo="valore") funzionano come AND logico.

Dopo un riavvio del container, i log restano disponibili in Grafana. La persistenza è garantita dallo storage di Loki.


Errori comuni

ErroreConseguenzaSoluzione
Concatenare stringhe nei loglogger.info("User " + id) non è filtrabileUsare oggetti: logger.info({ userId }, 'msg')
Tutto a livello infoIl livello perde utilitàdebug per dettaglio, warn per anomalie, error per fallimenti
serviceName mancanteLog indistinguibili in GrafanaImpostarlo nell’SDK o via OTEL_SERVICE_NAME
Nessun volume per Lokidocker compose down cancella i log ingeritiIn produzione, montare un volume persistente dedicato
Centralizzare senza strutturareLog persistenti ma non cercabiliPrima Pino (struttura), poi OTel (centralizzazione)

Security: non loggare mai token, password o dati personali nei campi strutturati. Con i log centralizzati, un logger.info({ password }) diventa visibile a chiunque abbia accesso a Grafana.

Cleanup

Al termine della sessione, per fermare i container e rimuovere i volumi:

docker compose down -v

Conclusioni

L’articolo ha coperto:

  1. Limiti di console.log - assenza di struttura, persistenza e centralizzazione
  2. Logging strutturato con Pino - JSON, livelli, child logger per contesto HTTP
  3. Centralizzazione con OpenTelemetry - 20 righe di instrumentation.js, zero modifiche al codice applicativo
  4. Infrastruttura di osservabilità - Collector, Loki e Grafana con tre servizi Docker
  5. Query LogQL - filtraggio per livello, utente, azione su dati centralizzati

Il logging è il primo pilastro dell’osservabilità. Nel prossimo articolo: distributed tracing per seguire una request attraverso più servizi.


Risorse utili