Blog

Log que derruba o serviço: quando a escrita de log síncrona vira o gargalo sob carga

Na primeira hora da Black Friday, a API de pedidos de um e-commerce passa de 45 ms para 2,8 segundos de p99. A CPU das 12 instâncias está em 35%, o banco responde em 4 ms, o pool de conexões tem folga e nenhum erro aparece no painel. O profile da thread principal conta outra história: 61% do tempo dentro de writeSync. Duas semanas antes, alguém ligou o nível debug para investigar um bug de frete e esqueceu de desligar; cada requisição passou a gerar nove linhas, uma delas com o payload inteiro do carrinho, e o logger escrevia de forma síncrona em um arquivo em um volume que divide IOPS com outros 30 pods do nó. Com o tráfego multiplicado por seis, cada escrita passou a esperar o disco, e cada espera parou o event loop inteiro. O time trocou para escrita assíncrona no mesmo dia, a latência voltou e, 40 minutos depois, os pods começaram a reiniciar com OOMKilled: o disco continuava lento, e agora as linhas se acumulavam no heap. Este artigo mostra por onde a linha de log passa até chegar ao disco e onde a espera volta para o processo, como medir o custo do log antes de trocar de biblioteca, como cortar volume na origem, como tirar a escrita do caminho da requisição com uma fila limitada e descarte explícito, e como provar em teste que um destino lento não derruba o serviço.

2026-10-24 / Arquitetura / 14 min

01

O serviço que ficou lento sem CPU, sem banco e sem erro

Log costuma ser tratado como efeito colateral grátis: uma chamada que não falha, não tem latência relevante e não entra no orçamento de nenhuma requisição. Isso só é verdade enquanto o destino do log é mais rápido do que a taxa com que o processo produz linhas. Quando deixa de ser, o log vira a dependência mais acoplada do serviço, porque toda requisição passa por ela, várias vezes, sem timeout, sem circuit breaker e sem que ninguém a liste no diagrama de arquitetura.

Em Node.js, o problema tem uma forma específica. A escrita síncrona em arquivo, que é o que um destino síncrono faz por baixo, executa a syscall write na thread principal e só volta quando o kernel aceitou os bytes. Com o page cache saudável, isso custa microssegundos. Com o disco saturado, com o volume de rede em throttling de IOPS ou com o kernel forçando writeback porque há páginas sujas demais, a mesma chamada leva dezenas de milissegundos. Nesse intervalo nenhuma outra requisição avança, nenhum timer dispara e nenhum health check responde. O sintoma é latência alta com CPU baixa, que é exatamente o padrão que faz o time procurar no banco primeiro.

A escrita assíncrona sem limite não resolve, só muda o lugar da fila. O processo entrega a linha ao stream, o stream devolve false porque o buffer interno passou do highWaterMark, ninguém verifica o retorno, e o stream continua aceitando e guardando tudo em memória. A latência fica ótima enquanto o heap cresce em linha reta, até o limite do cgroup. É o mesmo gargalo, agora com outro sintoma e um atraso de 40 minutos para aparecer.

Estratégia de escritaDestino lento causaSintoma visívelQuando aparece
Síncrona diretaThread principal parada na syscallp99 alto com CPU baixa, health check falhandoImediatamente, proporcional ao tráfego
Assíncrona sem limiteBuffer do stream crescendo no heapMemória em rampa, OOMKilled, perda do buffer inteiroMinutos ou horas depois do pico
Assíncrona com fila limitada e descarteLinhas de baixa prioridade descartadas e contadasContador de descarte subindo, latência e memória estáveisSó no indicador que você criou para isso

A terceira linha é o objetivo do artigo. Não existe configuração que torne um disco lento rápido; o que dá para escolher é quem paga quando ele fica lento. A resposta certa para quase todo serviço de requisição é que o log paga, perdendo linhas de forma visível e controlada, e não o cliente.

02

Por onde a linha de log passa até chegar ao disco

Em um pod de Kubernetes, o processo escreve no file descriptor 1, que normalmente é um pipe. Do outro lado está o runtime do contêiner, que lê o pipe, acrescenta timestamp e stream e grava em um arquivo em /var/log/pods no disco do nó. Um agente como Fluent Bit ou Vector lê esses arquivos e envia ao backend. Cada etapa tem sua própria capacidade, e a menor delas define a taxa máxima sustentável de log de todos os pods daquele nó juntos, não de um processo isolado.

Caminho de uma linha de log em um pod, e onde cada trecho pode empurrar a espera de volta.

  requisição ──► log.info() ──► JSON.stringify ──► write(fd 1)
                  (thread         (CPU na thread      │
                   principal)      principal)          ▼
                                              pipe do kernel (64 KiB)
                                                       │  cheio? write espera
                                                       ▼  ou o Node acumula em memória
                                              runtime do contêiner (containerd/docker)
                                                       │
                                                       ▼
                                              arquivo em /var/log/pods  ◄── disco do nó,
                                                       │                    dividido com
                                                       ▼                    os outros pods
                                              agente (Fluent Bit, Vector)
                                                       │
                                                       ▼
                                              backend (Loki, Elasticsearch, Datadog)

Qualquer trecho mais lento do que a taxa de produção vira fila.
A fila termina em um de dois lugares: no event loop (escrita síncrona) ou no heap (assíncrona sem limite).
TrechoCapacidade típicaO que acontece quando enche
Serialização (JSON.stringify)Limitada pela CPU da thread principal; objetos grandes custam milissegundosTempo de CPU tirado das requisições, mesmo com destino rápido
Pipe do kernel64 KiB por padrão no Linuxwrite bloqueia ou devolve EAGAIN, conforme o modo do fd
Runtime do contêinerLê o pipe e grava arquivo; no Docker, o modo padrão do driver é blockingPara de ler o pipe enquanto o disco não aceita; o pipe enche
Disco do nóIOPS e throughput divididos entre todos os podsLatência de escrita sobe para todos ao mesmo tempo
Agente de coletaLê arquivos com buffer próprio e limite de memóriaAtrasa o envio ou pausa a leitura; os arquivos crescem até a rotação

Dois detalhes desse caminho explicam a maioria dos incidentes. O primeiro é que a capacidade é compartilhada: um pod vizinho com debug ligado consome o disco do nó e deixa o seu log lento, sem que nada tenha mudado no seu serviço. O segundo é que a rotação de arquivo e a compressão do log antigo acontecem no mesmo disco, em rajadas, e criam picos de latência que aparecem como travadas periódicas a cada poucos minutos. Quando o p99 de um serviço tem um serrilhado regular sem relação com o tráfego, a rotação de log é uma das primeiras suspeitas.

O comportamento do processo diante do pipe cheio depende do tipo de destino do fd 1 e da plataforma, e a própria documentação do Node.js dedica uma seção a isso, porque process.stdout não se comporta como os outros streams. Em vez de depender dessa tabela, trate os dois casos como possíveis e meça: ou o write bloqueia a thread, ou o stream acumula no writableLength. A próxima seção mostra como ver qual dos dois está acontecendo.

03

Medir o custo do log antes de trocar a biblioteca

Trocar de logger sem medir é o caminho mais comum e o menos eficaz, porque a biblioteca raramente é a causa. Três números respondem se o log está custando caro: o atraso do event loop, os bytes por segundo que o processo produz e quanto está parado no buffer do stdout. Se o atraso sobe junto com o volume de log e o buffer fica perto de zero, a escrita está bloqueando. Se o buffer cresce sem parar, a escrita é assíncrona e o destino não acompanha. Se nenhum dos dois se mexe, o problema está em outro lugar.

// metricas-log.js
// Tres numeros respondem se o log esta custando caro: quanto o event loop atrasa,
// quantos bytes por segundo o processo produz e quanto ficou parado no buffer do stdout.
import { monitorEventLoopDelay } from 'node:perf_hooks';

const atraso = monitorEventLoopDelay({ resolution: 20 });
atraso.enable();

let bytesEscritos = 0;
const writeOriginal = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...resto) => {
  bytesEscritos += Buffer.byteLength(chunk);
  return writeOriginal(chunk, ...resto);
};

const INTERVALO_S = 10;
setInterval(() => {
  // stderr de proposito: a metrica sobre o stdout nao pode depender do stdout.
  process.stderr.write(
    JSON.stringify({
      msg: 'saude do log',
      eventLoopP99Ms: Number((atraso.percentile(99) / 1e6).toFixed(1)),
      eventLoopMaxMs: Number((atraso.max / 1e6).toFixed(1)),
      logBytesPorSegundo: Math.round(bytesEscritos / INTERVALO_S),
      stdoutBufferBytes: process.stdout.writableLength,
    }) + '\n',
  );
  bytesEscritos = 0;
  atraso.reset();
}, INTERVALO_S * 1000).unref();

Em produção, esses valores vão para o sistema de métricas que o serviço já usa, e não para o próprio log. A métrica sai pelo stderr no exemplo para não depender do canal que está sendo medido. Fora do processo, o diagnóstico se completa em poucos comandos: descobrir para onde o fd 1 aponta, medir a duração real de cada write com strace e descobrir quais mensagens ocupam o volume.

# Para onde o fd 1 do processo aponta: arquivo, pipe ou socket mudam o comportamento.
ls -l /proc/$(pgrep -f "node server.js")/fd/1
# l-wx------ 1 node node 64 ... 1 -> pipe:[48213907]

# Quanto tempo cada write() leva de verdade (-T mostra a duracao da syscall).
strace -f -T -e trace=write -p $(pgrep -f "node server.js") 2>&1 | head -20
# write(1, "{\"time\":1729...", 4120) = 4120 <0.031244>

# Volume por nivel e as 10 mensagens que mais ocupam espaco na ultima hora.
kubectl logs deploy/api-pedidos --since=1h \
  | jq -r '[.level, .msg] | @tsv' | sort | uniq -c | sort -rn | head -10

A conta de volume quase sempre surpreende. No incidente da introdução, cada instância atendia 250 requisições por segundo com nove linhas por requisição e média de 1,8 KB por linha, por causa do payload do carrinho. São 4 MB/s por processo, 48 MB/s para as 12 instâncias, mais de 170 GB por hora só de um serviço. Nenhuma biblioteca de log torna esse volume barato; a primeira correção é não produzi-lo.

04

Cortar volume na origem: nível, amostragem e o que não deve virar log

A linha mais barata é a que não é serializada. Antes de mexer no transporte, vale revisar o que o serviço registra por requisição e por quê. A maior parte do volume costuma vir de poucas mensagens: o log de entrada e saída de cada requisição, o payload completo de uma integração e o debug esquecido ligado. O comando de contagem da seção anterior mostra quais são em minutos.

  • Nível controlado em tempo de execução, não por deploy: o debug ligado para investigar precisa ter dono e prazo, por exemplo uma flag que volta para info sozinha depois de 30 minutos.
  • Verificar o nível antes de montar o objeto: log.debug(msg, montarDetalhes()) executa montarDetalhes mesmo com debug desligado; o guard nivelAtivo('debug') evita o custo inteiro.
  • Registrar identificadores, não objetos: pedidoId, clienteId e quantidade de itens explicam o evento; o carrinho inteiro pertence ao banco ou ao trace, onde já existe.
  • Amostrar o que é repetitivo e preservar o que é raro: 1 em cada 100 requisições bem-sucedidas, todas as que falharam ou passaram de um limite de latência.
  • Uma linha por requisição no fim, com duração, status e os campos relevantes, em vez de uma linha a cada etapa do handler.
  • Nunca registrar corpo de requisição, token, cabeçalho de autorização ou dado pessoal; além do volume, é o tipo de linha que vira incidente de privacidade.
MudançaLinhas por requisiçãoBytes médios por linhaVolume por instância
Situação do incidente (debug ligado, payload completo)91.8004,0 MB/s
Debug desligado41.1001,1 MB/s
Identificadores no lugar do payload4320320 KB/s
Uma linha de resumo por requisição1450112 KB/s
Amostragem de 1% nos sucessos, 100% nos erros0,05 em média4506 KB/s

A amostragem precisa ser determinística por requisição, não por linha: decidir uma vez, a partir do trace id, se aquela requisição será registrada, para que as linhas que sobrevivem contem uma história completa. Amostrar linha a linha produz fragmentos sem começo nem fim, que não servem para investigar nada.

05

Tirar a escrita do caminho da requisição com fila limitada e descarte explícito

Mesmo com volume baixo, o destino vai ficar lento em algum momento: rotação, disco do nó disputado, agente reiniciando. A defesa é garantir que a requisição nunca espere por ele e que a memória usada para absorver a lentidão tenha teto. O logger abaixo faz três coisas: coloca a linha em uma fila em memória e devolve na hora, escreve em lotes respeitando o retorno de write e o evento drain, e descarta linhas quando a fila passa do teto, com limites diferentes por nível e um resumo periódico do que foi perdido.

// logger.js
// A requisicao nunca espera o destino do log: a linha vai para uma fila em memoria
// com teto em bytes, e a escrita acontece em lotes, respeitando o backpressure do stream.
const NIVEIS = { debug: 20, info: 30, warn: 40, error: 50 };

export function criarLogger({
  destino = process.stdout,
  nivel = process.env.LOG_LEVEL || 'info',
  limiteBytes = 8 * 1024 * 1024, // acima disso, debug e info sao descartados
  limiteDuroBytes = 16 * 1024 * 1024, // acima disso, ate warn e error sao descartados
  tamanhoLote = 64 * 1024,
  intervaloResumoMs = 10_000,
} = {}) {
  const minimo = NIVEIS[nivel] ?? NIVEIS.info;
  const fila = [];
  let bytesNaFila = 0;
  let aguardandoDrain = false;
  let agendado = false;
  const descartadas = { debug: 0, info: 0, warn: 0, error: 0 };

  function escreverLotes() {
    agendado = false;
    while (fila.length > 0 && !aguardandoDrain) {
      let n = 0;
      let bytes = 0;
      while (n < fila.length && bytes < tamanhoLote) {
        bytes += fila[n].bytes;
        n += 1;
      }
      const lote = fila.splice(0, n);
      bytesNaFila -= bytes;
      // write() devolve false quando o buffer interno do stream passou do highWaterMark:
      // paramos de empurrar e so voltamos no 'drain', em vez de acumular sem limite.
      if (!destino.write(lote.map((l) => l.texto).join(''))) {
        aguardandoDrain = true;
        destino.once('drain', () => {
          aguardandoDrain = false;
          escreverLotes();
        });
      }
    }
  }

  function agendar() {
    if (agendado || aguardandoDrain) return;
    agendado = true;
    setImmediate(escreverLotes);
  }

  function enfileirar(texto, nomeNivel) {
    const bytes = Buffer.byteLength(texto);
    const teto = NIVEIS[nomeNivel] >= NIVEIS.warn ? limiteDuroBytes : limiteBytes;
    if (bytesNaFila + bytes > teto) {
      descartadas[nomeNivel] += 1;
      return;
    }
    fila.push({ texto, bytes });
    bytesNaFila += bytes;
    agendar();
  }

  function registrar(nomeNivel, msg, campos) {
    if (NIVEIS[nomeNivel] < minimo) return;
    let texto;
    try {
      texto = JSON.stringify({ time: Date.now(), level: nomeNivel, msg, ...campos }) + '\n';
    } catch (erro) {
      texto = JSON.stringify({ time: Date.now(), level: nomeNivel, msg, erroSerializacao: erro.message }) + '\n';
    }
    enfileirar(texto, nomeNivel);
  }

  // Descarte silencioso e pior que descarte: a cada intervalo, uma linha diz quanto se perdeu.
  const resumo = setInterval(() => {
    const total = Object.values(descartadas).reduce((a, b) => a + b, 0);
    if (total === 0) return;
    const texto = JSON.stringify({ time: Date.now(), level: 'warn', msg: 'logs descartados', ...descartadas }) + '\n';
    for (const chave of Object.keys(descartadas)) descartadas[chave] = 0;
    fila.push({ texto, bytes: Buffer.byteLength(texto) });
    bytesNaFila += Buffer.byteLength(texto);
    agendar();
  }, intervaloResumoMs);
  resumo.unref();

  return {
    nivelAtivo: (nomeNivel) => NIVEIS[nomeNivel] >= minimo,
    debug: (msg, campos) => registrar('debug', msg, campos),
    info: (msg, campos) => registrar('info', msg, campos),
    warn: (msg, campos) => registrar('warn', msg, campos),
    error: (msg, campos) => registrar('error', msg, campos),
    estado: () => ({ bytesNaFila, linhasNaFila: fila.length, bufferDoStream: destino.writableLength, descartadas: { ...descartadas } }),
    // No desligamento: escreve o que restou e espera o stream confirmar, com prazo.
    encerrar(prazoMs = 5_000) {
      clearInterval(resumo);
      return new Promise((resolve) => {
        const limite = setTimeout(resolve, prazoMs);
        const verificar = () => {
          if (fila.length > 0 || aguardandoDrain) {
            escreverLotes();
            setTimeout(verificar, 10);
            return;
          }
          destino.write('', () => {
            clearTimeout(limite);
            resolve();
          });
        };
        verificar();
      });
    },
  };
}

A política de descarte é a parte que mais exige decisão. Debug e info são descartados primeiro, porque são os mais volumosos e os menos necessários durante um incidente. Warn e error têm um limite duro maior, porque são justamente o que se quer ler depois. O que não pode acontecer é descarte silencioso: a linha "logs descartados", com a contagem por nível a cada 10 segundos, transforma a perda em fato observável e permite criar um alerta sobre ela. Uma lacuna nos logs sem essa linha parece ausência de evento; com ela, é uma lacuna explicada.

Esse logger resolve o caso assíncrono, mas não muda a natureza do destino. Se o fd 1 for um arquivo, cada write continua síncrono na thread principal, só que agora em lotes de 64 KB a cada volta do event loop em vez de uma syscall por linha, o que já reduz muito o custo. Quando o destino é inevitavelmente síncrono e lento, o passo seguinte é tirar a escrita da thread principal: o pino.transport, por exemplo, executa o destino em um worker thread e comunica as linhas por um buffer compartilhado. A mesma pergunta continua valendo nesse desenho: o que acontece quando o buffer entre as threads enche, e se essa perda fica visível.

DesenhoCusto na thread principalTeto de memóriaPerda em destino lentoQuando usar
console.log ou destino síncronoSerialização + syscall por linhaNenhum consumo extraNenhuma; quem perde é a latênciaScripts, CLIs e jobs curtos
Stream assíncrono sem verificar writeSó serializaçãoSem tetoTudo, quando o processo morre por OOMNunca de propósito
Fila limitada com descarte por nívelSerialização + syscall por loteConfigurado em bytesDebug e info primeiro, contadosServiços HTTP e consumidores de fila
Worker thread (pino.transport)Serialização + cópia para o bufferTamanho do buffer compartilhadoDepende da configuração do transporteDestino síncrono ou caro, como arquivo ou rede
Envio direto pela rede ao backendSerialização + cliente HTTPBuffer do clienteAlta em falha de redeRaramente; prefira stdout e agente

06

Desligar sem perder as últimas linhas e provar em teste

Uma fila em memória cria uma obrigação nova: as linhas que estão nela quando o processo recebe SIGTERM precisam ser escritas antes da saída, e são justamente as que explicam por que o processo está saindo. Chamar process.exit logo depois de registrar o erro fatal descarta o que ainda não foi escrito. O método encerrar escreve o que restou, espera o stream confirmar com o callback de write e tem um prazo, para que um destino travado não impeça o desligamento. No handler de SIGTERM, ele entra depois de fechar o servidor HTTP e antes de process.exit.

O teste é o que impede a regressão silenciosa, porque nenhum ambiente de desenvolvimento tem destino lento. O destino falso abaixo aceita 64 KB e leva 20 ms por escrita, simulando um coletor que não acompanha. O teste produz 100 mil linhas de info e 50 de error contra ele e verifica as quatro propriedades que importam: a fila nunca passa do teto, o event loop não trava, nenhum error é perdido abaixo do limite duro e parte dos info é de fato descartada.

// logger.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Writable } from 'node:stream';
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { criarLogger } from './logger.js';

// Destino que imita um coletor de log lento: aceita 64 KiB e leva 20 ms por escrita.
function destinoLento() {
  const linhas = [];
  const destino = new Writable({
    highWaterMark: 64 * 1024,
    write(chunk, _enc, cb) {
      linhas.push(...chunk.toString().split('\n').filter(Boolean));
      setTimeout(cb, 20);
    },
  });
  return { destino, linhas };
}

test('destino lento nao bloqueia o event loop nem faz a memoria crescer sem limite', async () => {
  const { destino, linhas } = destinoLento();
  const log = criarLogger({ destino, limiteBytes: 1024 * 1024, limiteDuroBytes: 2 * 1024 * 1024 });
  const atraso = monitorEventLoopDelay({ resolution: 10 });
  atraso.enable();

  let picoFila = 0;
  for (let rodada = 0; rodada < 50; rodada += 1) {
    for (let i = 0; i < 2_000; i += 1) log.info('pedido processado', { pedidoId: `${rodada}-${i}`, itens: 3 });
    log.error('falha ao cobrar', { pedidoId: `erro-${rodada}` });
    picoFila = Math.max(picoFila, log.estado().bytesNaFila);
    await new Promise((r) => setImmediate(r));
  }
  await log.encerrar();
  atraso.disable();

  assert.ok(picoFila <= 2 * 1024 * 1024, `fila passou do teto: ${picoFila}`);
  assert.ok(atraso.max / 1e6 < 200, `event loop travou ${atraso.max / 1e6} ms`);
  const erros = linhas.filter((l) => JSON.parse(l).level === 'error');
  assert.equal(erros.length, 50, 'nenhum error pode ser descartado abaixo do limite duro');
  assert.ok(linhas.length < 100_050, 'parte dos info deveria ter sido descartada');
});

test('encerrar escreve tudo o que estava na fila', async () => {
  const { destino, linhas } = destinoLento();
  const log = criarLogger({ destino });
  for (let i = 0; i < 5_000; i += 1) log.info('linha', { i });
  await log.encerrar();
  assert.equal(linhas.length, 5_000);
});

test('nivel abaixo do minimo nao serializa nada', () => {
  const { destino } = destinoLento();
  const log = criarLogger({ destino, nivel: 'info' });
  let chamado = false;
  log.debug('caro', { get payload() { chamado = true; return 1; } });
  assert.equal(chamado, false);
  assert.equal(log.estado().linhasNaFila, 0);
});

Com isso, o comportamento sob destino lento deixa de ser um acidente e vira uma especificação. Em produção, o complemento é um alerta sobre a linha de descarte e um painel com os três números da seção de medição. No incidente da introdução, o mesmo pico de disco teria aparecido como alguns milhares de linhas info descartadas e contadas, com p99 de 45 ms e memória estável, em vez de uma hora de checkout lento seguida de pods reiniciando.

FAQ

Perguntas frequentes

Trocar console.log por pino ou outro logger rápido resolve sozinho?

Resolve o custo de serialização, que é real, mas não resolve o destino lento. Um logger rápido produz linhas mais depressa e, com um disco saturado, chega mais rápido ao mesmo gargalo. O que resolve é a combinação de volume menor na origem, escrita fora do caminho da requisição e um limite explícito de memória com descarte contado. A biblioteca entra como implementação dessa política; sem a política, só muda a velocidade com que o problema aparece.

Descartar log não é perigoso durante um incidente?

É menos perigoso do que as alternativas. Sem descarte, o destino lento deixa o serviço lento ou derruba o processo por falta de memória, e aí se perdem os logs e as requisições. Com descarte por nível, o que sai primeiro é debug e info, e warn e error têm um teto maior. E o descarte é registrado: a linha com a contagem por nível diz exatamente quanto se perdeu e quando, o que permite separar "não aconteceu" de "aconteceu e não foi registrado".

Por que não mandar o log direto para o backend pela rede, sem stdout?

Porque isso coloca uma dependência de rede no caminho de cada requisição e move para dentro do processo responsabilidades que o agente de coleta já cumpre: buffer em disco, retentativa, backpressure e continuidade quando o backend cai. Escrever em stdout e deixar o agente enviar desacopla o ciclo de vida do serviço do ciclo de vida do backend de log. Envio direto só compensa em ambientes sem agente, como algumas plataformas serverless, e mesmo assim com fila limitada e descarte, pelos mesmos motivos deste artigo.

O log é uma dependência do serviço; trate o destino lento como trata qualquer dependência lenta

Toda requisição passa pelo log várias vezes, sem timeout e sem limite, e por isso um disco lento ou um pipe cheio afetam o serviço inteiro. A escrita síncrona transforma essa lentidão em event loop parado, e a assíncrona sem limite transforma em memória crescendo até o OOM. O caminho é medir antes de trocar de biblioteca, com atraso do event loop, bytes por segundo e buffer do stdout; cortar volume na origem com nível controlado, identificadores no lugar de objetos e amostragem por requisição; tirar a escrita do caminho da requisição com uma fila limitada em bytes, escrita em lotes que respeita o backpressure e descarte por nível sempre contado; escrever o que restou no desligamento com prazo; e provar em teste, com um destino falso lento, que a latência e a memória continuam estáveis quando o log não acompanha.