Immaginate di ricevere una richiesta dal team compliance: “Servono i log di audit degli ultimi tre anni.” Aprite Grafana, cercate in Loki e scoprite che la retention massima è 30 giorni. I log di audit sono stati cancellati insieme ai debug log, perché vivevano tutti nello stesso backend. Nessuna separazione, nessuna policy dedicata.

L’articolo precedente affronta il primo problema della produzione: il volume. Con tail sampling e retention, il volume si riduce del 90% senza perdere visibilità sugli errori. Ma resta una domanda: i dati che restano, dove finiscono?

Oggi tutto finisce nello stesso backend: log di debug, errori applicativi e audit trail vivono nella stessa istanza Loki. In sviluppo funziona. In produzione potrebbe essere un problema di compliance o di gestione.


Dati diversi, requisiti diversi

In una configurazione standard, il Collector riceve tutti i segnali e li inoltra a un’unica destinazione:

Applicazioni
    |
    v
  OTel Collector
    |
    v
  Loki (tutti i log)
    |
    v
  Grafana (tutto insieme)

Tutti i log, indipendentemente dal tipo, finiscono nello stesso posto.

Compliance e Audit

Normative come GDPR, SOC 2 e HIPAA richiedono (o raccomandano fortemente) che i dati audit siano:

  • Segregati dai log tecnici (accesso separato)
  • Integri e a prova di manomissione (write-once o append-only)
  • Accessibili solo a chi è autorizzato (controllo accesso dedicato)
  • Conservati per un periodo definito (da 1 a 6+ anni a seconda della normativa)

Se audit log e debug log vivono nella stessa istanza Loki, nessuno di questi requisiti è soddisfatto. Chi ha accesso a Grafana per il debug vede anche i dati di audit. Non c’è garanzia di immutabilità. La retention è la stessa per tutto.

Operatività

Mischiare i flussi crea anche problemi operativi:

ProblemaEsempio
Ricerca rumorosaCercare un evento audit tra milioni di log di debug
Costi uniformiPagare la stessa retention per debug log (utili 24h) e audit log (richiesti diversi anni)
Accesso indiscriminatoSviluppatori che vedono dati potenzialmente sensibili
Nessuna prioritàUn picco di debug log rallenta l’ingest anche per gli audit

Scenario concreto

Per un applicativo con 100 req/s, il volume giornaliero è:

Tipo di logVolume stimatoUtilitàRetention ideale
Debug~500.000/giornoSolo per troubleshooting attivo24-48 ore
Info/Warning~200.000/giornoMonitoring generale7-30 giorni
Errori applicativi~5.000/giornoPost-mortem, alerting30-90 giorni
Audit (checkout, login)~2.000/giornoCompliance, forensics1-7 anni

Con una configurazione flat, tutti e 707.000 log/giorno finiscono nello stesso Loki con la stessa retention di 30 giorni. I 2.000 log audit vengono cancellati dopo un mese insieme ai debug log. Questa configurazione non soddisfa i requisiti di compliance.

Il concetto è semplice: dati diversi hanno requisiti diversi. Convogliare dati con requisiti eterogenei verso un’unica destinazione impedisce di applicare policy differenziate.


Instradare in base al contenuto

L’OTel Collector può fare di più che raccogliere e inoltrare. Con il routing connector, diventa un router che instrada ogni dato in base ai suoi attributi.

Architettura

Applicazione (log con attributi)
    |
    | OTLP
    v
OTel Collector (Routing Connector)
    |
    +-- audit.event=false --> Loki (log tecnici)
    |
    +-- audit.event=true  --> Audit Service (compliance)
DestinazioneContenutoCaratteristiche
LokiInfo, Debug, Warning, ErrorQuery veloci, retention breve
Audit ServiceLog auditImmutabile, accesso controllato, retention 7 anni

Il principio: ogni log viene marcato nel codice con un attributo che indica il tipo. Il Collector legge l’attributo e instrada il log verso la destinazione corretta. L’applicazione non deve sapere dove finiscono i dati: decide solo cosa è un dato, non dove va.

Questo approccio ha un vantaggio fondamentale: la logica di routing è centralizzata. Se domani il team compliance chiede di inviare gli audit log anche su S3, si modifica la configurazione del Collector. Nessun cambio applicativo, nessun deploy dei microservizi.


Il Collector decide la destinazione

La configurazione si basa su tre elementi: receivers, exporters e pipelines. Il routing avviene configurando exporters multipli nella stessa pipeline.

Configurazione base: split degli exporters

Il punto di partenza è il file otel-collector-split.yaml del Module 06:

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  debug:
    verbosity: detailed
  otlphttp/loki:
    endpoint: "http://loki:3100/otlp"
    tls:
      insecure: true
  otlphttp/audit:
    endpoint: "http://audit-service:4000"
    tls:
      insecure: true

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

Questa configurazione invia tutti i log a tre destinazioni contemporaneamente:

  • otlphttp/loki: il backend standard per log tecnici (query via Grafana)
  • otlphttp/audit: un servizio dedicato che riceve i log via OTLP HTTP
  • debug: output verbose sulla console del Collector (utile in sviluppo)

Nota: in questa configurazione tutti i log arrivano ovunque. È un fan-out, non ancora routing selettivo. In una configurazione di produzione più avanzata, si usa il routing connector per inviare solo i log audit all’audit service, filtrando in base all’attributo audit.event. Per lo scenario demo, il fan-out è sufficiente a dimostrare la separazione delle destinazioni.

Routing selettivo con il routing connector

Per una separazione granulare, il routing connector permette di prendere decisioni basate sugli attributi del log. A differenza di un processor (che opera all’interno di una pipeline), il connector si colloca tra pipeline diverse: agisce come exporter per la pipeline a monte e come receiver per le pipeline a valle.

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  otlphttp/loki:
    endpoint: "http://loki:3100/otlp"
    tls:
      insecure: true
  otlphttp/audit:
    endpoint: "http://audit-service:4000"
    tls:
      insecure: true

connectors:
  routing/logs:
    default_pipelines: [logs/default]
    error_mode: ignore
    table:
      - context: log
        condition: attributes["audit.event"] == true
        pipelines: [logs/audit]

service:
  pipelines:
    logs/ingestion:
      receivers: [otlp]
      processors: [batch]
      exporters: [routing/logs]
    logs/default:
      receivers: [routing/logs]
      exporters: [otlphttp/loki]
    logs/audit:
      receivers: [routing/logs]
      exporters: [otlphttp/audit]

La logica è:

  1. La pipeline logs/ingestion riceve tutti i log via OTLP e li invia al connector routing/logs
  2. Il connector valuta la condizione OTTL: se attributes["audit.event"] == true, il log viene instradato alla pipeline logs/audit
  3. Tutti gli altri log finiscono nella pipeline logs/default (Loki)

Ogni pipeline a valle può avere i propri processor ed exporter. Le applicazioni non cambiano destinazione: il Collector decide per loro.

Nota: Il routing connector usa OTTL (OpenTelemetry Transformation Language) per le condizioni. Con context: log si accede direttamente agli attributi del log record. Questo permette di instradare su qualsiasi campo: severity_number, body, resource.attributes["service.name"], o attributi custom come audit.event (purché copiati esplicitamente dallo span al log record tramite logHook - vedi la sezione successiva).


L’applicazione marca, il Collector instrada

Perché il routing funzioni, l’applicazione deve marcare i log con gli attributi giusti. Nel Module 06, il shop-service aggiunge l’attributo audit.event sullo span attivo quando avviene un’operazione sensibile.

Endpoint di checkout con marcatura audit

// shop-service/index.js - endpoint /checkout
app.post('/checkout', async (req, res) => {
    const user = req.body.user || 'anonymous';
    const amount = req.body.amount;

    const currentSpan = trace.getActiveSpan();
    if (currentSpan) {
        currentSpan.setAttribute('audit.event', true);
        currentSpan.setAttribute('audit.user', user);
    }

    logger.info({ event: 'audit', user, amount }, 'User checking out');

    res.json({ status: 'processed', orderId: `ORD-${Date.now()}` });
});

Cosa succede qui:

  1. trace.getActiveSpan() recupera lo span corrente dal contesto OpenTelemetry
  2. setAttribute('audit.event', true) marca lo span come evento audit
  3. setAttribute('audit.user', user) aggiunge l’identità dell’utente per la tracciabilità
  4. Il log Pino include event: 'audit' come informazione strutturata

Propagare gli attributi dallo span al log

C’è un dettaglio importante: setAttribute sullo span non propaga automaticamente l’attributo ai log record. Span e log sono segnali separati in OpenTelemetry; condividono lo stesso trace_id e span_id (correlazione), ma non gli attributi. Senza un passaggio esplicito, il routing connector non vedrebbe audit.event sul log record.

La soluzione è un logHook nella configurazione di PinoInstrumentation. Il logHook viene invocato ogni volta che Pino emette un log all’interno di uno span attivo, e permette di copiare attributi dallo span al log record:

// instrumentation.js - logHook per propagare attributi audit
instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-pino': {
        logHook: (span, record) => {
            const auditEvent = span.attributes?.['audit.event'];
            if (auditEvent !== undefined) {
                record['audit.event'] = auditEvent;
                record['audit.user'] = span.attributes?.['audit.user'];
            }
        },
    },
})],

Il flusso completo è:

  1. span.setAttribute('audit.event', true) - l’applicazione marca lo span
  2. logHook - l’instrumentazione Pino copia audit.event nel log record
  3. Routing connector - il Collector legge attributes["audit.event"] sul log e instrada

Senza il logHook, il Collector vedrebbe audit.event solo sullo span (utile per le trace), ma non sul log record. Il routing dei log non funzionerebbe.

L’applicazione non sa dove finirà il log. Sa solo che è un evento audit. La decisione di routing è interamente nel Collector.

Cosa non marcare

Non tutto deve essere un audit log. Una regola pratica:

TipoAttributoEsempio
Auditaudit.event=trueCheckout, login, modifica permessi, accesso a dati sensibili
Tecniconessun attributo (default)Debug, info, warning, errori applicativi

Se in dubbio, non marcare. I log non marcati finiscono nel flusso default (Loki) e sono sempre disponibili per il debug.


Un servizio dedicato per ogni destinazione

L’audit service è un microservizio dedicato a ricevere e persistere i log di audit. Nel Module 06 è implementato come un server Express minimale:

// audit-service/index.js
const express = require('express');
const app = express();
const PORT = 4000;

app.use(express.json());

app.post('/v1/logs', (req, res) => {
    console.log('Received Audit Log Batch:', JSON.stringify(req.body, null, 2));
    res.status(200).send({ status: 'success' });
});

app.listen(PORT, () => {
    console.log(`Audit Service running on port ${PORT}`);
});

Il servizio espone un endpoint /v1/logs compatibile con il protocollo OTLP HTTP. Quando il Collector invia un batch di log con l’exporter otlphttp/audit, il payload arriva qui.

In produzione

Il servizio demo semplicemente stampa il payload. In un ambiente reale, l’audit service dovrebbe:

RequisitoImplementazione
PersistenzaScrivere su database append-only (es. PostgreSQL con trigger di protezione, ImmuDB)
ImmutabilitàImpedire UPDATE e DELETE sui record
EncryptionTLS in transito, encryption at rest
AccessoAutenticazione e autorizzazione dedicate
RetentionPolicy di retention separata (anni, non giorni)
BackupReplica geografica o export periodico su cold storage

Il punto chiave: separare fisicamente la destinazione permette di applicare requisiti diversi allo stesso flusso di dati. Un database Loki ottimizzato per query veloci non è il posto giusto per un audit trail che deve durare anni.


Ogni rotta ha il suo lifecycle

Separare le destinazioni non basta: ogni destinazione deve avere una strategia di persistenza coerente con il tipo di dato che riceve. L’articolo precedente mostra come configurare una retention unica (Tempo, 7 giorni) per tutte le trace. Con il routing, si possono applicare policy diverse per ogni flusso.

Mappa completa: rotta, destinazione, persistenza

RottaDestinazioneRetentionStorageCosto relativo
Debug logLoki (stream debug)24-48 oreLoki filesystemBasso
Info/Warning/ErrorLoki (stream default)7-30 giorniLoki filesystemMedio
TraceTempo7 giorni (block_retention: 168h)Tempo + object storageMedio
Audit logAudit service → DB1-7 anniPostgreSQL + S3 (cold)Alto per record, basso per volume

Il costo degli audit log è alto per singolo record (DB relazionale, encryption, backup) ma il volume è basso (~2.000 log/giorno nello scenario dell’articolo). Il costo dei debug log è basso per record (Loki) ma il volume è alto (~500.000/giorno). La strategia di persistenza riflette questo trade-off.

Loki: retention per stream

Loki supporta retention differenziata tramite retention_stream. I log instradati verso Loki possono avere retention diverse in base ai label:

# loki-config.yaml
limits_config:
  retention_period: 720h             # Default: 30 giorni

  retention_stream:
    - selector: '{level="debug"}'
      priority: 1
      period: 48h                    # Debug: 2 giorni
    - selector: '{level=~"info|warn"}'
      priority: 2
      period: 168h                   # Info/Warning: 7 giorni
    - selector: '{level="error"}'
      priority: 3
      period: 720h                   # Errori: 30 giorni

Con questa configurazione, i debug log occupano storage per 2 giorni invece di 30. Su un volume di 500.000 debug log/giorno, la differenza di storage è significativa.

Nota: retention_stream richiede che il compactor Loki sia attivo con retention_enabled: true. La feature è disponibile a partire da Loki 2.3+.

Audit service: persistenza su database

Il servizio demo usa console.log. In produzione, l’audit service persiste i log su un database append-only. Un esempio minimale con PostgreSQL:

-- Schema audit log
CREATE TABLE audit_logs (
    id          BIGSERIAL PRIMARY KEY,
    timestamp   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    trace_id    VARCHAR(32),
    service     VARCHAR(128) NOT NULL,
    user_id     VARCHAR(256),
    event_type  VARCHAR(64) NOT NULL,
    payload     JSONB NOT NULL,
    checksum    VARCHAR(64) NOT NULL   -- SHA-256 del payload
);

-- Indice per query frequenti
CREATE INDEX idx_audit_timestamp ON audit_logs (timestamp);
CREATE INDEX idx_audit_user ON audit_logs (user_id);
CREATE INDEX idx_audit_event ON audit_logs (event_type);

-- Protezione: impedire UPDATE e DELETE
CREATE RULE no_update AS ON UPDATE TO audit_logs DO INSTEAD NOTHING;
CREATE RULE no_delete AS ON DELETE TO audit_logs DO INSTEAD NOTHING;

Le RULE PostgreSQL impediscono qualsiasi modifica o cancellazione dei record dopo l’inserimento. Il campo checksum permette di verificare l’integrità del payload in qualsiasi momento.

Archiviazione a lungo termine: hot/warm/cold

Per retention di anni, mantenere tutti i record su PostgreSQL non è efficiente. Un pattern comune è il tiering hot/warm/cold:

TierStorageRetentionCosto/GB/meseQuery
HotPostgreSQL0-90 giorni~$0.10 (EBS)SQL, indici, <100ms
ColdS3 Standard90 giorni - 7 anni~$0.023Athena/BigQuery, secondi-minuti
ArchiveS3 Glacier7+ anni~$0.004Ore per il restore

L’export da hot a cold può essere un cron job o un processo batch:

# Export giornaliero: audit log > 90 giorni → S3
psql -h localhost -U audit_user -d auditdb -c \
  "COPY (SELECT * FROM audit_logs WHERE timestamp < NOW() - INTERVAL '90 days')
   TO STDOUT WITH CSV HEADER" | \
  gzip > "audit-$(date +%Y%m%d).csv.gz"

aws s3 cp "audit-$(date +%Y%m%d).csv.gz" \
  s3://company-audit-archive/year=$(date +%Y)/month=$(date +%m)/

Dopo l’export, i record cold possono essere rimossi da PostgreSQL (disabilitando temporaneamente la rule no_delete con un ruolo amministrativo dedicato) per mantenere il volume del database gestibile.

Collegamento con il tail sampling

Le strategie di persistenza si integrano con il tail sampling dell’articolo precedente in una pipeline completa:

Applicazione
    |
    v
OTel Collector
    |
    +-- Tail Sampling (trace) ---> Tempo (retention 7d)
    |
    +-- Routing Connector (log)
            |
            +-- audit.event=true ---> Audit Service ---> PostgreSQL (90d) ---> S3 (7 anni)
            |
            +-- level=debug -------> Loki (retention 48h)
            |
            +-- default ------------> Loki (retention 30d)

Prima il tail sampling riduce il volume delle trace (~90%). Poi il routing separa i log per tipo. Infine, ogni destinazione applica la propria retention. Il risultato: dati diversi, lifecycle diversi, costi proporzionati al valore.


Demo: Routing in Azione

Il Module 06 include tutto il necessario per vedere il routing in funzione. Il codice completo è nel repository otel-demo.

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

1. Avviare l’infrastruttura

make infra-up-otel   # Avvia LGTM stack (Loki, Grafana, Tempo, Prometheus, OTel Collector)
make infra-up-apps   # Avvia applicazioni di supporto
make mod06-up        # Avvia shop-service, audit-service e Collector con routing

Il comando make mod06-up avvia automaticamente:

  • Il shop-service con l’endpoint /checkout che marca gli eventi audit
  • L’audit-service sulla porta 4000
  • L’OTel Collector con la configurazione di split verso Loki e audit-service

2. Generare un evento audit

curl -X POST http://localhost:8002/checkout \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "user": "alice@example.com"}'

Risposta attesa:

{"status": "processed", "orderId": "ORD-1739350800000"}

3. Verificare che il log arrivi all’audit service

docker logs module-06-advanced-routing-audit-service-1

Output atteso (estratto semplificato):

Received Audit Log Batch: {
  "resourceLogs": [{
    "resource": { "attributes": [{ "key": "service.name", "value": { "stringValue": "shop-service" } }] },
    "scopeLogs": [{
      "logRecords": [{
        "body": { "stringValue": "User checking out" },
        "attributes": [
          { "key": "audit.event", "value": { "boolValue": true } },
          { "key": "audit.user", "value": { "stringValue": "alice@example.com" } }
        ]
      }]
    }]
  }]
}

L’output mostra il batch OTLP ricevuto dall’audit service. Nell’output JSON si possono identificare:

  • I resource attributes del servizio (service.name: shop-service)
  • Gli span attributes aggiunti nel codice (audit.event: true, audit.user: alice@example.com)
  • Il body del log con il messaggio strutturato

Questo conferma che il Collector ha inoltrato correttamente il log all’audit service.

4. Verificare che il log sia anche in Loki

Aprire Grafana (http://localhost:3000) e cercare in Loki:

{service_name="shop-service"} |= "checkout"

Il log è presente anche qui. Con la configurazione fan-out del demo, entrambe le destinazioni ricevono il log. Con il routing connector attivo, solo l’audit service riceverebbe i log marcati.

5. Pulizia

# Fermare il modulo
make mod06-down

# Pulizia completa dell'infrastruttura
make infra-down-all

Il routing oltre audit e tecnici

Il routing connector non serve solo per separare audit e tecnici. Ecco scenari reali dove il pattern si applica:

ScenarioAttributo di routingDestinazioneMotivazione
Compliance auditaudit.event=trueAudit service dedicatoSeparazione fisica, immutabilità, retention lunga
PII separationcontains.pii=trueVault cifratoGDPR: dati personali in backend con accesso controllato
Cost optimizationlog.level=debugNessuna (scartato)Debug log in produzione = volume altissimo, valore basso
Critical alertinglog.level=error + http.status_code >= 500Loki + sistema di alertErrori critici devono attivare notifiche immediate
Multi-tenanttenant.id=tenant-aLoki istanza tenant AIsolamento dati tra tenant diversi

Pattern: scartare i debug log

In produzione, i log di livello debug rappresentano spesso il 70-80% del volume totale ma hanno utilità solo durante il troubleshooting attivo. Una configurazione di routing può scartarli di default e attivarli on-demand:

connectors:
  routing/logs:
    # Nessun default_pipelines: i log non instradati vengono scartati
    table:
      - context: log
        condition: severity_number >= SEVERITY_NUMBER_INFO
        pipelines: [logs/default]

service:
  pipelines:
    logs/ingestion:
      receivers: [otlp]
      exporters: [routing/logs]
    logs/default:
      receivers: [routing/logs]
      exporters: [loki]

I log con severity inferiore a INFO (debug, trace) non vengono instradati a nessuna pipeline e vengono scartati. Questo è complementare al tail sampling: il sampling riduce le trace, il routing elimina intere categorie di log. Insieme, riducono il volume complessivo di un ordine di grandezza.

Pattern: errori critici verso canale di alert

connectors:
  routing/logs:
    default_pipelines: [logs/default]
    table:
      - context: log
        condition: severity_number >= SEVERITY_NUMBER_ERROR
        action: copy    # Invia sia ad alert che al default
        pipelines: [logs/alerts]

service:
  pipelines:
    logs/ingestion:
      receivers: [otlp]
      exporters: [routing/logs]
    logs/default:
      receivers: [routing/logs]
      exporters: [loki]
    logs/alerts:
      receivers: [routing/logs]
      exporters: [loki, slack_webhook]

Con action: copy, i log con severity ERROR o superiore vengono inviati sia alla pipeline di alert sia alla pipeline default (Loki). I log normali finiscono solo in Loki. La logica di notifica è nel Collector, non nell’applicazione.

Routing e Sampling: due strumenti complementari

Una domanda frequente: qual è la differenza tra routing e tail sampling?

AspettoTail SamplingRouting
ObiettivoRidurre il volume (tenere/scartare)Decidere la destinazione
Opera suTrace completeSingoli log, trace, metriche
DecisioneTenere o eliminareDove inviare
ConfigurazioneSampling policiesRouting table
EsempioTenere solo trace con erroriInviare audit log a servizio dedicato

In un setup di produzione maturo, si usano entrambi: il tail sampling riduce il volume totale, poi il routing distribuisce i dati rimasti verso le destinazioni appropriate. Prima si decide cosa tenere, poi dove mandarlo.


Riepilogo e Checklist

ProblemaSoluzioneRisultato
Audit log mischiati con debugRouting connector + exporter dedicatoSeparazione fisica
Compliance GDPR/SOC 2Audit service con DB immutabileRequisiti normativi soddisfatti
Costi uniformi per dati diversiRouting selettivo per livelloRetention e storage ottimizzati
Nessuna priorità sui logRouting verso sistemi di alertNotifiche immediate per errori critici
PII in backend condivisoRouting verso vault cifratoAccesso controllato ai dati sensibili

Checklist pre-production

Prima di abilitare il routing in produzione:

  • Identificare i tipi di log (audit, tecnico, PII, debug) e definire gli attributi di marcatura (audit.event, log.level, contains.pii)
  • Marcare i log nel codice applicativo (span attributes o log attributes)
  • Configurare exporters e routing connector con default_pipelines sicuro
  • Testare in staging: verificare che ogni tipo arrivi alla destinazione corretta e che il default copra tutti i log non marcati
  • Monitorare otelcol_exporter_sent_log_records per ogni exporter
  • Documentare la mappa di routing (attributo -> destinazione)
  • Validare con il team compliance/security

Se tutti i check passano, il routing è pronto per il roll-out.


Risorse

Repository demo:

Documentazione:

Articoli correlati:


Per domande o feedback: francesco@montelli.dev | LinkedIn | GitHub