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