Skip to content

perf: lazy-load Sentry SDK via dynamic import (admin-only) - #686

Open
iago1501 wants to merge 4 commits into
masterfrom
perf/sentry-lazy
Open

perf: lazy-load Sentry SDK via dynamic import (admin-only)#686
iago1501 wants to merge 4 commits into
masterfrom
perf/sentry-lazy

Conversation

@iago1501

@iago1501 iago1501 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📉 -42% render-runtime JS on the storefront (~1.7 MB), 0 behavior change for the admin

Before After
Storefront (6 entrypoints, prod account) 4048.4 KB 2348.4 KB (-1700 KB / -42%)
Dev workspace (unminified) 3992.9 KB 2347.9 KB (-41%)
Sentry requests on storefront full SDK bundled in common.js 0 bytes / 0 requests
Sentry on /admin/ eager, same session fetched on demand, same events still captured

What does this PR do? *

@sentry/react (+ Replay) was statically imported even though it's only ever used behind isAdmin(). 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 lazily import()s @sentry/react into its own chunk (sentry.js), only from isAdmin()-gated call sites. error.tsx/ErrorBoundary.tsx now use its drop-in async captureException(); config (DSN, replay sampling, beforeSend tagging) is unchanged.

How to test it? *

  1. Link the branch, load the storefront home page → no sentry.js request, no Sentry SDK in common.js.
  2. Load /admin/sentry.js fetched 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.

iago1501 and others added 2 commits September 4, 2026 12:48
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>
@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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:

  • Patch (backwards-compatible bug fixes)

  • Minor (backwards-compatible functionality)

  • Major (incompatible API changes)

And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.

  • No thanks, I would rather do it manually 😞

@iago1501 iago1501 self-assigned this Sep 4, 2026
@iago1501 iago1501 added the enhancement New feature or request label Sep 4, 2026

@mendescamara mendescamara left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread react/o11y/sentry.ts
* 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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

Comment thread react/error.tsx
/* global module */
import React, { Component } from 'react'
import { captureException } from '@sentry/react'
import { captureException } from './o11y/sentry'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread react/o11y/sentry.ts

type SentryModule = typeof import('@sentry/react')

let sentryModulePromise: Promise<SentryModule> | null = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

iago1501 and others added 2 commits September 4, 2026 17:36
… 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.ts as a lazy-loading wrapper around @sentry/react with 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.ts and 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.

Comment thread react/o11y/sentry.ts
Comment on lines +44 to +57

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
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants