Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@
<button id="trigger-error-3" type="button" style="background-color: #dc3545; color: white">
Trigger Worker 3 (lazily added) Error
</button>
<button id="trigger-primitive-error" type="button" style="background-color: #dc3545; color: white">
Trigger Worker Primitive Error
</button>
<button id="trigger-startup-error" type="button">Trigger Worker 4 Startup Error</button>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const worker2 = new MyWorker2();
const webWorkerIntegration = Sentry.webWorkerIntegration({ worker: [worker, worker2] });
Sentry.addIntegration(webWorkerIntegration);

worker.addEventListener('error', event => {
// this is part of the test, do not delete
(window as any).workerErrorEvents = [
...((window as any).workerErrorEvents ?? []),
{ message: event.message, hasError: !!event.error },
];
});

worker.addEventListener('message', event => {
// this is part of the test, do not delete
console.log('received message from worker:', event.data.msg);
Expand All @@ -34,12 +42,23 @@ document.querySelector<HTMLButtonElement>('#trigger-error')!.addEventListener('c
});
});

document.querySelector<HTMLButtonElement>('#trigger-primitive-error')!.addEventListener('click', () => {
worker.postMessage({
msg: 'TRIGGER_PRIMITIVE_ERROR',
});
});

document.querySelector<HTMLButtonElement>('#trigger-error-2')!.addEventListener('click', () => {
worker2.postMessage({
msg: 'TRIGGER_ERROR',
});
});

document.querySelector<HTMLButtonElement>('#trigger-startup-error')!.addEventListener('click', async () => {
const Worker4 = await import('./worker4.ts?worker');
webWorkerIntegration.addWorker(new Worker4.default());
});

document.querySelector<HTMLButtonElement>('#trigger-error-3')!.addEventListener('click', async () => {
const Worker3 = await import('./worker3.ts?worker');
const worker3 = new Worker3.default();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ self.addEventListener('message', event => {
// This will throw an uncaught error in the worker
throw new Error(`Uncaught error in worker`);
}

if (event.data.msg === 'TRIGGER_PRIMITIVE_ERROR') {
// A thrown primitive has no stack, so only the ErrorEvent knows where it came from
throw 'Primitive thrown in worker';
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Sentry from '@sentry/browser';

Sentry.registerWebWorker({ self });

// Thrown while the worker script first runs, before the page's acknowledgement can arrive
throw new Error('Uncaught error during worker startup');
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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');
Expand All @@ -52,6 +64,89 @@ 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<string | undefined> = [];
// Records on the same stream it resolves on, since events are not ordered across streams.
const secondErrorPromise = waitForError('browser-webworker-vite', event => {
if (!event.type && event.exception?.values?.[0]) {
mechanisms.push(event.exception.values[0].mechanism?.type);
}
return event.exception?.values?.[0]?.value === 'Uncaught error in worker 2';
});
const firstErrorPromise = waitForError('browser-webworker-vite', event => {
return event.exception?.values?.[0]?.value === 'Uncaught error in worker';
});

await page.goto('/');

await page.locator('#trigger-error').click();
await firstErrorPromise;

// Page listeners on the worker object still get the native event, once.
expect(await page.evaluate(() => (window as any).workerErrorEvents)).toEqual([
{ message: 'Uncaught Error: Uncaught error in worker', hasError: false },
]);

// A bubbled copy of the first throw would have been reported before the
// second worker's event, so its absence here shows it never happened.
await page.locator('#trigger-error-2').click();
await secondErrorPromise;

expect(mechanisms).toEqual([WORKER_MECHANISM, WORKER_MECHANISM]);
});

test('locates a thrown primitive by its ErrorEvent position', async ({ page }) => {
const errorEventPromise = waitForError('browser-webworker-vite', event => {
return event.exception?.values?.[0]?.value === 'Primitive thrown in worker';
});

await page.goto('/');

await page.locator('#trigger-primitive-error').click();

const errorEvent = await errorEventPromise;
const exception = errorEvent.exception?.values?.[0];

expect(exception?.mechanism?.type).toBe(WORKER_MECHANISM);
expect(exception?.stacktrace?.frames).toEqual([
{
filename: expect.stringMatching(/worker-.+\.js$/),
lineno: expect.any(Number),
colno: expect.any(Number),
function: '?',
in_app: true,
},
]);
expect(exception?.stacktrace?.frames?.[0]?.lineno).toBeGreaterThan(0);
expect(exception?.stacktrace?.frames?.[0]?.colno).toBeGreaterThan(0);
});

test('emits exactly one event for an error thrown during worker startup', async ({ page }) => {
const values: Array<string | undefined> = [];
// Records on the same stream it resolves on, since events are not ordered across streams.
const laterErrorPromise = waitForError('browser-webworker-vite', event => {
const value = event.exception?.values?.[0]?.value;
if (value?.includes('Uncaught error during worker startup')) {
values.push(value);
}
return value === 'Uncaught error in worker';
});
const startupErrorPromise = waitForError('browser-webworker-vite', event => {
return !!event.exception?.values?.[0]?.value?.includes('Uncaught error during worker startup');
});

await page.goto('/');

await page.locator('#trigger-startup-error').click();
await startupErrorPromise;

// Any duplicate of the startup error is sent long before this later error.
await page.locator('#trigger-error').click();
await laterErrorPromise;

expect(values).toHaveLength(1);
});
Comment thread
cursor[bot] marked this conversation as resolved.

test("user worker message handlers don't trigger for sentry messages", async ({ page }) => {
const workerReadyPromise = new Promise<number>(resolve => {
let workerMessageCount = 0;
Expand Down Expand Up @@ -90,9 +185,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');
Expand Down Expand Up @@ -135,9 +232,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');
Expand Down Expand Up @@ -165,7 +264,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('/');
Expand Down
40 changes: 37 additions & 3 deletions packages/browser/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Mechanism, WrappedFunction } from '@sentry/core';
import type { HandlerDataError, Mechanism, WrappedFunction } from '@sentry/core';
import {
addExceptionMechanism,
addExceptionTypeValue,
Expand All @@ -15,11 +15,45 @@ export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;

let ignoreOnError: number = 0;

type ErrorReport = Pick<HandlerDataError, 'msg' | 'url' | 'line' | 'column'>;

const ignoredErrorReports: ErrorReport[] = [];

/**
* @hidden
*/
export function shouldIgnoreOnError(): boolean {
return ignoreOnError > 0;
export function shouldIgnoreOnError(data?: HandlerDataError): boolean {
if (ignoreOnError > 0) {
return true;
}

const index = data ? ignoredErrorReports.findIndex(report => isSameErrorReport(report, data)) : -1;
if (index === -1) {
return false;
}

ignoredErrorReports.splice(index, 1);
return true;
}

/**
* Skips the next `onerror` report that matches `report`, once. A report that
* never arrives is forgotten after the current task.
*
* @hidden
*/
export function ignoreNextOnErrorMatching(report: ErrorReport): void {
ignoredErrorReports.push(report);
setTimeout(() => {
const index = ignoredErrorReports.indexOf(report);
if (index !== -1) {
ignoredErrorReports.splice(index, 1);
}
});
}

function isSameErrorReport(a: ErrorReport, b: ErrorReport): boolean {
return a.msg === b.msg && a.url === b.url && a.line === b.line && a.column === b.column;
}

/**
Expand Down
7 changes: 5 additions & 2 deletions packages/browser/src/integrations/globalhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function _installGlobalOnErrorHandler(client: Client): void {
addGlobalErrorInstrumentationHandler(data => {
const { stackParser, attachStacktrace } = getOptions();

if (getClient() !== client || shouldIgnoreOnError()) {
if (getClient() !== client || shouldIgnoreOnError(data)) {
return;
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading