From 68d483972261ddbf969ac9283e055f9a5d63de80 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 8 Jun 2026 10:16:23 +0200 Subject: [PATCH 1/8] feat(core): Default `dataCollection.httpBodies` to all valid body types (#21352) Updates the `dataCollection.httpBodies` spec default from `[]` (off) to all four valid body types (`incomingRequest`, `outgoingRequest`, `incomingResponse`, `outgoingResponse`), per the updated DataCollection spec (getsentry/sentry-docs#18276). This new default applies **only when a user explicitly sets the `dataCollection` option**. The `sendDefaultPii` bridge is left untouched. --- CHANGELOG.md | 1 + packages/core/src/types/datacollection.ts | 5 +++-- .../data-collection/resolveDataCollectionOptions.ts | 2 +- .../resolveDataCollectionOptions.test.ts | 10 ++++++++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbe38f88cc4..ad9a2916be6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Work in this release was contributed by @zhongrenfei1-hub. Thank you for your co Sentry.init({ dataCollection: { genAI: { inputs: false, outputs: false }, + httpBodies: [], httpHeaders: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, cookies: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index fa230f11f800..8d4de6484544 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -38,8 +38,9 @@ export interface DataCollection { }; /** - * Which HTTP body types to collect. An empty array disables body collection. - * @default [] + * Which HTTP body types to collect. An omitted value collects all body types valid for the + * platform; an empty array (`[]`) disables body collection. + * @default ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'] */ httpBodies?: HttpBodyCollectionTarget[]; diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index f8130ec8cb6e..1ba2069a814d 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -5,7 +5,7 @@ const DEFAULTS: ResolvedDataCollection = { userInfo: false, cookies: true, httpHeaders: { request: true, response: true }, - httpBodies: [], + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 284882d58766..b421578a01b5 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -6,7 +6,7 @@ describe('resolveDataCollectionOptions', () => { userInfo: false, cookies: true, httpHeaders: { request: true, response: true }, - httpBodies: [], + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, @@ -28,6 +28,12 @@ describe('resolveDataCollectionOptions', () => { it('returns spec defaults when dataCollection is explicitly set to empty object', () => { expect(resolveDataCollectionOptions({ dataCollection: {} })).toEqual(SPEC_DEFAULTS); }); + + it('collects all body types by default when dataCollection is set without httpBodies', () => { + const result = resolveDataCollectionOptions({ dataCollection: {} }); + + expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); + }); }); describe('sendDefaultPii bridge (no dataCollection)', () => { @@ -61,7 +67,7 @@ describe('resolveDataCollectionOptions', () => { // Explicit dataCollection override expect(result.userInfo).toBe(false); // Remaining fields use spec defaults (not sendDefaultPii bridge) - expect(result.httpBodies).toEqual([]); + expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); expect(result.genAI).toEqual({ inputs: true, outputs: true }); }); }); From f0b56b247383410718c7a5b2b9f0c6f385920657 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 8 Jun 2026 10:23:39 +0200 Subject: [PATCH 2/8] fix(core): Use `safeDateNow` calls for `new Date()` reads (#21351) Several spots in core read the ambient clock directly via a bare `new Date()`. In Next.js Cache Components this throws a `next-prerender-current-time` violation, breaking `captureException`/`captureMessage` in dev and at runtime during ISR/on-demand revalidation. closes https://github.com/getsentry/sentry-javascript/issues/21333 --- .../app/[id]/page.tsx | 23 +++++ .../tests/cacheComponents.spec.ts | 30 ++++++- packages/core/src/checkin.ts | 3 +- packages/core/src/envelope.ts | 5 +- .../http/record-request-session.ts | 3 +- packages/core/src/tracing/spans/envelope.ts | 3 +- packages/core/src/transports/offline.ts | 3 +- packages/core/src/utils/envelope.ts | 3 +- .../test/lib/envelope-safe-timestamp.test.ts | 88 +++++++++++++++++++ .../src/rules/no-unsafe-random-apis.js | 21 +++++ .../lib/rules/no-unsafe-random-apis.test.ts | 38 ++++++++ 11 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/[id]/page.tsx create mode 100644 packages/core/test/lib/envelope-safe-timestamp.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/[id]/page.tsx new file mode 100644 index 000000000000..18cb7fcf532d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/[id]/page.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +// Only `test` is prerendered at build time. `exception` and `message` are generated +// on-demand at request time. +export function generateStaticParams() { + return [{ id: 'test' }]; +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + if (id === 'exception') { + Sentry.captureException(new Error('Test error from cache components page')); + return

Error captured for id exception

; + } + + if (id === 'message') { + Sentry.captureMessage('Test message from cache components page'); + return

Message captured for id message

; + } + + return

Hello, {id}!

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9a60ac59cd8f..0be0e2e6e3a6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should render cached component', async ({ page }) => { const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { @@ -40,6 +40,34 @@ test('Should generate metadata', async ({ page }) => { await expect(page).toHaveTitle('Cache Components Metadata Test'); }); +// Capturing an event inside a Server Component that is (re)generated at request time must not +// trip Next.js Cache Components prerender guards (`new Date()` / `crypto`). +test('Should capture an exception from an on-demand generated Server Component', async ({ page }) => { + const errorPromise = waitForError('nextjs-16-cacheComponents', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Test error from cache components page'; + }); + + await page.goto('/exception'); + + await expect(page.locator('#result')).toHaveText('Error captured for id exception'); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.value).toBe('Test error from cache components page'); +}); + +test('Should capture a message from an on-demand generated Server Component', async ({ page }) => { + const messagePromise = waitForError('nextjs-16-cacheComponents', errorEvent => { + return errorEvent.message === 'Test message from cache components page'; + }); + + await page.goto('/message'); + + await expect(page.locator('#result')).toHaveText('Message captured for id message'); + + const message = await messagePromise; + expect(message.message).toBe('Test message from cache components page'); +}); + test('Should generate metadata async', async ({ page }) => { const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { return transactionEvent.contexts?.trace?.op === 'http.server'; diff --git a/packages/core/src/checkin.ts b/packages/core/src/checkin.ts index e00c1045b7d0..a4d01e8c8087 100644 --- a/packages/core/src/checkin.ts +++ b/packages/core/src/checkin.ts @@ -4,6 +4,7 @@ import type { CheckInEnvelope, CheckInItem, DynamicSamplingContext } from './typ import type { SdkMetadata } from './types/sdkmetadata'; import { dsnToString } from './utils/dsn'; import { createEnvelope } from './utils/envelope'; +import { safeDateNow } from './utils/randomSafeContext'; /** * Create envelope from check in item. @@ -16,7 +17,7 @@ export function createCheckInEnvelope( dsn?: DsnComponents, ): CheckInEnvelope { const headers: CheckInEnvelope[0] = { - sent_at: new Date().toISOString(), + sent_at: new Date(safeDateNow()).toISOString(), }; if (metadata?.sdk) { diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 15b0d4ba576a..8a2faa114951 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -27,6 +27,7 @@ import { getSdkMetadataForEnvelopeHeader, } from './utils/envelope'; import { uuid4 } from './utils/misc'; +import { safeDateNow } from './utils/randomSafeContext'; import { shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning, spanToJSON } from './utils/spanUtils'; @@ -70,7 +71,7 @@ export function createSessionEnvelope( ): SessionEnvelope { const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); const envelopeHeaders = { - sent_at: new Date().toISOString(), + sent_at: new Date(safeDateNow()).toISOString(), ...(sdkInfo && { sdk: sdkInfo }), ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; @@ -134,7 +135,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const tunnel = client?.getOptions().tunnel; const headers: SpanEnvelope[0] = { - sent_at: new Date().toISOString(), + sent_at: new Date(safeDateNow()).toISOString(), ...(dscHasRequiredProps(dsc) && { trace: dsc }), ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; diff --git a/packages/core/src/integrations/http/record-request-session.ts b/packages/core/src/integrations/http/record-request-session.ts index dfda6580cf16..cdef0538d3ee 100644 --- a/packages/core/src/integrations/http/record-request-session.ts +++ b/packages/core/src/integrations/http/record-request-session.ts @@ -4,6 +4,7 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { Scope } from '../../scope'; import type { HttpServerResponse } from './types'; import type { AggregationCounts } from '../../types/session'; +import { safeDateNow } from '../../utils/randomSafeContext'; import { safeUnref } from '../../utils/timer'; const clientToRequestSessionAggregatesMap = new WeakMap< @@ -42,7 +43,7 @@ export function recordRequestSession( if (client && requestSession) { DEBUG_BUILD && debug.log(`Recorded request session with status: ${requestSession.status}`); - const roundedDate = new Date(); + const roundedDate = new Date(safeDateNow()); roundedDate.setSeconds(0, 0); const dateBucketKey = roundedDate.toISOString(); diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts index 1bd99b03fd68..aeea71262b14 100644 --- a/packages/core/src/tracing/spans/envelope.ts +++ b/packages/core/src/tracing/spans/envelope.ts @@ -4,6 +4,7 @@ import type { SerializedStreamedSpan } from '../../types/span'; import { dsnToString } from '../../utils/dsn'; import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; import { isBrowser } from '../../utils/isBrowser'; +import { safeDateNow } from '../../utils/randomSafeContext'; /** * Creates a span v2 span streaming envelope @@ -19,7 +20,7 @@ export function createStreamedSpanEnvelope( const sdk = getSdkMetadataForEnvelopeHeader(options._metadata); const headers: StreamedSpanEnvelope[0] = { - sent_at: new Date().toISOString(), + sent_at: new Date(safeDateNow()).toISOString(), ...(dscHasRequiredProps(dsc) && { trace: dsc }), ...(sdk && { sdk }), ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index c21d3dd4a7fb..8c58a1b0e00b 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -3,6 +3,7 @@ import type { Envelope } from '../types/envelope'; import type { InternalBaseTransportOptions, Transport, TransportMakeRequestResponse } from '../types/transport'; import { debug } from '../utils/debug-logger'; import { envelopeContainsItemType } from '../utils/envelope'; +import { safeDateNow } from '../utils/randomSafeContext'; import { parseRetryAfterHeader } from '../utils/ratelimit'; import { safeUnref } from '../utils/timer'; @@ -108,7 +109,7 @@ export function makeOfflineTransport( log('Attempting to send previously queued event'); // We should to update the sent_at timestamp to the current time. - found[0].sent_at = new Date().toISOString(); + found[0].sent_at = new Date(safeDateNow()).toISOString(); void send(found, true).catch(e => { log('Failed to retry sending', e); diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index 19239dc31bc3..bcb34359f23c 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -17,6 +17,7 @@ import type { SdkMetadata } from '../types/sdkmetadata'; import type { SpanJSON } from '../types/span'; import { dsnToString } from './dsn'; import { normalize } from './normalize'; +import { safeDateNow } from './randomSafeContext'; import { GLOBAL_OBJ } from './worldwide'; /** @@ -255,7 +256,7 @@ export function createEventEnvelopeHeaders( const dynamicSamplingContext = event.sdkProcessingMetadata?.dynamicSamplingContext; return { event_id: event.event_id as string, - sent_at: new Date().toISOString(), + sent_at: new Date(safeDateNow()).toISOString(), ...(sdkInfo && { sdk: sdkInfo }), ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), ...(dynamicSamplingContext && { diff --git a/packages/core/test/lib/envelope-safe-timestamp.test.ts b/packages/core/test/lib/envelope-safe-timestamp.test.ts new file mode 100644 index 000000000000..ee5f3af3c1bf --- /dev/null +++ b/packages/core/test/lib/envelope-safe-timestamp.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createCheckInEnvelope } from '../../src/checkin'; +import { createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from '../../src/envelope'; +import { SentrySpan } from '../../src/tracing/sentrySpan'; +import type { Event } from '../../src/types/event'; +import { GLOBAL_OBJ } from '../../src/utils/worldwide'; + +/** + * Envelope `sent_at` headers must derive their timestamp from `safeDateNow()` rather than + * reading the ambient clock via a bare `new Date()`. Reading the clock directly is disallowed + * in some restricted execution contexts (e.g. React Server Component prerendering); the SDK + * lets host SDKs install a runner via the `__SENTRY_SAFE_RANDOM_ID_WRAPPER__` global symbol + * (see `utils/randomSafeContext`) so that wrapped clock/random reads happen in a permitted + * context. These tests assert the envelope creators route their timestamp through that runner. + */ + +const SAFE_RANDOM_SYMBOL = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + +let inSafeContext = false; + +// Install a runner like a host SDK would. Set at module scope so it is resolved by +// `withRandomSafeContext` before the first call. +(GLOBAL_OBJ as unknown as Record)[SAFE_RANDOM_SYMBOL] = (cb: () => T): T => { + const previous = inSafeContext; + inSafeContext = true; + try { + return cb(); + } finally { + inSafeContext = previous; + } +}; + +const RealDate = Date; + +/** + * Runs `fn` with a guarded clock that throws on a bare `new Date()` / `Date.now()` unless the + * read happens inside the safe-context runner - mimicking a restricted prerender environment. + */ +function runWithGuardedClock(fn: () => T): T { + const GuardedDate = class extends RealDate { + public constructor(...args: unknown[]) { + if (args.length === 0 && !inSafeContext) { + throw new Error('Read the ambient clock via `new Date()` outside the safe context'); + } + super(...(args as [number | string | Date])); + } + + public static now(): number { + if (!inSafeContext) { + throw new Error('Read the ambient clock via `Date.now()` outside the safe context'); + } + return RealDate.now(); + } + }; + + (globalThis as { Date: DateConstructor }).Date = GuardedDate as unknown as DateConstructor; + try { + return fn(); + } finally { + (globalThis as { Date: DateConstructor }).Date = RealDate; + } +} + +describe('envelope sent_at uses the safe time context', () => { + beforeEach(() => { + inSafeContext = false; + }); + + it('createEventEnvelope does not read the ambient clock directly', () => { + const event: Event = { event_id: 'abc123', message: 'Test message' }; + expect(() => runWithGuardedClock(() => createEventEnvelope(event))).not.toThrow(); + }); + + it('createSessionEnvelope does not read the ambient clock directly', () => { + expect(() => runWithGuardedClock(() => createSessionEnvelope({ aggregates: [] }))).not.toThrow(); + }); + + it('createSpanEnvelope does not read the ambient clock directly', () => { + const span = new SentrySpan({ name: 'test-span', sampled: true }); + expect(() => runWithGuardedClock(() => createSpanEnvelope([span]))).not.toThrow(); + }); + + it('createCheckInEnvelope does not read the ambient clock directly', () => { + expect(() => + runWithGuardedClock(() => createCheckInEnvelope({ check_in_id: 'check-in', monitor_slug: 'slug', status: 'ok' })), + ).not.toThrow(); + }); +}); diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js index 8a9a27795481..e90f24f2003d 100644 --- a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -59,6 +59,8 @@ module.exports = { '`crypto.randomUUID()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', unsafeCryptoGetRandomValues: '`crypto.getRandomValues()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeDateConstructor: + '`new Date()` reads the ambient clock and should be replaced with `new Date(safeDateNow())` (with `safeDateNow()` from `@sentry/core`) to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', }, }, create: function (context) { @@ -142,6 +144,25 @@ module.exports = { } } }, + // Flag the `new Date()` constructor with no arguments, which reads the ambient clock. + // `new Date()` is safe because it does not read the current time. + NewExpression(node) { + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'Date' && + node.arguments.length === 0 && + !isInsidewithRandomSafeContext(node) + ) { + context.report({ + node, + messageId: 'unsafeDateConstructor', + }); + } + }, }; }, }; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts index e145336d6c3e..533c048f979c 100644 --- a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -54,6 +54,19 @@ describe('no-unsafe-random-apis', () => { { code: 'const x = performance.mark("test")', }, + // `new Date()` does not read the ambient clock and is safe + { + code: 'const d = new Date(safeDateNow())', + }, + { + code: 'const d = new Date(1234567890)', + }, + { + code: 'const d = new Date("2021-01-01")', + }, + { + code: 'withRandomSafeContext(() => new Date())', + }, ], invalid: [ // Direct Date.now() calls @@ -140,6 +153,31 @@ describe('no-unsafe-random-apis', () => { }, ], }, + // Bare `new Date()` constructor reads the ambient clock + { + code: 'const now = new Date()', + errors: [ + { + messageId: 'unsafeDateConstructor', + }, + ], + }, + { + code: 'const iso = new Date().toISOString()', + errors: [ + { + messageId: 'unsafeDateConstructor', + }, + ], + }, + { + code: 'someOtherWrapper(() => new Date())', + errors: [ + { + messageId: 'unsafeDateConstructor', + }, + ], + }, ], }); }); From 5b81b4123ce96d5d41e9bd5a10f7a748dc35994c Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:42:54 +0200 Subject: [PATCH 3/8] feat(core): Change default of `dataCollection.userInfo` to `true` (#21348) Changes the default of `dataColelction.userInfo` to `true`. Closes https://github.com/getsentry/sentry-javascript/issues/21345 --- CHANGELOG.md | 6 ++++-- .../data-collection/defaultPiiToCollectionOptions.ts | 4 ++++ .../utils/data-collection/resolveDataCollectionOptions.ts | 8 +++++++- packages/core/test/lib/metrics/internal.test.ts | 5 +++-- packages/core/test/lib/tracing/spans/envelope.test.ts | 6 ++++-- .../data-collection/resolveDataCollectionOptions.test.ts | 4 ++-- 6 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9a2916be6f..419b81051685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,16 @@ Work in this release was contributed by @zhongrenfei1-hub. Thank you for your co `sendDefaultPii` is deprecated and will be removed in v11. The new `dataCollection` option lets you control each category of collected data. `sendDefaultPii: true` still works and maps to enabling all `dataCollection` categories. - `dataCollection.userInfo` defaults to `false` and only gates auto-populated `user.*` fields (e.g. IP address from a request). - Data you set explicitly via `Sentry.setUser()` is always sent regardless. + `dataCollection.userInfo` defaults to `true` when `dataCollection` is provided, meaning auto-populated `user.*` fields (e.g. IP address from a request) are collected by default. + Data you set explicitly (like via `Sentry.setUser()`) is always sent regardless. + When `dataCollection` is not set at all, the legacy `sendDefaultPii` behavior applies (`userInfo: false` by default) to preserve backward compatibility. Note that an empty `dataCollection: {}` falls back to more permissive defaults than `sendDefaultPii: false`, so replicate the old behavior by opting out explicitly: ```js Sentry.init({ dataCollection: { + userInfo: false, genAI: { inputs: false, outputs: false }, httpBodies: [], httpHeaders: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, diff --git a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts index e87cd8ae7614..361275f62f02 100644 --- a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts @@ -3,6 +3,10 @@ import type { ResolvedDataCollection } from '../../types/datacollection'; /** * Helper function that maps the `sendDefaultPii` boolean flag to the corresponding `DataCollection` configuration. + * Used as a backward-compatibility bridge when `dataCollection` is not set by the user. + * + * TODO(v11): Remove this function along with `sendDefaultPii`. Once `dataCollection` is the only API, + * the DEFAULTS in `resolveDataCollectionOptions` (including `userInfo: true`) will always apply. */ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): ResolvedDataCollection { return sendDefaultPii === true diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index 1ba2069a814d..558bb05a468c 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -2,7 +2,7 @@ import type { DataCollection, ResolvedDataCollection } from '../../types/datacol import { defaultPiiToCollectionOptions } from './defaultPiiToCollectionOptions'; const DEFAULTS: ResolvedDataCollection = { - userInfo: false, + userInfo: true, cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], @@ -19,11 +19,17 @@ const DEFAULTS: ResolvedDataCollection = { * 1. Fields explicitly set in `dataCollection` * 2. If `sendDefaultPii` is set and `dataCollection` is absent, bridge via `defaultPiiToCollectionOptions` * 3. Spec defaults + * + * TODO(v11): Remove `sendDefaultPii` support and always fall through to DEFAULTS so that `userInfo: true` + * NOTE: In v10, DEFAULTS only apply when `dataCollection` is explicitly provided. + * When `dataCollection` is absent, the legacy `sendDefaultPii` bridge is used, which defaults to + * `userInfo: false` to preserve backward compatibility. */ export function resolveDataCollectionOptions(options: { dataCollection?: DataCollection; sendDefaultPii?: boolean; }): ResolvedDataCollection { + // TODO(v11): Remove the sendDefaultPii bridge and always use DEFAULTS. const base = options.dataCollection != null ? DEFAULTS : defaultPiiToCollectionOptions(options.sendDefaultPii); const dc = options.dataCollection ?? {}; diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index e95421b38f93..8faf24f992d0 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -256,7 +256,8 @@ describe('_INTERNAL_captureMetric', () => { it('includes ingest_settings with auto when dataCollection.userInfo is true', () => { vi.spyOn(isBrowserModule, 'isBrowser').mockReturnValue(true); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, dataCollection: { userInfo: true } }); + // TODO(v11) Remove `dataCollection` as the defaults should be applied without explicitly adding the option + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, dataCollection: {} }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -271,7 +272,7 @@ describe('_INTERNAL_captureMetric', () => { expect(envelopeItemPayload.ingest_settings).toEqual({ infer_ip: 'auto', infer_user_agent: 'auto' }); }); - it('includes ingest_settings with never when dataCollection.userInfo is not set', () => { + it('includes ingest_settings with never when dataCollection is not set (sendDefaultPii bridge defaults to userInfo: false)', () => { vi.spyOn(isBrowserModule, 'isBrowser').mockReturnValue(true); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts index 97955850131b..c62477af91a4 100644 --- a/packages/core/test/lib/tracing/spans/envelope.test.ts +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -244,7 +244,8 @@ describe('createStreamedSpanEnvelope', () => { vi.mocked(isBrowser).mockReturnValue(true); const mockSpan = createMockSerializedSpan(); - const mockClient = new TestClient(getDefaultTestClientOptions({ dataCollection: { userInfo: true } })); + // TODO(v11) Remove `dataCollection` as the defaults should be applied without explicitly adding the option + const mockClient = new TestClient(getDefaultTestClientOptions({ dataCollection: {} })); const dsc: Partial = {}; const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; @@ -284,7 +285,8 @@ describe('createStreamedSpanEnvelope', () => { it('omits ingest_settings when not in browser', () => { const mockSpan = createMockSerializedSpan(); - const mockClient = new TestClient(getDefaultTestClientOptions({ dataCollection: { userInfo: true } })); + // TODO(v11) Remove `dataCollection` as the defaults should be applied without explicitly adding the option + const mockClient = new TestClient(getDefaultTestClientOptions({ dataCollection: {} })); const dsc: Partial = {}; const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index b421578a01b5..2cd76e599a05 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -3,7 +3,7 @@ import { resolveDataCollectionOptions } from '../../../../src/utils/data-collect describe('resolveDataCollectionOptions', () => { const SPEC_DEFAULTS = { - userInfo: false, + userInfo: true, cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], @@ -17,7 +17,7 @@ describe('resolveDataCollectionOptions', () => { it('falls through to sendDefaultPii: undefined bridge when neither option is set', () => { const result = resolveDataCollectionOptions({}); - // sendDefaultPii undefined → restrictive bridge (backward compat) + // sendDefaultPii undefined → restrictive bridge (backward compat; userInfo defaults to true only when dataCollection is set) expect(result.userInfo).toBe(false); expect(result.httpBodies).toEqual([]); expect(result.genAI).toEqual({ inputs: false, outputs: false }); From 4819c44328382aa82a17a452794aebbdb3d257c7 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:51:30 +0200 Subject: [PATCH 4/8] chore: Bump volta node version from 20.19.2 to 20.19.5 (#21359) Our aws-serverless layer build failed because `pkg-entry-points@1.1.2` requires Node >= 20.19.5. This should not affect the SDK builds. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 44ac0d95b9a3..3fa9d1a8e074 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "yalc:publish": "nx run-many -t yalc:publish" }, "volta": { - "node": "20.19.2", + "node": "20.19.5", "yarn": "1.22.22", "pnpm": "9.15.9" }, From 99988a8e3bf73ea121bcd275f15aa6b385ff15f7 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 8 Jun 2026 06:47:44 -0400 Subject: [PATCH 5/8] fix(node-core): Read `__SENTRY_SERVER_MODULES__` lazily so Turbopack injection is honored (#21339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the root cause behind #19147. On **Next.js 16 / Turbopack production builds** (e.g. Vercel), `modulesIntegration` returns no injected modules, which silently disables every module-detection-based auto integration — `vercelAIIntegration`, `openAIIntegration`, `anthropicAIIntegration`, `googleGenAIIntegration`, `langChainIntegration`, `langGraphIntegration` — and leaves `event.modules` missing server dependencies. The result users see: raw `ai.*` spans (`op: default`) instead of `gen_ai.*`. ## Root cause `packages/node-core/src/integrations/modules.ts` captured the injected value into a **module-level `const` at evaluation time**: ```ts const SERVER_MODULES = typeof __SENTRY_SERVER_MODULES__ === 'undefined' ? {} : __SENTRY_SERVER_MODULES__; ``` The two bundlers inject `__SENTRY_SERVER_MODULES__` differently: - **webpack** replaces the bare token with a literal at build time via `DefinePlugin` → available the moment this module evaluates. ✅ - **Turbopack** (added in #19231) assigns `globalThis.__SENTRY_SERVER_MODULES__` at **runtime**, via a value-injection loader on `instrumentation.*`. The catch: the `instrumentation.*` file's ESM `import`s are **hoisted above** the injected assignment. Verified in a real Turbopack build (`.next/server/chunks/[root-of-the-server]__*.js`): ```js 769449, e=>{ "use strict"; var r = e.i(298962); // import @sentry/* — evaluates modules.ts (SERVER_MODULES captured = {}) async function s(){ await e.A(145684) } // register() globalThis.__SENTRY_SERVER_MODULES__ = {/* …deps… */} // injection runs AFTER the import } ``` So `@sentry/node-core/modules` evaluates **before** the global is assigned, and the `const` is frozen as `{}`. The other two sources in `collectModules()` also come up empty on a bundled server (no full-dependency `package.json` at `process.cwd()`; `ai` is bundled so it's not in `require.cache`, and the server is ESM not CJS). Net: `getModules().ai` is `undefined` → `shouldForceIntegration` returns `false` → `addVercelAiProcessors` never attaches. ## Why #19231 didn't catch it #19231 was unit-tested at the config-generation layer (asserting the value-injection rule is emitted). The `nextjs-16` AI E2E that asserts `gen_ai.*` spans passes for the wrong reason — it runs `next start` **locally**, where `getModulesFromPackageJson()` reads `process.cwd()/package.json` (present, lists `ai`) and masks the broken `SERVER_MODULES` path. On Vercel that fallback is empty, so detection fails. ## Fix Read the value **lazily** (per call) instead of capturing it at module-eval time, and support both injection styles: ```ts function getServerModules(): Record { if (typeof __SENTRY_SERVER_MODULES__ !== 'undefined') return __SENTRY_SERVER_MODULES__; // webpack return (GLOBAL_OBJ as ...).__SENTRY_SERVER_MODULES__ ?? {}; // turbopack } ``` By the time `getModules()` is first called (during integration `afterAllSetup`, i.e. after `register()` → `Sentry.init()`), the instrumentation module body has fully executed and the global is set. webpack is unaffected (token still replaced). ## Regression test `packages/node-core/test/integrations/modules.test.ts` re-imports the module with no global set (mirroring Turbopack), then assigns `globalThis.__SENTRY_SERVER_MODULES__` **after** import and asserts `getModules()` reflects it. This **fails on the previous code** and passes with the fix. ## Blast radius Low. webpack path unchanged; Turbopack now honored; `event.modules` restored on Turbopack. Re-enables all module-detection-based auto integrations on Next.js 16 without requiring `vercelAIIntegration({ force: true })`. ## Follow-up (separate) The existing `nextjs-16` AI E2E should be hardened so it can't pass via the `process.cwd()` package.json fallback — e.g. a `--turbopack` build variant run from a working directory whose `package.json` does not list the AI SDK, asserting `gen_ai.*` spans still appear. Happy to do this in a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++ .../node-core/src/integrations/modules.ts | 24 ++++++++++--- .../test/integrations/modules.test.ts | 36 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 packages/node-core/test/integrations/modules.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 419b81051685..0334e291efae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ Work in this release was contributed by @zhongrenfei1-hub. Thank you for your co `@sentry/angular` now officially supports Angular 22. +- **fix(node-core): Read `__SENTRY_SERVER_MODULES__` lazily so Turbopack injection is honored [#21339](https://github.com/getsentry/sentry-javascript/pull/21339)** + + On Next.js 16 / Turbopack production builds, `modulesIntegration` captured `__SENTRY_SERVER_MODULES__` at module-evaluation time, before Turbopack's runtime `globalThis` assignment ran. This silently disabled module-detection-based auto integrations (Vercel AI, OpenAI, Anthropic, Google GenAI, LangChain, LangGraph) and left `event.modules` empty. The value is now read lazily, supporting both the webpack (`DefinePlugin`) and Turbopack (runtime global) injection styles. + - **ref(core): Deprecate `sendDefaultPii` in favor of `dataCollection` [#21277](https://github.com/getsentry/sentry-javascript/pull/21277)** `sendDefaultPii` is deprecated and will be removed in v11. The new `dataCollection` option lets you control each category of collected data. diff --git a/packages/node-core/src/integrations/modules.ts b/packages/node-core/src/integrations/modules.ts index 7079f4a2fab8..701e413d55fd 100644 --- a/packages/node-core/src/integrations/modules.ts +++ b/packages/node-core/src/integrations/modules.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import type { IntegrationFn } from '@sentry/core'; +import { GLOBAL_OBJ, type IntegrationFn } from '@sentry/core'; import { isCjs } from '../utils/detection'; type ModuleInfo = Record; @@ -12,10 +12,24 @@ const INTEGRATION_NAME = 'Modules'; declare const __SENTRY_SERVER_MODULES__: Record; /** - * `__SENTRY_SERVER_MODULES__` can be replaced at build time with the modules loaded by the server. - * Right now, we leverage this in Next.js to circumvent the problem that we do not get access to these things at runtime. + * Reads the modules injected at build time into `__SENTRY_SERVER_MODULES__` (e.g. by the Next.js SDK). + * + * Must be read lazily (per call), not captured at module-eval time: webpack replaces the token via + * `DefinePlugin` (available immediately), but Turbopack assigns `globalThis.__SENTRY_SERVER_MODULES__` + * at runtime, *after* this module's imports are hoisted and evaluated. A `const` capture would be + * empty under Turbopack. See getsentry/sentry-javascript#19147. */ -const SERVER_MODULES = typeof __SENTRY_SERVER_MODULES__ === 'undefined' ? {} : __SENTRY_SERVER_MODULES__; +function getServerModules(): Record { + // webpack: the token is replaced with a literal at build time. + if (typeof __SENTRY_SERVER_MODULES__ !== 'undefined') { + return __SENTRY_SERVER_MODULES__; + } + // Turbopack: the value is assigned onto the global object at runtime. + return ( + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_SERVER_MODULES__?: Record }) + .__SENTRY_SERVER_MODULES__ ?? {} + ); +} const _modulesIntegration = (() => { return { @@ -52,7 +66,7 @@ function getRequireCachePaths(): string[] { /** Extract information about package.json modules */ function collectModules(): ModuleInfo { return { - ...SERVER_MODULES, + ...getServerModules(), ...getModulesFromPackageJson(), ...(isCjs() ? collectRequireModules() : {}), }; diff --git a/packages/node-core/test/integrations/modules.test.ts b/packages/node-core/test/integrations/modules.test.ts new file mode 100644 index 000000000000..31ee5671e40a --- /dev/null +++ b/packages/node-core/test/integrations/modules.test.ts @@ -0,0 +1,36 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type GlobalWithModules = typeof GLOBAL_OBJ & { __SENTRY_SERVER_MODULES__?: Record }; + +describe('modulesIntegration', () => { + afterEach(() => { + delete (GLOBAL_OBJ as GlobalWithModules).__SENTRY_SERVER_MODULES__; + vi.resetModules(); + }); + + it('includes modules injected onto the global AFTER this module was evaluated (Turbopack ordering)', async () => { + // Re-evaluate the integration module with no injected global present. This mirrors Turbopack: + // the instrumentation file's hoisted ESM imports evaluate this module *before* its + // `globalThis.__SENTRY_SERVER_MODULES__ = {...}` assignment runs. A module-level capture would + // freeze an empty value here and never see the injection (getsentry/sentry-javascript#19147). + vi.resetModules(); + const { modulesIntegration } = await import('../../src/integrations/modules'); + + // The runtime injection happens only now — after the module is already evaluated. + (GLOBAL_OBJ as GlobalWithModules).__SENTRY_SERVER_MODULES__ = { '@sentry/turbopack-injected': '1.2.3' }; + + const modules = modulesIntegration().getModules?.() ?? {}; + expect(modules['@sentry/turbopack-injected']).toBe('1.2.3'); + }); + + it('reads modules already present before evaluation (webpack DefinePlugin / pre-set global)', async () => { + (GLOBAL_OBJ as GlobalWithModules).__SENTRY_SERVER_MODULES__ = { '@sentry/prebuilt-injected': '4.5.6' }; + + vi.resetModules(); + const { modulesIntegration } = await import('../../src/integrations/modules'); + + const modules = modulesIntegration().getModules?.() ?? {}; + expect(modules['@sentry/prebuilt-injected']).toBe('4.5.6'); + }); +}); From 4293015f67d2042ce6bd92e7fede4edd1cb2869c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:02:28 +0200 Subject: [PATCH 6/8] feat(deps): Bump @types/aws-lambda from 8.10.150 to 8.10.161 (#21105) Bumps [@types/aws-lambda](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/aws-lambda) from 8.10.150 to 8.10.161.
Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Andrei Borza Co-authored-by: Claude --- packages/aws-serverless/package.json | 2 +- packages/aws-serverless/src/utils.ts | 6 +++--- packages/aws-serverless/test/utils.test.ts | 4 ++-- yarn.lock | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 64f968291361..1f0ef4cf389e 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -73,7 +73,7 @@ "@sentry/core": "10.56.0", "@sentry/node": "10.56.0", "@sentry/node-core": "10.56.0", - "@types/aws-lambda": "^8.10.62" + "@types/aws-lambda": "^8.10.161" }, "devDependencies": { "@types/node": "^18.19.1", diff --git a/packages/aws-serverless/src/utils.ts b/packages/aws-serverless/src/utils.ts index f298a2bfec48..ff8e8810992b 100644 --- a/packages/aws-serverless/src/utils.ts +++ b/packages/aws-serverless/src/utils.ts @@ -40,7 +40,7 @@ export function markEventUnhandled(scope: Scope, type: string): Scope { * back to the `event`. * * When instrumenting the Lambda function with Sentry, the sentry trace data - * is placed on `context.clientContext.Custom`. Users are free to modify context + * is placed on `context.clientContext.custom`. Users are free to modify context * tho and provide this data via `event` or `context`. */ export function getAwsTraceData(event: HandlerEvent, context?: HandlerContext): TraceData { @@ -51,8 +51,8 @@ export function getAwsTraceData(event: HandlerEvent, context?: HandlerContext): baggage: headers.baggage, }; - if (context?.clientContext?.Custom) { - const customContext: Record = context.clientContext.Custom; + if (context?.clientContext?.custom) { + const customContext: Record = context.clientContext.custom; const sentryTrace = isString(customContext['sentry-trace']) ? customContext['sentry-trace'] : undefined; if (sentryTrace) { diff --git a/packages/aws-serverless/test/utils.test.ts b/packages/aws-serverless/test/utils.test.ts index e77171454a6a..f3e0f00dc97b 100644 --- a/packages/aws-serverless/test/utils.test.ts +++ b/packages/aws-serverless/test/utils.test.ts @@ -14,7 +14,7 @@ vi.mock('@opentelemetry/api', async () => { const mockContext = { clientContext: { - Custom: { + custom: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', }, @@ -54,7 +54,7 @@ describe('getTraceData', () => { test('gets sentry trace data from the event if the context sentry trace is undefined', () => { const traceData = getAwsTraceData(mockEvent, { // @ts-expect-error, a partial context object is fine here - clientContext: { Custom: { 'sentry-trace': undefined, baggage: '' } }, + clientContext: { custom: { 'sentry-trace': undefined, baggage: '' } }, }); expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-2'); diff --git a/yarn.lock b/yarn.lock index 8395b0d2c76e..99085442331a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8900,10 +8900,10 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/aws-lambda@^8.10.62": - version "8.10.150" - resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.150.tgz#4998b238750ec389a326a7cdb625808834036bd3" - integrity sha512-AX+AbjH/rH5ezX1fbK8onC/a+HyQHo7QGmvoxAE42n22OsciAxvZoZNEr22tbXs8WfP1nIsBjKDpgPm3HjOZbA== +"@types/aws-lambda@^8.10.161": + version "8.10.161" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.161.tgz#36d95723ec46d3d555bf0684f83cf4d4369a28ad" + integrity sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ== "@types/babel__core@^7.20.1", "@types/babel__core@^7.20.4": version "7.20.5" From d64534903f34fe4f2c7a0876b3b8481aeff1f858 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 8 Jun 2026 14:20:00 +0200 Subject: [PATCH 7/8] ref(node): Streamline lru-memoizer instrumentation (#21350) Streamlines the vendored `@opentelemetry/instrumentation-lru-memoizer`: - Ported the upstream OTel unit tests for the instrumentation but using a fake lru memoizer instead of real module. - Removed the unused `config` constructor param (the SDK always constructs it with no config). Fixes #20735 Co-authored-by: Claude Opus 4.8 (1M context) --- .../lrumemoizer/vendored/instrumentation.ts | 31 ++--- .../integrations/tracing/lrumemoizer.test.ts | 112 ++++++++++++++++++ 2 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 packages/node/test/integrations/tracing/lrumemoizer.test.ts diff --git a/packages/node/src/integrations/tracing/lrumemoizer/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/lrumemoizer/vendored/instrumentation.ts index 1f24c1b570d1..884df24a7489 100644 --- a/packages/node/src/integrations/tracing/lrumemoizer/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/lrumemoizer/vendored/instrumentation.ts @@ -17,21 +17,19 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-lru-memoizer * - Upstream version: @opentelemetry/instrumentation-lru-memoizer@0.62.0 */ -/* eslint-disable */ import { context } from '@opentelemetry/api'; -import { - InstrumentationBase, - InstrumentationConfig, - InstrumentationNodeModuleDefinition, -} from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { SDK_VERSION } from '@sentry/core'; const PACKAGE_NAME = '@sentry/instrumentation-lru-memoizer'; +type Memoizer = (this: unknown, ...args: unknown[]) => unknown; +type LruMemoizerModule = ((this: unknown, ...args: unknown[]) => Memoizer) & { sync: unknown }; + export class LruMemoizerInstrumentation extends InstrumentationBase { - constructor(config: InstrumentationConfig = {}) { - super(PACKAGE_NAME, SDK_VERSION, config); + constructor() { + super(PACKAGE_NAME, SDK_VERSION, {}); } init(): InstrumentationNodeModuleDefinition[] { @@ -39,29 +37,26 @@ export class LruMemoizerInstrumentation extends InstrumentationBase { new InstrumentationNodeModuleDefinition( 'lru-memoizer', ['>=1.3 <4'], - moduleExports => { + (moduleExports: LruMemoizerModule) => { // moduleExports is a function which receives an options object, // and returns a "memoizer" function upon invocation. // We want to patch this "memoizer's" internal function - const asyncMemoizer = function (this: unknown) { + const asyncMemoizer = function (this: unknown, ...args: unknown[]): unknown { // This following function is invoked every time the user wants to get a (possible) memoized value // We replace it with another function in which we bind the current context to the last argument (callback) - const origMemoizer = moduleExports.apply(this, arguments); - return function (this: unknown) { - const modifiedArguments = [...arguments]; + const origMemoizer = moduleExports.apply(this, args) as Memoizer; + return function (this: unknown, ...memoizerArgs: unknown[]): unknown { // last argument is the callback - const origCallback = modifiedArguments.pop(); + const origCallback = memoizerArgs.pop(); const callbackWithContext = typeof origCallback === 'function' ? context.bind(context.active(), origCallback) : origCallback; - modifiedArguments.push(callbackWithContext); - return origMemoizer.apply(this, modifiedArguments); + return origMemoizer.apply(this, [...memoizerArgs, callbackWithContext]); }; }; // sync function preserves context, but we still need to export it // as the lru-memoizer package does - asyncMemoizer.sync = moduleExports.sync; - return asyncMemoizer; + return Object.assign(asyncMemoizer, { sync: moduleExports.sync }); }, undefined, // no need to disable as this instrumentation does not create any spans ), diff --git a/packages/node/test/integrations/tracing/lrumemoizer.test.ts b/packages/node/test/integrations/tracing/lrumemoizer.test.ts new file mode 100644 index 000000000000..da026c2c4df0 --- /dev/null +++ b/packages/node/test/integrations/tracing/lrumemoizer.test.ts @@ -0,0 +1,112 @@ +/* + * Tests ported from @opentelemetry/instrumentation-lru-memoizer@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-lru-memoizer + * Licensed under the Apache License, Version 2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../../src'; +import { LruMemoizerInstrumentation } from '../../../src/integrations/tracing/lrumemoizer/vendored/instrumentation'; +import { cleanupOtel, mockSdkInit } from '../../helpers/mockSdkInit'; + +type MemoizerCallback = (err: Error | null, result?: string) => void; +type Memoizer = (param: unknown, callback?: MemoizerCallback | null) => void; +type MemoizerModule = ((options: unknown) => Memoizer) & { sync: (options: unknown) => (param: unknown) => string }; + +describe('lru-memoizer instrumentation', () => { + let instrumentation: LruMemoizerInstrumentation; + + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + instrumentation = new LruMemoizerInstrumentation(); + }); + + afterEach(() => { + instrumentation.disable(); + cleanupOtel(); + }); + + // Create a fake `lru-memoizer`. + // The fake queues the instrumented callback so it can be invoked from outside the originating span. + function getMemoizer(): { memoizer: MemoizerModule; queuedCallbacks: MemoizerCallback[] } { + const queuedCallbacks: MemoizerCallback[] = []; + const fakeModule = Object.assign( + (_options: unknown) => (_param: unknown, callback?: MemoizerCallback | null) => { + if (callback) { + queuedCallbacks.push(callback); + } + }, + { sync: (_options: unknown) => (_param: unknown) => 'foo' }, + ) as MemoizerModule; + + const memoizer = instrumentation.getModuleDefinitions()[0]!.patch!(fakeModule) as MemoizerModule; + return { memoizer, queuedCallbacks }; + } + + describe('async', () => { + it('should invoke load callback with original context', () => { + const { memoizer, queuedCallbacks } = getMemoizer(); + const memoizedFoo = memoizer({ max: 10, load: () => {}, hash: () => 'bar' }); + + let outerSpan: unknown; + let activeSpanInCallback: unknown; + Sentry.startSpan({ name: 'memoized invocation' }, span => { + outerSpan = span; + memoizedFoo({ foo: 'bar' }, () => { + activeSpanInCallback = Sentry.getActiveSpan(); + }); + }); + + // we invoke the callback from outside of the above span's context. + // however, we expect that the callback is called with the context of the original invocation + queuedCallbacks[0]!(null, 'result'); + expect(activeSpanInCallback).toBe(outerSpan); + }); + + it('should invoke callback with right context when serving 2 parallel async requests', () => { + const { memoizer, queuedCallbacks } = getMemoizer(); + const memoizedFoo = memoizer({ max: 10, load: () => {}, hash: () => 'bar' }); + + const observed: Array<{ expected: unknown; actual: unknown }> = []; + + Sentry.startSpan({ name: 'first request' }, firstSpan => { + memoizedFoo({ foo: 'bar' }, () => { + observed.push({ expected: firstSpan, actual: Sentry.getActiveSpan() }); + }); + }); + + Sentry.startSpan({ name: 'second request' }, secondSpan => { + memoizedFoo({ foo: 'bar' }, () => { + observed.push({ expected: secondSpan, actual: Sentry.getActiveSpan() }); + }); + }); + + expect(queuedCallbacks.length).toBe(2); + queuedCallbacks[0]!(null, 'result'); + queuedCallbacks[1]!(null, 'result'); + + expect(observed).toHaveLength(2); + observed.forEach(({ expected, actual }) => { + expect(actual).toBe(expected); + }); + }); + + it('should not throw when last argument is not callback', () => { + const { memoizer } = getMemoizer(); + const memoizedFoo = memoizer({ max: 10, load: () => 'foo', hash: () => 'bar' }); + + // this is not valid but we want to make sure it does not throw or act badly + expect(() => memoizedFoo({ foo: 'bar' }, null)).not.toThrow(); + }); + }); + + describe('sync', () => { + it('should not break sync memoizer', () => { + const { memoizer } = getMemoizer(); + + // the sync memoizer is passed through untouched by the patch + const memoizedFoo = memoizer.sync({ max: 10, load: () => 'foo', hash: () => 'bar' }); + expect(memoizedFoo({ foo: 'bar' })).toBe('foo'); + }); + }); +}); From c6f790bf8d46cd0529529a1788d2af863ec1df6b Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:59:06 +0200 Subject: [PATCH 8/8] fix(node): Prevent PostgresJs integration from emitting duplicate spans per query (#21364) postgres.js calls handle() from then/catch/finally, but only the first invocation executes SQL (guarded by this.executed). The patched handle was creating a new span on every call, inflating span count 2-3x. Fixes #21355 --------- Co-authored-by: Claude claude-opus-4-6 --- packages/core/src/integrations/postgresjs.ts | 6 ++- .../test/lib/integrations/postgresjs.test.ts | 37 +++++++++++++++++++ .../src/integrations/tracing/postgresjs.ts | 7 +++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index bb45aedbc4b0..93602d71a6e5 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -217,8 +217,10 @@ function _wrapSingleQueryHandle( // IMPORTANT: We must replace the handle function directly, not use a Proxy, // because Query.then() internally calls this.handle(), which would bypass a Proxy wrapper. - const wrappedHandle = async function (this: unknown, ...args: unknown[]): Promise { - if (!_shouldCreateSpans(options)) { + const wrappedHandle = async function (this: { executed?: boolean }, ...args: unknown[]): Promise { + // postgres.js calls handle() from then/catch/finally — only the first call executes SQL, + // subsequent calls are no-ops (guarded by this.executed). Skip span creation for no-ops. + if (this.executed || !_shouldCreateSpans(options)) { return originalHandle.apply(this, args); } diff --git a/packages/core/test/lib/integrations/postgresjs.test.ts b/packages/core/test/lib/integrations/postgresjs.test.ts index dfc159808377..921e82b89a42 100644 --- a/packages/core/test/lib/integrations/postgresjs.test.ts +++ b/packages/core/test/lib/integrations/postgresjs.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _reconstructQuery, _sanitizeSqlQuery, instrumentPostgresJsSql } from '../../../src/integrations/postgresjs'; +import * as tracing from '../../../src/tracing'; import * as spanUtils from '../../../src/utils/spanUtils'; describe('PostgresJs portable instrumentation', () => { @@ -554,6 +555,42 @@ describe('PostgresJs portable instrumentation', () => { // handle was wrapped expect((mockQuery.handle as any).__sentryWrapped).toBe(true); }); + + it('only creates one span even when handle() is called multiple times', async () => { + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; + const startSpanManualSpy = vi + .spyOn(tracing, 'startSpanManual') + .mockImplementation((_opts, callback) => callback(mockSpan as any, () => {})); + + const originalHandle = vi.fn().mockResolvedValue([]); + const mockQuery = { + handle: originalHandle, + strings: ['SELECT 1'], + resolve: vi.fn(), + reject: vi.fn(), + executed: false, + }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql, { requireParentSpan: false }); + instrumented(['SELECT 1']); + + const wrappedHandle = mockQuery.handle as (...args: unknown[]) => Promise; + + // First call — executed is false, should create a span + await wrappedHandle.call(mockQuery); + expect(startSpanManualSpy).toHaveBeenCalledTimes(1); + + // Simulate postgres.js setting executed = true after first handle() + mockQuery.executed = true; + + // Second and third calls (from .then/.catch/.finally) — should NOT create more spans + await wrappedHandle.call(mockQuery); + await wrappedHandle.call(mockQuery); + expect(startSpanManualSpy).toHaveBeenCalledTimes(1); + + startSpanManualSpy.mockRestore(); + }); }); it('does not wrap non-query results from sql call', () => { diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index cbc5647e6f21..62f9a01ccfc8 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -281,11 +281,14 @@ export class PostgresJsInstrumentation extends InstrumentationBase { - // Skip if this query came from an instrumented sql instance (already handled by wrapper) - if ((this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { + // Skip if this query came from an instrumented sql instance (already handled by wrapper), + // or if handle() was already called (postgres.js calls handle() from then/catch/finally — + // only the first call executes SQL, subsequent calls are no-ops). + if (this.executed || (this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { return originalHandle.apply(this, args); }