Todo desenvolvedor já viveu essa cena: um bug chega pelo Slack às 23h, você abre os logs e encontra mil linhas de erros sem contexto. Você sabe que algo quebrou, mas não sabe quem estava usando, o que estava fazendo, nem onde exatamente a coisa falhou — se foi no frontend, na API Node, no serviço PHP, ou no banco.
A maioria dos artigos sobre logging ensina a configurar o Winston ou o Monolog em JSON. Isso é necessário, mas insuficiente. O problema real não é o formato do log — é a falta de um fio condutor que conecte todos os logs de uma única operação do usuário, de ponta a ponta.
Esse fio condutor tem nome: correlation ID. E este artigo mostra como implementá-lo de verdade, do clique no React até a query no banco, passando por uma API Node.js e um serviço PHP legado.
---
O que torna um log inútil
Antes de escrever código, vale entender por que a maioria dos logs não responde às perguntas que importam.
Log inútil:
[2026-04-15 23:14:02] ERROR: Payment failed
[2026-04-15 23:14:02] INFO: Request completed in 1243ms
[2026-04-15 23:14:03] ERROR: Database connection timeout
Você sabe que algo deu errado. Mas não sabe:
- Qual usuário estava tentando pagar?
- Era uma compra nova ou uma recompra?
- O timeout do banco causou o erro de pagamento, ou eram eventos distintos?
- Em qual das três instâncias do servidor isso aconteceu?
Log útil:
{"timestamp":"2026-04-15T23:14:02Z","level":"error","correlation_id":"req-7f3a9b","user_id":"usr-4821","service":"payment-api","event":"payment_failed","order_id":"ord-9981","reason":"provider_timeout","duration_ms":1243}
{"timestamp":"2026-04-15T23:14:03Z","level":"error","correlation_id":"req-7f3a9b","service":"payment-api","event":"db_timeout","query":"SELECT * FROM orders WHERE...","host":"api-03"}
Agora você consegue filtrar por correlation_id: req-7f3a9b e ver exatamente o que aconteceu naquela requisição específica, em ordem, em todos os serviços.
---
A arquitetura que vamos logar
O sistema de exemplo tem três camadas que se comunicam:
[React SPA] → [API Node.js/Express] → [Serviço PHP legado]
↓ ↓ ↓
logs no logs em logs em
console/ arquivo JSON arquivo JSON
analytics + stdout + stdout
O correlation ID nasce no React (ou no Nginx, se a requisição vier direto), viaja como header HTTP por todas as camadas, e aparece em todos os logs. Quando algo quebra, um único filtro mostra tudo.
---
Camada 1: React — onde o log começa
O frontend costuma ser ignorado em estratégias de logging. Erro grave: é onde o usuário está, e o contexto do que ele estava fazendo é insubstituível.
Gerando e propagando o correlation ID
// src/lib/logger.ts
import { v4 as uuidv4 } from 'uuid';
// Gera um ID único por sessão de navegação (persiste enquanto a aba estiver aberta)
const SESSION_ID = uuidv4();
// Gera um novo ID para cada "operação" do usuário (clique em botão, submit de form)
export function createCorrelationId(): string {
return `req-${uuidv4().slice(0, 8)}`;
}
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
interface LogEntry {
timestamp: string;
level: LogLevel;
correlation_id: string;
session_id: string;
user_id?: string;
event: string;
[key: string]: unknown;
}
class FrontendLogger {
private correlationId: string = '';
private userId: string = '';
setCorrelationId(id: string) {
this.correlationId = id;
}
setUserId(id: string) {
this.userId = id;
}
private buildEntry(level: LogLevel, event: string, data?: Record<string, unknown>): LogEntry {
return {
timestamp: new Date().toISOString(),
level,
correlation_id: this.correlationId,
session_id: SESSION_ID,
user_id: this.userId || undefined,
event,
url: window.location.pathname,
...data,
};
}
info(event: string, data?: Record<string, unknown>) {
const entry = this.buildEntry('info', event, data);
// Em dev: mostra no console legível
if (process.env.NODE_ENV === 'development') {
console.log(`[${entry.level.toUpperCase()}] ${event}`, data);
}
// Em prod: envia para o backend de coleta de logs (ex: seu próprio endpoint)
this.send(entry);
}
error(event: string, error: Error, data?: Record<string, unknown>) {
const entry = this.buildEntry('error', event, {
error_message: error.message,
error_stack: error.stack,
...data,
});
console.error(`[ERROR] ${event}`, error);
this.send(entry);
}
private send(entry: LogEntry) {
// Usa sendBeacon para não bloquear navegação e não perder logs em page unload
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(entry)], { type: 'application/json' });
navigator.sendBeacon('/api/logs/client', blob);
}
}
}
export const logger = new FrontendLogger();
Usando o logger com o correlation ID nos fetches
A chave é passar o correlation ID como header em toda requisição HTTP:
// src/lib/api.ts
import { logger, createCorrelationId } from './logger';
export async function apiRequest<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
// Cria um novo ID para esta operação específica
const correlationId = createCorrelationId();
logger.setCorrelationId(correlationId);
const startTime = performance.now();
logger.info('api_request_start', {
method: options.method || 'GET',
url,
});
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
// Este header viaja para todos os serviços
'X-Correlation-ID': correlationId,
...options.headers,
},
});
const duration = Math.round(performance.now() - startTime);
if (!response.ok) {
logger.error('api_request_failed', new Error(`HTTP ${response.status}`), {
status: response.status,
url,
duration_ms: duration,
});
throw new Error(`Request failed: ${response.status}`);
}
logger.info('api_request_success', { url, status: response.status, duration_ms: duration });
return response.json();
} catch (err) {
const duration = Math.round(performance.now() - startTime);
logger.error('api_request_error', err as Error, { url, duration_ms: duration });
throw err;
}
}
// src/components/CheckoutButton.tsx
import { apiRequest } from '../lib/api';
import { logger } from '../lib/logger';
export function CheckoutButton({ orderId }: { orderId: string }) {
async function handleCheckout() {
logger.info('checkout_initiated', { order_id: orderId });
try {
const result = await apiRequest('/api/orders/checkout', {
method: 'POST',
body: JSON.stringify({ order_id: orderId }),
});
logger.info('checkout_success', { order_id: orderId });
} catch (err) {
// O erro já foi logado dentro do apiRequest com o mesmo correlation_id
}
}
return <button onClick={handleCheckout}>Finalizar compra</button>;
}
---
Camada 2: Node.js — o hub que propaga o contexto
A API Node.js recebe o correlation ID do frontend (ou gera um novo se a requisição não vier do React), injeta em todos os logs da requisição, e o repassa para o serviço PHP.
Setup do Pino com contexto por requisição
O Pino é mais rápido que o Winston para produção. A chave aqui é usar AsyncLocalStorage para carregar o contexto da requisição sem precisar passar o logger por toda a cadeia de chamadas.
npm install pino pino-pretty express uuid
// src/logger.js
const pino = require('pino');
const { AsyncLocalStorage } = require('async_hooks');
// AsyncLocalStorage mantém um "contexto" isolado por requisição (como uma thread local)
// Isso permite que qualquer função chamada durante o request acesse o contexto
// sem receber o logger como parâmetro
const requestContext = new AsyncLocalStorage();
const baseLogger = pino({
level: process.env.LOG_LEVEL || 'info',
// Em produção: JSON puro para ser consumido por Loki, CloudWatch, Datadog etc.
// Em dev: usa pino-pretty para leitura humana
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
// Campos que aparecem em TODOS os logs desta instância
base: {
service: process.env.SERVICE_NAME || 'api',
env: process.env.NODE_ENV || 'development',
host: require('os').hostname(),
},
// Padroniza o nome do campo de timestamp para compatibilidade com ferramentas
timestamp: pino.stdTimeFunctions.isoTime,
});
// Logger que injeta automaticamente o contexto da requisição atual
const logger = {
info: (event, data) => getLogger().info({ event, ...data }),
warn: (event, data) => getLogger().warn({ event, ...data }),
error: (event, err, data) => getLogger().error({
event,
error_message: err?.message,
error_stack: err?.stack,
...data,
}),
debug: (event, data) => getLogger().debug({ event, ...data }),
};
function getLogger() {
const ctx = requestContext.getStore();
// Se estiver dentro de uma requisição, retorna um logger com o contexto injetado
if (ctx) {
return baseLogger.child(ctx);
}
return baseLogger;
}
module.exports = { logger, requestContext };
// src/middleware/requestContext.js
const { v4: uuidv4 } = require('uuid');
const { requestContext, logger } = require('../logger');
function requestContextMiddleware(req, res, next) {
// Usa o correlation ID do cliente ou gera um novo
const correlationId = req.headers['x-correlation-id'] || `req-${uuidv4().slice(0, 8)}`;
// Contexto que vai aparecer em todos os logs desta requisição
const ctx = {
correlation_id: correlationId,
request_id: uuidv4(), // ID único por passagem neste serviço (diferente do correlation_id)
user_id: req.user?.id, // preenchido após middleware de autenticação
ip: req.ip || req.headers['x-forwarded-for']?.split(',')[0].trim(),
method: req.method,
path: req.path,
};
// Propaga o correlation ID na resposta para o cliente poder rastrear
res.setHeader('X-Correlation-ID', correlationId);
// Executa o restante da cadeia dentro do contexto desta requisição
requestContext.run(ctx, () => {
const startTime = Date.now();
logger.info('request_start');
res.on('finish', () => {
logger.info('request_end', {
status: res.statusCode,
duration_ms: Date.now() - startTime,
});
});
next();
});
}
module.exports = { requestContextMiddleware };
// src/app.js
const express = require('express');
const { requestContextMiddleware } = require('./middleware/requestContext');
const { logger } = require('./logger');
const app = express();
app.use(express.json());
app.set('trust proxy', 1);
// Aplica o contexto em todas as requisições — deve ser o primeiro middleware
app.use(requestContextMiddleware);
// Captura erros não tratados e loga com o contexto correto
app.use((err, req, res, next) => {
logger.error('unhandled_error', err);
res.status(500).json({ error: 'Internal server error' });
});
module.exports = app;
Propagando o correlation ID para o serviço PHP
// src/services/paymentService.js
const { logger } = require('../logger');
const { requestContext } = require('../logger');
async function processPayment(orderId, amount) {
// Pega o correlation ID do contexto atual da requisição
const ctx = requestContext.getStore();
const correlationId = ctx?.correlation_id;
logger.info('payment_start', { order_id: orderId, amount });
try {
// Passa o correlation ID para o serviço PHP via header
const response = await fetch(`${process.env.PHP_SERVICE_URL}/payment/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Correlation-ID': correlationId, // o fio condutor continua
'X-Request-Source': 'node-api',
'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
},
body: JSON.stringify({ order_id: orderId, amount }),
});
if (!response.ok) {
const error = await response.json();
logger.error('payment_provider_error', new Error(error.message), {
order_id: orderId,
provider_code: error.code,
});
throw new Error('Payment failed');
}
const result = await response.json();
logger.info('payment_success', { order_id: orderId, transaction_id: result.transaction_id });
return result;
} catch (err) {
logger.error('payment_exception', err, { order_id: orderId });
throw err;
}
}
module.exports = { processPayment };
---
Camada 3: PHP — o serviço legado que também precisa do fio
O serviço PHP recebe o X-Correlation-ID do Node.js e injeta em todos os seus próprios logs via processor do Monolog.
composer require monolog/monolog
<?php
// src/Logger/RequestContext.php
namespace App\Logger;
/**
* Contexto global da requisição.
* Em PHP, como cada requisição é um processo isolado, podemos usar uma variável
* estática simples em vez de AsyncLocalStorage.
*/
class RequestContext
{
private static array $context = [];
public static function set(string $key, mixed $value): void
{
self::$context[$key] = $value;
}
public static function get(string $key, mixed $default = null): mixed
{
return self::$context[$key] ?? $default;
}
public static function all(): array
{
return self::$context;
}
public static function initialize(array $serverVars = []): void
{
// Lê o correlation ID do header (PHP converte headers para $_SERVER com HTTP_ prefixo)
$correlationId = $serverVars['HTTP_X_CORRELATION_ID']
?? $serverVars['HTTP_X_REQUEST_ID']
?? 'req-' . substr(bin2hex(random_bytes(4)), 0, 8);
self::set('correlation_id', $correlationId);
self::set('request_source', $serverVars['HTTP_X_REQUEST_SOURCE'] ?? 'direct');
self::set('ip', $serverVars['HTTP_X_FORWARDED_FOR']
? explode(',', $serverVars['HTTP_X_FORWARDED_FOR'])[0]
: ($serverVars['REMOTE_ADDR'] ?? 'unknown'));
self::set('method', $serverVars['REQUEST_METHOD'] ?? 'CLI');
self::set('path', $serverVars['REQUEST_URI'] ?? '');
self::set('host', gethostname());
}
}
<?php
// src/Logger/LoggerFactory.php
namespace App\Logger;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Processor\ProcessorInterface;
use Monolog\LogRecord;
/**
* Processor que injeta o contexto da requisição em todos os logs.
* Com Monolog 3.x, processors recebem um LogRecord imutável e retornam um novo.
*/
class RequestContextProcessor implements ProcessorInterface
{
public function __invoke(LogRecord $record): LogRecord
{
$ctx = RequestContext::all();
// Injeta o contexto como campos de primeiro nível no log
// (não como "extra", para facilitar busca no Grafana/Elasticsearch)
return $record->with(extra: array_merge($record->extra, $ctx));
}
}
class LoggerFactory
{
public static function create(string $channel): Logger
{
$logger = new Logger($channel);
// Handler: stdout em JSON (para ser capturado pelo Docker/systemd e enviado ao agregador)
$handler = new StreamHandler('php://stdout', Level::Debug);
$handler->setFormatter(new JsonFormatter());
// Em produção, arquivo com rotação diária pode complementar o stdout
// $fileHandler = new RotatingFileHandler('/var/log/app/payment.log', 30, Level::Info);
// $fileHandler->setFormatter(new JsonFormatter());
// $logger->pushHandler($fileHandler);
$logger->pushHandler($handler);
// Processor que injeta o contexto da requisição (correlation_id, user_id, etc.)
$logger->pushProcessor(new RequestContextProcessor());
return $logger;
}
}
<?php
// public/index.php (bootstrap da aplicação)
require_once __DIR__ . '/../vendor/autoload.php';
use App\Logger\RequestContext;
use App\Logger\LoggerFactory;
// Inicializa o contexto da requisição com os dados do servidor
RequestContext::initialize($_SERVER);
// A partir daqui, todos os logs terão o correlation_id injetado automaticamente
$logger = LoggerFactory::create('payment-service');
$startTime = microtime(true);
$correlationId = RequestContext::get('correlation_id');
// Propaga o correlation ID na resposta para facilitar debug
header("X-Correlation-ID: {$correlationId}");
$logger->info('request_start', [
'path' => $_SERVER['REQUEST_URI'],
'method' => $_SERVER['REQUEST_METHOD'],
]);
// Roteamento simples (em produção, use um framework com middleware)
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
try {
if ($path === '/payment/process' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$body = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
// Injeta o user_id no contexto se disponível no body
if (isset($body['user_id'])) {
RequestContext::set('user_id', $body['user_id']);
}
$result = processPayment($body, $logger);
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($result);
} else {
http_response_code(404);
echo json_encode(['error' => 'Not found']);
}
} catch (\Throwable $e) {
$logger->error('unhandled_exception', [
'exception_class' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
} finally {
$durationMs = round((microtime(true) - $startTime) * 1000);
$logger->info('request_end', [
'status' => http_response_code(),
'duration_ms' => $durationMs,
]);
}
function processPayment(array $body, \Monolog\Logger $logger): array
{
$orderId = $body['order_id'] ?? null;
$amount = $body['amount'] ?? null;
if (!$orderId || !$amount) {
$logger->warning('payment_invalid_input', [
'received_keys' => array_keys($body),
]);
throw new \InvalidArgumentException('order_id e amount são obrigatórios');
}
$logger->info('payment_processing', [
'order_id' => $orderId,
'amount' => $amount,
]);
// Simula integração com provedor de pagamento
// Em produção, aqui viria a chamada ao gateway com try/catch específico
$transactionId = 'txn-' . bin2hex(random_bytes(6));
$logger->info('payment_approved', [
'order_id' => $orderId,
'transaction_id' => $transactionId,
]);
return ['transaction_id' => $transactionId, 'status' => 'approved'];
}
---
O que você vê nos logs ao final
Depois de um checkout, todos os logs da operação compartilham o mesmo correlation_id. Em qualquer ferramenta de busca — Grafana Loki, Elasticsearch, CloudWatch — basta filtrar por esse ID:
// Frontend (enviado para /api/logs/client)
{"timestamp":"2026-04-15T23:14:01Z","level":"info","event":"checkout_initiated","correlation_id":"req-7f3a9b","session_id":"sess-a1b2c3","user_id":"usr-4821","url":"/checkout","order_id":"ord-9981"}
{"timestamp":"2026-04-15T23:14:01Z","level":"info","event":"api_request_start","correlation_id":"req-7f3a9b","method":"POST","url":"/api/orders/checkout"}
// Node.js
{"timestamp":"2026-04-15T23:14:01Z","level":"info","event":"request_start","correlation_id":"req-7f3a9b","service":"api","user_id":"usr-4821","method":"POST","path":"/api/orders/checkout","host":"api-03"}
{"timestamp":"2026-04-15T23:14:01Z","level":"info","event":"payment_start","correlation_id":"req-7f3a9b","order_id":"ord-9981","amount":159.90}
// PHP
{"timestamp":"2026-04-15T23:14:02Z","level":"info","event":"request_start","correlation_id":"req-7f3a9b","service":"payment-service","request_source":"node-api","path":"/payment/process","host":"php-01"}
{"timestamp":"2026-04-15T23:14:02Z","level":"info","event":"payment_processing","correlation_id":"req-7f3a9b","order_id":"ord-9981","amount":159.90}
{"timestamp":"2026-04-15T23:14:02Z","level":"info","event":"payment_approved","correlation_id":"req-7f3a9b","order_id":"ord-9981","transaction_id":"txn-a3f9c2b1d4e7"}
// Node.js (resposta do PHP)
{"timestamp":"2026-04-15T23:14:02Z","level":"info","event":"payment_success","correlation_id":"req-7f3a9b","order_id":"ord-9981","transaction_id":"txn-a3f9c2b1d4e7"}
{"timestamp":"2026-04-15T23:14:02Z","level":"info","event":"request_end","correlation_id":"req-7f3a9b","status":200,"duration_ms":412}
Um único filtro por req-7f3a9b. Toda a história de um checkout, em ordem, em três serviços diferentes.
---
O que não logar: dados que você vai se arrepender
Com LGPD e GDPR, logar os dados errados tem consequências reais. Nunca inclua nos logs:
- Senhas, tokens de sessão, chaves de API — mesmo que sejam apenas os primeiros/últimos caracteres
- Números de cartão de crédito (inclusive parciais — PCI-DSS é rigoroso)
- CPF, RG, data de nascimento completa
- Endereços de e-mail em endpoints de autenticação (aparecem em logs de tentativa de login)
- Corpo completo de requests em endpoints de cadastro
O padrão seguro é logar identificadores (IDs de entidades), não os dados em si. Em vez de email: joao@exemplo.com, logue user_id: usr-4821. Em vez de card_number: 4111...1111, logue payment_method_id: pm-9a3f.
Se precisar inspecionar dados em produção para debug, use ferramentas de APM com acesso controlado e auditado — não logs.
---
Configurando retenção e rotação
Logs sem política de retenção viram passivo. Defina antes de ir para produção:
No Nginx (rotação de arquivo):
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
missingok
rotate 14 # mantém 14 dias
compress
delaycompress
notifempty
sharedscripts
postrotate
nginx -s reopen
endscript
}
No Node.js com Pino (rotação por tamanho):
const logger = pino(pino.destination({
dest: '/var/log/app/api.log',
sync: false, // async para não bloquear o event loop
}));
// Use pino-roll para rotação automática:
// npm install pino-roll
// node app.js | pino-roll /var/log/app/api.log --size 100m --limit 7
No PHP com Monolog:
use Monolog\Handler\RotatingFileHandler;
// Mantém 30 arquivos diários, comprime os antigos
$handler = new RotatingFileHandler(
filename: '/var/log/app/payment.log',
maxFiles: 30,
level: Level::Info
);
---
Referências
- Pino — Node.js logger
- Monolog — PHP logging library
- AsyncLocalStorage — Node.js docs
- OWASP Logging Cheat Sheet
- The Twelve-Factor App — Logs
- W3C Trace Context spec — o padrão que inspirou o correlation ID
Tags: logging, observabilidade, nodejs, php, react, correlation-id, pino, monolog