perf: lazy-load Sentry SDK via dynamic import (admin-only) - #686
perf: lazy-load Sentry SDK via dynamic import (admin-only)#686iago1501 wants to merge 4 commits into
Conversation
Sentry (with Replay) is ~174KB raw / 53KB gzip in common.min.js (35% of the bundle), statically imported from o11y/instrument.ts, error.tsx and ErrorBoundary.tsx even though it is only ever used when isAdmin() is true. Replacing the static imports with a lazy wrapper (react/o11y/sentry.ts) that dynamic-imports '@sentry/react' lets webpack split it into its own chunk, so storefront (non-admin) requests never fetch or parse it. EXPERIMENTAL - testing on storecomponents/qareview before deciding whether to pursue upstream. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖 Please select which version do you want to release:
And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.
|
mendescamara
left a comment
There was a problem hiding this comment.
Reviewed the lazy-loading approach — the goal and measured bundle-size win are solid. Left 3 inline notes on the new sentry.ts wrapper around failure handling.
| * call sites (see isAdmin() checks in error.tsx/ErrorBoundary.tsx), so | ||
| * storefront (non-admin) bundles never pay for the dynamic chunk. | ||
| */ | ||
| function loadSentry(): Promise<SentryModule> { |
There was a problem hiding this comment.
loadSentry() caches the import() promise in sentryModulePromise and never resets it on rejection. If the dynamic import fails once during a page session (e.g. an ad-blocker blocking a chunk literally named sentry.js, or a transient network error), sentryModulePromise stays pointed at a rejected promise forever — every subsequent initSentry()/captureException() call in that session immediately re-rejects instead of retrying, silently disabling Sentry for the rest of the session. This failure mode didn't exist with the previous static import.
Consider resetting sentryModulePromise (and initialized) back to null/false in a .catch() so a later call can retry.
There was a problem hiding this comment.
Fixed in 2e7217e: loadSentry() now resets sentryModulePromise back to null in a .catch() when the dynamic import rejects (and ensureInitialized() resets initialized if Sentry.init throws), so a transient chunk-load failure no longer disables Sentry for the rest of the session — the next call retries. Also added test coverage for this exact reject-then-retry path in 7283a06 (react/o11y/sentry.test.ts).
| /* global module */ | ||
| import React, { Component } from 'react' | ||
| import { captureException } from '@sentry/react' | ||
| import { captureException } from './o11y/sentry' |
There was a problem hiding this comment.
None of the call sites await or .catch() the new async captureException/initSentry (this import is the same pattern used at ErrorBoundary.tsx:32 and instrument.ts's top-level initSentry() call). Since captureException used to be a synchronous, non-throwing export of @sentry/react, the try/catch in componentDidMount below (lines 37-52) can no longer catch anything it rejects with — a rejection (e.g. from the caching issue on loadSentry()) now surfaces only as an unhandled promise rejection in the console instead of being handled.
Worth adding a .catch(() => {}) (or a shared no-op handler) at each call site.
There was a problem hiding this comment.
Fixed in 2e7217e: initSentry()/captureException() now catch any failure internally (chunk-load error, Sentry.init throwing, etc.) and log it via console.error instead of letting the promise reject, since every call site still invokes them fire-and-forget. So a failure here never surfaces as an unhandled rejection anymore, and (importantly) it no longer masks/interferes with the caller's own error handling below.
|
|
||
| type SentryModule = typeof import('@sentry/react') | ||
|
|
||
| let sentryModulePromise: Promise<SentryModule> | null = null |
There was a problem hiding this comment.
This module introduces new stateful logic (import caching via sentryModulePromise, one-time init via initialized) with no unit tests. A test exercising the reject/retry path would likely have caught the caching issue flagged above — worth adding coverage before merging.
There was a problem hiding this comment.
Added in 7283a06 (react/o11y/sentry.test.ts): coverage for the caching/init state, including the exact reject-then-retry scenario you flagged (a chunk-load failure followed by a later successful call), plus the admin-gating no-op paths and the 'initialize once across multiple calls' behavior.
… rejection - loadSentry(): reset the cached module promise on import failure so a transient chunk-load error (network blip, ad-blocker, deploy-time hash mismatch) doesn't permanently disable error reporting for the rest of the page session. - ensureInitialized(): reset the initialized flag if Sentry.init throws, allowing a retry instead of treating the SDK as initialized when it isn't. - initSentry()/captureException(): catch and log failures internally instead of rejecting, since every call site invokes them fire-and-forget (no await/.catch). - instrument.ts: stop eagerly calling initSentry() on every admin page load. Sentry is now only loaded/initialized on demand, the first time captureException()/initSentry() is actually invoked from an admin-gated error path, matching this PR's stated on-demand goal and avoiding an extra chunk request+parse/exec on every admin session that never errors. Co-authored-by: Cursor <cursoragent@cursor.com>
Adds react/o11y/sentry.test.ts covering: - captureException()/initSentry() no-op outside admin without loading the SDK. - The SDK is loaded and initialized only once across multiple calls. - A dynamic-import (chunk load) failure never rejects/throws back at the caller. - After a chunk-load failure, a later call retries instead of reusing the dead cached promise (the exact reject/retry path flagged in review). Addresses the missing test coverage requested in review. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new Sentry init guard has a concurrency race that can allow reporting before initialization and should be fixed before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR reduces storefront bundle size by moving the admin-only Sentry SDK usage behind a lazily loaded dynamic import, keeping Sentry code out of storefront entrypoints while preserving admin error reporting behavior.
Changes:
- Added
react/o11y/sentry.tsas a lazy-loading wrapper around@sentry/reactwith retry-on-chunk-failure behavior. - Added unit tests covering admin/no-admin behavior and import failure retry semantics.
- Removed the eager Sentry initialization side-effect from
react/o11y/instrument.tsand updated call sites to use the wrapper.
File summaries
| File | Description |
|---|---|
| react/o11y/sentry.ts | New lazy-load wrapper that imports/initializes Sentry on demand and exposes async captureException/initSentry. |
| react/o11y/sentry.test.ts | Tests for admin gating, single-init behavior, failure swallowing, and retry after chunk load failure. |
| react/o11y/instrument.ts | Removes eager Sentry init side effects; leaves explanatory stub. |
| react/error.tsx | Switches error reporting import to the lazy Sentry wrapper. |
| react/components/ErrorBoundary.tsx | Switches error reporting import to the lazy Sentry wrapper. |
| CHANGELOG.md | Documents the lazy-load change under Unreleased. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| async function ensureInitialized(Sentry: SentryModule) { | ||
| if (initialized) return | ||
| initialized = true | ||
|
|
||
| try { | ||
| doInit(Sentry) | ||
| } catch (error) { | ||
| // Allow a later call to retry `Sentry.init` instead of permanently | ||
| // treating the SDK as initialized when it actually threw. | ||
| initialized = false | ||
| throw error | ||
| } | ||
| } |
📉 -42% render-runtime JS on the storefront (~1.7 MB), 0 behavior change for the admin
common.js/admin/What does this PR do? *
@sentry/react(+ Replay) was statically imported even though it's only ever used behindisAdmin(). Static imports can't be tree-shaken, so every storefront visitor downloaded the full SDK for nothing.This PR adds
react/o11y/sentry.ts, a thin wrapper that lazilyimport()s@sentry/reactinto its own chunk (sentry.js), only fromisAdmin()-gated call sites.error.tsx/ErrorBoundary.tsxnow use its drop-in asynccaptureException(); config (DSN, replay sampling,beforeSendtagging) is unchanged.How to test it? *
sentry.jsrequest, no Sentry SDK incommon.js./admin/→sentry.jsfetched on demand,window.__SENTRY__initializes, errors are still captured (verified a real event reaching Sentry's ingest endpoint).No new console errors, no FCP/LCP/DCL regression in either environment.