Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion react/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion react/error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* 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.


require('myvtex-sse')

Expand Down
73 changes: 13 additions & 60 deletions react/o11y/instrument.ts
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 {}
136 changes: 136 additions & 0 deletions react/o11y/sentry.test.ts
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)
})
})
150 changes: 150 additions & 0 deletions react/o11y/sentry.ts
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

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.

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> {

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

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
}
}