Skip to content

feat(kyc): watchdog + telemetry so a stuck Sumsub SDK can never be silent again#2443

Merged
Hugo0 merged 8 commits into
devfrom
feat/sumsub-sdk-watchdog
Jul 17, 2026
Merged

feat(kyc): watchdog + telemetry so a stuck Sumsub SDK can never be silent again#2443
Hugo0 merged 8 commits into
devfrom
feat/sumsub-sdk-watchdog

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

⚠️ Stacks on #2442 (back-merge main→dev). Merge that first — this branch is based on it, so the diff here shows only the watchdog once #2442 lands.

Action item #9 from the outage post-mortem.

Why

The card outage ran 19 hours and was found by a customer in Dhaka, not by us. That wasn't bad luck — it's structural: a WebSDK that never launches throws nothing, requests nothing, and logs nothing. Sentry cannot see code that doesn't run. The only trace was throughput quietly going to zero, and nothing was watching throughput.

#2441 fixed the bug. This closes the class.

What

1. Watchdog — modal open + SDK not launched within 20s → show the error UI instead of an infinite spinner.

Keyed on visible alone, on purpose. Every silent path — null container, token that never arrives, script that never loads — ends with the init effect simply not running. Anything keyed on those deps would also never run. Watching the one fact the user actually experiences ("I opened it and nothing happened") catches the whole class, including causes we haven't thought of yet.

2. Telemetrykyc_sdk_launched / kyc_sdk_launch_timeout / kyc_sdk_init_failed.

The timeout event carries hadAccessToken / sdkScriptLoaded / hadContainer — the three facts that tell the silent stalls apart. Turns "opened but never launched" into something alertable instead of archaeology.

Enables post-mortem action item #2: alert on card_sumsub_opened > 20 with card_sumsub_completed = 0 over 3h. With kyc_sdk_launched it gets sharper — you no longer have to infer launch from a downstream conversion metric.

3. Corrects the mechanism comments shipped in #2441.

The delay comes from headlessui's <Transition> promoting tree state inside an effect — not the Portal, which resolves synchronously. Isolation-tested:

wrapper container at effect
dialog-only (Portal, no Transition) SET
transition-only (no Portal) NULL
transition+dialog (real Modal) NULL

The fix was right; my explanation was wrong. A wrong comment misleads the next reader, so it's corrected in both the component and the test.

Tests

  • Watchdog fires → error UI + kyc_sdk_launch_timeout with hadAccessToken: false
  • Watchdog does not fire once the SDK launched (and kyc_sdk_launched is captured)
  • Existing 3 regression tests still pass → 5/5

Gates

eslint clean on changed files · typecheck clean · prettier clean · full suite: 4 pre-existing failures, byte-identical to clean main (content-submodule/worktree env), none from this change.

Risk

One component + consts. No API/contract change, no migration. Worst case the watchdog is too eager — 20s is generous for a slow script on a bad connection, and the failure mode is "user sees an actionable error" rather than "user stares at a spinner for 19 hours".

Hugo0 and others added 8 commits July 16, 2026 18:26
Prod Release Sprint 151 — KYC verdict rendering · Solana/Tron/Base withdrawals · profile fixes (2026-07-16)
…r late

Card creation has been at zero since the #2407 release went live: the Sumsub
WebSDK opens but nobody can finish. card_sumsub_opened is normal (137/day)
while card_sumsub_completed went 49-85/day -> 0, kyc_submitted 27-76 -> 0 and
kyc_approved 16-42 -> 0. Users sit on a spinner and give up (51 closes, zero
completions today). Downstream that is a full card outage — no new verified
users, and the 334 approved-but-cardless users are routed back through the
same SDK for Rain's extra docs, so card_apply terms-required collapsed from
27-42% of outcomes to 1.4% and 0 cards were created in 15h+ (baseline 24-43).

Root cause: #2407 replaced the StartVerificationView click gate with auto-init
on `visible`. The init effect bails on `!sdkContainerRef.current`, but Modal is
a headlessui <Transition>/<Dialog> that renders through a Portal — the portal
target is created in the portal's own effect, so the container mounts a commit
AFTER `visible` flips true. A ref is not reactive and was not in the dep array,
so the effect read null on its only run and never re-ran: the SDK was never
launched. The old click gate hid this by guaranteeing the container was mounted
long before init. It only reproduces when the wrapper is already mounted (the
real SumsubKycModals case) so `sdkLoaded` has settled before `visible` flips —
mounting straight to visible lets the sdkLoaded flip re-run the effect.

Fix: hold the container in state via a callback ref so attachment re-runs the
init effect. Keeps #2407's intended no-interstitial UX rather than reverting.

Test reproduces the portal's late mount and fails on the current prod code
(launch called 0 times), passes with the fix.
Review pass on my own test: replace a tautological waitFor that did not
actually guarantee sdkLoaded had settled (the premise the repro depends on),
reset the mock's mounted flag so a close/re-open replays the late mount like
the real portal, name the mock component so rules-of-hooks recognises it
(it was adding 2 eslint errors), and stop leaking window.snsWebSdk.
…completes

fix(kyc): launch Sumsub SDK when the modal portal mounts the container late
… only)

#2440 was meant to fix the card outage but landed the regression test WITHOUT
the code change: while proving the test failed against prod, a
`git checkout origin/main -- SumsubKycWrapper.tsx` staged the reverted file,
and the follow-up commit swept that staged revert in. Net effect: main got a
test asserting behaviour that main does not have. Its unit job is red and the
outage is still live.

Re-applies the fix on top of main, unchanged from what #2440 intended: hold the
SDK container in state via a callback ref so the init effect re-runs when the
headlessui portal attaches the node a commit after `visible` flips. Without it
the effect reads a null ref on its only run, never re-runs (a ref is not
reactive and is not a dep), and the SDK is never launched — every user gets an
infinite spinner (card_sumsub_opened normal, card_sumsub_completed 0).

Verified the honest way this time: the test ALREADY on main fails against
main's wrapper (launch called 0 times) and passes with this commit.
…llback-ref

fix(kyc): actually apply the callback-ref fix (#2440 shipped the test only)
Hotfix debt from the 2026-07-16/17 card-issuance outage. main carries the
callback-ref fix that dev lacks; without this back-merge the next dev → main
release would revert prod to the broken wrapper.
…lent again

The Jul-16/17 card outage ran 19h and was found by a customer in Dhaka, not by
us. The reason is structural, not bad luck: a WebSDK that never launches throws
nothing, requests nothing and logs nothing. Sentry cannot see code that does not
run, so the only trace was throughput quietly going to zero. This closes that
class of failure rather than the one instance of it.

Watchdog: if the modal is open and the SDK has not launched within 20s, show the
error UI instead of the infinite spinner. Deliberately keyed on `visible` ALONE —
every silent path (null container, token that never arrives, script that never
loads) ends with the init effect simply not running, so anything keyed on those
deps would also never run. Watching the one fact the user experiences ("I opened
it and nothing happened") catches the whole class, including causes we have not
thought of yet.

Telemetry: kyc_sdk_launched / kyc_sdk_launch_timeout / kyc_sdk_init_failed. The
timeout event carries hadAccessToken / sdkScriptLoaded / hadContainer, which are
the three facts that distinguish the silent stalls from each other. Makes
"opened but never launched" alertable instead of archaeology.

Also corrects the mechanism comments shipped in #2441: the one-commit delay comes
from headlessui's <Transition> promoting tree state inside an effect, NOT the
Portal (isolation-tested: dialog-only mounts the container synchronously;
transition-only does not). The fix was right, the explanation was not, and a
wrong comment misleads the next reader.

Tests: the watchdog fires and surfaces the error UI when the SDK never launches,
and does not fire once it has.
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 17, 2026 1:56pm

Request Review

@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 914d987e-b954-4389-a9ff-b10b9d9ae4e2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sumsub-sdk-watchdog

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6207.13 → 6207.44 (+0.31)
Findings: 0 net (+11 new, -11 resolved)

🆕 New findings (11)

  • critical complexity — src/components/Kyc/SumsubKycWrapper.tsx — CC 57, MI 60.77, SLOC 278
  • high hotspot — src/constants/analytics.consts.ts — 33 commits, +316/-7 lines since 6 months ago
  • high complexity — src/constants/analytics.consts.ts — CC 1, MI 33.17, SLOC 170
  • medium high-mdd — src/components/Kyc/SumsubKycWrapper.tsx:38 — SumsubKycWrapper: MDD 59.2 (uses across many lines from declarations)
  • medium high-dlt — src/components/Kyc/SumsubKycWrapper.tsx:38 — SumsubKycWrapper: DLT 41 (calls 41 distinct functions — high context load)
  • medium high-mdd — src/components/Kyc/SumsubKycWrapper.tsx:125 — : MDD 23.4 (uses across many lines from declarations)
  • medium react-direct-dom — src/components/Kyc/SumsubKycWrapper.tsx:98 — direct DOM: document.getElementById
  • medium react-direct-dom — src/components/Kyc/SumsubKycWrapper.tsx:114 — direct DOM: document.createElement
  • low high-dlt — src/components/Kyc/SumsubKycWrapper.tsx:125 — : DLT 21 (calls 21 distinct functions — high context load)
  • low high-mdd — src/components/Kyc/SumsubKycWrapper.tsx:279 — : MDD 12.0 (uses across many lines from declarations)
  • low missing-return-type — src/components/Kyc/SumsubKycWrapper.tsx:38 — SumsubKycWrapper: exported fn missing return type annotation

✅ Resolved (11)

  • src/components/Kyc/SumsubKycWrapper.tsx — CC 51, MI 60.94, SLOC 248
  • src/constants/analytics.consts.ts — 32 commits, +308/-7 lines since 6 months ago
  • src/constants/analytics.consts.ts — CC 1, MI 33.38, SLOC 167
  • src/components/Kyc/SumsubKycWrapper.tsx:26 — SumsubKycWrapper: MDD 60.9 (uses across many lines from declarations)
  • src/components/Kyc/SumsubKycWrapper.tsx:26 — SumsubKycWrapper: DLT 37 (calls 37 distinct functions — high context load)
  • src/components/Kyc/SumsubKycWrapper.tsx:105 — : MDD 24.5 (uses across many lines from declarations)
  • src/components/Kyc/SumsubKycWrapper.tsx:235 — small useEffect that only sets state from deps
  • src/components/Kyc/SumsubKycWrapper.tsx:78 — direct DOM: document.getElementById
  • src/components/Kyc/SumsubKycWrapper.tsx:94 — direct DOM: document.createElement
  • src/components/Kyc/SumsubKycWrapper.tsx:105 — : DLT 19 (calls 19 distinct functions — high context load)
  • src/components/Kyc/SumsubKycWrapper.tsx:26 — SumsubKycWrapper: exported fn missing return type annotation

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1997 ran, 0 failed, 0 skipped, 32.3s

📊 Coverage (unit)

metric %
statements 59.6%
branches 43.3%
functions 48.5%
lines 59.9%
⏱ 10 slowest test cases
time test
3.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.1s src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx › shows the mailto fallback + retry when the proxy reports CRISP_FAILED
0.1s src/utils/__tests__/demo-balance.test.ts › resetDemoBalance refills and restarts the TTL window
0.1s src/utils/__tests__/demo-balance.test.ts › auto-refills a wallet older than the TTL on cold start
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@Hugo0
Hugo0 merged commit 919cd4b into dev Jul 17, 2026
18 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant