Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions dev-packages/rollup-utils/plugins/bundlePlugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
*/
Expand All @@ -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';
Expand Down
9 changes: 8 additions & 1 deletion packages/browser-utils/src/web-vitals/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) ||
options.spanStreamingEnabled;
const timeOrigin = msToSec(origin);

// Measurements are only available for pageload transactions
Expand Down
24 changes: 24 additions & 0 deletions packages/browser-utils/test/web-vitals/tracking-fp-fcp.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
4 changes: 3 additions & 1 deletion packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 }));
Expand Down
15 changes: 15 additions & 0 deletions packages/cloudflare/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 35 additions & 4 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -261,11 +264,31 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user with traceLifecycle: 'static' and beforeSendSpan: Sentry.withStaticSpan(scrub) gets the lifecycle forced to 'stream'. Then captureSpan (packages/core/src/tracing/spans/captureSpan.ts line 84) skips the static callback, so scrub never runs. The only warning for this mismatch (packages/core/src/client.ts lines 296-308) is gated on DEBUG_BUILD, and people who set this flag will usually also set __SENTRY_DEBUG__: false (the new size-limit entry does). So the scrubber stops running with no message at all.

The other warning, maybeWarnAboutIgnoredTransactionOptions, does fire (it is not debug-gated), but it tells the user to "set traceLifecycle: 'static'", which cannot work under the flag.

Suggestion: when options.traceLifecycle === 'static' and the flag is set, emit one non-debug console.warn from the constructor (inside the flag-true branch, so it costs nothing in builds without the flag). Something like "traceLifecycle: 'static' is not available in this build (__SENTRY_EXCLUDE_STATIC_LIFECYCLE__). beforeSendTransaction, ignoreTransactions and withStaticSpan callbacks are ignored." Then the warning in packages/core/src/utils/warnAboutIgnoredTransactionOptions.ts does not need to change.

(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 = {};
Expand Down Expand Up @@ -607,7 +630,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {

// 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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low: gen_ai extraction is skipped for manual transaction events.

Same root as the comment on packages/core/src/client.ts line 1812, but smaller: a manually captured transaction event with gen_ai spans keeps them inline instead of having them split into a v2 span item. This is probably fine, but it is a behavior change for the same input. If we resolve the other comment by dropping transaction events under the flag, this goes away.

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

Expand Down Expand Up @@ -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);

Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/server-runtime-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportO

declare const __SENTRY_TRACING__: boolean;

// Treeshakable guard to always stream spans and drop the code paths that build and process transactions
declare const __SENTRY_EXCLUDE_STATIC_LIFECYCLE__: boolean | undefined;

/**
* The Sentry Server Runtime Client SDK.
*/
Expand All @@ -52,7 +55,8 @@ export class ServerRuntimeClient<
// A user-provided `spanStreamingIntegration` always takes precedence over the one we add.
if (
(typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) &&
options.traceLifecycle !== 'static' &&
((typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) ||
options.traceLifecycle !== 'static') &&
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
!options.integrations.some(i => i.name === SPAN_STREAMING_INTEGRATION_NAME)
) {
options.integrations.push(spanStreamingIntegration());
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 (
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
(typeof __SENTRY_EXCLUDE_STATIC_LIFECYCLE__ === 'boolean' && __SENTRY_EXCLUDE_STATIC_LIFECYCLE__) ||
(client && hasSpanStreamingEnabled(client))
) {
client?.emit('afterSegmentSpanEnd', this);
return;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 (
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
!(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
Expand Down
76 changes: 76 additions & 0 deletions packages/core/test/lib/tracing/excludeStaticLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
Loading