Luiz Dubiela · engineering notes
Observabilidade

Wide Events: cortando 80% do volume de log e melhorando a observabilidade

Um fluxo, 48 linhas de log, e o que muda quando você para de logar passos e passa a logar etapas.

Luiz Dubiela — Staff Software Engineer, autorização de cartões e infraestrutura de pagamentos · Agosto de 2026
Os números medidos vêm de uma única transação em ambiente de pré-produção, antes e depois da mudança. Nomes de serviço, identificadores e textos de log são fictícios. Figuras marcadas como modelled multiplicam uma medição por uma premissa de tráfego.

Um sistema pode gerar centenas de linhas de log para contar a história de uma requisição.

Isso não é descuido, é o padrão. Uma autorização, por exemplo, atravessa vários serviços; cada um contribui com dezenas de linhas; no caminho inteiro, vira centena. A primeira coisa que isso te custa é dinheiro. A segunda, e a mais cara, é que ninguém consegue ler.

E quando o volume aperta, a saída costuma ser perder log, amostrar ou encurtar retenção — três formas diferentes de jogar informação fora.

Nesse ponto a resposta fácil está ali, barata o suficiente pra aprovar sem reunião: sobe o limite de ingestão, estende a retenção, toca a sprint. Todo time já tomou essa decisão, e na maioria das vezes é a decisão certa.

E aí vem a pergunta chata, que é a que explica por que vale seguir outro caminho:

Estamos resolvendo isso, ou estamos pagando pra não ter que resolver?

Responder passa por contar primeiro.

One authorization across the whole path
Uma autorização no caminho inteiro — a contagem de linhas por serviço é ilustrativa; a razão de colapso é a medida.

1. O que a contagem mostra

Pega um fluxo dentro de um único serviço de autorização — não o caminho todo, só um serviço. 48 linhas de log, produzidas por 14 loggers diferentes, pra um fluxo que terminou em 75 milissegundos.

Cada uma dessas linhas é verdadeira. Nenhuma delas serve sozinha:

[IntegrityLocker] Locking consumer
[HttpClient] Send POST https://fake-url.com/v1/receivables
[HttpClient] Receive response 200
[HttpClient] Send POST https://antifraude/v1/authorize
[HttpClient] Receive response 200
[Auth::UseCase] Validating responses
[Auth::UseCase] Processing authorization
[IntegrityLocker] Release integrity locker
[Server] Response built

Para entender uma única transação, alguém filtra por correlation id, ordena por timestamp, lê 48 linhas e subtrai timestamp na mão pra descobrir onde o tempo foi.

The story is all there. It is just unreadable.
A história está toda ali. Só que ilegível.

E vale parar aqui: ninguém decidiu logar 48 vezes. Cada linha entrou por um bom motivo, escrita por gente competente, em pull requests diferentes, ao longo de anos. Ninguém foi descuidado. O volume não foi projetado — ele se acumulou. Isso vale pra quase todo problema de log que eu já vi, e é por isso que "loga menos" é conselho inútil. Logar menos onde? Toda linha tem um autor que tinha um motivo.


2. De onde o volume realmente vem

Aqui está o pulo do gato.

Antes de mudar qualquer coisa, precisamos olhar do que uma linha de log é feita:

2026-08-10 10:11:44.443  INFO  [pay-7f3a91c4…2311]  AntifraudEvaluationService
[event-loop-thread-5]  pod=payments-api-6d9f4c7b85-x2knq  svc=payments-api  ver=v2.14.3
az=sa-east-1a  OrderId=ORD-4B7C2E  AuthorizationId=AUTH-8D31C2  MerchantId=MER-2F19A8
│ Performing antifraud evaluation.

Tudo antes do │ é carimbo. Tudo depois é o que aconteceu.

O carimbo é cobrado uma vez por linha, e é idêntico em todas as linhas do mesmo correlation id. Pra contar uma história, você paga por ele 48 vezes. É daí que o volume vem: não do que você está registrando, mas de quantas vezes você registra o contexto em volta.

Uma ressalva de precisão, porque é aqui que o argumento costuma ser exagerado e a pessoa leva correção nos comentários: num sistema como o Loki, stream labels — cluster, namespace, region, job — são indexados uma vez por stream, não uma vez por linha. Nesses campos específicos, colapsar linhas não economiza nada. O que de fato encolhe é:

One log line, expanded.
Uma linha de log, expandida. Só o Body e os atributos estruturados dizem o que aconteceu; tudo na camada do meio é cobrado nas 48 linhas.

É aqui que wide events brilham: cortam linhas sem perder o stream de eventos que aconteceu. Corte as linhas em 16×, e você corta o carimbo em 16× — sem perder o que importa.


3. Log não é registro. É narrativa.

Vínhamos tratando log como transcrição de execução, um registro fiel de cada passo que o programa deu. Por esse critério, 48 linhas é um sucesso. Não falta nada.

Mas ninguém lê transcrição. Quando alguém abre um log às 3 da manhã, não está auditando execução. Está fazendo uma pergunta de história: o que aconteceu com esta transação, em que ordem, e onde deu errado?

Completude e legibilidade não são a mesma propriedade. A gente tinha maximizado uma e nunca medido a outra.

E narrativa se lê melhor resumida — com o detalhe disponível no instante em que você quiser. Isso não é concessão. É como toda outra forma de informação escrita funciona.


4. O acumulador

Em vez de escrever uma linha a cada passo, você mantém um acumulador no escopo da requisição. Cada passo anexa um evento. Nos checkpoints que significam alguma coisa no fluxo, você faz o flush.

no início da requisição:
  ctx.events  = []
  ctx.started = now()
  ctx.cid     = correlationFrom(request)

# substitui log.info("Performing antifraud evaluation.", attrs)
ctx.addEvent("Performing antifraud evaluation.", attrs)

# implementação do método que substitui log.info
addEvent(description, attrs = {}, level = INFO) {
  this.events.append({
      Description: description,
      OffsetMs:    now() - this.started,
      Attributes:  attrs,
      Level:       level
  })
}

# em cada checkpoint
flush(description, attrs = {}) {
  events = this.flushEvents()
  level  = greaterSeverity(events)
  attrs  = attrs << [
    EventCount: sizeOf(events),
    Events:     events
  ]

  log.onSeverity(this.cid, level, {
      Body:  description,
      Attrs: attrs
  })
}

Três decisões de design carregam quase todo o valor:

Checkpoints cortam fatias disjuntas. ctx.events é esvaziado em todo flush. Nenhum evento aparece em duas linhas, e perder uma linha te custa exatamente aquela fatia do fluxo, não a história inteira. Isso importa quando o problema de origem era perda de log.

OffsetMs é medido desde o início do fluxo, não entre passos. Offsets a partir de uma origem única continuam fazendo sentido mesmo se uma linha se perder; deltas entre passos adjacentes, não.

O texto das mensagens não muda. Passamos as strings originais sem alteração para o Description. Toda query, alerta e dashboard que casava com texto de mensagem continuou funcionando durante o rollout. É a diferença entre uma mudança que você sobe aos poucos e uma que exige virada de chave coordenada.


5. O que merece virar evento

Colapsar 48 linhas em 3 é mecânico. E é a metade menor do ganho — se você parar aí, comprimiu o ruído em vez de remover.

A pergunta de verdade que o acumulador te obriga a responder é uma que o log linha a linha deixa você evitar pra sempre: o que de fato merece ser registrado?

Quando cada statement de log é uma linha própria, adicionar mais uma é de graça e ninguém revisa. Quando os eventos se acumulam num payload que você vai ler como uma narrativa só, um evento inútil fica visivelmente inútil — ele está ali no array, na sua frente, sem acrescentar nada.

O que mantivemos:

O que descartamos:

Um teste grosseiro que se sustentou bem: se esse evento sumisse da narrativa, eu notaria, e ficaria pior? Se a resposta for não, aquilo nunca foi observabilidade — foi um log de debug que alguém deixou pra si mesmo enquanto escrevia o código, e que vem sendo faturado todo mês desde então.

Fizemos isso de forma conservadora — 48 linhas viraram 47 eventos, ou seja, quase não podamos na primeira passada. Foi deliberado: muda a forma primeiro, prova que nada quebrou, poda depois. É da poda que vêm os segundos 80%, e é muito mais fácil discutir sobre ela quando dá pra ver os 47 eventos juntos num lugar só.


6. Por que três linhas e não uma

Três não é número mágico nem boa prática: é o que o domínio do exemplo produziu. Portanto, não existe número certo — cada domínio terá o seu, e em muitos deles nunca vai ser necessário usar wide events.

Um checkpoint não é um intervalo de tempo, e não é uma contagem de eventos. É uma etapa do processo de negócio, nomeada na linguagem do negócio. Pra um fluxo de cartão isso significa coisas como: a requisição foi recebida e entendida; a decisão de autorização foi tomada e registrada; o resultado foi persistido e devolvido. Essas são as etapas que alguém usaria de verdade se você perguntasse o que aconteceu com uma transação. Ninguém nunca respondeu essa pergunta com "os primeiros 26 passos foram bem".

Acerte as etapas e o número cai sozinho. O nosso deu três. Uma captura com perna de liquidação daria quatro. Uma leitura simples dá uma — e uma está certo, é a forma canônica da literatura. Se a sua unidade de trabalho não tem etapas internas com significado, não invente pra bater um número.

O teste que mantém isso honesto: você consegue nomear o checkpoint sem se referir ao código? OrderValidated e AuthorizationDecided significam alguma coisa pra quem nunca abriu o repositório. Checkpoint2 e AfterServiceCall não — e um checkpoint que você não consegue nomear em linguagem de domínio é um que não sobrevive ao próximo refactor, porque não tem nada ancorando ele.

Uma vez que as etapas vêm do domínio, outras quatro coisas se encaixam, o que costuma ser sinal de que a fronteira está no lugar certo:

Uma linha perdida custa uma etapa, não a história. Perda de log sob carga é o que começa a maioria desses projetos. Se a narrativa inteira depende de uma linha, perder ela torna a transação invisível. Três fatias disjuntas significam que você perde um terço e ainda sabe que o fluxo existiu e mais ou menos onde ele parou.

Um fluxo que morre ainda deixa evidência. Um evento emitido no fim só existe se houver um fim. Crash, timeout ou uma mensagem envenenada no meio do parse, e um design de evento único te dá silêncio justamente nas transações que você mais precisa ver. Com etapas, você tem tudo até o último flush — e a ausência do próximo é, ela mesma, o sinal.

Etapas de domínio costumam coincidir com fronteiras de responsabilidade. Nossas três etapas por acaso são as fronteiras entre os componentes que as possuem, e o fluxo atravessa fronteiras assíncronas entre elas. Cada linha vem do componente que a produziu. Não é coincidência — é o que fronteiras de serviço bem desenhadas parecem quando funcionam.

Linhas têm limite prático de tamanho. 47 eventos com seus atributos num payload só é uma linha grande, e muitos pipelines truncam num tamanho fixo. Um wide event truncado é pior que vários íntegros, porque você perde a cauda silenciosamente.

O trade-off, dito com todas as letras: três linhas significam pagar o carimbo três vezes em vez de uma, e remontar o fluxo inteiro significa buscar três linhas em vez de uma. É um negócio que vale a pena quando durabilidade sob carga é o motivo pelo qual você começou.


7. Erro e warning não vão dentro do blob

Se uma falha vira uma entrada dentro de um array Events numa linha carimbada como INFO, então todo alerta baseado em severidade para de disparar, seu error tracker não vê nada, o dashboard filtrado em level=error fica em silêncio, e o gráfico em que todo mundo confia mostra uma melhora que é inteiramente artefato da sua mudança de logging. Você vai ter zerado sua taxa de erro tornando os erros inobserváveis.

Três regras resolvem.

1. A linha herda a maior severidade que ela carrega. Essa é a importante, e é o truque inteiro. Um checkpoint não é INFO por padrão — sua severidade é a severidade máxima dos eventos acumulados nele. Um evento WARN na fatia e a linha do checkpoint é emitida como WARN. Um erro e ela é emitida como ERROR.

flush(description, attrs = {}) {
  events = this.flushEvents()
  level  = greaterSeverity(events)
  attrs  = attrs << [
    EventCount: sizeOf(events),
    Events:     events
  ]

  log.onSeverity(this.cid, level, {
      Body:  description,
      Attrs: attrs
  })
}

Severidade continua significando exatamente o que significava antes: tem alguma coisa aqui que um humano deveria olhar? Todo alerta, filtro e dashboard que se apoia em level continua funcionando sem saber nada do novo formato — e é isso que torna a mudança subível sem uma migração coordenada do seu alerting.

2. Erros também são emitidos na hora, em linha própria. Promoção de severidade não basta sozinha para erros, por dois motivos: o stack trace pertence a uma linha própria, não enfiado dentro de uma string de descrição, e uma exceção pode impedir que o flush aconteça. Então erros são escritos no momento em que ocorrem, exatamente como antes, e registrados no acumulador pra que a narrativa continue completa, com a falha em sequência e com seu offset. Sim, o erro aparece duas vezes. Essa duplicação é deliberada e barata, porque erro é raro — otimizar a contagem de bytes do seu caminho de erro é otimizar a coisa errada.

3. Contagens e desfecho ficam no nível da linha, não dentro do payload.

{
  "Cid": "...",
  "Body": "AuthorizationDecided",
  "EventCount": 16,
  "ErrorCount": 0,
  "WarnCount": 1,
  "Outcome": "degraded",
  "StatusCode": 202,
  "Events": [ ... ]
}

É isso que torna as perguntas interessantes baratas de fazer: quais fluxos completaram mas carregaram um warning, qual a taxa de erro por etapa, quais clientes veem desfecho degradado. Tudo filtro em campo, sem parsing de JSON no caminho da query.

E uma coisa fácil de esquecer: faça flush na saída de uma falha. Se uma exceção escapa antes do próximo checkpoint, emita o que você tem e marque como fatia parcial. Os eventos acumulados são mais valiosos justamente quando o fluxo não terminou, e um finally que faz flush é a diferença entre ter essa história e perder ela.


8. Como fica depois

Três linhas. O resumo é a visão padrão:

OrderValidated        · OrderService          · EventCount 26 · 0→47 ms
AuthorizationDecided  · AuthorizationService  · EventCount 16 · 49→71 ms
PaymentCompleted      · PaymentHandler        · EventCount  5 · 73→75 ms · 202
Same transaction, same correlation id
Mesma transação, mesmo correlation id — 48 linhas contra 3.

E o passo a passo está a um clique de distância:

{
  "Cid": "pay-7f3a91c4-2e58-4b10-9d07-c6ae5f0d2311",
  "Body": "OrderValidated",
  "EventCount": 26,
  "Events": [
    { "Description": "PaymentReceived", "OffsetMs": 0, "Attributes": { "Ingress": "sqs" } },
    { "Description": "Mapping body into Order.", "OffsetMs": 0 },
    { "Description": "Performing antifraud evaluation.", "OffsetMs": 10,
      "Attributes": { "AuthorizationId": "AUTH-8D31C2", "MerchantId": "MER-2F19A8" } }
  ]
}
Expanding a checkpoint
Expandindo um checkpoint: nada foi jogado fora, mudou de lugar.

Nada foi apagado. Mudou de lugar. 47 eventos de negócio continuam registrados, agora carregados por 3 linhas em vez de 48 — e cada um chega com seu próprio offset, então o perfil de latência do fluxo está dentro da linha. Antes, você conseguia isso subtraindo timestamps ao longo de 48 linhas.


9. Números

Metodologia primeiro, porque ela delimita o que esses números significam. Uma transação, medida ponta a ponta, em ambiente de pré-produção, antes e depois da mudança. As contagens de bytes são UTF-8 sobre a linha de log crua. Isso é uma indicação de ordem de grandeza, não uma média de produção, e o seu sistema não é o sistema medido aqui.

Headline numbers, one transaction.
Os números de capa, uma transação.
Métrica Antes Depois Δ
Linhas totais 53 7 −86,8%
Linhas — fluxo de pagamento 48 3 −93,8%
Linhas — outros logs 5 4 inalterado
Bytes totais 72.871 17.035 −76,6%
Bytes — fluxo de pagamento 69.247 13.846 −80,0%
Bytes por linha (média) 1.374 2.433 +77%
Eventos de negócio registrados 48 linhas 47 eventos preservados
Bytes varridos pela query 2,19 MB 240 kB −89,0%
Lines emitted per transaction.
Linhas emitidas por transação.
Bytes emitted per transaction.
Bytes emitidos por transação — escala diferente, então gráfico separado.
Per-stage latency.
Latência por etapa, carregada dentro da linha.

Duas linhas da tabela merecem comentário.

Bytes por linha subiram 77%. Isso não é efeito colateral, é o mecanismo. Uma linha agora carrega uma fatia inteira do fluxo. Se esse número não tivesse subido, a mudança não teria funcionado.

As linhas que não foram convertidas não se mexeram. Cinco outras linhas de log estavam na mesma janela e continuaram exatamente como estavam. Vale mostrar o que não muda — números de antes/depois que melhoram uniformemente normalmente estão medindo outra coisa que não a que dizem medir.


10. O que ainda dá pra cortar

Medimos a composição do payload resultante, e cerca de um terço dele é a mesma chave repetida evento após evento dentro de uma única linha: OrderId 19 vezes, MerchantId 17 vezes, AuthorizationId 12 vezes.

Essas são propriedades do fluxo, não de passos individuais. Promover elas pro nível da linha projeta ≈13,2 KB — −81,9% no total, −85,5% no próprio fluxo.

Composition of the remaining payload.
Composição do payload restante.
Where the reduction comes from.
De onde vem a redução, ponta a ponta.

A regra geral que sai daí: um atributo que é constante para a unidade de trabalho inteira pertence à linha, não ao evento. A gente errou isso na primeira vez, traduzindo mecanicamente cada linha de log existente num evento com os atributos que ela por acaso carregava.


11. Quando não fazer isso

Processo longo e streaming. Um wide event é emitido quando uma fatia se completa. Se sua unidade de trabalho é um batch de seis horas ou um WebSocket aberto, você não tem telemetria nenhuma até terminar — e nenhuma se nunca terminar. Checkpoints amenizam; não resolvem. Laban Eilers, escrevendo a partir da experiência de produção da SimpliSafe, é direto ao dizer que spans de longa duração são um problema amplamente não resolvido.

Debug dentro de um único passo. Um wide event é resumo, e resumo perde sequência. Ele vai te dizer que a etapa de persistência levou 22 ms; não vai te dizer qual branch o handler pegou. Perguntas entre requisições são o que esse formato faz bem. Causalidade dentro da requisição ainda pede span ou log de debug comum. Quem te disser pra apagar todos os seus logs está vendendo demais.

Backends que punem alta cardinalidade. Tudo aqui pressupõe que você consegue consultar campos com muitos valores distintos. Se o seu armazenamento indexa labels de baixa cardinalidade e faz força bruta no resto, você vai sentir.

Schema drift. Wide events acumulam campos ao longo dos anos do mesmo jeito que linhas de log acumulam linhas. O modo de falha é adiado, não evitado.

Sistemas que já logam cinco linhas por requisição. Você não tem esse problema. Vá fazer outra coisa.

Os ganhos aqui são específicos de um sistema, e não há garantia de que se repitam. Mas se o seu log serve como trilha de auditoria — pagamento, razão contábil, qualquer coisa que um regulador ou um processo de disputa possa ler — então legibilidade não é estética. É a diferença entre reconstruir um incidente em dois minutos e reconstruir em quarenta.


12. Isso não é só um span?

É o mesmo instinto, e a resposta honesta é que as ideias se sobrepõem bastante.

Um wide event é próximo do que você tem de um root span bem atribuído. Se você já roda OpenTelemetry ponta a ponta e seu backend consulta atributos de span com conforto, coloque esses campos no span e pare de ler.

O motivo pra fazer isso no stream de log: em sistemas como esse, log é o registro de auditoria durável e consultável. Ele é retido num calendário diferente do dos traces, lido por gente que não usa a UI de tracing, e referenciado em processos que não têm nada a ver com engenharia. Tirar a narrativa desse stream teria resolvido um problema de legibilidade criando um problema de acesso.

Os dois são complementares, não concorrentes. Trace responde onde na topologia; wide event responde o que aconteceu com esta unidade de trabalho.


13. De onde a ideia vem

Nada disso é novo, e vale ler quem chegou lá primeiro:


14. Como começar

Um serviço. Um fluxo. Três checkpoints.

Meça antes de mudar qualquer coisa — linhas e bytes por unidade de trabalho, e os bytes que sua query habitual varre. Sem uma linha de base você vai ter uma opinião, não um resultado.

Preserve as strings originais das mensagens pra que nada quebre downstream. Suba atrás de uma flag, rode os dois por uma semana, compare.

Depois faça a pergunta que começou tudo isso, sobre qualquer outra coisa que esteja pegando fogo agora:

Estamos resolvendo, ou estamos pagando pra não ter que resolver?

Às vezes pagar é genuinamente a resposta certa. Só vale saber qual das duas você escolheu.


Apêndice — como isso fica em escala

Tudo neste apêndice é modelo, não medição. Pega as contagens de bytes por transação medidas acima e multiplica por uma premissa de tráfego. Mostra a forma da economia, não uma promessa — sua contagem de linhas, o tamanho das suas linhas, sua vazão e seu preço são todos diferentes.

Log volume per day, one service.
Volume de log por dia para um único serviço, modelado a partir dos bytes por transação medidos.

Não existe jeito honesto de publicar um valor em dinheiro que se aplique à sua plataforma: preço de ingestão varia numa ordem de grandeza entre fornecedores, tiers e compromissos. Então aqui está a mesma aritmética com a taxa deixada como sua entrada.

Cost is your rate times your volume.
Custo é sua taxa vezes seu volume.

A razão é o que se transfere. O total, não.