Skip to content

perf(G): defer Sentry init off the critical path#4005

Open
SebastienMelki wants to merge 3 commits into
mainfrom
perf/issue-G-sentry-defer
Open

perf(G): defer Sentry init off the critical path#4005
SebastienMelki wants to merge 3 commits into
mainfrom
perf/issue-G-sentry-defer

Conversation

@SebastienMelki

Copy link
Copy Markdown
Collaborator

Closes #3994. Parent: #3987.

Why

Eager import * as Sentry from '@sentry/browser' + synchronous Sentry.init({...}) in main.ts cost ~1.96 s of main-thread CPU before LCP (Lighthouse flagged 175 ms TBT from the init-time forced reflow). The SDK chunk was also modulepreloaded from the entry HTML because five service-layer files statically imported @sentry/browser.

What

  • src/bootstrap/sentry-defer.ts owns the init config + three public APIs:
    • installPreInitErrorQueue() — installs eager error/unhandledrejection listeners that buffer events into a bounded queue (cap = 50, to prevent unbounded growth under an adversarial extension).
    • enqueueSentryCall(fn) — queues explicit Sentry API calls for code paths that need to capture before the SDK lands.
    • scheduleSentryInit() — schedules the actual await import('@sentry/browser') via requestIdleCallback({ timeout: 4000 }). Safari fallback: setTimeout(0) off the load event. Mirrors the pattern in services/clerk.ts:scheduleClerkLoad.
  • On init, queues drain in order — direct calls first, then buffered error events, then buffered unhandledrejection events. Pre-init listeners detach so Sentry's globalHandlers integration takes over without double-capture.
  • main.ts installs the queue + schedules init at the top (synchronous, <1 ms). The CSP violation listener stays eagerly installed and routes its captureMessage through enqueueSentryCall.
  • Five service-layer files (services/auth-state.ts, services/billing.ts, services/premium-fetch.ts, services/checkout.ts, components/checkout-failure-banner.ts) drop their static import * as Sentry from '@sentry/browser' in favor of enqueueSentryCall so the SDK chunk falls out of the entry's modulepreload manifest. Scope-expansion note: the parent issue listed only main.ts + a helper file, but the deterministic acceptance signal ("sentry-*.js NOT in initial waterfall") required pulling these five static imports too — confirmed up-front with the requester before touching them.

Deterministic acceptance signals

  • grep -oE 'sentry-[A-Za-z0-9_-]+\.js' dist/index.html returns nothing — the SDK chunk is no longer modulepreloaded from the entry HTML.
  • grep -rn \"from '@sentry/\" src/ returns nothing — no file statically imports the SDK; it enters the bundle only through await import('@sentry/browser') inside sentry-defer.ts.
  • main-*.js shrinks (-17 kB raw, -6 kB gzipped) because the ignoreErrors regex array + beforeSend suppression logic now ship in the deferred chunk.

Measurable signals (filled in after merge)

3-sample run will be captured against production after promotion (the prior PR-4004 3-sample LCP spread was 7956–10872 ms, so single-sample LCP is too noisy to attribute G alone). Baseline = median of post-F samples: LCP 8504 ms, load 5950 ms, panels-visible 31554 ms, transfer 7.43 MB.

Coverage notes

  • Errors thrown during the first ~4 s defer window are buffered and flushed once Sentry.init() resolves — the transition is transparent for the first-paint window.
  • The unhandledrejection NotAllowedError preventDefault (YouTube autoplay) stays installed eagerly in main.ts so it runs before our buffering listener sees the event, preserving prior suppression behavior.
  • The CSP allowlist + suppression machinery is unchanged; only the final captureMessage is queued.
  • No vercel.json / index.html edits. The Sentry ingest entries in connect-src and the preconnect link already cover the deferred load.

Verification done locally

  • npm run typecheck — clean.
  • npm run lint — clean (one pre-existing warning in tests/ci-workflow-coverage.test.mts unrelated to this PR).
  • npm run build — sentry chunk emitted as sentry-DMxp_zBn.js (431 kB raw / 142 kB gzipped); no modulepreload reference in dist/index.html.

Test plan

  • Production deploy: curl https://www.worldmonitor.app/ and confirm no sentry-*.js link in the HTML.
  • Production deploy: 3-sample multi-run Playwright recording to confirm CPU drop without LCP regression.
  • Open DevTools Sentry transport panel and confirm captures (CSP violation, billing exception) still arrive after the deferred-load window completes.

🤖 Generated with Claude Code

Closes #3994. Parent: #3987.

Eager `import * as Sentry from '@sentry/browser'` + synchronous
`Sentry.init({...})` in main.ts cost ~1.96 s of main-thread CPU before
LCP (Lighthouse flagged 175 ms TBT from the init-time forced reflow).
The SDK chunk was also modulepreloaded from the entry HTML through five
service-layer static imports of `@sentry/browser`.

This moves the init off the critical path:

- New `src/bootstrap/sentry-defer.ts` owns the init config and three
  public APIs: `installPreInitErrorQueue()` installs eager
  `error`/`unhandledrejection` listeners that buffer events into a
  bounded queue; `enqueueSentryCall(fn)` queues explicit Sentry API
  calls for code paths that need to capture before the SDK lands;
  `scheduleSentryInit()` schedules the actual `await import('@sentry/
  browser')` via `requestIdleCallback({ timeout: 4000 })`, with the
  same Safari fallback (`setTimeout(0)` off the `load` event) used by
  `services/clerk.ts:scheduleClerkLoad`.

- On init, the queues drain in order — direct calls first, then
  buffered `error` events, then buffered `unhandledrejection` events.
  Our pre-init listeners detach so Sentry's globalHandlers integration
  takes over without double-capture.

- `main.ts` now installs the queue + schedules init at the top of the
  file (the two synchronous steps cost <1 ms). The CSP violation
  listener stays installed eagerly and routes its `captureMessage`
  call through `enqueueSentryCall`.

- Five service-layer files (`services/auth-state.ts`, `services/
  billing.ts`, `services/premium-fetch.ts`, `services/checkout.ts`,
  `components/checkout-failure-banner.ts`) drop their static
  `import * as Sentry from '@sentry/browser'` in favor of
  `enqueueSentryCall` so the SDK chunk falls out of the entry's
  modulepreload manifest. The set-user / capture-exception / capture-
  message / add-breadcrumb call sites are otherwise unchanged.

Deterministic acceptance signals:

- `grep -oE 'sentry-[A-Za-z0-9_-]+\.js' dist/index.html` returns
  nothing — the SDK chunk is no longer modulepreloaded from the
  entry HTML.
- `grep -rn "from '@sentry/" src/` returns nothing — no file
  statically imports the SDK; it only enters the bundle through the
  `await import('@sentry/browser')` inside `sentry-defer.ts`.
- `main-*.js` shrinks (-17 kB raw, -6 kB gzipped) because the giant
  ignoreErrors regex array + beforeSend suppression logic now live
  in the deferred chunk.

Coverage notes:

- Errors thrown during the first ~4 s defer window are buffered and
  flushed once `Sentry.init()` resolves, so the Sentry transition is
  transparent for the first-paint window. Queues are capped at 50
  entries to prevent unbounded growth under an adversarial extension
  hammering errors during init.
- The unhandledrejection NotAllowedError preventDefault (YouTube
  autoplay) stays installed eagerly in main.ts so it runs before our
  buffering listener sees the event, preserving prior suppression.
- The CSP allowlist + suppression machinery is unchanged; only the
  final `Sentry.captureMessage` is queued.

No CSP / `vercel.json` / `index.html` edits — the existing Sentry
ingest entries in `connect-src` and the preconnect link already cover
the deferred load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented May 31, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 31, 2026 8:20pm

Request Review

The Sentry.init({...}) block — including the beforeSend closure and
THIRD_PARTY_FETCH_HOST_ALLOWLIST — moved from src/main.ts to
src/bootstrap/sentry-defer.ts in the previous commit, but the
tests/sentry-beforesend.test.mjs harness was still reading
src/main.ts. CI flagged it as
`AssertionError: beforeSend must exist in src/main.ts`.

Repoint the readFileSync to src/bootstrap/sentry-defer.ts and
correspondingly update both `assert.ok` messages.

Also revert two type-assertion shapes (`as unknown as { ... }` back
to the original `as any`) on the two casts the test extractor parses
as a standalone JS function — its TS-stripping regex strips
`as IDENT(\[\])?` but not the brace-bearing complex-assertion shape,
which raised `SyntaxError: Unexpected identifier 'as'` when the
extracted body was eval'd via `new Function`. The original
main.ts had `as any` on both casts and passed lint cleanly, so this
is a pure-revert of an unnecessary type-tightening I introduced
during the file move.

Verified locally: 145/145 sentry-beforesend cases pass; typecheck +
lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR defers the @sentry/browser SDK load off the critical path to eliminate ~1.96 s of pre-LCP main-thread CPU cost, replacing a synchronous Sentry.init() in main.ts with a lightweight pre-init error-buffering queue that drains into the live SDK once it lands via requestIdleCallback.

  • src/bootstrap/sentry-defer.ts (new): owns the init config, bounded pre-init queues (MAX_QUEUE = 50), drain logic with mechanism: { handled: false } hints so replayed events are classified correctly in Sentry's issue list, and a loadFailed + teardownPreInitState() cleanup path that leaves zero runtime footprint when the SDK chunk is permanently blocked.
  • Five service-layer files (auth-state, billing, premium-fetch, checkout, checkout-failure-banner) drop their static import * as Sentry in favor of enqueueSentryCall, removing the SDK from the entry's modulepreload manifest.
  • tests/sentry-beforesend.test.mjs: updated source path from main.ts to sentry-defer.ts; 145/145 cases pass.

Confidence Score: 5/5

Safe to merge — the deferred-init logic is correct and the two issues flagged in the previous review pass are fully addressed.

The drain loops now carry the proper mechanism hints so pre-init errors appear as unhandled in Sentry's issue list and alert rules, the failure cleanup path leaves zero retained listeners or queued closures, all six service-layer conversion sites eagerly capture values before their closures so no data is stale at drain time, and the 145/145 beforeSend test cases continue to pass.

No files require special attention.

Important Files Changed

Filename Overview
src/bootstrap/sentry-defer.ts New 783-line file implementing deferred Sentry init with pre-init error buffering, queue draining with mechanism hints, and failure cleanup; both previously-flagged issues are correctly addressed in this revision.
src/main.ts Replaces ~580-line eager Sentry.init block with three lightweight calls: installPreInitErrorQueue(), scheduleSentryInit(), and enqueueSentryCall for the CSP violation listener; change is correct and minimal.
src/services/auth-state.ts Drops static @sentry/browser import; setUser calls wrapped in enqueueSentryCall closures; values captured correctly at call time.
src/services/billing.ts Drops static Sentry import; error objects and tag maps eagerly computed before closures, so queue captures the right snapshot at throw time.
src/services/checkout.ts All six Sentry call sites converted to enqueueSentryCall; template-literal strings evaluated at closure-creation time so messages are correct regardless of when the closure fires.
src/services/premium-fetch.ts Drops static Sentry import; message, level, tags, and extra all computed eagerly before closure, correct pattern.
src/components/checkout-failure-banner.ts Drops static Sentry import; single captureMessage wrapped in enqueueSentryCall; rawStatus captured at call time, correct.
tests/sentry-beforesend.test.mjs Updated source-file path from src/main.ts to src/bootstrap/sentry-defer.ts; assertion messages updated to match; 145/145 cases confirmed passing per reviewer note.

Sequence Diagram

sequenceDiagram
    participant M as main.ts
    participant SD as sentry-defer.ts
    participant W as window
    participant RIC as requestIdleCallback
    participant S as at-sentry/browser

    M->>SD: installPreInitErrorQueue()
    SD->>W: addEventListener error + unhandledrejection
    M->>SD: scheduleSentryInit()
    SD->>RIC: ric(start, timeout 4000)

    note over W,SD: Pre-init window 0 to 4 s
    W-->>SD: error events to pendingErrors cap 50
    W-->>SD: unhandledrejection to pendingRejections cap 50
    M->>SD: enqueueSentryCall(fn) to pendingCalls cap 50

    RIC-->>SD: start() fires
    SD->>S: await import at-sentry/browser

    alt SDK loads OK
        S-->>SD: ns
        SD->>S: ns.init(buildSentryInitOptions())
        SD->>SD: "sentryNs = ns"
        SD->>S: drain pendingCalls fn(ns)
        SD->>S: drain pendingErrors captureException mechanism onerror handled false
        SD->>S: drain pendingRejections captureException mechanism onunhandledrejection handled false
        SD->>W: removeEventListener error + unhandledrejection
        SD->>SD: clear queues via teardownPreInitState
    else SDK fails ad blocker or CDN
        SD->>SD: "loadFailed = true"
        SD->>SD: teardownPreInitState remove listeners clear queues
    end
Loading

Reviews (2): Last reviewed commit: "fix(perf-G): address Greptile review on ..." | Re-trigger Greptile

Comment thread src/bootstrap/sentry-defer.ts
Comment thread src/bootstrap/sentry-defer.ts
P1 — mechanism hint on replayed errors (handled: false)
========================================================
Sentry's `captureException` defaults to `mechanism.handled = true`,
but its own `globalHandlers` integration explicitly tags the events it
raises from `window.onerror` / `unhandledrejection` with
`{ type: 'onerror', handled: false }` and
`{ type: 'onunhandledrejection', handled: false }`. Without that hint,
the events we replay out of the pre-init queue would arrive in Sentry
classified as "handled," and any alert rule filtering on
`handled: false` (a common ops setup) would silently miss every
unhandled error from the pre-init window. Crash-rate / ANR metrics
would also be understated.

Add the matching `mechanism` second-arg to both drain loops so the
replayed events line up with what Sentry's globalHandlers would have
produced if the SDK had been live when they fired.

P2 — cleanup path when the SDK chunk fails to load
====================================================
Previously, if `loadAndInit()` rejected (network error, ad blocker,
CDN outage), `sentryNs` stayed null forever, `enqueueSentryCall`
would keep pushing into the bounded queue (silently dropping past
MAX_QUEUE), and the pre-init listeners would stay attached for the
page lifetime — runtime footprint > 0 for users where the SDK was
permanently blocked, with no observability and no retry.

Add:
- A `loadFailed` flag, set in the `loadAndInit()` `.catch`.
- A `teardownPreInitState()` helper that removes the two listeners
  and empties the three queues. Called from BOTH the success path
  (after queues drain into the live SDK) AND the failure path.
- A short-circuit in `enqueueSentryCall` so post-failure calls
  return immediately without growing the queue.

Net effect for users where the SDK never loads: zero retained
listeners, zero queued closures, zero leaked memory for the rest
of the page lifetime. Trade-off: explicit Sentry calls made after
load failure (e.g. CSP violation captureMessage) are silently
dropped — same as before, but cleaner.

Verified: 145/145 sentry-beforesend cases pass; typecheck + lint
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

@greptileai I addressed both findings in a0a7bd3:

P1 (mechanism hint) — both drain loops now pass { mechanism: { type: 'onerror' | 'onunhandledrejection', handled: false } } so the replayed events match what globalHandlers would emit. Alert rules filtering on handled: false will now see pre-init errors correctly.

P2 (failure cleanup) — added a loadFailed flag + teardownPreInitState() helper. On loadAndInit().catch, we remove both listeners, empty all three queues, and short-circuit enqueueSentryCall to a no-op. Net effect for users where the SDK is permanently blocked: zero retained listeners, zero queued closures.

Both 145/145 sentry-beforesend cases still pass; typecheck + lint clean. Ready for another pass.

@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

@koala73 could you take a look when you have a moment? This touches Sentry init + the five service-layer Sentry call sites (auth-state, billing, premium-fetch, checkout, checkout-failure-banner), so I'd like a second pair of eyes before merge. Greptile's P1 (mechanism hint for handled:false) and P2 (failure cleanup) are addressed in a0a7bd3. All checks green.

@koala73

koala73 commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Good work on moving Sentry init off the critical path and keeping the service-layer call sites narrowly converted. The structure is clean, and the follow-up fixes for listener cleanup and handled: false are heading in the right direction.

One issue I found before merge:

P1 - queued primitive/object promise rejections no longer use Sentry's global-handler event shape

In src/bootstrap/sentry-defer.ts, the queued PromiseRejectionEvents are replayed with:

ns.captureException(
  ev.reason ?? new Error('Unhandled promise rejection'),
  { mechanism: { type: 'onunhandledrejection', handled: false } },
);

That is fine for Error reasons, but it changes behavior for primitive or plain-object rejection reasons during the deferred-init window. Sentry's own globalHandlers path special-cases these into UnhandledRejection / Non-Error promise rejection captured with value: ... / Object captured as promise rejection with keys: ... shapes. Passing ev.reason directly through captureException does not preserve that exact unhandled-rejection shaping, so the existing suppressors for those known noisy patterns can stop matching for early rejections and create new grouping or alert volume.

I would either replay non-Error rejection reasons through the same event shape Sentry's global handler would create, or add focused tests proving queued primitive/object rejections still hit the intended ignoreErrors filters after drain.

Validation: I reviewed the full patch in a temp worktree and ran node --test tests/sentry-beforesend.test.mjs successfully: 145/145 passing. I did not run the full repo suite because this checkout did not have node_modules.

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments posted earlier

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.

perf(G): defer Sentry init off the critical path (-1.96 s main-thread CPU)

2 participants