Vinicius Aguiar
Frontend

O erro que ninguém via: quando o frontend quebra e a receita some do log

10 de ago de 2026 · 14 min de leitura

Durante meses, uma plataforma SaaS de dropshipping que ajudei a construir perdeu vendas de um jeito que nenhum gráfico mostrava. O comprador chegava ao checkout, o backend gerava a cobrança em PIX e respondia 200, o pedido existia no banco — e, para uma parte dos compradores, a tela de pagamento simplesmente desaparecia antes de o QR Code aparecer. Ninguém no time via nada. O log estava limpo.

Este post é sobre a lacuna onde esse tipo de erro mora: o espaço entre a última linha de log do servidor e o que o usuário de fato viveu na tela. É onde o frontend inteiro acontece — e onde, por padrão, você é cego. A correção final foi barata. Cara foi a cegueira.

Uma requisição de checkout: o servidor registra três etapas bem-sucedidas e o log termina ali; do outro lado da fronteira, o cliente recebe um payload incompleto, o componente do QR Code lança e a tela de pagamento some — nada disso aparece no logUma requisição de checkout: o servidor registra três etapas bem-sucedidas e o log termina ali; do outro lado da fronteira, o cliente recebe um payload incompleto, o componente do QR Code lança e a tela de pagamento some — nada disso aparece no log

O sintoma: 200 no log, tela em branco no navegador

O fluxo era o de qualquer checkout PIX. O comprador finalizava o pedido, o frontend em Next.js chamava a API em Node, a API pedia a cobrança ao gateway e devolvia os dados do PIX. A tela renderizava o QR Code e o código copia-e-cola. Do lado do servidor, esse caminho tinha uma taxa de sucesso essencialmente perfeita.

Do lado do comprador, não. Em uma fração das cobranças, a tela de pagamento sumia: nem QR Code, nem código, nem mensagem de erro. Uma área em branco no lugar da única coisa que importava naquele momento.

[info] order.created      orderId=7f3a91 sellerId=442 total=189.90
[info] charge.requested   orderId=7f3a91 gateway=pix
[info] charge.created     orderId=7f3a91 status=pending
[info] http               POST /orders 201 148ms

Lendo esse log, não existe incidente. Existe um pedido criado, uma cobrança gerada e uma resposta entregue. Qualquer alerta baseado em status HTTP, taxa de erro do servidor ou exceção no backend ficaria em silêncio para sempre — porque, do ponto de vista do backend, nada falhou.

Como descobrimos: um cliente avisou

Não foi um alerta. Foi um vendedor da plataforma relatando que um comprador dele não conseguia pagar. É o pior canal de detecção possível, e vale encarar por quê.

Quem reclama é uma minoria autosselecionada. Um comprador que abre o checkout, vê uma tela em branco e vai embora não abre chamado — ele desiste, e talvez compre em outro lugar. Para o relato chegar até você, precisa existir alguém suficientemente motivado, com canal aberto e paciência para descrever o problema. Cada reclamação que chega representa um número desconhecido — e maior — de desistências silenciosas.

Quando o seu canal de detecção é o usuário irritado, você não está medindo falhas. Está medindo persistência.

Por que o log do servidor nunca ia pegar isso

A resposta curta: o erro nascia depois do último ponto que a gente observava. A requisição inteira já tinha terminado com sucesso quando o problema aconteceu.

  • Observado: o pedido é criado e persistido — 201.
  • Observado: a cobrança é solicitada ao gateway e volta sem erro — 200.
  • Observado: a resposta sai da API e é entregue ao navegador — 200.
  • Cego: o cliente desserializa o payload e monta o estado da tela.
  • Cego: o componente do QR Code renderiza — e lança.
  • Cego: a árvore de componentes cai e a tela de pagamento some.
  • Cego: o comprador não paga, fecha a aba e some da sua base.

Metade dessa lista é território do frontend, e nenhuma linha dela existia em lugar nenhum. Observabilidade de backend responde “o meu sistema respondeu?”. Observabilidade de frontend responde “o usuário conseguiu?”. São perguntas diferentes, e a segunda é a que paga a conta.

A causa raiz: um 200 com o payload incompleto

Em determinadas condições, o gateway respondia com sucesso mas sem o campo do código PIX — a cobrança existia do lado dele, o status voltava coerente, só o dado de que o frontend precisava para desenhar a tela não vinha. A API repassava a resposta adiante sem validar o que estava repassando. E o componente confiava.

// Original version: assumes the payload always carries the PIX code
export function PixQrCode({ charge }: { charge: Charge }) {
  return (
    <div className="checkout-pix">
      <img
        src={`data:image/png;base64,${charge.qrCodeBase64}`}
        alt="PIX QR Code"
      />
      <code>{charge.qrCodePayload.toUpperCase()}</code>
    </div>
  )
}

charge.qrCodePayload chegava undefined, .toUpperCase() lançava um TypeError, e o React fazia exatamente o que promete: derrubava a árvore a partir dali. Como não havia error boundary isolando o checkout, o que caía não era o QR Code — era a tela de pagamento inteira.

Isso também explica o “só para alguns compradores”, que é a característica que torna esse bug tão difícil de enxergar. Não era um erro determinístico que apareceria no primeiro teste. Era uma resposta de terceiro que variava, atingindo um subconjunto de cobranças que ninguém conseguia reproduzir sob demanda.

O que a instrumentação mostrou

O Sentry entrou depois do relato, não antes — e essa é a parte honesta da história. A correção pontual a gente conseguiria fazer só com o relato do vendedor. O que não dava para responder eram as perguntas seguintes: acontece com quantos? desde quando? só nesse gateway? já parou?

Capturar a exceção sozinha não responde nada disso. Um TypeError sem contexto é uma linha de stack trace num arquivo minificado. O que transforma um erro em algo acionável é o que vai junto dele:

// The context that turns "an error" into "an error you can reproduce"
Sentry.setUser({ id: seller.id })            // who — no email, no name
Sentry.setTag("checkout.gateway", "pix")

Sentry.setContext("charge", {
  orderId: charge.orderId,
  status: charge.status,
  hasQrPayload: Boolean(charge.qrCodePayload), // the shape, never the value
  hasQrImage: Boolean(charge.qrCodeBase64),
})

Sentry.addBreadcrumb({
  category: "checkout",
  message: "charge.received",
  level: "info",
})

O detalhe que mais rendeu foi registrar a forma do payload, não o conteúdo: hasQrPayload: Boolean(charge.qrCodePayload). É seguro para um fluxo de pagamento — não carrega valor nenhum — e é exatamente o que permite agrupar os eventos e ver a distribuição. Foi ali que ficou claro que não era um comprador com um navegador estranho: era um padrão, reproduzível na estatística mesmo sem ser reproduzível na mão.

Source maps no build fecham o ciclo. Sem eles, você recebe o erro no lugar certo do produto e no lugar errado do código.

A correção em duas camadas

A primeira camada é defensiva, no cliente. O frontend deixou de assumir que o payload vem completo e passou a tratar a ausência do código PIX como um estado possível da tela — com caminho alternativo, não com tela branca:

export function PixQrCode({ charge }: { charge: Charge }) {
  // No payload means no payment is possible: that is a screen state,
  // not an exception
  if (!charge.qrCodePayload) {
    Sentry.captureMessage("checkout.pix.missing_payload", {
      level: "error",
      extra: { orderId: charge.orderId, status: charge.status },
    })
    return <PixUnavailable orderId={charge.orderId} />
  }

  return (
    <div className="checkout-pix">
      {charge.qrCodeBase64 ? (
        <img
          src={`data:image/png;base64,${charge.qrCodeBase64}`}
          alt="PIX QR Code"
        />
      ) : (
        <QrCodeFromPayload value={charge.qrCodePayload} />
      )}
      <CopyablePixCode value={charge.qrCodePayload} />
    </div>
  )
}

Repare que o fallback não é uma mensagem de erro genérica. Se o código PIX existe mas a imagem não veio, o QR é gerado no cliente a partir do payload; e o código copia-e-cola aparece sempre, porque ele sozinho já permite concluir o pagamento no app do banco. A pergunta que guia um fallback de checkout não é “como avisar que falhou”, é “o que ainda dá para o usuário fazer”.

A segunda camada é o contrato, no servidor. Uma cobrança que o cliente não consegue renderizar não é uma cobrança bem-sucedida, e não deveria sair da API como 200:

// A charge the client cannot render is not a successful charge
const charge = await this.gateway.createCharge(order)

if (!charge.qrCodePayload) {
  this.logger.error(
    { orderId: order.id, gatewayStatus: charge.status },
    "charge.incomplete",
  )
  throw new ServiceUnavailableException("PIX_CHARGE_INCOMPLETE")
}

return toChargeResponse(charge)

As duas camadas juntas mudam a natureza do problema. O backend para de propagar um estado impossível e passa a gerar um erro visível — que o alerta pega. O frontend para de depender de o backend estar sempre certo. Nenhuma das duas sozinha resolveria: a validação no servidor não protege contra outros campos que faltem no futuro, e a defesa no cliente sem o contrato apenas troca um erro barulhento por uma degradação silenciosa.

O que deliberadamente não instrumentamos

Checkout é o fluxo onde mais dá vontade de capturar tudo — e onde isso sai mais caro. As regras que fixamos:

  • Nenhum dado de pagamento no evento. Código PIX, valores e identificação do comprador ficam fora. Registra-se a presença do campo, nunca o valor.
  • Sem replay de sessão no checkout. Gravar a tela onde o usuário lida com dados de pagamento é enviar informação sensível para um terceiro. O ganho de debug não paga o risco.
  • Sampling nas transações, não nos erros. Traces de performance são amostrados; exceção em fluxo de pagamento é capturada sempre. São coisas com valor diferente.
  • Ruído tratado como bug. Erro de extensão de navegador, ResizeObserver loop, falha de rede de terceiro que não afeta o usuário — tudo filtrado. Um painel com 400 eventos por dia que ninguém lê é indistinguível de não ter painel nenhum.

O resultado

Depois que esse caminho parou de quebrar, o volume transacionado deixou de oscilar na casa dos R$ 20 mil — com uma parte se perdendo nas plataformas de origem — e passou a se sustentar entre R$ 60 mil e R$ 70 mil por mês, com a operação chegando à casa dos 2.000 pedidos mensais.

Não vou dizer que observabilidade gerou esse número: um produto cresce por muitos motivos ao mesmo tempo, e seria desonesto atribuir tudo a uma correção. O que dá para afirmar com segurança é mais específico e, na minha opinião, mais assustador — existia um vazamento no ponto exato onde o dinheiro entra, ele durou o tempo que durou porque nada no sistema era capaz de mostrá-lo, e o custo de descobri-lo por relato de cliente foi pago em vendas que ninguém nunca contabilizou.

O gap que continua aberto

O que montamos resolve “o cliente quebrou e eu sei o suficiente para reproduzir”. Não resolve “este erro no navegador corresponde a esta requisição no servidor”. Hoje eu fecharia isso com um correlation ID: o frontend gera um identificador por requisição, envia num header, o backend o registra em toda linha de log daquele fluxo, e o mesmo identificador acompanha o contexto do erro no cliente.

Com isso, investigar deixa de ser cruzar dois mundos por horário aproximado e passa a ser uma busca por chave. É a diferença entre correlacionar e adivinhar — e é o próximo passo natural de qualquer instrumentação de frontend que já esteja capturando contexto.

O que eu levo disso

  • Log de servidor mede o seu sistema, não o seu usuário. Os dois divergem exatamente no lugar mais caro: depois da resposta.
  • Erro de frontend não é “erro de tela”. Quando mora no checkout, é perda de receita — e aparece no faturamento antes de aparecer em qualquer painel técnico.
  • Todo dado externo é entrada não confiável, inclusive o do seu próprio backend. Renderizar campo de terceiro sem validar é confiar num contrato que você não controla.
  • Error boundary é contenção de raio. Sem isolar o componente, um campo ausente derruba a tela inteira em vez de degradar um pedaço dela.
  • Contexto vale mais que volume. Um erro com usuário, rota, estado e forma do payload é acionável. Mil erros sem contexto são um painel que ninguém abre.