Vinicius Aguiar
Frontend

L'erreur que personne ne voyait : quand le frontend casse et que le chiffre d'affaires disparaît du log

10 août 2026 · 14 min de lecture

Pendant des mois, une plateforme SaaS de dropshipping que j'ai aidé à construire a perdu des ventes d'une manière qu'aucun graphique ne montrait. L'acheteur arrivait au checkout, le backend créait le paiement PIX et répondait 200, la commande existait en base — et pour une partie des acheteurs, l'écran de paiement disparaissait tout simplement avant que le QR Code n'apparaisse. Personne dans l'équipe ne voyait rien. Le log était propre.

Ce post parle de l'espace où vit ce genre d'erreur : l'intervalle entre la dernière ligne de log du serveur et ce que l'utilisateur a réellement vécu à l'écran. C'est là que tout le frontend se passe — et là que, par défaut, vous êtes aveugle. Le correctif final a été bon marché. C'est l'aveuglement qui a coûté cher.

Une requête de checkout : le serveur enregistre trois étapes réussies et le log s'arrête là ; de l'autre côté de la frontière, le client reçoit un payload incomplet, le composant du QR Code lève une exception et l'écran de paiement disparaît — rien de tout cela n'apparaît dans le logUne requête de checkout : le serveur enregistre trois étapes réussies et le log s'arrête là ; de l'autre côté de la frontière, le client reçoit un payload incomplet, le composant du QR Code lève une exception et l'écran de paiement disparaît — rien de tout cela n'apparaît dans le log

Le symptôme : 200 dans le log, écran blanc dans le navigateur

Le flux était celui de n'importe quel checkout PIX. L'acheteur validait la commande, le frontend en Next.js appelait l'API en Node, l'API demandait le paiement au gateway et renvoyait les données PIX. L'écran affichait le QR Code et le code à copier-coller. Côté serveur, ce chemin avait un taux de succès pratiquement parfait.

Côté acheteur, non. Sur une fraction des paiements, l'écran disparaissait : ni QR Code, ni code, ni message d'erreur. Une zone blanche à la place de la seule chose qui comptait à ce moment-là.

[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

À la lecture de ce log, il n'y a pas d'incident. Il y a une commande créée, un paiement généré et une réponse livrée. Toute alerte fondée sur le statut HTTP, le taux d'erreur serveur ou une exception backend resterait silencieuse pour toujours — car du point de vue du backend, rien n'a échoué.

Comment nous l'avons découvert : un client nous l'a signalé

Ce n'était pas une alerte. C'était un vendeur de la plateforme signalant qu'un de ses acheteurs n'arrivait pas à payer. C'est le pire canal de détection possible, et il vaut la peine de regarder pourquoi.

Ceux qui se plaignent sont une minorité auto-sélectionnée. Un acheteur qui ouvre le checkout, voit un écran blanc et s'en va n'ouvre pas de ticket — il abandonne, et achète peut-être ailleurs. Pour que le signalement vous parvienne, il faut quelqu'un d'assez motivé, avec un canal ouvert et la patience de décrire le problème. Chaque plainte qui arrive représente un nombre inconnu — et plus grand — d'abandons silencieux.

Quand votre canal de détection est l'utilisateur agacé, vous ne mesurez pas des défaillances. Vous mesurez de la persévérance.

Pourquoi les logs serveur n'allaient jamais attraper ça

La réponse courte : l'erreur naissait après le dernier point que nous observions. La requête entière s'était déjà terminée avec succès au moment où le problème survenait.

  • Observé : la commande est créée et persistée — 201.
  • Observé : le paiement est demandé au gateway et revient sans erreur — 200.
  • Observé : la réponse quitte l'API et est livrée au navigateur — 200.
  • Aveugle : le client désérialise le payload et construit l'état de l'écran.
  • Aveugle : le composant du QR Code s'affiche — et lève une exception.
  • Aveugle : l'arbre de composants s'effondre et l'écran de paiement disparaît.
  • Aveugle : l'acheteur ne paie pas, ferme l'onglet et disparaît de votre entonnoir.

La moitié de cette liste est du territoire frontend, et pas une seule ligne n'existait nulle part. L'observabilité backend répond à « mon système a-t-il répondu ? ». L'observabilité frontend répond à « l'utilisateur a-t-il réussi ? ». Ce sont deux questions différentes, et c'est la seconde qui paie les factures.

La cause racine : un 200 avec un payload incomplet

Dans certaines conditions, le gateway répondait avec succès mais sans le champ du code PIX — le paiement existait de son côté, le statut revenait cohérent, seule la donnée dont le frontend avait besoin pour dessiner l'écran manquait. L'API transmettait la réponse sans valider ce qu'elle transmettait. Et le composant faisait confiance.

// 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 arrivait à undefined, .toUpperCase() levait un TypeError, et React faisait exactement ce qu'il promet : il démontait l'arbre à partir de là. Sans error boundary isolant le checkout, ce qui tombait n'était pas le QR Code — c'était tout l'écran de paiement.

Cela explique aussi le « seulement pour certains acheteurs », qui est la propriété rendant ce bug si difficile à voir. Ce n'était pas une erreur déterministe qui apparaîtrait au premier test. C'était une réponse tierce variable, touchant un sous-ensemble de paiements que personne ne pouvait reproduire à la demande.

Ce que l'instrumentation a révélé

Sentry est arrivé après le signalement, pas avant — et c'est la part honnête de l'histoire. Le correctif ponctuel, nous pouvions le faire avec le seul signalement du vendeur. Ce que nous ne pouvions pas faire, c'était répondre aux questions suivantes : combien d'acheteurs sont touchés ? depuis quand ? seulement sur ce gateway ? est-ce que ça s'est arrêté ?

Capturer l'exception seule ne répond à rien de tout cela. Un TypeError sans contexte, c'est une ligne de stack trace dans un fichier minifié. Ce qui transforme une erreur en quelque chose d'actionnable, c'est ce qui l'accompagne :

// 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",
})

Le détail le plus rentable a été d'enregistrer la forme du payload, et non son contenu : hasQrPayload: Boolean(charge.qrCodePayload). C'est sûr pour un flux de paiement — cela ne transporte aucune valeur — et c'est exactement ce qui permet de regrouper les événements et de voir la distribution. C'est là qu'il est devenu clair qu'il ne s'agissait pas d'un acheteur au navigateur exotique : c'était un motif, reproductible statistiquement même s'il ne l'était pas à la main.

Les source maps au build referment la boucle. Sans elles, vous recevez l'erreur au bon endroit du produit et au mauvais endroit du code.

Le correctif, en deux couches

La première couche est défensive, côté client. Le frontend a cessé de supposer que le payload arrive complet et s'est mis à traiter l'absence du code PIX comme un état possible de l'écran — avec un chemin alternatif, pas un écran blanc :

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>
  )
}

Notez que le fallback n'est pas un message d'erreur générique. Si le code PIX existe mais que l'image n'est pas arrivée, le QR est généré côté client à partir du payload ; et le code à copier-coller s'affiche toujours, car à lui seul il permet de finaliser le paiement dans l'application bancaire. La question qui guide un fallback de checkout n'est pas « comment annoncer l'échec », c'est « que peut encore faire l'utilisateur ».

La seconde couche est le contrat, côté serveur. Un paiement que le client ne peut pas afficher n'est pas un paiement réussi, et il ne devrait pas sortir de l'API en 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)

Ensemble, les deux couches changent la nature du problème. Le backend cesse de propager un état impossible et se met à produire une erreur visible — que l'alerting attrape. Le frontend cesse de dépendre du fait que le backend a toujours raison. Aucune des deux ne suffirait seule : la validation serveur ne protège pas contre d'autres champs manquants à l'avenir, et la défense côté client sans le contrat ne fait qu'échanger une erreur bruyante contre une dégradation silencieuse.

Ce que nous n'avons délibérément pas instrumenté

Le checkout est le flux où l'envie de tout capturer est la plus forte — et où elle coûte le plus cher. Les règles que nous avons fixées :

  • Aucune donnée de paiement dans l'événement. Code PIX, montants et identité de l'acheteur restent dehors. On enregistre la présence du champ, jamais sa valeur.
  • Pas de session replay dans le checkout. Enregistrer l'écran où l'utilisateur manipule des données de paiement revient à envoyer de l'information sensible à un tiers. Le gain de debug ne paie pas le risque.
  • Sampling sur les transactions, pas sur les erreurs. Les traces de performance sont échantillonnées ; une exception dans un flux de paiement est toujours capturée. Elles n'ont pas la même valeur.
  • Le bruit traité comme un bug. Erreurs d'extensions de navigateur, ResizeObserver loop, pannes réseau tierces sans impact utilisateur — tout est filtré. Un tableau de bord avec 400 événements par jour que personne ne lit est indiscernable de l'absence de tableau de bord.

Le résultat

Une fois que ce chemin a cessé de casser, le volume transactionné est passé d'oscillations autour de 20 000 R$ — dont une partie se perdait sur les marketplaces d'origine — à une tenue entre 60 000 et 70 000 R$ par mois, l'opération atteignant l'ordre de 2 000 commandes mensuelles.

Je ne dirai pas que l'observabilité a produit ce chiffre : un produit croît pour de nombreuses raisons à la fois, et tout attribuer à un correctif serait malhonnête. Ce que l'on peut affirmer sans risque est plus précis et, à mon avis, plus inquiétant — il y avait une fuite exactement au point où l'argent entre, elle a duré aussi longtemps parce que rien dans le système n'était capable de la montrer, et le coût de la découvrir via le signalement d'un client a été payé en ventes que personne n'a jamais comptées.

L'écart qui reste ouvert

Ce que nous avons construit répond à « le client a cassé et j'en sais assez pour reproduire ». Cela ne répond pas à « cette erreur navigateur correspond à cette requête serveur ». Aujourd'hui je fermerais ça avec un correlation ID : le frontend génère un identifiant par requête, l'envoie dans un header, le backend l'écrit sur chaque ligne de log de ce flux, et le même identifiant accompagne le contexte de l'erreur côté client.

Avec ça, enquêter cesse d'être un rapprochement de deux mondes à l'horodatage approximatif pour devenir une recherche par clé. C'est la différence entre corréler et deviner — et c'est l'étape suivante naturelle de toute instrumentation frontend qui capture déjà du contexte.

Ce que j'en retiens

  • Les logs serveur mesurent votre système, pas votre utilisateur. Les deux divergent exactement à l'endroit le plus coûteux : après la réponse.
  • Une erreur frontend n'est pas une « erreur d'écran ». Quand elle vit dans le checkout, c'est du chiffre d'affaires perdu — et cela apparaît dans les ventes avant d'apparaître sur le moindre tableau de bord technique.
  • Toute donnée externe est une entrée non fiable, y compris celle de votre propre backend. Afficher un champ tiers sans le valider, c'est faire confiance à un contrat que vous ne contrôlez pas.
  • Une error boundary est un confinement de rayon d'impact. Sans isoler le composant, un champ manquant fait tomber l'écran entier au lieu d'en dégrader une partie.
  • Le contexte vaut mieux que le volume. Une erreur avec utilisateur, route, état et forme du payload est actionnable. Mille erreurs sans contexte forment un tableau de bord que personne n'ouvre.