perf(G): defer Sentry init off the critical path#4005
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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 SummaryThis PR defers the
Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (2): Last reviewed commit: "fix(perf-G): address Greptile review on ..." | Re-trigger Greptile |
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>
|
@greptileai I addressed both findings in a0a7bd3: P1 (mechanism hint) — both drain loops now pass P2 (failure cleanup) — added a Both 145/145 |
|
@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. |
|
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 One issue I found before merge: P1 - queued primitive/object promise rejections no longer use Sentry's global-handler event shape In ns.captureException(
ev.reason ?? new Error('Unhandled promise rejection'),
{ mechanism: { type: 'onunhandledrejection', handled: false } },
);That is fine for 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 Validation: I reviewed the full patch in a temp worktree and ran |
Closes #3994. Parent: #3987.
Why
Eager
import * as Sentry from '@sentry/browser'+ synchronousSentry.init({...})inmain.tscost ~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.tsowns the init config + three public APIs:installPreInitErrorQueue()— installs eagererror/unhandledrejectionlisteners 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 actualawait import('@sentry/browser')viarequestIdleCallback({ timeout: 4000 }). Safari fallback:setTimeout(0)off theloadevent. Mirrors the pattern inservices/clerk.ts:scheduleClerkLoad.errorevents, then bufferedunhandledrejectionevents. Pre-init listeners detach so Sentry'sglobalHandlersintegration takes over without double-capture.main.tsinstalls the queue + schedules init at the top (synchronous, <1 ms). The CSP violation listener stays eagerly installed and routes itscaptureMessagethroughenqueueSentryCall.services/auth-state.ts,services/billing.ts,services/premium-fetch.ts,services/checkout.ts,components/checkout-failure-banner.ts) drop their staticimport * as Sentry from '@sentry/browser'in favor ofenqueueSentryCallso the SDK chunk falls out of the entry's modulepreload manifest. Scope-expansion note: the parent issue listed onlymain.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.htmlreturns 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 throughawait import('@sentry/browser')insidesentry-defer.ts.main-*.jsshrinks (-17 kB raw, -6 kB gzipped) because the ignoreErrors regex array +beforeSendsuppression 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
Sentry.init()resolves — the transition is transparent for the first-paint window.unhandledrejectionNotAllowedErrorpreventDefault(YouTube autoplay) stays installed eagerly inmain.tsso it runs before our buffering listener sees the event, preserving prior suppression behavior.captureMessageis queued.vercel.json/index.htmledits. The Sentry ingest entries inconnect-srcand the preconnect link already cover the deferred load.Verification done locally
npm run typecheck— clean.npm run lint— clean (one pre-existing warning intests/ci-workflow-coverage.test.mtsunrelated to this PR).npm run build— sentry chunk emitted assentry-DMxp_zBn.js(431 kB raw / 142 kB gzipped); no modulepreload reference indist/index.html.Test plan
https://www.worldmonitor.app/and confirm nosentry-*.jslink in the HTML.🤖 Generated with Claude Code