diff --git a/CHANGELOG.md b/CHANGELOG.md index d1224249..7688c7a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Update DK Catalog platform-flow-id +- Lazy-load the Sentry SDK via dynamic import so it's only ever downloaded on admin pages, keeping it out of the storefront bundle ## [8.136.2] - 2026-03-02 diff --git a/react/components/ErrorBoundary.tsx b/react/components/ErrorBoundary.tsx index 35a22f2f..ef858406 100644 --- a/react/components/ErrorBoundary.tsx +++ b/react/components/ErrorBoundary.tsx @@ -3,7 +3,7 @@ import ErrorDisplay from './ExtensionPoint/ErrorDisplay' import { useRuntime } from './RenderContext' import type { RenderContext } from './RenderContext' import { isAdmin } from '../utils/isAdmin' -import { captureException } from '@sentry/react' +import { captureException } from '../o11y/sentry' import { CustomAdminTags } from '../o11y/types' import ErrorPage from './ErrorPage/ErrorPage' diff --git a/react/error.tsx b/react/error.tsx index 07671406..542f854e 100644 --- a/react/error.tsx +++ b/react/error.tsx @@ -1,6 +1,6 @@ /* global module */ import React, { Component } from 'react' -import { captureException } from '@sentry/react' +import { captureException } from './o11y/sentry' require('myvtex-sse') diff --git a/react/o11y/instrument.ts b/react/o11y/instrument.ts index 2f78faa4..b2e45377 100644 --- a/react/o11y/instrument.ts +++ b/react/o11y/instrument.ts @@ -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 {} diff --git a/react/o11y/sentry.test.ts b/react/o11y/sentry.test.ts new file mode 100644 index 00000000..8d0c1f73 --- /dev/null +++ b/react/o11y/sentry.test.ts @@ -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) + }) +}) diff --git a/react/o11y/sentry.ts b/react/o11y/sentry.ts new file mode 100644 index 00000000..f12af7d9 --- /dev/null +++ b/react/o11y/sentry.ts @@ -0,0 +1,150 @@ +import { isAdmin } from '../utils/isAdmin' +import { getIOContext } from './ctx' + +type SentryModule = typeof import('@sentry/react') + +let sentryModulePromise: Promise | null = null +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 { + 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 + } +} + +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 + } +}