-
Notifications
You must be signed in to change notification settings - Fork 25
perf: lazy-load Sentry SDK via dynamic import (admin-only) #686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7020a21
0ad6b0e
2e7217e
7283a06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,60 +1,13 @@ | ||
| import * as Sentry from '@sentry/react' | ||
| import { isAdmin } from '../utils/isAdmin' | ||
| import { getIOContext } from './ctx' | ||
|
|
||
| if (isAdmin()) { | ||
| Sentry.init({ | ||
| dsn: | ||
| 'https://2fac72ea180d48ae9bf1dbb3104b4000@o191317.ingest.us.sentry.io/1292015', | ||
| integrations: [Sentry.replayIntegration()], | ||
|
|
||
| // Set tracesSampleRate to 0.1 to capture 10% | ||
| // of transactions for tracing. | ||
| tracesSampleRate: 0.1, | ||
|
|
||
| // Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled | ||
| tracePropagationTargets: [ | ||
| /^\//, | ||
| /^(https?:\/\/)?([a-z0-9]+[.])*myvtex\.com/, | ||
| ], | ||
|
|
||
| // Capture Replay for 0% of all sessions, | ||
| // plus for 50% of sessions with an error | ||
| replaysSessionSampleRate: 0, | ||
| replaysOnErrorSampleRate: 0.5, | ||
|
|
||
| beforeSend: (event) => { | ||
| const ctx = getIOContext() | ||
|
|
||
| // Must check with false, otherwise default null's | ||
| // value leads to data mistakenly not sent to Sentry, | ||
| // which can occur if somehow we can't infer whether | ||
| // the apps are running under a production or development | ||
| // environment. | ||
| if (ctx.admin_production === false) { | ||
| const params = new URL(document?.location?.toString())?.searchParams | ||
| const shouldLog = params.get('forceLogs') | ||
|
|
||
| if (shouldLog === 'true') { | ||
| return makeEventWithCtx(event, ctx) | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| return makeEventWithCtx(event, ctx) | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| function makeEventWithCtx(event: any, ctx: any) { | ||
| const eventWithCtx = { | ||
| ...event, | ||
| tags: { | ||
| ...event?.tags, | ||
| ...ctx, | ||
| }, | ||
| } | ||
|
|
||
| return eventWithCtx | ||
| } | ||
| // Sentry is only ever used in the admin, and only to report errors. Loading | ||
| // it lazily (dynamic import) keeps it out of the storefront's main bundle | ||
| // entirely, since bundlers can only tree-shake/split code that isn't | ||
| // statically imported. | ||
| // | ||
| // This file intentionally does NOT eagerly call `initSentry()`: doing so | ||
| // would make every admin page pay the extra `sentry.js` chunk | ||
| // request/parse/exec cost on every session, even when no error ever | ||
| // occurs, which defeats the "on demand" goal. Instead, the SDK is loaded | ||
| // and initialized lazily, the first time `captureException`/`initSentry` | ||
| // is actually invoked from an admin-gated error path (see | ||
| // react/error.tsx and react/components/ErrorBoundary.tsx). | ||
| export {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| jest.mock('../utils/isAdmin') | ||
| jest.mock('./ctx') | ||
|
|
||
| const mockInit = jest.fn() | ||
| const mockCaptureException = jest.fn() | ||
| const mockReplayIntegration = jest.fn(() => ({})) | ||
|
|
||
| // Controls whether the mocked dynamic import of '@sentry/react' rejects | ||
| // (simulating a ChunkLoadError) or resolves. Must be prefixed with "mock" | ||
| // so babel-plugin-jest-hoist allows referencing it from the jest.mock | ||
| // factory below. | ||
| let mockShouldFailImport = false | ||
|
|
||
| jest.mock('@sentry/react', () => { | ||
| if (mockShouldFailImport) { | ||
| throw new Error('ChunkLoadError: Loading chunk sentry failed.') | ||
| } | ||
|
|
||
| return { | ||
| init: mockInit, | ||
| captureException: mockCaptureException, | ||
| replayIntegration: mockReplayIntegration, | ||
| } | ||
| }) | ||
|
|
||
| /** | ||
| * `./sentry` keeps module-scoped singleton state (the cached import promise | ||
| * and the "initialized" flag), which is exactly the state under test here | ||
| * (the reject/retry behavior). So each test gets a fully fresh module | ||
| * registry via `jest.resetModules()`, and re-requires everything (including | ||
| * the mocked `isAdmin`/`ctx`) *after* resetting, instead of importing them | ||
| * once at the top of the file — otherwise the `isAdmin`/`ctx` references | ||
| * held by the test would become stale copies, decoupled from the ones | ||
| * `./sentry` actually calls after the reset. | ||
| */ | ||
| function setup({ isAdmin }: { isAdmin: boolean }) { | ||
| jest.resetModules() | ||
| mockShouldFailImport = false | ||
| mockInit.mockClear() | ||
| mockCaptureException.mockClear() | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const { isAdmin: mockIsAdmin } = require('../utils/isAdmin') | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const { getIOContext: mockGetIOContext } = require('./ctx') | ||
|
|
||
| mockIsAdmin.mockReturnValue(isAdmin) | ||
| mockGetIOContext.mockReturnValue({ admin_production: true }) | ||
|
|
||
| return require('./sentry') | ||
| } | ||
|
|
||
| describe('sentry', () => { | ||
| test('captureException no-ops outside admin and never loads the SDK', async () => { | ||
| const { captureException } = setup({ isAdmin: false }) | ||
|
|
||
| const result = await captureException(new Error('boom')) | ||
|
|
||
| expect(result).toBeUndefined() | ||
| expect(mockInit).not.toHaveBeenCalled() | ||
| expect(mockCaptureException).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| test('captureException lazily loads and initializes Sentry once, then reports', async () => { | ||
| const { captureException } = setup({ isAdmin: true }) | ||
|
|
||
| await captureException(new Error('first')) | ||
| await captureException(new Error('second')) | ||
|
|
||
| expect(mockInit).toHaveBeenCalledTimes(1) | ||
| expect(mockCaptureException).toHaveBeenCalledTimes(2) | ||
| expect(mockCaptureException).toHaveBeenNthCalledWith( | ||
| 1, | ||
| expect.objectContaining({ message: 'first' }), | ||
| undefined | ||
| ) | ||
| }) | ||
|
|
||
| test('a chunk-load failure never throws/rejects back at the caller (fire-and-forget safe)', async () => { | ||
| const { captureException } = setup({ isAdmin: true }) | ||
|
|
||
| mockShouldFailImport = true | ||
|
|
||
| await expect(captureException(new Error('boom'))).resolves.toBeUndefined() | ||
| expect(mockCaptureException).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| test('after a chunk-load failure, a later call retries instead of reusing the dead promise', async () => { | ||
| const { captureException } = setup({ isAdmin: true }) | ||
|
|
||
| // First call fails to load the chunk. | ||
| mockShouldFailImport = true | ||
| await captureException(new Error('boom')) | ||
| expect(mockCaptureException).not.toHaveBeenCalled() | ||
|
|
||
| // The chunk becomes available (e.g. network recovers) — a later call | ||
| // must not keep reusing the rejected promise from the failed attempt. | ||
| mockShouldFailImport = false | ||
| await captureException(new Error('recovered')) | ||
|
|
||
| expect(mockInit).toHaveBeenCalledTimes(1) | ||
| expect(mockCaptureException).toHaveBeenCalledTimes(1) | ||
| expect(mockCaptureException).toHaveBeenCalledWith( | ||
| expect.objectContaining({ message: 'recovered' }), | ||
| undefined | ||
| ) | ||
| }) | ||
|
|
||
| test('initSentry no-ops outside admin', async () => { | ||
| const { initSentry } = setup({ isAdmin: false }) | ||
|
|
||
| await initSentry() | ||
|
|
||
| expect(mockInit).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| test('initSentry loads and initializes Sentry when in admin', async () => { | ||
| const { initSentry } = setup({ isAdmin: true }) | ||
|
|
||
| await initSentry() | ||
|
|
||
| expect(mockInit).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| test('a failure in initSentry is swallowed, never rejects, and a later call can retry', async () => { | ||
| const { initSentry } = setup({ isAdmin: true }) | ||
|
|
||
| mockShouldFailImport = true | ||
| await expect(initSentry()).resolves.toBeUndefined() | ||
| expect(mockInit).not.toHaveBeenCalled() | ||
|
|
||
| mockShouldFailImport = false | ||
| await initSentry() | ||
| expect(mockInit).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { isAdmin } from '../utils/isAdmin' | ||
| import { getIOContext } from './ctx' | ||
|
|
||
| type SentryModule = typeof import('@sentry/react') | ||
|
|
||
| let sentryModulePromise: Promise<SentryModule> | null = null | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This module introduces new stateful logic (import caching via
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 7283a06 ( |
||
| let initialized = false | ||
|
|
||
| function makeEventWithCtx(event: any, ctx: any) { | ||
| const eventWithCtx = { | ||
| ...event, | ||
| tags: { | ||
| ...event?.tags, | ||
| ...ctx, | ||
| }, | ||
| } | ||
|
|
||
| return eventWithCtx | ||
| } | ||
|
|
||
| /** | ||
| * Lazily loads the Sentry SDK. It is only ever invoked from admin-gated | ||
| * call sites (see isAdmin() checks in error.tsx/ErrorBoundary.tsx), so | ||
| * storefront (non-admin) bundles never pay for the dynamic chunk. | ||
| * | ||
| * If the dynamic import fails (network blip, ad-blocker filtering | ||
| * `sentry.js`, a chunk hash mismatch right after a deploy, etc.) the | ||
| * cached promise is cleared so the *next* call gets a fresh retry instead | ||
| * of permanently reusing a rejected promise for the rest of the page | ||
| * session. | ||
| */ | ||
| function loadSentry(): Promise<SentryModule> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider resetting
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 2e7217e: |
||
| if (!sentryModulePromise) { | ||
| sentryModulePromise = import( | ||
| /* webpackChunkName: "sentry" */ '@sentry/react' | ||
| ).catch((error) => { | ||
| sentryModulePromise = null | ||
| throw error | ||
| }) | ||
| } | ||
|
|
||
| return sentryModulePromise | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
|
Comment on lines
+44
to
+57
|
||
|
|
||
| function doInit(Sentry: SentryModule) { | ||
| Sentry.init({ | ||
| dsn: | ||
| 'https://2fac72ea180d48ae9bf1dbb3104b4000@o191317.ingest.us.sentry.io/1292015', | ||
| integrations: [Sentry.replayIntegration()], | ||
|
|
||
| // Set tracesSampleRate to 0.1 to capture 10% | ||
| // of transactions for tracing. | ||
| tracesSampleRate: 0.1, | ||
|
|
||
| // Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled | ||
| tracePropagationTargets: [ | ||
| /^\//, | ||
| /^(https?:\/\/)?([a-z0-9]+[.])*myvtex\.com/, | ||
| ], | ||
|
|
||
| // Capture Replay for 0% of all sessions, | ||
| // plus for 50% of sessions with an error | ||
| replaysSessionSampleRate: 0, | ||
| replaysOnErrorSampleRate: 0.5, | ||
|
|
||
| beforeSend: (event) => { | ||
| const ctx = getIOContext() | ||
|
|
||
| // Must check with false, otherwise default null's | ||
| // value leads to data mistakenly not sent to Sentry, | ||
| // which can occur if somehow we can't infer whether | ||
| // the apps are running under a production or development | ||
| // environment. | ||
| if (ctx.admin_production === false) { | ||
| const params = new URL(document?.location?.toString())?.searchParams | ||
| const shouldLog = params.get('forceLogs') | ||
|
|
||
| if (shouldLog === 'true') { | ||
| return makeEventWithCtx(event, ctx) | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| return makeEventWithCtx(event, ctx) | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Ensures Sentry is loaded and initialized. Safe to call multiple times, | ||
| * and safe to call without awaiting/catching: failures (e.g. the chunk | ||
| * failing to load) are caught here and logged instead of becoming an | ||
| * unhandled promise rejection. No-ops outside admin, so it should only be | ||
| * called from admin-gated code. | ||
| */ | ||
| export async function initSentry() { | ||
| if (!isAdmin()) return | ||
|
|
||
| try { | ||
| const Sentry = await loadSentry() | ||
| await ensureInitialized(Sentry) | ||
| } catch (error) { | ||
| // A failure to load/initialize the SDK must never surface as an | ||
| // unhandled rejection on admin pages. | ||
| // eslint-disable-next-line no-console | ||
| console.error('[render-runtime] failed to load/init Sentry', error) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Drop-in async replacement for `captureException` from '@sentry/react'. | ||
| * Lazily loads + initializes the SDK on first use. Safe to call without | ||
| * awaiting/catching (see `initSentry` above for the failure-handling | ||
| * rationale): a failure to load/init/report never throws back at the | ||
| * caller, so it can never mask or interrupt the caller's own error | ||
| * handling. | ||
| */ | ||
| export async function captureException(exception: any, captureContext?: any) { | ||
| if (!isAdmin()) return | ||
|
|
||
| try { | ||
| const Sentry = await loadSentry() | ||
| await ensureInitialized(Sentry) | ||
|
|
||
| return Sentry.captureException(exception, captureContext) | ||
| } catch (error) { | ||
| // eslint-disable-next-line no-console | ||
| console.error( | ||
| '[render-runtime] failed to report exception to Sentry', | ||
| error | ||
| ) | ||
|
|
||
| return undefined | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 asynccaptureException/initSentry(this import is the same pattern used atErrorBoundary.tsx:32andinstrument.ts's top-levelinitSentry()call). SincecaptureExceptionused to be a synchronous, non-throwing export of@sentry/react, thetry/catchincomponentDidMountbelow (lines 37-52) can no longer catch anything it rejects with — a rejection (e.g. from the caching issue onloadSentry()) 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.
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.initthrowing, etc.) and log it viaconsole.errorinstead 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.