Ferramentas & Produtividade • 17 min de leitura

Logs que realmente respondem perguntas: do clique no React até o banco de dados

Logs que realmente respondem perguntas: do clique no React até o banco de dados

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

Tags: logging, observabilidade, nodejs, php, react, correlation-id, pino, monolog