diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index a253c5ef4847..8a6f4ab60910 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -7,6 +7,11 @@ function waitForPageloadSpan() { }); } +// The throw still bubbles to the page after the worker forwards it, but +// the integration makes globalHandlers skip that frameless copy. So the +// first error event to arrive must be the forwarded one. +const WORKER_MECHANISM = 'auto.browser.web_worker.onerror'; + test('captures an error with debug ids and pageload trace context', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { return !event.type && !!event.exception?.values?.[0]; @@ -24,9 +29,16 @@ test('captures an error with debug ids and pageload trace context', async ({ pag const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); + expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker-.+\.js$/) })]), + ); + + expect(errorEvent.contexts?.worker).toEqual({ + filename: expect.stringMatching(/worker-.+\.js$/), + }); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -52,6 +64,38 @@ test('captures an error with debug ids and pageload trace context', async ({ pag }); }); +test('emits exactly one event for an uncaught worker error', async ({ page }) => { + const mechanisms: Array = []; + // Never resolves. It only records every error event that arrives, so + // the global handler's copy of a throw would show up here. + void waitForError('browser-webworker-vite', event => { + if (!event.type && event.exception?.values?.[0]) { + mechanisms.push(event.exception.values[0].mechanism?.type); + } + return false; + }); + + const firstErrorPromise = waitForError('browser-webworker-vite', event => { + return event.exception?.values?.[0]?.value === 'Uncaught error in worker'; + }); + const secondErrorPromise = waitForError('browser-webworker-vite', event => { + return event.exception?.values?.[0]?.value === 'Uncaught error in worker 2'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').click(); + await firstErrorPromise; + + // The bubbled copy of the first throw is queued right behind the forwarded + // one, so the second worker's event arriving without it in between is the + // signal that it was suppressed. + await page.locator('#trigger-error-2').click(); + await secondErrorPromise; + + expect(mechanisms).toEqual([WORKER_MECHANISM, WORKER_MECHANISM]); +}); + test("user worker message handlers don't trigger for sentry messages", async ({ page }) => { const workerReadyPromise = new Promise(resolve => { let workerMessageCount = 0; @@ -90,9 +134,11 @@ test('captures an error from the second eagerly added worker', async ({ page }) const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 2'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker2-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 2'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker2-.+\.js$/) })]), + ); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -135,9 +181,11 @@ test('captures an error from the third lazily added worker', async ({ page }) => const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 3'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker3-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 3'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker3-.+\.js$/) })]), + ); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -165,7 +213,7 @@ test('captures an error from the third lazily added worker', async ({ page }) => test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker'; + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught error in worker'; }); await page.goto('/'); diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index 0dbeb46a0fce..f258913a8e16 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -156,7 +156,10 @@ export function _eventFromRejectionWithPrimitive(reason: Primitive): Event { }; } -function _enhanceEventWithInitialFrame( +/** + * Adds a frame built from the error location when the event has none. + */ +export function _enhanceEventWithInitialFrame( event: Event, url: string | undefined, lineno: number | undefined, diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index e9a8e338d287..72748a01a450 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,9 +1,23 @@ import type { DebugImage, Integration, IntegrationFn } from '@sentry/core'; -import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core'; +import { + addNonEnumerableProperty, + captureEvent, + debug, + defineIntegration, + getClient, + isError, + isPlainObject, + isPrimitive, + normalize, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { eventFromUnknownInput } from '../eventbuilder'; -import { WINDOW } from '../helpers'; -import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers'; +import { eventFromUnknownInput, extractMessage, extractType } from '../eventbuilder'; +import { ignoreNextOnError, WINDOW } from '../helpers'; +import { + _enhanceEventWithInitialFrame, + _eventFromRejectionWithPrimitive, + _getUnhandledRejectionError, +} from './globalhandlers'; export const INTEGRATION_NAME = 'WebWorker' as const; @@ -13,11 +27,25 @@ interface WebWorkerMessage { _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; _sentryWasmImages?: Array; + /** Sent by workers that forward uncaught errors, not only rejections. */ + _sentryForwardsErrors?: boolean; } +type WorkerErrorKind = 'error' | 'unhandledrejection'; + interface SerializedWorkerError { reason: unknown; filename?: string; + /** Absent on workers registered by an SDK version that only forwarded rejections. */ + kind?: WorkerErrorKind; + /** Structured clone resets any name outside the built-in set to `Error`. */ + name?: string; + /** Script the error was thrown in, which can differ from the worker script. */ + url?: string; + lineno?: number; + colno?: number; + /** Set when `reason` is a plain `{ message, stack }` copy of an error that did not clone. */ + plainError?: boolean; } interface WebWorkerIntegrationOptions { @@ -110,10 +138,27 @@ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerInte })) as IntegrationFn; function listenForSentryMessages(worker: Worker): void { + let forwardsErrors = false; + + // An uncaught worker error fires `error` on the worker object and, unless + // cancelled, is then reported to `window.onerror` in the same task. The + // worker already forwarded it with a real stack, so the global handler + // must skip the message-only copy. Not cancelling keeps the browser's own + // console report. + worker.addEventListener('error', () => { + if (forwardsErrors) { + ignoreNextOnError(); + } + }); + worker.addEventListener('message', event => { if (isSentryMessage(event.data)) { event.stopImmediatePropagation(); // other listeners should not receive this message + if (event.data._sentryForwardsErrors) { + forwardsErrors = true; + } + // Handle debug IDs if (event.data._sentryDebugIds) { DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data); @@ -153,40 +198,53 @@ function listenForSentryMessages(worker: Worker): void { ]; } - // Handle unhandled rejections forwarded from worker + // Handle errors and unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { - DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); - handleForwardedWorkerRejection(event.data._sentryWorkerError); + DEBUG_BUILD && debug.log('Sentry worker error message received', event.data._sentryWorkerError); + handleForwardedWorkerError(event.data._sentryWorkerError); } } }); } -function handleForwardedWorkerRejection(workerError: SerializedWorkerError): void { +function handleForwardedWorkerError(workerError: SerializedWorkerError): void { const client = getClient(); if (!client) { return; } - const stackParser = client.getOptions().stackParser; - const attachStacktrace = client.getOptions().attachStacktrace; + const { stackParser, attachStacktrace } = client.getOptions(); + + const { reason, kind, name, filename, url, lineno, colno, plainError } = workerError; + // Older workers only ever forwarded rejections and send no `kind`. + const isUnhandledRejection = kind !== 'error'; + + const error = plainError && isPlainObject(reason) ? errorFromPlain(reason) : reason; + + if (name && isError(error) && error.name !== name) { + addNonEnumerableProperty(error, 'name', name); + } - const error = workerError.reason; + // Follow same pattern as globalHandlers for each source. + // A thrown primitive is not a rejection, so the rejection-specific wording must not apply to it. + const event = + isUnhandledRejection && isPrimitive(error) + ? _eventFromRejectionWithPrimitive(error) + : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection); - // Follow same pattern as globalHandlers for unhandledrejection - // Handle both primitives and errors the same way - const event = isPrimitive(error) - ? _eventFromRejectionWithPrimitive(error) - : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true); + if (!isUnhandledRejection) { + // An ErrorEvent reports an unknown script as an empty string. + _enhanceEventWithInitialFrame(event, url || filename, lineno, colno); + } event.level = 'error'; // Add worker-specific context - if (workerError.filename) { + if (filename) { event.contexts = { ...event.contexts, worker: { - filename: workerError.filename, + filename, }, }; } @@ -195,11 +253,11 @@ function handleForwardedWorkerRejection(workerError: SerializedWorkerError): voi originalException: error, mechanism: { handled: false, - type: 'auto.browser.web_worker.onunhandledrejection', + type: isUnhandledRejection ? 'auto.browser.web_worker.onunhandledrejection' : 'auto.browser.web_worker.onerror', }, }); - DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error); + DEBUG_BUILD && debug.log(`Captured worker ${isUnhandledRejection ? 'unhandled rejection' : 'error'}`, error); } /** @@ -230,11 +288,12 @@ interface RegisterWebWorkerOptions { * This function will: * - Send debug IDs to the parent thread * - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration) - * - Set up a handler for unhandled rejections in the worker - * - Forward unhandled rejections to the parent thread for capture + * - Set up handlers for uncaught errors and unhandled rejections in the worker + * - Forward both to the parent thread for capture * - * Note: Synchronous errors in workers are already captured by globalHandlers. - * This only handles unhandled promise rejections which don't bubble to the parent. + * Note: uncaught errors do bubble to the parent, but the propagated `ErrorEvent` carries + * no `error` object, so globalHandlers can only build an event from the message string. + * Forwarding them here preserves the real stack, which matters most for wasm frames. * * @example * ```ts filename={worker.js} @@ -250,37 +309,92 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { + // Mirrors globalHandlersIntegration. The worker has no client of its own, so without this + // V8's default of 10 truncates stacks before this code forwards them. + Error.stackTraceLimit = 50; + // Send debug IDs and raw module metadata to parent thread // The metadata will be parsed lazily on the main thread when needed self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, + _sentryForwardsErrors: true, }); - // Set up unhandledrejection handler inside the worker - // Following the same pattern as globalHandlers - // unhandled rejections don't bubble to the parent thread, so we need to handle them here - self.addEventListener('unhandledrejection', (event: unknown) => { - const reason = _getUnhandledRejectionError(event); + const forward = (serializedError: Omit): void => { + const { reason } = serializedError; - // Forward the raw reason to parent thread - // The parent will handle primitives vs errors the same way globalHandlers does - const serializedError: SerializedWorkerError = { - reason: reason, + postSerializedWorkerError(self, { + ...serializedError, filename: self.location?.href, + name: isError(reason) ? extractType(reason) : undefined, + }); + + DEBUG_BUILD && debug.log(`[Sentry Worker] Forwarding ${serializedError.kind} to parent`, serializedError); + }; + + // Uncaught errors bubble to the parent, but the propagated ErrorEvent + // carries no error object. Forwarding the object keeps the real stack. + self.addEventListener('error', (event: unknown) => { + const { error, message, filename, lineno, colno } = event as { + error?: unknown; + message?: string; + filename?: string; + lineno?: number; + colno?: number; }; - // Forward to parent thread + forward({ kind: 'error', reason: error ?? message, url: filename, lineno, colno }); + }); + + // Unhandled rejections do not bubble to the parent thread at all. + self.addEventListener('unhandledrejection', (event: unknown) => { + forward({ kind: 'unhandledrejection', reason: _getUnhandledRejectionError(event) }); + }); + + DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with error and unhandled rejection handling'); +} + +/** + * `postMessage` structured-clones the reason. A `DataCloneError` must never + * escape the worker's own error handler, so the forward is retried with + * plain data. The page suppresses the bubbled copy of every error once the + * worker has announced itself, so the retry must clone in every browser, + * including ones that cannot clone `Error` at all. + */ +function postSerializedWorkerError( + self: MinimalDedicatedWorkerGlobalScope, + serializedError: SerializedWorkerError, +): void { + try { self.postMessage({ _sentryMessage: true, _sentryWorkerError: serializedError, }); + return; + } catch { + // Not cloneable, fall through and send plain data instead. + } - DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError); - }); + const { reason } = serializedError; + const plainError = isError(reason); + const plainReason = plainError ? { message: extractMessage(reason), stack: reason.stack } : normalize(reason); + + try { + self.postMessage({ + _sentryMessage: true, + _sentryWorkerError: { ...serializedError, reason: plainReason, plainError }, + }); + } catch { + // Dropping the forward is better than throwing out of the worker's error handler. + } +} - DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling'); +function errorFromPlain(plain: Record): Error { + const error = new Error(String(plain.message)); + error.stack = typeof plain.stack === 'string' ? plain.stack : undefined; + return error; } function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index c239e31bd638..6d1dc4cc4997 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -3,9 +3,14 @@ */ import * as SentryCore from '@sentry/core'; +import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isError } from '@sentry/core'; +import { BrowserClient } from '../../src/client'; import * as helpers from '../../src/helpers'; import { INTEGRATION_NAME, registerWebWorker, webWorkerIntegration } from '../../src/integrations/webWorker'; +import { defaultStackParser } from '../../src/stack-parsers'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; // Mock @sentry/core vi.mock('@sentry/core', async importActual => { @@ -27,8 +32,17 @@ vi.mock('../../src/helpers', () => ({ WINDOW: { _sentryDebugIds: undefined, }, + ignoreNextOnError: vi.fn(), })); +function getListener(addEventListener: ReturnType, type: string): (event: any) => void { + const call = addEventListener.mock.calls.find(([eventType]) => eventType === type); + if (!call) { + throw new Error(`No ${type} listener registered`); + } + return call[1]; +} + describe('webWorkerIntegration', () => { const mockDebugLog = SentryCore.debug.log as any; @@ -115,9 +129,7 @@ describe('webWorkerIntegration', () => { const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); - // Extract the message handler from the addEventListener call - expect(mockWorker.addEventListener.mock.calls).toBeDefined(); - messageHandler = mockWorker.addEventListener.mock.calls[0]![1]; + messageHandler = getListener(mockWorker.addEventListener, 'message'); }); it('ignores non-Sentry messages', () => { @@ -407,8 +419,12 @@ describe('registerWebWorker', () => { addEventListener: ReturnType; _sentryDebugIds?: Record; _sentryModuleMetadata?: Record; + location?: { href?: string }; }; + // registerWebWorker raises this globally, so every test has to put it back. + const originalStackTraceLimit = Error.stackTraceLimit; + beforeEach(() => { vi.clearAllMocks(); @@ -418,6 +434,10 @@ describe('registerWebWorker', () => { }; }); + afterEach(() => { + Error.stackTraceLimit = originalStackTraceLimit; + }); + it('posts message with _sentryMessage flag', () => { registerWebWorker({ self: mockWorkerSelf as any }); @@ -426,6 +446,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -445,6 +466,7 @@ describe('registerWebWorker', () => { 'worker-file2.js': 'debug-id-2', }, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -458,6 +480,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -475,6 +498,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: rawMetadata, + _sentryForwardsErrors: true, }); }); @@ -487,6 +511,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -508,6 +533,183 @@ describe('registerWebWorker', () => { 'worker-file.js': 'debug-id-1', }, _sentryModuleMetadata: rawMetadata, + _sentryForwardsErrors: true, + }); + }); + + describe('error forwarding', () => { + function trigger(type: string, event: unknown): void { + getListener(mockWorkerSelf.addEventListener, type)(event); + } + + it('raises the stack trace limit so forwarded stacks are not truncated', () => { + Error.stackTraceLimit = 10; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(Error.stackTraceLimit).toBe(50); + }); + + it('forwards an uncaught error with its location, name and kind "error"', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + mockWorkerSelf.location = { href: 'http://localhost/worker.js' }; + const error = new Error('boom'); + trigger('error', { + error, + message: 'Uncaught Error: boom', + filename: 'http://localhost/chunk.js', + lineno: 12, + colno: 9, + }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: { + reason: error, + filename: 'http://localhost/worker.js', + kind: 'error', + name: 'Error', + url: 'http://localhost/chunk.js', + lineno: 12, + colno: 9, + }, + }); + }); + + it('sends the error name separately because structured clone resets it', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const error = new Error('divide by zero'); + error.name = 'RuntimeError'; + trigger('error', { error }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ reason: error, name: 'RuntimeError' }), + }); + }); + + it('falls back to the event message when there is no error object', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + trigger('error', { error: null, message: 'Uncaught Error: boom', lineno: 3, colno: 7 }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: 'Uncaught Error: boom', + kind: 'error', + name: undefined, + lineno: 3, + colno: 7, + }), + }); + }); + + it('tags forwarded rejections with kind "unhandledrejection"', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const reason = new Error('rejected'); + trigger('unhandledrejection', { reason }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: { + reason, + filename: undefined, + kind: 'unhandledrejection', + name: 'Error', + }, + }); + }); + + describe('when the reason cannot be structured-cloned', () => { + beforeEach(() => { + mockWorkerSelf.postMessage.mockImplementation(message => structuredClone(message)); + }); + + it('retries with a plain copy that keeps message and stack but drops the cause', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const error = new Error('boom') as Error & { cause?: unknown }; + error.cause = () => {}; + expect(() => trigger('error', { error })).not.toThrow(); + + // The mocked postMessage clones for real, so a third call proves the + // retry no longer carries the function that blocked the first one. + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(3); + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { message: 'boom', stack: error.stack }, + plainError: true, + name: 'Error', + kind: 'error', + }), + }); + }); + + it('sends only plain data when the browser cannot clone errors at all', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + mockWorkerSelf.postMessage.mockImplementation(message => { + if (isError(message._sentryWorkerError?.reason)) { + throw new DOMException('could not be cloned', 'DataCloneError'); + } + }); + + const error = new Error('boom'); + trigger('error', { error }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { message: 'boom', stack: error.stack }, + plainError: true, + }), + }); + }); + + it('keeps the message and stack of a WebAssembly.Exception', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const tag = new WebAssembly.Tag({ parameters: [] }); + const exception = new WebAssembly.Exception(tag, [], { traceStack: true }); + trigger('error', { error: exception }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { message: 'wasm exception', stack: exception.stack }, + plainError: true, + name: 'WebAssembly.Exception', + }), + }); + }); + + it('normalizes a reason that is not an error', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + trigger('unhandledrejection', { reason: { retry: () => {} } }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { retry: '[Function: retry]' }, + plainError: false, + kind: 'unhandledrejection', + }), + }); + }); + + it('does not throw out of the error handler when the retry fails as well', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + mockWorkerSelf.postMessage.mockImplementation(() => { + throw new DOMException('could not be cloned', 'DataCloneError'); + }); + + expect(() => trigger('error', { error: new Error('boom') })).not.toThrow(); + }); }); }); }); @@ -578,6 +780,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -599,6 +802,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { _sentryMessage: true, _sentryDebugIds: mockWorker3._sentryDebugIds, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -614,3 +818,163 @@ describe('registerWebWorker and webWorkerIntegration', () => { }); }); }); + +describe('forwarded worker errors', () => { + let client: BrowserClient; + let captureEventSpy: MockInstance; + let mockWorker: { addEventListener: ReturnType; postMessage: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + + client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + stackParser: defaultStackParser, + }); + SentryCore.setCurrentClient(client); + client.init(); + captureEventSpy = vi.spyOn(client, 'captureEvent'); + + mockWorker = { addEventListener: vi.fn(), postMessage: vi.fn() }; + const integration = webWorkerIntegration({ worker: mockWorker as any }); + integration.setupOnce!(); + }); + + function receive(data: Record): void { + getListener( + mockWorker.addEventListener, + 'message', + )({ + data: { _sentryMessage: true, ...data }, + stopImmediatePropagation: vi.fn(), + }); + } + + function forward(workerError: Record): void { + receive({ _sentryWorkerError: workerError }); + } + + function expectCapturedException(exception: Record): void { + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ exception: { values: [expect.objectContaining(exception)] } }), + expect.anything(), + expect.anything(), + ); + } + + it('captures a forwarded error with the onerror mechanism', () => { + const error = new Error('boom'); + + forward({ reason: error, filename: 'http://localhost/worker.js', kind: 'error' }); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ level: 'error' }), + expect.objectContaining({ + originalException: error, + mechanism: { handled: false, type: 'auto.browser.web_worker.onerror' }, + }), + expect.anything(), + ); + }); + + it.each([ + ['kind "unhandledrejection"', 'unhandledrejection'], + // Workers registered by an older SDK only forwarded rejections and sent no kind. + ['no kind', undefined], + ])('captures a forwarded rejection with %s using the onunhandledrejection mechanism', (_, kind) => { + forward({ reason: new Error('rejected'), kind }); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.browser.web_worker.onunhandledrejection' }, + }), + expect.anything(), + ); + }); + + it('does not apply promise-rejection wording to a thrown primitive', () => { + forward({ reason: 'just a string', kind: 'error' }); + + expectCapturedException({ type: 'Error', value: 'just a string' }); + }); + + it('keeps promise-rejection wording for a rejected primitive', () => { + forward({ reason: 'just a string', kind: 'unhandledrejection' }); + + expectCapturedException({ + type: 'UnhandledRejection', + value: 'Non-Error promise rejection captured with value: just a string', + }); + }); + + it('restores the name that structured clone dropped and parses the forwarded stack', () => { + const error = new Error('divide by zero'); + error.name = 'RuntimeError'; + error.stack = [ + 'RuntimeError: divide by zero', + ' at trigger_crash (http://localhost:8080/maze.wasm:wasm-function[36]:0x2877)', + ' at runStepGame (http://localhost:8080/worker.js:12:9)', + ].join('\n'); + const cloned = structuredClone(error); + expect(cloned.name).toBe('Error'); + + forward({ reason: cloned, name: 'RuntimeError', kind: 'error' }); + + expectCapturedException({ + type: 'RuntimeError', + value: 'divide by zero', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ filename: 'http://localhost:8080/maze.wasm:wasm-function[36]:0x2877' }), + expect.objectContaining({ filename: 'http://localhost:8080/worker.js' }), + ]), + }, + }); + }); + + it('rebuilds an error from a plain copy and restores its name', () => { + const stack = ['RuntimeError: divide by zero', ' at runStepGame (http://localhost:8080/worker.js:12:9)'].join( + '\n', + ); + + forward({ reason: { message: 'divide by zero', stack }, plainError: true, name: 'RuntimeError', kind: 'error' }); + + expectCapturedException({ + type: 'RuntimeError', + value: 'divide by zero', + stacktrace: { frames: [expect.objectContaining({ filename: 'http://localhost:8080/worker.js', lineno: 12 })] }, + }); + }); + + it.each([ + ['the script that threw', 'http://localhost/chunk.js', 'http://localhost/chunk.js'], + ['the worker script when the event has no url', undefined, 'http://localhost/worker.js'], + ['the worker script when the event url is empty', '', 'http://localhost/worker.js'], + ])('adds a frame at %s when a message-only error has no stack', (_, url, frameFilename) => { + forward({ + reason: 'Uncaught Error: boom', + kind: 'error', + filename: 'http://localhost/worker.js', + url, + lineno: 12, + colno: 9, + }); + + expectCapturedException({ + value: 'Uncaught Error: boom', + stacktrace: { frames: [expect.objectContaining({ filename: frameFilename, lineno: 12, colno: 9 })] }, + }); + }); + + it('skips the global onerror copy only once the worker announced that it forwards errors', () => { + const onWorkerError = getListener(mockWorker.addEventListener, 'error'); + + onWorkerError({}); + expect(helpers.ignoreNextOnError).not.toHaveBeenCalled(); + + receive({ _sentryDebugIds: undefined, _sentryForwardsErrors: true }); + onWorkerError({}); + expect(helpers.ignoreNextOnError).toHaveBeenCalledTimes(1); + }); +});