私が構築に関わったドロップシッピングの SaaS プラットフォームは、どのグラフにも現れない形で、数か月にわたって売上を失っていました。購入者はチェックアウトに到達し、バックエンドは PIX の請求を作成して 200 を返し、注文はデータベースに存在していました — それでも一部の購入者では、QR コードが表示される前に決済画面がただ消えていたのです。チームの誰にも見えていませんでした。ログはきれいなままでした。
この記事は、その種のエラーが潜む隙間についてです:サーバーの最後のログ行と、ユーザーが実際に画面で体験したことのあいだの空白。フロントエンドのすべてはそこで起こります — そして既定では、そこが盲点になります。最終的な修正は安上がりでした。高くついたのは、見えていなかったことです。
症状:ログには 200、ブラウザには白い画面
フローはどこにでもある PIX チェックアウトでした。購入者が注文を確定し、Next.js のフロントエンドが Node の API を呼び、API がゲートウェイに請求を依頼して PIX のデータを返す。画面は QR コードとコピー用のコードを描画する。サーバー側から見れば、この経路の成功率はほぼ完璧でした。
購入者側はそうではありませんでした。一部の請求で決済画面が消えたのです:QR コードもなく、コードもなく、エラーメッセージもない。その瞬間に唯一重要だったものの代わりに、空白の領域だけがありました。
[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このログを読む限り、インシデントは存在しません。あるのは作成された注文、生成された請求、配信されたレスポンスだけです。HTTP ステータス、サーバーのエラー率、バックエンドの例外にもとづくアラートは、永遠に沈黙し続けます — バックエンドの視点では、何も失敗していないからです。
どう気づいたか:顧客が教えてくれた
アラートではありませんでした。プラットフォームの出品者が、自分の購入者が支払えないと報告してきたのです。考えうる最悪の検知チャネルであり、その理由と向き合う価値があります。
苦情を言う人は、自己選択された少数派です。チェックアウトを開き、白い画面を見て立ち去る購入者は、問い合わせを出しません — 諦めて、おそらく別の場所で買います。報告があなたに届くには、十分に動機のある人が、開かれた連絡経路を持ち、問題を説明する忍耐を持っている必要があります。届く苦情の 1 件ごとに、未知の — そしてより多くの — 静かな離脱が背後にあります。
検知チャネルが「怒ったユーザー」であるとき、あなたが測っているのは障害ではありません。粘り強さです。
なぜサーバーログでは決して捕まえられなかったのか
短い答え:エラーは、私たちが観測していた最後の地点より後で生まれていました。問題が起きた時点で、リクエスト全体はすでに成功して終わっていたのです。
- 観測済み: 注文が作成され永続化される — 201。
- 観測済み: 請求がゲートウェイに要求され、エラーなく返る — 200。
- 観測済み: レスポンスが API を出てブラウザに届く — 200。
- 盲点: クライアントがペイロードをデシリアライズし、画面の状態を組み立てる。
- 盲点: QR コードのコンポーネントが描画され — 例外を投げる。
- 盲点: コンポーネントツリーが崩れ、決済画面が消える。
- 盲点: 購入者は支払わず、タブを閉じ、ファネルから消える。
このリストの半分はフロントエンドの領域であり、その 1 行もどこにも記録されていませんでした。バックエンドのオブザーバビリティは「自分のシステムは応答したか?」に答えます。フロントエンドのオブザーバビリティは「ユーザーは完了できたか?」に答えます。異なる問いであり、支払いを生むのは後者です。
根本原因:不完全なペイロードを伴う 200
特定の条件下で、ゲートウェイは成功を返しながら PIX コードのフィールドを欠いていました — 請求は向こう側に存在し、ステータスも整合していて、ただフロントエンドが画面を描くために必要なデータだけが来なかったのです。API は転送するものを検証せずにそのまま渡していました。そしてコンポーネントはそれを信頼していました。
// 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 は undefined として届き、.toUpperCase() が TypeError を投げ、React は約束どおりのことをしました:そこから先のツリーを破棄したのです。チェックアウトを隔離する error boundary がなかったため、落ちたのは QR コードではなく — 決済画面そのものでした。
これは「一部の購入者だけ」という点も説明します。それこそが、このバグを見つけにくくしている性質です。最初のテストで現れる決定的なエラーではありませんでした。変動するサードパーティのレスポンスが、オンデマンドでは誰も再現できない一部の請求に当たっていたのです。
計装が明らかにしたこと
Sentry が入ったのは報告のあと、前ではありません — そこがこの話の正直な部分です。個別の修正だけなら、出品者の報告だけでもできました。答えられなかったのは、その次に来る問いです:何人に起きている?いつから?このゲートウェイだけ?もう止まった?
例外を捕捉するだけでは、そのどれにも答えられません。コンテキストのない TypeError は、圧縮されたファイル内のスタックトレース 1 行にすぎません。エラーを行動可能なものに変えるのは、それに付随する情報です:
// 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",
})もっとも効いたのは、ペイロードの中身ではなく 形 を記録したことです:hasQrPayload: Boolean(charge.qrCodePayload)。決済フローでも安全で — 値を一切運びません — しかもイベントをグルーピングして分布を見るにはこれで十分です。そこで、変わったブラウザを使う 1 人の購入者の話ではないことが明確になりました:手作業では再現できなくても、統計の上では再現するパターンだったのです。
ビルドでの source map がこの輪を閉じます。それがないと、エラーはプロダクト上の正しい場所と、コード上の間違った場所に届きます。
2 層に分けた修正
1 層目はクライアント側の防御です。フロントエンドはペイロードが完全に届くという前提をやめ、PIX コードの欠落を画面のありうる状態として扱うようにしました — 白い画面ではなく、代替経路とともに:
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>
)
}フォールバックが汎用のエラーメッセージではないことに注目してください。PIX コードはあるが画像が来なかった場合、QR はペイロードからクライアント側で生成されます。そしてコピー用のコードは常に表示されます。それだけで銀行アプリでの支払いを完了できるからです。チェックアウトのフォールバックを導く問いは「失敗をどう伝えるか」ではなく「ユーザーはまだ何ができるか」です。
2 層目はサーバー側の契約です。クライアントが描画できない請求は成功した請求ではなく、200 として API を出るべきではありません:
// 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)この 2 層が揃うと、問題の性質そのものが変わります。バックエンドはありえない状態を伝播するのをやめ、可視のエラー — アラートが捕まえられるもの — を生み出すようになります。フロントエンドはバックエンドが常に正しいことに依存しなくなります。どちらか一方では不十分です:サーバー側の検証は将来欠けうる別のフィールドを守りませんし、契約のないクライアント側の防御は、うるさいエラーを静かな劣化に置き換えるだけです。
意図的に計装しなかったもの
チェックアウトは、すべてを捕捉したくなる誘惑がもっとも強く — そしてその代償がもっとも大きいフローです。定めたルール:
- イベントに決済データを入れない。 PIX コード、金額、購入者の識別情報は対象外。記録するのはフィールドの有無であり、値ではありません。
- チェックアウトでセッションリプレイを使わない。 ユーザーが決済データを扱う画面を録画することは、機微な情報を第三者に送ることです。デバッグ上の利得はリスクに見合いません。
- サンプリングはトランザクションに、エラーにはかけない。 パフォーマンスのトレースはサンプリングし、決済フローの例外は常に捕捉します。価値が異なるものだからです。
- ノイズはバグとして扱う。 ブラウザ拡張のエラー、
ResizeObserver loop、ユーザーに影響しないサードパーティのネットワーク障害 — すべて除外します。誰も読まない 1 日 400 件のダッシュボードは、ダッシュボードがないのと区別がつきません。
結果
この経路が壊れなくなったあと、取引額は R$ 2 万前後で揺れる状態 — その一部は流入元のマーケットプレイスで失われていました — から、月あたり R$ 6 万〜7 万で安定するようになり、オペレーションは月およそ 2,000 件の注文規模に到達しました。
オブザーバビリティがこの数字を生んだ、とは言いません:プロダクトは同時に多くの理由で成長しますし、すべてを 1 つの修正に帰するのは不誠実です。安全に言えることはもっと具体的で、私の見方ではもっと不気味です — お金が入ってくるまさにその地点に漏れがあり、システムのどこにもそれを示せるものがなかったために続き、顧客の報告で気づくまでの代償は、誰も数えなかった売上で支払われた、ということです。
まだ開いたままのギャップ
私たちが作ったものは「クライアントが壊れ、再現するのに十分な情報がある」に答えます。「このブラウザのエラーはこのサーバーのリクエストに対応する」には答えません。今なら correlation ID で閉じます:フロントエンドがリクエストごとに識別子を生成してヘッダーで送り、バックエンドがそのフローのすべてのログ行に書き込み、同じ識別子がクライアント側のエラーコンテキストにも同行します。
そうすれば、調査はおおよその時刻で 2 つの世界を突き合わせる作業ではなく、キーによる検索になります。相関させることと当てずっぽうの違いであり、すでにコンテキストを収集しているフロントエンド計装にとって自然な次の一歩です。
ここから得たもの
- サーバーログが測るのはシステムであって、ユーザーではない。 両者はもっとも高くつく地点、つまりレスポンスの後で分岐します。
- フロントエンドのエラーは「画面のエラー」ではない。 それがチェックアウトに住み着くとき、それは失われた売上であり、技術ダッシュボードより先に売上として現れます。
- 外部データはすべて信頼できない入力であり、自社バックエンドのデータも含まれる。 サードパーティのフィールドを検証せず描画するのは、自分が制御しない契約を信じることです。
- error boundary は被害範囲の封じ込め。 コンポーネントを隔離しなければ、欠けた 1 フィールドが画面の一部を劣化させるのではなく、画面全体を落とします。
- コンテキストは量に勝る。 ユーザー、ルート、状態、ペイロードの形を伴うエラー 1 件は行動可能です。コンテキストのないエラー 1,000 件は、誰も開かないダッシュボードです。
