Durante meses, una plataforma SaaS de dropshipping que ayudé a construir perdió ventas de una forma que ningún gráfico mostraba. El comprador llegaba al checkout, el backend generaba el cobro por PIX y respondía 200, el pedido existía en la base — y, para una parte de los compradores, la pantalla de pago simplemente desaparecía antes de que el QR apareciera. Nadie en el equipo veía nada. El log estaba limpio.
Este post trata de la brecha donde vive ese tipo de error: el espacio entre la última línea de log del servidor y lo que el usuario realmente vivió en pantalla. Ahí ocurre todo el frontend — y ahí, por defecto, estás ciego. La corrección final fue barata. Cara fue la ceguera.
El síntoma: 200 en el log, pantalla en blanco en el navegador
El flujo era el de cualquier checkout PIX. El comprador finalizaba el pedido, el frontend en Next.js llamaba a la API en Node, la API pedía el cobro al gateway y devolvía los datos del PIX. La pantalla renderizaba el QR y el código de copiar y pegar. Del lado del servidor, ese camino tenía una tasa de éxito esencialmente perfecta.
Del lado del comprador, no. En una fracción de los cobros, la pantalla de pago desaparecía: ni QR, ni código, ni mensaje de error. Un área en blanco en lugar de lo único que importaba en ese 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 148msLeyendo ese log, no existe incidente. Existe un pedido creado, un cobro generado y una respuesta entregada. Cualquier alerta basada en estado HTTP, tasa de error del servidor o excepción en el backend quedaría en silencio para siempre — porque, desde el punto de vista del backend, nada falló.
Cómo lo descubrimos: nos avisó un cliente
No fue una alerta. Fue un vendedor de la plataforma reportando que uno de sus compradores no lograba pagar. Es el peor canal de detección posible, y vale la pena entender por qué.
Quien se queja es una minoría autoseleccionada. Un comprador que abre el checkout, ve una pantalla en blanco y se va no abre un ticket — desiste, y quizá compra en otro lado. Para que el reporte te llegue tiene que existir alguien suficientemente motivado, con canal abierto y paciencia para describir el problema. Cada queja que llega representa un número desconocido — y mayor — de abandonos silenciosos.
Cuando tu canal de detección es el usuario molesto, no estás midiendo fallos. Estás midiendo persistencia.
Por qué el log del servidor nunca iba a atrapar esto
La respuesta corta: el error nacía después del último punto que observábamos. La petición entera ya había terminado con éxito cuando ocurrió el problema.
- Observado: el pedido se crea y se persiste — 201.
- Observado: el cobro se solicita al gateway y vuelve sin error — 200.
- Observado: la respuesta sale de la API y se entrega al navegador — 200.
- Ciego: el cliente deserializa el payload y arma el estado de la pantalla.
- Ciego: el componente del QR renderiza — y lanza.
- Ciego: el árbol de componentes cae y la pantalla de pago desaparece.
- Ciego: el comprador no paga, cierra la pestaña y desaparece de tu embudo.
La mitad de esa lista es territorio del frontend, y ni una línea existía en ningún lado. La observabilidad de backend responde “¿mi sistema respondió?”. La de frontend responde “¿el usuario lo logró?”. Son preguntas distintas, y la segunda es la que paga las cuentas.
La causa raíz: un 200 con el payload incompleto
Bajo ciertas condiciones el gateway respondía con éxito pero sin el campo del código PIX — el cobro existía de su lado, el estado volvía coherente, solo faltaba el dato que el frontend necesitaba para dibujar la pantalla. La API reenviaba la respuesta sin validar lo que estaba reenviando. Y el componente confiaba.
// 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 llegaba como undefined, .toUpperCase() lanzaba un TypeError, y React hacía exactamente lo que promete: derribaba el árbol a partir de ahí. Como no había error boundary aislando el checkout, lo que caía no era el QR — era la pantalla de pago entera.
Eso también explica el “solo para algunos compradores”, que es la característica que vuelve este bug tan difícil de ver. No era un error determinista que aparecería en la primera prueba. Era una respuesta de terceros que variaba, afectando a un subconjunto de cobros que nadie lograba reproducir bajo demanda.
Lo que la instrumentación reveló
Sentry entró después del reporte, no antes — y esa es la parte honesta de la historia. La corrección puntual la podíamos hacer solo con el reporte del vendedor. Lo que no podíamos responder eran las preguntas siguientes: ¿a cuántos les pasa? ¿desde cuándo? ¿solo en ese gateway? ¿ya paró?
Capturar la excepción sola no responde nada de eso. Un TypeError sin contexto es una línea de stack trace en un archivo minificado. Lo que convierte un error en algo accionable es lo que viaja con él:
// 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",
})El detalle que más rindió fue registrar la forma del payload, no su contenido: hasQrPayload: Boolean(charge.qrCodePayload). Es seguro para un flujo de pago — no lleva ningún valor — y es exactamente lo que permite agrupar los eventos y ver la distribución. Ahí quedó claro que no era un comprador con un navegador raro: era un patrón, reproducible en la estadística aunque no lo fuera a mano.
Los source maps en el build cierran el ciclo. Sin ellos, recibes el error en el lugar correcto del producto y en el lugar equivocado del código.
La corrección en dos capas
La primera capa es defensiva, en el cliente. El frontend dejó de asumir que el payload viene completo y pasó a tratar la ausencia del código PIX como un estado posible de la pantalla — con camino alternativo, no con pantalla en blanco:
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>
)
}Nota que el fallback no es un mensaje de error genérico. Si el código PIX existe pero la imagen no llegó, el QR se genera en el cliente a partir del payload; y el código de copiar y pegar siempre aparece, porque por sí solo ya permite concluir el pago en la app del banco. La pregunta que guía un fallback de checkout no es “cómo aviso que falló”, es “qué puede hacer todavía el usuario”.
La segunda capa es el contrato, en el servidor. Un cobro que el cliente no puede renderizar no es un cobro exitoso, y no debería salir de la 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)Juntas, las dos capas cambian la naturaleza del problema. El backend deja de propagar un estado imposible y pasa a generar un error visible — que la alerta sí atrapa. El frontend deja de depender de que el backend siempre esté en lo correcto. Ninguna alcanzaría sola: la validación en el servidor no protege contra otros campos que falten en el futuro, y la defensa en el cliente sin el contrato solo cambia un error ruidoso por una degradación silenciosa.
Lo que deliberadamente no instrumentamos
El checkout es el flujo donde más ganas dan de capturar todo — y donde eso sale más caro. Las reglas que fijamos:
- Ningún dato de pago en el evento. Código PIX, montos e identificación del comprador quedan fuera. Se registra la presencia del campo, nunca su valor.
- Sin session replay en el checkout. Grabar la pantalla donde el usuario maneja datos de pago es enviar información sensible a un tercero. La ganancia de debug no paga el riesgo.
- Sampling en las transacciones, no en los errores. Los traces de performance se muestrean; una excepción en flujo de pago se captura siempre. Valen cosas distintas.
- El ruido tratado como bug. Errores de extensiones del navegador,
ResizeObserver loop, fallos de red de terceros que no afectan al usuario — todo filtrado. Un panel con 400 eventos diarios que nadie lee es indistinguible de no tener panel.
El resultado
Después de que ese camino dejó de romperse, el volumen transaccionado dejó de oscilar en torno a los R$ 20 mil — con una parte perdiéndose en los marketplaces de origen — y pasó a sostenerse entre R$ 60 mil y R$ 70 mil por mes, con la operación llegando al orden de los 2.000 pedidos mensuales.
No voy a decir que la observabilidad generó ese número: un producto crece por muchos motivos a la vez, y atribuirlo todo a una corrección sería deshonesto. Lo que sí se puede afirmar con seguridad es más específico y, en mi opinión, más inquietante — había una fuga en el punto exacto donde entra el dinero, duró lo que duró porque nada en el sistema era capaz de mostrarla, y el costo de descubrirla por el reporte de un cliente se pagó en ventas que nadie contabilizó jamás.
La brecha que sigue abierta
Lo que armamos resuelve “el cliente se rompió y sé lo suficiente para reproducirlo”. No resuelve “este error en el navegador corresponde a esta petición en el servidor”. Hoy cerraría eso con un correlation ID: el frontend genera un identificador por petición, lo envía en un header, el backend lo registra en cada línea de log de ese flujo, y el mismo identificador acompaña el contexto del error en el cliente.
Con eso, investigar deja de ser cruzar dos mundos por horario aproximado y pasa a ser una búsqueda por clave. Es la diferencia entre correlacionar y adivinar — y es el siguiente paso natural de cualquier instrumentación de frontend que ya capture contexto.
Lo que me llevo de esto
- El log del servidor mide tu sistema, no a tu usuario. Ambos divergen justo en el lugar más caro: después de la respuesta.
- Un error de frontend no es un “error de pantalla”. Cuando vive en el checkout, es pérdida de ingresos — y aparece en la facturación antes que en cualquier panel técnico.
- Todo dato externo es entrada no confiable, incluido el de tu propio backend. Renderizar un campo de terceros sin validarlo es confiar en un contrato que no controlas.
- El error boundary es contención de radio. Sin aislar el componente, un campo ausente derriba la pantalla entera en vez de degradar una parte.
- El contexto vale más que el volumen. Un error con usuario, ruta, estado y forma del payload es accionable. Mil errores sin contexto son un panel que nadie abre.
