diff --git a/.size-limit.js b/.size-limit.js index b841667d9d89..c8895c7db133 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 static lifecycle', + 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_STATIC_LIFECYCLE__: 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/dev-packages/rollup-utils/plugins/bundlePlugins.mjs b/dev-packages/rollup-utils/plugins/bundlePlugins.mjs index 23aab90f4edf..ea7cf300cc65 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_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 * @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_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 ee2907cf3e92..40f1dbcfbfd0 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_STATIC_LIFECYCLE__: boolean | undefined; + let _measurements: Measurements = {}; let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; @@ -178,7 +181,11 @@ export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOption return; } - const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; + 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; 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..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 @@ -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'; @@ -92,4 +93,27 @@ 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 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, + 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)); + // 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..de21f2d3a5e0 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_STATIC_LIFECYCLE__: 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_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) || + 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..4d3f10ce944d 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 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' }); + + 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 { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 54f0c2dae0d2..f974b44861ff 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_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'; @@ -261,11 +264,31 @@ 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 the static lifecycle excluded from the bundle, the SDK can only stream spans. this._options = { attachStacktrace: true, ...options, - traceLifecycle: options.traceLifecycle === 'static' ? 'static' : 'stream', + traceLifecycle: + (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 = {}; @@ -607,7 +630,11 @@ 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); + // 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 + : extractGenAiSpansFromEvent(event, this); let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); @@ -1799,8 +1826,12 @@ function processBeforeSend( } if (isTransactionEvent(processedEvent)) { - // Avoid processing if we don't have to - if (beforeSendSpan || ignoreSpans) { + // 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/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index ab9729f60732..db193722f66e 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..60611d2029b7 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_STATIC_LIFECYCLE__: boolean | undefined; + /** * Span contains all data about a span */ @@ -410,8 +413,12 @@ export class SentrySpan implements Span { return; } - if (client && hasSpanStreamingEnabled(client)) { - client.emit('afterSegmentSpanEnd', this); + // 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)) + ) { + client?.emit('afterSegmentSpanEnd', this); return; } @@ -433,6 +440,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_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) { + return undefined; + } + // We can only convert finished spans if (!isFullFinishedSpan(spanToStaticSpanJSON(this))) { return undefined; @@ -579,7 +591,12 @@ 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)) { + // 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' && + isStaticBeforeSendSpanCallback(beforeSendSpan) + ) { const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); const dsc = getDynamicSamplingContextFromSpan(span); // sendEnvelope should not throw diff --git a/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts new file mode 100644 index 000000000000..0f378841205d --- /dev/null +++ b/packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts @@ -0,0 +1,76 @@ +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_STATIC_LIFECYCLE__', () => { + beforeEach(() => { + vi.stubGlobal('__SENTRY_EXCLUDE_STATIC_LIFECYCLE__', true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + 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', () => { + 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('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).toHaveBeenCalledTimes(1); + expect(sendEventSpy).toHaveBeenCalledWith(expect.objectContaining({ transaction: 'scrubbed' }), expect.anything()); + }); + + 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'); + }); +});