From e6aa1e92f08e93be89ab229fe6a90222bfd197a9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:29:56 +0000 Subject: [PATCH 1/5] feat(core): Add `__SENTRY_EXCLUDE_TRANSACTIONS__` flag to tree-shake transaction code With span streaming, the code that builds and processes transaction events is dead but still ships. Replacing `__SENTRY_EXCLUDE_TRANSACTIONS__` with `true` makes the SDK always stream spans and lets bundlers drop those paths. Defaults are unchanged. Refs #22580 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .size-limit.js | 21 +++++++ .../browser-utils/src/web-vitals/tracking.ts | 8 ++- .../test/web-vitals/tracking-fp-fcp.test.ts | 22 +++++++ packages/core/src/client.ts | 20 +++++- packages/core/src/server-runtime-client.ts | 6 +- packages/core/src/tracing/sentrySpan.ts | 21 ++++++- .../lib/tracing/excludeTransactions.test.ts | 63 +++++++++++++++++++ 7 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 packages/core/test/lib/tracing/excludeTransactions.test.ts diff --git a/.size-limit.js b/.size-limit.js index b841667d9d89..f01edf461f3a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -64,6 +64,27 @@ module.exports = [ gzip: true, disablePlugins: ['@size-limit/esbuild'], }, + { + name: '@sentry/browser (incl. Tracing) - with treeshaking flags excluding transactions', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'browserTracingIntegration'), + gzip: true, + disablePlugins: ['@size-limit/esbuild'], + modifyWebpackConfig: function (config) { + const webpack = require('webpack'); + + config.plugins.push( + new webpack.DefinePlugin({ + __SENTRY_DEBUG__: false, + __SENTRY_EXCLUDE_TRANSACTIONS__: true, + }), + ); + + config.optimization.minimize = true; + + return config; + }, + }, { name: '@sentry/browser (incl. Tracing + Span Streaming)', path: 'packages/browser/build/npm/esm/prod/index.js', diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts index ee2907cf3e92..78b3dc29359d 100644 --- a/packages/browser-utils/src/web-vitals/tracking.ts +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -29,6 +29,9 @@ import { getBrowserPerformanceAPI, msToSec } from '../performance/utils'; import { isValidLcpMetric } from './lcp'; import { getActivationStart, getNavigationEntry, getVisibilityWatcher } from './utils'; +// Treeshakable guard to always stream spans and drop the code paths that build and process transactions +declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; + let _measurements: Measurements = {}; let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; @@ -178,7 +181,10 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption return; } - const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + const { recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + const spanStreamingEnabled = + (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + options.spanStreamingEnabled; const timeOrigin = msToSec(origin); // Measurements are only available for pageload transactions diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts index 3a8c43c601a2..aa4318227a6b 100644 --- a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts @@ -92,4 +92,26 @@ describe('startTrackingWebVitals', () => { expect(spanToJSON(pageloadSpan).attributes['browser.web_vital.fcp.value']).toBe(18); expect(spanToJSON(pageloadSpan).attributes['browser.navigation.type']).toBe('navigate'); }); + + it('reports web vitals as span attributes when transactions are excluded from the bundle', () => { + vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + vi.stubGlobal('addEventListener', vi.fn()); + vi.stubGlobal('performance', { + timeOrigin: realPerformance.timeOrigin, + now: () => realPerformance.now(), + getEntries: () => [], + getEntriesByType: () => [], + }); + + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + addWebVitalsToSpan(pageloadSpan, { + recordClsOnPageloadSpan: true, + recordLcpOnPageloadSpan: true, + spanStreamingEnabled: false, + }); + + const { attributes } = spanToJSON(pageloadSpan); + expect(attributes['browser.performance.time_origin']).toEqual(expect.any(Number)); + expect(attributes['performance.timeOrigin']).toBeUndefined(); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 54f0c2dae0d2..4056cd2c9086 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -59,6 +59,9 @@ import { maybeWarnAboutIgnoredTransactionOptions } from './utils/warnAboutIgnore import { resolveDataCollectionOptions } from './utils/data-collection/resolveDataCollectionOptions'; import { applyBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +// Treeshakable guard to always stream spans and drop the code paths that build and process transactions +declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; + const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; @@ -261,10 +264,15 @@ export abstract class Client { protected constructor(options: O) { // Any value other than `'static'` normalizes to the `'stream'` default, so that `traceLifecycle` // is always one of the two known values for the rest of the SDK. + // With transactions excluded from the bundle, the SDK can only stream spans. this._options = { attachStacktrace: true, ...options, - traceLifecycle: options.traceLifecycle === 'static' ? 'static' : 'stream', + traceLifecycle: + options.traceLifecycle === 'static' && + !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) + ? 'static' + : 'stream', }; this._integrations = {}; this._numProcessing = 0; @@ -607,7 +615,10 @@ export abstract class Client { // Extract gen_ai spans from transaction and convert to span v2 format. // This mutates event.spans to remove the extracted spans. - const genAiSpanItem = extractGenAiSpansFromEvent(event, this); + const genAiSpanItem = + typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__ + ? undefined + : extractGenAiSpansFromEvent(event, this); let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); @@ -1798,7 +1809,10 @@ function processBeforeSend( ); } - if (isTransactionEvent(processedEvent)) { + if ( + !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) && + isTransactionEvent(processedEvent) + ) { // Avoid processing if we don't have to if (beforeSendSpan || ignoreSpans) { // 1. Process root span diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index ab9729f60732..f8167632aa13 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -32,6 +32,9 @@ export interface ServerRuntimeClientOptions extends ClientOptions i.name === SPAN_STREAMING_INTEGRATION_NAME) ) { options.integrations.push(spanStreamingIntegration()); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 41c3a0257de3..48bbada0b15d 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -57,6 +57,9 @@ import { getCapturedScopesOnSpan, spanIsTracerProviderSpan } from './utils'; const MAX_SPAN_COUNT = 1000; +// Treeshakable guard to always stream spans and drop the code paths that build and process transactions +declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; + /** * Span contains all data about a span */ @@ -410,8 +413,11 @@ export class SentrySpan implements Span { return; } - if (client && hasSpanStreamingEnabled(client)) { - client.emit('afterSegmentSpanEnd', this); + if ( + (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + (client && hasSpanStreamingEnabled(client)) + ) { + client?.emit('afterSegmentSpanEnd', this); return; } @@ -433,6 +439,11 @@ export class SentrySpan implements Span { * Finish the transaction & prepare the event to send to Sentry. */ private _convertSpanToTransaction(options: SegmentSpanCaptureConvertOptions = {}): TransactionEvent | undefined { + // Class methods can't be tree-shaken, so we empty the body instead + if (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) { + return undefined; + } + // We can only convert finished spans if (!isFullFinishedSpan(spanToStaticSpanJSON(this))) { return undefined; @@ -579,7 +590,11 @@ function sendStandaloneSpan(span: SentrySpan, client: Client): void { // callbacks), so scrub the span in its native v1 shape and convert it forward to v2, mirroring the // gen_ai extraction path. // TODO(standalone): remove this branch once the static trace lifecycle is dropped. - if (traceLifecycle === 'static' && isStaticBeforeSendSpanCallback(beforeSendSpan)) { + if ( + !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) && + traceLifecycle === 'static' && + isStaticBeforeSendSpanCallback(beforeSendSpan) + ) { const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); const dsc = getDynamicSamplingContextFromSpan(span); // sendEnvelope should not throw diff --git a/packages/core/test/lib/tracing/excludeTransactions.test.ts b/packages/core/test/lib/tracing/excludeTransactions.test.ts new file mode 100644 index 000000000000..3e974c3ce787 --- /dev/null +++ b/packages/core/test/lib/tracing/excludeTransactions.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope } from '../../../src/currentScopes'; +import { setCurrentClient } from '../../../src/sdk'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; +import { SentrySpan } from '../../../src/tracing/sentrySpan'; +import { createTransport } from '../../../src/transports/base'; +import type { TransactionEvent } from '../../../src/types/event'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('__SENTRY_EXCLUDE_TRANSACTIONS__', () => { + beforeEach(() => { + vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('ignores `traceLifecycle: "static"`', () => { + const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'static' })); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + it('hands segment spans to span streaming instead of capturing a transaction', () => { + const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'static', tracesSampleRate: 1 })); + setCurrentClient(client); + const onSegmentSpanEnd = vi.fn(); + client.on('afterSegmentSpanEnd', onSegmentSpanEnd); + const captureEventSpy = vi.spyOn(getCurrentScope(), 'captureEvent'); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true, startTimestamp: 1 }); + segmentSpan.end(2); + + expect(onSegmentSpanEnd).toHaveBeenCalledWith(segmentSpan); + expect(captureEventSpy).not.toHaveBeenCalled(); + }); + + it('does not run transaction processing on transaction events', async () => { + const beforeSendTransaction = vi.fn(event => event); + const client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://username@domain/123', beforeSendTransaction }), + ); + const event: TransactionEvent = { type: 'transaction', transaction: 'test', spans: [] }; + + client.captureEvent(event); + await client.flush(); + + expect(beforeSendTransaction).not.toHaveBeenCalled(); + }); + + it('adds the span streaming integration to server clients with `traceLifecycle: "static"`', () => { + const client = new ServerRuntimeClient({ + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + traceLifecycle: 'static', + }); + + expect(client.getOptions().integrations.map(integration => integration.name)).toContain('SpanStreaming'); + }); +}); From dc6054439c0fc021a87fd6b658777f56e14b06f6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:37:28 +0000 Subject: [PATCH 2/5] fix(cloudflare): Keep the segment-end flush timer off when transactions are excluded With `__SENTRY_EXCLUDE_TRANSACTIONS__`, `traceLifecycle: 'static'` is ignored, so the Cloudflare client must still add its own `spanStreamingIntegration` with `flushOnSegmentEnd: false` instead of inheriting the default one from `ServerRuntimeClient`. Also use the conventions constants in the web vitals test. --- .../test/web-vitals/tracking-fp-fcp.test.ts | 6 ++++-- packages/cloudflare/src/client.ts | 4 +++- packages/cloudflare/test/sdk.test.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts index aa4318227a6b..b75dd153bfd9 100644 --- a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts @@ -1,3 +1,4 @@ +import { BROWSER_PERFORMANCE_TIME_ORIGIN, PERFORMANCE_TIMEORIGIN } from '@sentry/conventions/attributes'; import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking'; @@ -111,7 +112,8 @@ describe('startTrackingWebVitals', () => { }); const { attributes } = spanToJSON(pageloadSpan); - expect(attributes['browser.performance.time_origin']).toEqual(expect.any(Number)); - expect(attributes['performance.timeOrigin']).toBeUndefined(); + expect(attributes[BROWSER_PERFORMANCE_TIME_ORIGIN]).toEqual(expect.any(Number)); + // oxlint-disable-next-line typescript/no-deprecated -- Asserting the legacy attribute is not set. + expect(attributes[PERFORMANCE_TIMEORIGIN]).toBeUndefined(); }); }); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index ca9e75dbef64..0b9c14a9d6fb 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -17,6 +17,7 @@ import type { CloudflareTransportOptions } from './transport'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; declare const __SENTRY_TRACING__: boolean; +declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; const SPAN_STREAMING_INTEGRATION_NAME = 'SpanStreaming'; @@ -69,7 +70,8 @@ export class CloudflareClient extends ServerRuntimeClient { // flush and the eager drain already deliver streamed spans here. if ( (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) && - clientOptions.traceLifecycle !== 'static' && + ((typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + clientOptions.traceLifecycle !== 'static') && !clientOptions.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME) ) { clientOptions.integrations.push(spanStreamingIntegration({ flushOnSegmentEnd: false })); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 29a7ea64b999..2652bd84dd04 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -99,6 +99,21 @@ describe('init', () => { } }); + test('does not leave a timer pending when transactions are excluded from the bundle', () => { + vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + vi.useFakeTimers(); + try { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 0, traceLifecycle: 'static' }); + + startSpan({ name: 'alarm' }, () => undefined); + + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + test("doesn't add spanStreamingIntegration when tracing is excluded from the bundle", () => { vi.stubGlobal('__SENTRY_TRACING__', false); try { From 479b89148e701627c39765daf8b9c8f00564056f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:42:12 +0000 Subject: [PATCH 3/5] ref(rollup-utils): Replace `__SENTRY_EXCLUDE_TRANSACTIONS__` with `false` in CDN bundles CDN bundles always keep transaction support, so the unresolved runtime checks for the flag only added bytes there. --- dev-packages/rollup-utils/plugins/bundlePlugins.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index 23aab90f4edf..5350627479bc 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -85,6 +85,9 @@ export function makeSetSDKSourcePlugin(sdkSource) { /** * Create a plugin to set the value of the `__SENTRY_BROWSER_BUNDLE__` magic string. * + * It also sets `__SENTRY_EXCLUDE_TRANSACTIONS__` to `false`. CDN bundles always keep transaction support, so the + * unresolved runtime checks for that flag would only add bytes. + * * @param isBrowserBuild Whether or not the resulting build will be run in the browser * @returns An instance of the `replace` plugin to do the replacement of the magic string with `true` or 'false` */ @@ -94,6 +97,7 @@ export function makeBrowserBuildPlugin(isBrowserBuild) { preventAssignment: true, values: { __SENTRY_BROWSER_BUNDLE__: isBrowserBuild, + __SENTRY_EXCLUDE_TRANSACTIONS__: false, }, }); plugin.name = 'replace-browser-bundle-flag'; From c0ce932c5113953e17409eb97449bec9a50320ca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:56:28 +0000 Subject: [PATCH 4/5] ref(core): Rename flag to `__SENTRY_EXCLUDE_STATIC_LIFECYCLE__` The flag removes `traceLifecycle: 'static'`, not transactions as a concept, and the new name matches `__SENTRY_EXCLUDE_REPLAY_WORKER__`. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .size-limit.js | 4 ++-- dev-packages/rollup-utils/plugins/bundlePlugins.mjs | 4 ++-- packages/browser-utils/src/web-vitals/tracking.ts | 4 ++-- .../test/web-vitals/tracking-fp-fcp.test.ts | 4 ++-- packages/cloudflare/src/client.ts | 4 ++-- packages/cloudflare/test/sdk.test.ts | 4 ++-- packages/core/src/client.ts | 10 +++++----- packages/core/src/server-runtime-client.ts | 4 ++-- packages/core/src/tracing/sentrySpan.ts | 8 ++++---- ...sactions.test.ts => excludeStaticLifecycle.test.ts} | 4 ++-- 10 files changed, 25 insertions(+), 25 deletions(-) rename packages/core/test/lib/tracing/{excludeTransactions.test.ts => excludeStaticLifecycle.test.ts} (95%) diff --git a/.size-limit.js b/.size-limit.js index f01edf461f3a..c8895c7db133 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -65,7 +65,7 @@ module.exports = [ disablePlugins: ['@size-limit/esbuild'], }, { - name: '@sentry/browser (incl. Tracing) - with treeshaking flags excluding transactions', + name: '@sentry/browser (incl. Tracing) - with treeshaking flags excluding static lifecycle', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, @@ -76,7 +76,7 @@ module.exports = [ config.plugins.push( new webpack.DefinePlugin({ __SENTRY_DEBUG__: false, - __SENTRY_EXCLUDE_TRANSACTIONS__: true, + __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: true, }), ); diff --git a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index 5350627479bc..ea7cf300cc65 100644 --- a/dev-packages/rollup-utils/plugins/bundlePlugins.mjs +++ b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs @@ -85,7 +85,7 @@ export function makeSetSDKSourcePlugin(sdkSource) { /** * Create a plugin to set the value of the `__SENTRY_BROWSER_BUNDLE__` magic string. * - * It also sets `__SENTRY_EXCLUDE_TRANSACTIONS__` to `false`. CDN bundles always keep transaction support, so the + * It also sets `__SENTRY_EXCLUDE_STATIC_LIFECYCLE__` to `false`. CDN bundles always keep the static lifecycle, so the * unresolved runtime checks for that flag would only add bytes. * * @param isBrowserBuild Whether or not the resulting build will be run in the browser @@ -97,7 +97,7 @@ export function makeBrowserBuildPlugin(isBrowserBuild) { preventAssignment: true, values: { __SENTRY_BROWSER_BUNDLE__: isBrowserBuild, - __SENTRY_EXCLUDE_TRANSACTIONS__: false, + __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: false, }, }); plugin.name = 'replace-browser-bundle-flag'; diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts index 78b3dc29359d..c825ff79ec52 100644 --- a/packages/browser-utils/src/web-vitals/tracking.ts +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -30,7 +30,7 @@ import { isValidLcpMetric } from './lcp'; import { getActivationStart, getNavigationEntry, getVisibilityWatcher } from './utils'; // Treeshakable guard to always stream spans and drop the code paths that build and process transactions -declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; +declare const __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: boolean | undefined; let _measurements: Measurements = {}; let _lcpEntry: LargestContentfulPaint | undefined; @@ -183,7 +183,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption const { recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; const spanStreamingEnabled = - (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || options.spanStreamingEnabled; const timeOrigin = msToSec(origin); diff --git a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts index b75dd153bfd9..04dc96c054da 100644 --- a/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts +++ b/packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts @@ -94,8 +94,8 @@ describe('startTrackingWebVitals', () => { expect(spanToJSON(pageloadSpan).attributes['browser.navigation.type']).toBe('navigate'); }); - it('reports web vitals as span attributes when transactions are excluded from the bundle', () => { - vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + it('reports web vitals as span attributes when the static lifecycle is excluded from the bundle', () => { + vi.stubGlobal('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', true); vi.stubGlobal('addEventListener', vi.fn()); vi.stubGlobal('performance', { timeOrigin: realPerformance.timeOrigin, diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 0b9c14a9d6fb..de21f2d3a5e0 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -17,7 +17,7 @@ import type { CloudflareTransportOptions } from './transport'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; declare const __SENTRY_TRACING__: boolean; -declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; +declare const __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: boolean | undefined; const SPAN_STREAMING_INTEGRATION_NAME = 'SpanStreaming'; @@ -70,7 +70,7 @@ export class CloudflareClient extends ServerRuntimeClient { // flush and the eager drain already deliver streamed spans here. if ( (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) && - ((typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + ((typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || clientOptions.traceLifecycle !== 'static') && !clientOptions.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME) ) { diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 2652bd84dd04..4d3f10ce944d 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -99,8 +99,8 @@ describe('init', () => { } }); - test('does not leave a timer pending when transactions are excluded from the bundle', () => { - vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + test('does not leave a timer pending when the static lifecycle is excluded from the bundle', () => { + vi.stubGlobal('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', true); vi.useFakeTimers(); try { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 0, traceLifecycle: 'static' }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 4056cd2c9086..c743b4678673 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -60,7 +60,7 @@ import { resolveDataCollectionOptions } from './utils/data-collection/resolveDat import { applyBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; // Treeshakable guard to always stream spans and drop the code paths that build and process transactions -declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; +declare const __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: boolean | undefined; const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; @@ -264,13 +264,13 @@ export abstract class Client { protected constructor(options: O) { // Any value other than `'static'` normalizes to the `'stream'` default, so that `traceLifecycle` // is always one of the two known values for the rest of the SDK. - // With transactions excluded from the bundle, the SDK can only stream spans. + // With the static lifecycle excluded from the bundle, the SDK can only stream spans. this._options = { attachStacktrace: true, ...options, traceLifecycle: options.traceLifecycle === 'static' && - !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) + !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) ? 'static' : 'stream', }; @@ -616,7 +616,7 @@ export abstract class Client { // Extract gen_ai spans from transaction and convert to span v2 format. // This mutates event.spans to remove the extracted spans. const genAiSpanItem = - typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__ + typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ ? undefined : extractGenAiSpansFromEvent(event, this); @@ -1810,7 +1810,7 @@ function processBeforeSend( } if ( - !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) && + !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) && isTransactionEvent(processedEvent) ) { // Avoid processing if we don't have to diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index f8167632aa13..db193722f66e 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -33,7 +33,7 @@ export interface ServerRuntimeClientOptions extends ClientOptions i.name === SPAN_STREAMING_INTEGRATION_NAME) ) { diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 48bbada0b15d..72e42aa4fd90 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -58,7 +58,7 @@ import { getCapturedScopesOnSpan, spanIsTracerProviderSpan } from './utils'; const MAX_SPAN_COUNT = 1000; // Treeshakable guard to always stream spans and drop the code paths that build and process transactions -declare const __SENTRY_EXCLUDE_TRANSACTIONS__: boolean | undefined; +declare const __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: boolean | undefined; /** * Span contains all data about a span @@ -414,7 +414,7 @@ export class SentrySpan implements Span { } if ( - (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) || + (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || (client && hasSpanStreamingEnabled(client)) ) { client?.emit('afterSegmentSpanEnd', this); @@ -440,7 +440,7 @@ export class SentrySpan implements Span { */ private _convertSpanToTransaction(options: SegmentSpanCaptureConvertOptions = {}): TransactionEvent | undefined { // Class methods can't be tree-shaken, so we empty the body instead - if (typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) { + if (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) { return undefined; } @@ -591,7 +591,7 @@ function sendStandaloneSpan(span: SentrySpan, client: Client): void { // gen_ai extraction path. // TODO(standalone): remove this branch once the static trace lifecycle is dropped. if ( - !(typeof __SENTRY_EXCLUDE_TRANSACTIONS__ === 'boolean' && __SENTRY_EXCLUDE_TRANSACTIONS__) && + !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) && traceLifecycle === 'static' && isStaticBeforeSendSpanCallback(beforeSendSpan) ) { diff --git a/packages/core/test/lib/tracing/excludeTransactions.test.ts b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts similarity index 95% rename from packages/core/test/lib/tracing/excludeTransactions.test.ts rename to packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts index 3e974c3ce787..9ce02b8546ba 100644 --- a/packages/core/test/lib/tracing/excludeTransactions.test.ts +++ b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts @@ -7,9 +7,9 @@ import { createTransport } from '../../../src/transports/base'; import type { TransactionEvent } from '../../../src/types/event'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; -describe('__SENTRY_EXCLUDE_TRANSACTIONS__', () => { +describe('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', () => { beforeEach(() => { - vi.stubGlobal('__SENTRY_EXCLUDE_TRANSACTIONS__', true); + vi.stubGlobal('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', true); }); afterEach(() => { From d83094c2247928291963e940a1d2ac50741329dd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:35:22 +0000 Subject: [PATCH 5/5] fix(core): Keep `beforeSendTransaction` and warn about ignored static lifecycle With `__SENTRY_EXCLUDE_STATIC_LIFECYCLE__`, manually captured transaction events skipped `beforeSendTransaction`, so a PII scrubber could stop running. Only the span processing is skipped now. Setting `traceLifecycle: 'static'` under the flag also logs a non-debug warning, because the static callbacks would otherwise stop running silently. --- .../browser-utils/src/web-vitals/tracking.ts | 1 + packages/core/src/client.ts | 37 ++++++++++++++----- packages/core/src/tracing/sentrySpan.ts | 2 + .../tracing/excludeStaticLifecycle.test.ts | 21 +++++++++-- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts index c825ff79ec52..40f1dbcfbfd0 100644 --- a/packages/browser-utils/src/web-vitals/tracking.ts +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -182,6 +182,7 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption } const { recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + // Redundant at runtime (the constructor forces 'stream'); lets bundlers drop the static branch. const spanStreamingEnabled = (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || options.spanStreamingEnabled; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c743b4678673..f974b44861ff 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -269,11 +269,26 @@ export abstract class Client { attachStacktrace: true, ...options, traceLifecycle: - options.traceLifecycle === 'static' && - !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) - ? 'static' - : 'stream', + (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || + options.traceLifecycle !== 'static' + ? 'stream' + : 'static', }; + + // Not debug-gated: builds that set the flag usually strip debug logging too, and the user's + // static callbacks (often PII scrubbers) would otherwise stop running without any message. + if ( + typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && + __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ && + options.traceLifecycle === 'static' + ) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + "[Sentry] `traceLifecycle: 'static'` is not available in this build (`__SENTRY_EXCLUDE_STATIC_LIFECYCLE__`). `beforeSendTransaction`, `ignoreTransactions` and `withStaticSpan` callbacks are ignored.", + ); + }); + } this._integrations = {}; this._numProcessing = 0; this._outcomes = {}; @@ -615,6 +630,7 @@ export abstract class Client { // Extract gen_ai spans from transaction and convert to span v2 format. // This mutates event.spans to remove the extracted spans. + // Manually captured transaction events keep their gen_ai spans inline under the flag. const genAiSpanItem = typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ ? undefined @@ -1809,12 +1825,13 @@ function processBeforeSend( ); } - if ( - !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) && - isTransactionEvent(processedEvent) - ) { - // Avoid processing if we don't have to - if (beforeSendSpan || ignoreSpans) { + if (isTransactionEvent(processedEvent)) { + // Only manually captured transaction events get here under the flag. They skip span processing, + // but still run `beforeSendTransaction`, which users often rely on to scrub PII. + if ( + !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) && + (beforeSendSpan || ignoreSpans) + ) { // 1. Process root span const rootSpanJson = convertTransactionEventToSpanJson(processedEvent); diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 72e42aa4fd90..60611d2029b7 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -413,6 +413,7 @@ export class SentrySpan implements Span { return; } + // Redundant at runtime (the constructor forces 'stream'); lets bundlers drop the static branch. if ( (typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || (client && hasSpanStreamingEnabled(client)) @@ -590,6 +591,7 @@ function sendStandaloneSpan(span: SentrySpan, client: Client): void { // callbacks), so scrub the span in its native v1 shape and convert it forward to v2, mirroring the // gen_ai extraction path. // TODO(standalone): remove this branch once the static trace lifecycle is dropped. + // Redundant at runtime (the constructor forces 'stream'); lets bundlers drop the static branch. if ( !(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) && traceLifecycle === 'static' && diff --git a/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts index 9ce02b8546ba..0f378841205d 100644 --- a/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts +++ b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts @@ -17,10 +17,21 @@ describe('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', () => { vi.restoreAllMocks(); }); - it('ignores `traceLifecycle: "static"`', () => { + it('ignores `traceLifecycle: "static"` and warns about it', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'static' })); expect(client.getOptions().traceLifecycle).toBe('stream'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("`traceLifecycle: 'static'` is not available")); + }); + + it('does not warn with `traceLifecycle: "stream"`', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' })); + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("`traceLifecycle: 'static'` is not available")); }); it('hands segment spans to span streaming instead of capturing a transaction', () => { @@ -37,17 +48,19 @@ describe('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', () => { expect(captureEventSpy).not.toHaveBeenCalled(); }); - it('does not run transaction processing on transaction events', async () => { - const beforeSendTransaction = vi.fn(event => event); + it('still runs `beforeSendTransaction` on manually captured transaction events', async () => { + const beforeSendTransaction = vi.fn(event => ({ ...event, transaction: 'scrubbed' })); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', beforeSendTransaction }), ); + const sendEventSpy = vi.spyOn(client, 'sendEvent'); const event: TransactionEvent = { type: 'transaction', transaction: 'test', spans: [] }; client.captureEvent(event); await client.flush(); - expect(beforeSendTransaction).not.toHaveBeenCalled(); + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(sendEventSpy).toHaveBeenCalledWith(expect.objectContaining({ transaction: 'scrubbed' }), expect.anything()); }); it('adds the span streaming integration to server clients with `traceLifecycle: "static"`', () => {