From 342bd89e98eced67fda2af5efba98b802beaec88 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:03:13 +0200 Subject: [PATCH 1/7] fix(core): Update session status before event is dropped or `beforeSend` returns `null` --- .../sessions/sampled-session-update/init.js | 10 ++ .../sampled-session-update/subject.js | 7 ++ .../sampled-session-update/template.html | 10 ++ .../sessions/sampled-session-update/test.ts | 53 +++++++++ packages/browser/src/helpers.ts | 8 +- packages/core/src/client.ts | 20 +++- packages/core/test/lib/client.test.ts | 106 ++++++++++++++++++ 7 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/init.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/template.html create mode 100644 dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts diff --git a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/init.js b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/init.js new file mode 100644 index 000000000000..f5c6bdef5e5f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '0.1', + sampleRate: 0, +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/subject.js b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/subject.js new file mode 100644 index 000000000000..49160a6dd1fa --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/subject.js @@ -0,0 +1,7 @@ +document.getElementById('throw-error').addEventListener('click', () => { + throw new Error('unhandled crash'); +}); + +document.getElementById('capture-exception').addEventListener('click', () => { + Sentry.captureException(new Error('handled capture')); +}); diff --git a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/template.html b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/template.html new file mode 100644 index 000000000000..d0cb96c31a41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts new file mode 100644 index 000000000000..c0a25ef9a716 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts @@ -0,0 +1,53 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { countEnvelopes, waitForSession } from '../../../utils/helpers'; + +sentryTest( + 'marks session as crashed when unhandled error is sampled out by sampleRate', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const pageloadSession = await pageloadSessionPromise; + + const updatedSessionPromise = waitForSession(page, s => !s.init && s.status !== 'ok'); + const errorCountPromise = countEnvelopes(page, { envelopeType: 'event', timeout: 2000 }); + await page.locator('#throw-error').click(); + const updatedSession = await updatedSessionPromise; + const errorCount = await errorCountPromise; + + // The error event is not sent — it was sampled out + expect(errorCount).toBe(0); + + // But the session update is still sent, reflecting the crash + expect(updatedSession.sid).toBe(pageloadSession.sid); + expect(updatedSession.errors).toBe(1); + expect(updatedSession.status).toBe('unhandled'); + }, +); + +sentryTest( + 'marks session as errored when handled exception is sampled out by sampleRate', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSessionPromise = waitForSession(page, s => !!s.init && s.status === 'ok'); + await page.goto(url); + const pageloadSession = await pageloadSessionPromise; + + const updatedSessionPromise = waitForSession(page, s => !s.init); + const errorCountPromise = countEnvelopes(page, { envelopeType: 'event', timeout: 2000 }); + await page.locator('#capture-exception').click(); + const updatedSession = await updatedSessionPromise; + const errorCount = await errorCountPromise; + + // The error event is not sent — it was sampled out + expect(errorCount).toBe(0); + + // But the session update is still sent, recording the error + expect(updatedSession.sid).toBe(pageloadSession.sid); + expect(updatedSession.errors).toBe(1); + expect(updatedSession.status).toBe('ok'); + }, +); diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 5e31528b6dea..27a04b7d3fd7 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -138,8 +138,12 @@ export function wrap( return event; }); - // no need to add a mechanism here, we already add it via an event processor above - captureException(ex); + // Mechanism is passed via hint so it's available even when the event + // is dropped before event processors run (e.g. sampleRate sampling). + // We also add it via an event processor above. + captureException(ex, { + mechanism: options.mechanism, + }); }); throw ex; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8171a53c02c4..f1a6eb8fe8a0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,7 +45,7 @@ import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils/envelop import { getPossibleEventMessages } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive, isThenable } from './utils/is'; import { merge } from './utils/merge'; -import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; +import { addExceptionMechanism, checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; @@ -1445,6 +1445,17 @@ export abstract class Client { const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); + + // Reflects crashes inside release health sessions, regardless of event sampling + const session = currentScope.getSession() || isolationScope.getSession(); + if (session) { + // _prepareEvent (which normally merges hint.mechanism) is skipped in this code path, so we add it here + if (hint.mechanism) { + addExceptionMechanism(event, hint.mechanism); + } + this._updateSessionFromEvent(session, event); + } + return rejectedSyncPromise( _makeDoNotSendEventError( `Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`, @@ -1478,6 +1489,13 @@ export abstract class Client { const spanCount = 1 + spans.length; this.recordDroppedEvent('before_send', 'span', spanCount); } + + // Reflects crashes inside release health sessions, regardless of beforeSend dropping the event. + const session = currentScope.getSession() || isolationScope.getSession(); + if (isError && session) { + this._updateSessionFromEvent(session, event); + } + throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`); } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index e0d3c4b3c8c1..810f2fac4d63 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2555,6 +2555,112 @@ describe('Client', () => { }); }); + describe('session updates are decoupled from event sampling and beforeSend', () => { + test('drops unhandled error event when sampled out but still marks session as crashed', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + // The error event is not sent — it was sampled out + expect(TestClient.instance!.event).toBeUndefined(); + + // But the session update is still sent, reflecting the crash + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); + + test('drops handled error event when sampled out but still marks session as errored', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + // The error event is not sent — it was sampled out + expect(TestClient.instance!.event).toBeUndefined(); + + // But the session update is still sent, recording the error + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('ok'); + }); + + test('drops unhandled error event when beforeSend returns null but still marks session as crashed', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + // The error event is not sent — beforeSend discarded it + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + + // But the session update is still sent, reflecting the crash + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); + + test('drops handled error event when beforeSend returns null but still marks session as errored', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + // The error event is not sent — beforeSend discarded it + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + + // But the session update is still sent, recording the error + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('ok'); + }); + }); + describe('recordDroppedEvent()/_clearOutcomes()', () => { test('records and returns outcomes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); From b002e45e1a22ac386134c4f5760151e6021935a7 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:29:36 +0200 Subject: [PATCH 2/7] fix(core): Update session status before event is dropped or `beforeSend` returns `null` --- packages/core/src/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index f1a6eb8fe8a0..7ae6c8b96560 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1465,6 +1465,8 @@ export abstract class Client { const dataCategory = getDataCategoryByType(event.type); + let preparedEvent: Event = event; + return this._prepareEvent(event, hint, currentScope, isolationScope) .then(prepared => { if (prepared === null) { @@ -1472,6 +1474,8 @@ export abstract class Client { throw _makeDoNotSendEventError('An event processor returned `null`, will not send event.'); } + preparedEvent = prepared; + const isInternalException = (hint.data as { __sentry__: boolean })?.__sentry__ === true; if (isInternalException) { return prepared; @@ -1493,7 +1497,7 @@ export abstract class Client { // Reflects crashes inside release health sessions, regardless of beforeSend dropping the event. const session = currentScope.getSession() || isolationScope.getSession(); if (isError && session) { - this._updateSessionFromEvent(session, event); + this._updateSessionFromEvent(session, preparedEvent); } throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`); From db044d094e98328317674d9c4123c792e56afc44 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:06:27 +0200 Subject: [PATCH 3/7] events dropped via beforeSend do not update a session --- .../sessions/sampled-session-update/test.ts | 2 +- packages/core/src/client.ts | 6 - packages/core/test/lib/client.test.ts | 229 ++++++++++++------ 3 files changed, 153 insertions(+), 84 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts index c0a25ef9a716..1b1e63162dab 100644 --- a/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts +++ b/dev-packages/browser-integration-tests/suites/sessions/sampled-session-update/test.ts @@ -3,7 +3,7 @@ import { sentryTest } from '../../../utils/fixtures'; import { countEnvelopes, waitForSession } from '../../../utils/helpers'; sentryTest( - 'marks session as crashed when unhandled error is sampled out by sampleRate', + 'marks session as unhandled when unhandled error is sampled out by sampleRate', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 7ae6c8b96560..e87a018b4750 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1494,12 +1494,6 @@ export abstract class Client { this.recordDroppedEvent('before_send', 'span', spanCount); } - // Reflects crashes inside release health sessions, regardless of beforeSend dropping the event. - const session = currentScope.getSession() || isolationScope.getSession(); - if (isError && session) { - this._updateSessionFromEvent(session, preparedEvent); - } - throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`); } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 810f2fac4d63..d6b4380139a2 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2555,109 +2555,184 @@ describe('Client', () => { }); }); - describe('session updates are decoupled from event sampling and beforeSend', () => { - test('drops unhandled error event when sampled out but still marks session as crashed', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); - const client = new TestClient(options); - setCurrentClient(client); + describe('session update filtering', () => { + describe('sampleRate drop updates session', () => { + test('marks session as crashed for sampled-out unhandled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); - const session = makeSession(); - getCurrentScope().setSession(session); + const session = makeSession(); + getCurrentScope().setSession(session); - client.captureEvent( - { - exception: { - values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, }, - }, - { mechanism: { handled: false } }, - ); + { mechanism: { handled: false } }, + ); - // The error event is not sent — it was sampled out - expect(TestClient.instance!.event).toBeUndefined(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); + + test('marks session as errored for sampled-out handled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); - // But the session update is still sent, reflecting the crash - expect(client.session?.errors).toBe(1); - expect(client.session?.status).toBe('crashed'); + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('ok'); + }); }); - test('drops handled error event when sampled out but still marks session as errored', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); - const client = new TestClient(options); - setCurrentClient(client); + describe('beforeSend drop does not update session', () => { + test('does not update session when beforeSend returns null for unhandled error', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); - const session = makeSession(); - getCurrentScope().setSession(session); + const session = makeSession(); + getCurrentScope().setSession(session); - client.captureEvent( - { - exception: { - values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, }, - }, - {}, - ); + { mechanism: { handled: false } }, + ); - // The error event is not sent — it was sampled out - expect(TestClient.instance!.event).toBeUndefined(); + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + + test('does not update session when beforeSend returns null for handled error', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); - // But the session update is still sent, recording the error - expect(client.session?.errors).toBe(1); - expect(client.session?.status).toBe('ok'); + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); }); - test('drops unhandled error event when beforeSend returns null but still marks session as crashed', () => { - const beforeSend = vi.fn(() => null); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); - const client = new TestClient(options); - setCurrentClient(client); + describe('event processor drop does not update session', () => { + test('does not update session when event processor returns null for unhandled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + setCurrentClient(client); - const session = makeSession(); - getCurrentScope().setSession(session); + client.addEventProcessor(() => null); - client.captureEvent( - { - exception: { - values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, }, - }, - { mechanism: { handled: false } }, - ); + { mechanism: { handled: false } }, + ); - // The error event is not sent — beforeSend discarded it - expect(beforeSend).toHaveBeenCalledOnce(); - expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + }); - // But the session update is still sent, reflecting the crash - expect(client.session?.errors).toBe(1); - expect(client.session?.status).toBe('crashed'); + describe('error that passes through beforeSend updates session', () => { + test('updates session when beforeSend passes unhandled error through', () => { + const beforeSend = vi.fn(event => event); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeDefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); }); - test('drops handled error event when beforeSend returns null but still marks session as errored', () => { - const beforeSend = vi.fn(() => null); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); - const client = new TestClient(options); - setCurrentClient(client); + describe('edge case: sampled-out error that beforeSend would have filtered', () => { + test('updates session even though beforeSend would have dropped the error', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); - const session = makeSession(); - getCurrentScope().setSession(session); + const session = makeSession(); + getCurrentScope().setSession(session); - client.captureEvent( - { - exception: { - values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, }, - }, - {}, - ); + { mechanism: { handled: false } }, + ); - // The error event is not sent — beforeSend discarded it - expect(beforeSend).toHaveBeenCalledOnce(); - expect(TestClient.instance!.event).toBeUndefined(); + // sampleRate runs first — the error never reaches beforeSend + expect(beforeSend).not.toHaveBeenCalled(); + expect(TestClient.instance!.event).toBeUndefined(); - // But the session update is still sent, recording the error - expect(client.session?.errors).toBe(1); - expect(client.session?.status).toBe('ok'); + // But the session is updated because sampleRate drops always update the session + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); }); }); From a7cb866f7a02f81961804485954fcf4ceb8f3a85 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:15:57 +0200 Subject: [PATCH 4/7] remove unused variable --- packages/core/src/client.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e87a018b4750..4443316c73ee 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1465,8 +1465,6 @@ export abstract class Client { const dataCategory = getDataCategoryByType(event.type); - let preparedEvent: Event = event; - return this._prepareEvent(event, hint, currentScope, isolationScope) .then(prepared => { if (prepared === null) { @@ -1474,8 +1472,6 @@ export abstract class Client { throw _makeDoNotSendEventError('An event processor returned `null`, will not send event.'); } - preparedEvent = prepared; - const isInternalException = (hint.data as { __sentry__: boolean })?.__sentry__ === true; if (isInternalException) { return prepared; From f711e80277e759d98b7af40cbb6b9fde4c799292 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:45:32 +0200 Subject: [PATCH 5/7] fix(core): Sample error events after beforeSend --- packages/browser/src/helpers.ts | 8 ++--- packages/core/src/client.ts | 31 +++++--------------- packages/core/test/lib/client.test.ts | 42 ++++++++++++++++++++++----- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 27a04b7d3fd7..5e31528b6dea 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -138,12 +138,8 @@ export function wrap( return event; }); - // Mechanism is passed via hint so it's available even when the event - // is dropped before event processors run (e.g. sampleRate sampling). - // We also add it via an event processor above. - captureException(ex, { - mechanism: options.mechanism, - }); + // no need to add a mechanism here, we already add it via an event processor above + captureException(ex); }); throw ex; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 4443316c73ee..e7a86db65788 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,14 +45,13 @@ import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils/envelop import { getPossibleEventMessages } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive, isThenable } from './utils/is'; import { merge } from './utils/merge'; -import { addExceptionMechanism, checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; +import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; -import { rejectedSyncPromise } from './utils/syncpromise'; import { safeUnref } from './utils/timer'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; import { resolveDataCollectionOptions } from './utils/data-collection/resolveDataCollectionOptions'; @@ -1443,26 +1442,6 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { - this.recordDroppedEvent('sample_rate', 'error'); - - // Reflects crashes inside release health sessions, regardless of event sampling - const session = currentScope.getSession() || isolationScope.getSession(); - if (session) { - // _prepareEvent (which normally merges hint.mechanism) is skipped in this code path, so we add it here - if (hint.mechanism) { - addExceptionMechanism(event, hint.mechanism); - } - this._updateSessionFromEvent(session, event); - } - - return rejectedSyncPromise( - _makeDoNotSendEventError( - `Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`, - ), - ); - } - const dataCategory = getDataCategoryByType(event.type); return this._prepareEvent(event, hint, currentScope, isolationScope) @@ -1489,7 +1468,6 @@ export abstract class Client { const spanCount = 1 + spans.length; this.recordDroppedEvent('before_send', 'span', spanCount); } - throw _makeDoNotSendEventError(`${beforeSendLabel} returned \`null\`, will not send event.`); } @@ -1498,6 +1476,13 @@ export abstract class Client { this._updateSessionFromEvent(session, processedEvent); } + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { + this.recordDroppedEvent('sample_rate', 'error'); + throw _makeDoNotSendEventError( + `Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`, + ); + } + if (isTransaction) { const spanCountBefore = processedEvent.sdkProcessingMetadata?.spanCountBeforeProcessing || 0; const spanCountAfter = processedEvent.spans ? processedEvent.spans.length : 0; diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index d6b4380139a2..b0f0dc081ac2 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2706,8 +2706,8 @@ describe('Client', () => { }); }); - describe('edge case: sampled-out error that beforeSend would have filtered', () => { - test('updates session even though beforeSend would have dropped the error', () => { + describe('sampleRate runs after beforeSend', () => { + test('does not update session when beforeSend drops an error that would be sampled out', () => { const beforeSend = vi.fn(() => null); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0, beforeSend }); const client = new TestClient(options); @@ -2719,19 +2719,47 @@ describe('Client', () => { client.captureEvent( { exception: { - values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + values: [{ type: 'Error', value: 'filtered crash', mechanism: { type: 'generic', handled: false } }], }, }, { mechanism: { handled: false } }, ); - // sampleRate runs first — the error never reaches beforeSend - expect(beforeSend).not.toHaveBeenCalled(); + expect(beforeSend).toHaveBeenCalledOnce(); expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + + test('uses the event returned by beforeSend to update a sampled-out session', () => { + const beforeSend = vi.fn((event: ErrorEvent) => { + const exception = event.exception?.values?.[0]; + if (exception) { + exception.mechanism = { type: 'generic', handled: true }; + } + return event; + }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); - // But the session is updated because sampleRate drops always update the session + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'reclassified crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); expect(client.session?.errors).toBe(1); - expect(client.session?.status).toBe('crashed'); + expect(client.session?.status).toBe('ok'); }); }); }); From ae2228e648c6b0a21a69d64d931796726871644d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:26:56 +0200 Subject: [PATCH 6/7] fix integration test --- .../suites/public-api/init/stringSampleRate/init.js | 6 ------ .../suites/public-api/init/stringSampleRate/test.ts | 9 ++++----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/init.js b/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/init.js index 0144febaeb38..cebc5f71b52b 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/init.js @@ -2,14 +2,8 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; -window._errorCount = 0; - Sentry.init({ traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', sampleRate: '0', - beforeSend() { - window._errorCount++; - return null; - }, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/test.ts b/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/test.ts index 3b74fd7f7860..cd95d0098f74 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/init/stringSampleRate/test.ts @@ -1,15 +1,14 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; +import { countEnvelopes } from '../../../../utils/helpers'; -sentryTest('parses a string sample rate', async ({ getLocalTestUrl, page }) => { +sentryTest('drops error events when sampleRate is the string "0"', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); + const errorCountPromise = countEnvelopes(page, { envelopeType: 'event', timeout: 2000 }); await page.goto(url); - await page.waitForFunction('window._testDone'); await page.evaluate('window.Sentry.getClient().flush()'); - const count = await page.evaluate('window._errorCount'); - - expect(count).toStrictEqual(0); + expect(await errorCountPromise).toBe(0); }); From b653230db7506abd0e621ce82cfabc60dba52501 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:45:26 +0200 Subject: [PATCH 7/7] add test --- packages/core/src/exports.ts | 2 +- packages/core/test/lib/client.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 95b0234434bf..e2322766287b 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -174,7 +174,7 @@ export function setConversationId(conversationId: string | null | undefined): vo * isolation scope. If you call this function after handling a certain error and another error * is captured in between, the last one is returned instead of the one you might expect. * Also, ids of events that were never sent to Sentry (for example because - * they were dropped in `beforeSend`) could be returned. + * they were dropped by sampling or `beforeSend`) could be returned. * * @returns The last event id of the isolation scope. */ diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 3be116cdd675..5846b48b8185 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -321,6 +321,29 @@ describe('Client', () => { expect(eventId).toEqual(lastEventId()); }); + test('sets lastEventId when an error is sampled out', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + + const eventId = client.captureException(new Error('sampled-out exception')); + + expect(eventId).toEqual(lastEventId()); + expect(TestClient.instance!.event).toBeUndefined(); + }); + + test('(known limitation) replaces lastEventId with a sampled-out error ID', () => { + // After a successfully sent error, a subsequent sampled-out error replaces lastEventId() even though that new ID has no corresponding event in Sentry. + // The `setLastEventId` call in `_prepareEvent` now executes before the `sampleRate` check + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + + client.captureException(new Error('sent exception'), { event_id: 'sent-event-id' }); + client.getOptions().sampleRate = 0; + client.captureException(new Error('sampled-out exception'), { event_id: 'sampled-out-event-id' }); + + expect(TestClient.instance!.event?.event_id).toBe('sent-event-id'); + expect(lastEventId()).toBe('sampled-out-event-id'); + }); + test('allows for providing explicit scope', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options);