Introduzione: la sfida del logging asincrono in Node.js per sistemi ad alte prestazioni
Il logging sincrono, pur semplice, introduce blocchi nel flusso event loop di Node.js, compromettendo la non-bloccante che ne garantisce le performance. In ambienti moderni, soprattutto microservizi distribuiti o backend ad alta concorrenza, il logging deve essere asincrono: capace di gestire volumi elevati senza rallentare il sistema. Tuttavia, una semplice implementazione con `console.log` ovvieta criticità: mancanza di buffer, rischio di perdita di log in caso di errore, serializzazione inefficiente e difficoltà nel tracciamento distribuito.
L’esatto controllo del logging asincrono richiede una progettazione che integri stream, backpressure, retry e contestualizzazione tramite trace ID, correlazione richiesta e log strutturato. Solo così si può garantire un sistema resiliente, scalabile e conforme a standard di osservabilità come quelli promossi da OpenTelemetry e Loki.
“Un log mal progettato è un errore silenzioso che compromette la diagnostica in produzione. Il logging asincrono efficace non è solo un dettaglio tecnico: è una responsabilità architetturale.”
Fondamenti: differenze tra logging sincrono e asincrono e impatto sul Event Loop
Il logging sincrono blocca il thread event, rallentando l’intero stack Node.js. Ogni `console.log` o scrittura su file effettua operazioni di I/O bloccanti, riducendo throughput e aumentando latenza, soprattutto sotto carico.
Il logging asincrono, al contrario, utilizza meccanismi come `write-buffers`, promesse, stream Transform o async queues per evitare blocchi. Questo permette al loop di continuare a gestire richieste in parallelo, migliorando la capacità di risposta.
Tuttavia, un approccio asincrono mal implementato introduce rischi: promise non catturate possono persistere, errori silenziosi, serializzazione inefficiente o backpressure non gestita possono saturare buffer o creare ritardi.
**Fase 1 di progettazione:** definire un buffer asincrono con backpressure (es. usando `p-queue` o `AsyncQueue`) e un sistema di serializzazione compressa (zlib async) per ottimizzare l’I/O.
Meccanismi asincroni di buffer e serializzazione: write-buffers, streaming e promesse
Node.js permette di costruire pipeline di logging basate su stream Transform, dove log grezzi vengono filtrati, arricchiti con ID richiesta, timestamp e contestuali, poi serializzati in formato strutturato (JSON line) o compresso (gelf + zlib).
Un esempio pratico:
const { createReadStream, Writable } = require(‘fs’);
const { Transform } = require(‘stream’);
const zlib = require(‘zlib’);
const { join } = require(‘path’);
const { promisify } = require(‘util’);
const gzipAsync = promisify(zlib.gzip);
const logStream = new Writable({
write(chunk, enc, callback) {
callback();
},
final(callback) {
callback();
}
});
class AsyncLogTransform extends Transform {
constructor(options) {
super({ objectMode: true, …options });
this.queue = [];
this.processing = false;
}
_transform(logEntry, enc, cb) {
this.queue.push(logEntry);
if (!this.processing) this.processQueue();
cb();
}
async processQueue() {
this.processing = true;
while (this.queue.length > 0) {
const batch = this.queue.splice(0, 100); // batch size configurabile
const compressed = await gzipAsync(JSON.stringify(batch));
// invio asincrono a storage esterno o archiviazione locale
// esempio: scrivi su file o invia via HTTP async client
logStream.write(compressed);
}
this.processing = false;
}
_flush(cb) {
if (this.queue.length > 0) this.processQueue().then(cb).catch(cb);
}
}
Questo approccio garantisce backpressure, serializzazione efficiente e resilienza: i log vengono processati in batch, evitando saturazioni e garantendo integrità anche sotto picchi di traffico.
Integrazione con middleware Express e logging strutturato asincrono
Il logging in Express deve essere non bloccante e strutturato. Utilizzare middleware custom che cattura trace ID, correlazione richiesta e invia log a pipeline asincrona.
Esempio avanzato:
const express = require(‘express’);
const pino = require(‘pino’);
const { v4: uuidv4 } = require(‘uuid’);
const AsyncLogTransform = require(‘./AsyncLogTransform’);
const app = express();
const logger = pino({
level: ‘info’,
transport: {
target: ‘pino-async’, // custom transport async
options: { bufferSize: 1000, compress: zlib.gzip }
},
serializers: {
default: (data) => ({
level: data.level.toLowerCase(),
message: data.message,
timestamp: data.timestamp.toISOString(),
traceId: data.traceId || uuidv4(),
spanId: data.spanId || null,
})
}
});
app.use((req, res, next) => {
const traceId = req.headers[‘x-trace-id’] || uuidv4();
const spanId = req.headers[‘x-span-id’] || null;
logger.bind({ traceId, spanId }).info(‘Richiesta ricevuta’, { method: req.method, url: req.url });
next();
});
app.use((log, err, req, res, next) => {
// gestione errori con trace correlati
const { traceId, spanId } = log.bind({}) || {};
if (err) log.bind({ level: ‘error’, message: err.message }).error(‘Errore’, { err, traceId, spanId });
next();
});
// pipeline di logging asincrona
const logPipeline = new AsyncLogTransform({ bufferSize: 200, compress: zlib.gzip });
app.use((req, res, _) => {
logPipeline.write(req._get(‘logEntry’) || req.body);
logPipeline.end();
});
app.listen(3000, () => console.log(‘Server in ascolto su port 3000’));
Questo setup evita blocking, gestisce errori con contesto completo e permette integrazione con backend di tracciamento.
Errori comuni e come evitarli: backpressure, memory leak e serializzazione pesante
– **Blocco Event Loop**: cause frequenti sono promise non catturate o logging sincrono in middleware. Soluzione: usare solo async pipeline, evitare `.then(null)` senza `.catch`.
– **Memory Leak**: accumulo di buffer non svuotati o promesse in attesa. Risolvibile con pipeline ben bilanciate, timeout di batch e monitoraggio con `perf_hooks`.
– **Serializzazione pesante**: serializzazione sincrona di grandi payload blocca. Usare gzip async o compressione a blocchi (chunked).
– **Perdita di log**: mancata gestione promise rifiutate. Implementare catch globali o middleware di logging di errore con fallback async.
– **Formattazione inconsistente**: uso improprio di `JSON.stringify` senza controllo. Usare `JSON.stringify({…}, null, 2)` per coerenza, con schemi JSON rigorosi.
Testare con stress test di carico (es. `autocannon`) per identificare colli di bottiglia.