From 257e7c891255b8ec6b2f07d07b0adc930e2527db Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 10:05:55 +0200 Subject: [PATCH 1/6] feat(node): Stop sending the process session once request sessions are tracked A Node server reported release health twice: once through request session aggregates, and once through the process session started by `processSessionIntegration`. Request isolation scopes inherited the process session, so the first error in any request also marked the process session as errored. No other server SDK sends a process session alongside request aggregates, and the sessions spec describes servers as request mode only. `recordRequestSession` now removes the inherited session from the request scope and emits a `startRequestSession` client hook. On the first one, `processSessionIntegration` discards the process session, closing it as `exited` if an update was already sent. Processes that never serve tracked requests (scripts, workers, or servers with `sessions: false`) keep the process session as before. Co-Authored-By: Claude Opus 5.5 --- .../sessions/process-session/instrument.mjs | 8 +++ .../sessions/process-session/scenario.mjs | 3 + .../suites/sessions/process-session/test.ts | 18 ++++++ .../server-with-startup-error.mjs | 13 +++++ .../server-without-process-session/test.ts | 55 ++++++++++++++++++ packages/core/src/client.ts | 11 ++++ .../http/record-request-session.ts | 4 ++ .../node/src/integrations/processSession.ts | 27 ++++++++- .../httpServerIntegration.test.ts | 23 +++++++- .../test/integrations/processSession.test.ts | 56 ++++++++++++++++++- 10 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/sessions/process-session/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/sessions/process-session/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/sessions/process-session/test.ts create mode 100644 dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs create mode 100644 dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts diff --git a/dev-packages/node-integration-tests/suites/sessions/process-session/instrument.mjs b/dev-packages/node-integration-tests/suites/sessions/process-session/instrument.mjs new file mode 100644 index 000000000000..9ffde125d498 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/sessions/process-session/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/sessions/process-session/scenario.mjs b/dev-packages/node-integration-tests/suites/sessions/process-session/scenario.mjs new file mode 100644 index 000000000000..329cecf7fd16 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/sessions/process-session/scenario.mjs @@ -0,0 +1,3 @@ +import * as Sentry from '@sentry/node'; + +Sentry.captureException(new Error('Scenario error')); diff --git a/dev-packages/node-integration-tests/suites/sessions/process-session/test.ts b/dev-packages/node-integration-tests/suites/sessions/process-session/test.ts new file mode 100644 index 000000000000..a7e9260bd16c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/sessions/process-session/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { join } from 'path'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends a process session for a process that does not serve requests', async () => { + await createRunner(__dirname, 'scenario.mjs') + .withInstrument(join(__dirname, 'instrument.mjs')) + .ignore('event') + .unignore('session') + .expect({ session: { init: true, status: 'ok', errors: 1 } }) + .expect({ session: { init: false, status: 'exited', errors: 1 } }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs new file mode 100644 index 000000000000..2af54ca8a062 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +Sentry.captureException(new Error('Startup error')); + +const app = express(); + +app.get('/test/success', (_req, res) => { + res.send('Success!'); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts new file mode 100644 index 000000000000..5e2702e1ff45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts @@ -0,0 +1,55 @@ +import { afterAll, expect, test } from 'vitest'; +import { join } from 'path'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import type { SessionAggregates } from '@sentry/core'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +function sumAggregates(agg: SessionAggregates): { exited: number; errored: number; crashed: number } { + return agg.aggregates.reduce( + (acc, b) => ({ + exited: acc.exited + (b.exited ?? 0), + errored: acc.errored + (b.errored ?? 0), + crashed: acc.crashed + (b.crashed ?? 0), + }), + { exited: 0, errored: 0, crashed: 0 }, + ); +} + +test('does not send a process session once requests are tracked as request sessions', async () => { + const runner = createRunner(__dirname, '..', 'server.mjs') + .withInstrument(join(__dirname, '..', 'instrument.mjs')) + .ignore('transaction', 'event') + .unignore('session', 'sessions') + .expect({ + sessions: agg => { + expect(sumAggregates(agg)).toEqual({ exited: 1, errored: 1, crashed: 1 }); + }, + }) + .start(); + + runner.makeRequest('get', '/test/success'); + runner.makeRequest('get', '/test/error_handled'); + runner.makeRequest('get', '/test/error_unhandled', { expectError: true }); + await runner.completed(); +}); + +test('closes a process session that was already sent before the first request', async () => { + const runner = createRunner(__dirname, 'server-with-startup-error.mjs') + .withInstrument(join(__dirname, '..', 'instrument.mjs')) + .ignore('transaction', 'event') + .unignore('session', 'sessions') + .expect({ session: { init: true, status: 'ok', errors: 1 } }) + .expect({ session: { init: false, status: 'exited', errors: 1 } }) + .expect({ + sessions: agg => { + expect(sumAggregates(agg)).toEqual({ exited: 1, errored: 0, crashed: 0 }); + }, + }) + .start(); + + runner.makeRequest('get', '/test/success'); + await runner.completed(); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 12d02b348bb2..045c61cb259a 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -883,6 +883,12 @@ export abstract class Client { callback: (breadcrumb: Breadcrumb, hint: XhrBreadcrumbHint | FetchBreadcrumbHint) => void, ): () => void; + /** + * A hook that is called when an incoming request starts being tracked as a request session. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'startRequestSession', callback: () => void): () => void; + /** * A hook that is called when the client is flushing * @returns {() => void} A function that, when executed, removes the registered callback. @@ -1194,6 +1200,11 @@ export abstract class Client { hint: XhrBreadcrumbHint | FetchBreadcrumbHint, ): void; + /** + * Emit a hook event when an incoming request starts being tracked as a request session. + */ + public emit(hook: 'startRequestSession'): void; + /** * Emit a hook event for client flush */ diff --git a/packages/core/src/integrations/http/record-request-session.ts b/packages/core/src/integrations/http/record-request-session.ts index cdef0538d3ee..4ab0eccb88dd 100644 --- a/packages/core/src/integrations/http/record-request-session.ts +++ b/packages/core/src/integrations/http/record-request-session.ts @@ -33,9 +33,13 @@ export function recordRequestSession( sessionFlushingDelayMS?: number; }, ): void { + // Errors in this request are counted by the request session aggregate, so they must not also + // update a process session inherited from the parent isolation scope. + requestIsolationScope.setSession(); requestIsolationScope.setSDKProcessingMetadata({ requestSession: { status: 'ok' }, }); + client.emit('startRequestSession'); response.once('close', () => { const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; diff --git a/packages/node/src/integrations/processSession.ts b/packages/node/src/integrations/processSession.ts index d19948cbe6f9..985f3451b91f 100644 --- a/packages/node/src/integrations/processSession.ts +++ b/packages/node/src/integrations/processSession.ts @@ -1,15 +1,22 @@ -import { defineIntegration, endSession, getIsolationScope, startSession } from '@sentry/core'; +import type { Scope } from '@sentry/core'; +import { closeSession, defineIntegration, endSession, getIsolationScope, startSession } from '@sentry/core'; const INTEGRATION_NAME = 'ProcessSession' as const; /** * Records a Session for the current process to track release health. + * + * Once the process starts serving requests that are tracked as request sessions, release health is + * reported through session aggregates instead, and the process session is discarded. */ export const processSessionIntegration = defineIntegration(() => { + let processIsolationScope: Scope | undefined; + return { name: INTEGRATION_NAME, setupOnce() { startSession(); + processIsolationScope = getIsolationScope(); // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because // The 'beforeExit' event is not emitted for conditions causing explicit termination, @@ -27,5 +34,23 @@ export const processSessionIntegration = defineIntegration(() => { } }); }, + setup(client) { + const unsubscribe = client.on('startRequestSession', () => { + unsubscribe(); + + const scope = processIsolationScope; + const session = scope?.getSession(); + if (!scope || !session) { + return; + } + + // A session that was already sent upstream is closed so it does not linger as never-ended. + if (!session.init && session.status === 'ok') { + closeSession(session); + client.captureSession(session); + } + scope.setSession(); + }); + }, }; }); diff --git a/packages/node/test/integrations/httpServerIntegration.test.ts b/packages/node/test/integrations/httpServerIntegration.test.ts index 0a677246773b..f6b27eb35888 100644 --- a/packages/node/test/integrations/httpServerIntegration.test.ts +++ b/packages/node/test/integrations/httpServerIntegration.test.ts @@ -1,5 +1,5 @@ import type { Client } from '@sentry/core'; -import { createTransport, Scope, withScope } from '@sentry/core'; +import { createTransport, makeSession, Scope, withScope } from '@sentry/core'; import { ServerRuntimeClient } from '@sentry/core/server'; import { EventEmitter } from 'stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -125,6 +125,27 @@ describe('recordRequestSession()', () => { ], }); }); + + it('removes a session inherited from the parent isolation scope', () => { + const client = createTestClient(); + const requestIsolationScope = new Scope(); + requestIsolationScope.setSession(makeSession()); + + recordRequestSession(client, { requestIsolationScope, response: new EventEmitter() }); + + expect(requestIsolationScope.getSession()).toBeUndefined(); + }); + + it('emits the "startRequestSession" hook for every recorded request', () => { + const client = createTestClient(); + const onStartRequestSession = vi.fn(); + client.on('startRequestSession', onStartRequestSession); + + simulateRequest(client, 'ok'); + simulateRequest(client, 'ok'); + + expect(onStartRequestSession).toHaveBeenCalledTimes(2); + }); }); function simulateRequest(client: Client, status: 'ok' | 'errored' | 'crashed') { diff --git a/packages/node/test/integrations/processSession.test.ts b/packages/node/test/integrations/processSession.test.ts index 4d511035b8f9..23cf43f93a12 100644 --- a/packages/node/test/integrations/processSession.test.ts +++ b/packages/node/test/integrations/processSession.test.ts @@ -1,4 +1,4 @@ -import { getIsolationScope, setCurrentClient } from '@sentry/core'; +import { getIsolationScope, makeSession, setCurrentClient } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { processSessionIntegration } from '../../src/integrations/processSession'; import { NodeClient } from '../../src/sdk/client'; @@ -24,7 +24,9 @@ describe('processSessionIntegration', () => { return process; }) as never); - processSessionIntegration().setupOnce!(); + const integration = processSessionIntegration(); + integration.setupOnce!(); + integration.setup!(client); processOn.mockRestore(); }); @@ -62,6 +64,56 @@ describe('processSessionIntegration', () => { expect(sendSession).not.toHaveBeenCalled(); }); + describe('when requests start being tracked as request sessions', () => { + it('removes a session that was never sent, without sending it', () => { + client.emit('startRequestSession'); + + expect(getIsolationScope().getSession()).toBeUndefined(); + expect(sendSession).not.toHaveBeenCalled(); + }); + + it('closes and removes a session that was already sent', () => { + const session = getIsolationScope().getSession()!; + session.init = false; + session.errors = 1; + + client.emit('startRequestSession'); + + expect(sendSession).toHaveBeenCalledTimes(1); + expect(sendSession).toHaveBeenCalledWith(expect.objectContaining({ status: 'exited', errors: 1 })); + expect(getIsolationScope().getSession()).toBeUndefined(); + }); + + it('removes an already-crashed session without updating it', () => { + const session = getIsolationScope().getSession()!; + session.init = false; + session.status = 'crashed'; + + client.emit('startRequestSession'); + + expect(sendSession).not.toHaveBeenCalled(); + expect(getIsolationScope().getSession()).toBeUndefined(); + }); + + it('only reacts to the first request session', () => { + client.emit('startRequestSession'); + const laterSession = makeSession(); + getIsolationScope().setSession(laterSession); + + client.emit('startRequestSession'); + + expect(getIsolationScope().getSession()).toBe(laterSession); + }); + + it('does not send a session on exit afterwards', () => { + client.emit('startRequestSession'); + + beforeExitHandler(); + + expect(sendSession).not.toHaveBeenCalled(); + }); + }); + it('does nothing when no session is on the scope', () => { getIsolationScope().setSession(undefined); sendSession.mockClear(); From 3df3e634bf62534dcf22d1a67cf7e52d05af66df Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 10:10:21 +0200 Subject: [PATCH 2/6] test(node): Cover switching from the process session to request aggregates Co-Authored-By: Claude Opus 5.5 --- .../server-with-startup-error.mjs | 5 +++++ .../suites/sessions/server-without-process-session/test.ts | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs index 2af54ca8a062..497381c12178 100644 --- a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs +++ b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs @@ -10,4 +10,9 @@ app.get('/test/success', (_req, res) => { res.send('Success!'); }); +app.get('/test/error_handled', (_req, res) => { + Sentry.captureException(new Error('Request error')); + res.send('Handled!'); +}); + startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts index 5e2702e1ff45..f22ea4c47ad5 100644 --- a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts +++ b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/test.ts @@ -36,7 +36,7 @@ test('does not send a process session once requests are tracked as request sessi await runner.completed(); }); -test('closes a process session that was already sent before the first request', async () => { +test('counts an error before the first request in the process session, and later errors in the aggregate', async () => { const runner = createRunner(__dirname, 'server-with-startup-error.mjs') .withInstrument(join(__dirname, '..', 'instrument.mjs')) .ignore('transaction', 'event') @@ -45,11 +45,12 @@ test('closes a process session that was already sent before the first request', .expect({ session: { init: false, status: 'exited', errors: 1 } }) .expect({ sessions: agg => { - expect(sumAggregates(agg)).toEqual({ exited: 1, errored: 0, crashed: 0 }); + expect(sumAggregates(agg)).toEqual({ exited: 1, errored: 1, crashed: 0 }); }, }) .start(); - runner.makeRequest('get', '/test/success'); + await runner.makeRequest('get', '/test/success'); + await runner.makeRequest('get', '/test/error_handled'); await runner.completed(); }); From 2b1d3eefac1274a8d5a33a2fef831ad5a9d41b62 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 10:25:01 +0200 Subject: [PATCH 3/6] ref(node): Start the process session in `setup` instead of `setupOnce` Co-Authored-By: Claude Opus 5.5 --- .../node/src/integrations/processSession.ts | 17 ++++++----------- .../test/integrations/processSession.test.ts | 4 +--- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/node/src/integrations/processSession.ts b/packages/node/src/integrations/processSession.ts index 985f3451b91f..c530b2c5fabe 100644 --- a/packages/node/src/integrations/processSession.ts +++ b/packages/node/src/integrations/processSession.ts @@ -1,4 +1,3 @@ -import type { Scope } from '@sentry/core'; import { closeSession, defineIntegration, endSession, getIsolationScope, startSession } from '@sentry/core'; const INTEGRATION_NAME = 'ProcessSession' as const; @@ -10,13 +9,11 @@ const INTEGRATION_NAME = 'ProcessSession' as const; * reported through session aggregates instead, and the process session is discarded. */ export const processSessionIntegration = defineIntegration(() => { - let processIsolationScope: Scope | undefined; - return { name: INTEGRATION_NAME, - setupOnce() { + setup(client) { startSession(); - processIsolationScope = getIsolationScope(); + const processIsolationScope = getIsolationScope(); // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because // The 'beforeExit' event is not emitted for conditions causing explicit termination, @@ -33,14 +30,12 @@ export const processSessionIntegration = defineIntegration(() => { endSession(); } }); - }, - setup(client) { + const unsubscribe = client.on('startRequestSession', () => { unsubscribe(); - const scope = processIsolationScope; - const session = scope?.getSession(); - if (!scope || !session) { + const session = processIsolationScope.getSession(); + if (!session) { return; } @@ -49,7 +44,7 @@ export const processSessionIntegration = defineIntegration(() => { closeSession(session); client.captureSession(session); } - scope.setSession(); + processIsolationScope.setSession(); }); }, }; diff --git a/packages/node/test/integrations/processSession.test.ts b/packages/node/test/integrations/processSession.test.ts index 23cf43f93a12..69e8ce19dc68 100644 --- a/packages/node/test/integrations/processSession.test.ts +++ b/packages/node/test/integrations/processSession.test.ts @@ -24,9 +24,7 @@ describe('processSessionIntegration', () => { return process; }) as never); - const integration = processSessionIntegration(); - integration.setupOnce!(); - integration.setup!(client); + processSessionIntegration().setup!(client); processOn.mockRestore(); }); From 53187669889fd9a3b9afec43e80104f0724d0514 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 10:53:43 +0200 Subject: [PATCH 4/6] test(node): Wait for the startup error to be processed before serving requests Event processing is async (e.g. ContextLines reads files), so on slower machines the first request could arrive and drop the process session before the startup error had updated it. Co-Authored-By: Claude Opus 5.5 --- .../server-with-startup-error.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs index 497381c12178..0eed9d1fe8a5 100644 --- a/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs +++ b/dev-packages/node-integration-tests/suites/sessions/server-without-process-session/server-with-startup-error.mjs @@ -3,6 +3,8 @@ import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-int import express from 'express'; Sentry.captureException(new Error('Startup error')); +// Event processing is async; make sure the error has updated the process session before the first request. +await Sentry.flush(); const app = express(); From 0515ec518aa64bf1e24cc988cc93b835eb39d1ff Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 11:08:37 +0200 Subject: [PATCH 5/6] test(bun): Skip the server process session suite under `@sentry/bun` Like `exited-session-aggregate`, it relies on `sessionFlushingDelayMS` passed to `httpIntegration`, which `bunHttpServerIntegration` ignores, so the aggregate is not flushed before the test times out. Co-Authored-By: Claude Opus 5.5 --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c9a89b180612..0ea4c85af423 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -180,6 +180,7 @@ const FETCH_INTEGRATION_DIFFERS = [ // that sets `emit` back to the `emit` it had before its first request gets no spans after that. const HTTP_SERVER_OPTIONS_IGNORED = [ 'suites/sessions/exited-session-aggregate/test.ts', + 'suites/sessions/server-without-process-session/test.ts', 'suites/tracing/httpIntegration/test.ts', ]; From 38930054d1b953c1eb4900990b71398365226f92 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 12:44:06 +0200 Subject: [PATCH 6/6] add comment --- packages/node/src/integrations/processSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/node/src/integrations/processSession.ts b/packages/node/src/integrations/processSession.ts index c530b2c5fabe..4e9c23134c8b 100644 --- a/packages/node/src/integrations/processSession.ts +++ b/packages/node/src/integrations/processSession.ts @@ -7,6 +7,7 @@ const INTEGRATION_NAME = 'ProcessSession' as const; * * Once the process starts serving requests that are tracked as request sessions, release health is * reported through session aggregates instead, and the process session is discarded. + * This integration exists so that we capture some form of session health data even when the process is not serving requests. */ export const processSessionIntegration = defineIntegration(() => { return {