diff --git a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts index 257934434358..5c520e975524 100644 --- a/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts +++ b/dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/pageloadTransaction/test.ts @@ -1,27 +1,21 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; -import { - envelopeRequestParser, - shouldSkipTracingTest, - waitForTransactionRequestOnUrl, -} from '../../../../utils/helpers'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; -sentryTest('should create a pageload transaction', async ({ getLocalTestUrl, page }) => { +sentryTest('should create a pageload span', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } + const spanPromise = waitForStreamedSpan(page, s => getSpanOp(s) === 'pageload'); const url = await getLocalTestUrl({ testDir: __dirname }); - const req = await waitForTransactionRequestOnUrl(page, url); + await page.goto(url); + const pageloadSpan = await spanPromise; - const eventData = envelopeRequestParser(req); const timeOrigin = await page.evaluate('window._testBaseTimestamp'); - const { start_timestamp: startTimestamp } = eventData; + expect(pageloadSpan.start_timestamp).toBeCloseTo(timeOrigin, 1); - expect(startTimestamp).toBeCloseTo(timeOrigin, 1); - - expect(eventData.contexts?.trace?.op).toBe('pageload'); - expect(eventData.spans?.length).toBeGreaterThan(0); - expect(eventData.transaction_info?.source).toEqual('url'); + expect(pageloadSpan.attributes?.['sentry.segment.name.source'].value).toEqual('url'); }); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc index 9dbebf825355..135d0dbcd544 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/wrangler.jsonc @@ -9,6 +9,7 @@ "SENTRY_ENVIRONMENT": "qa", "SENTRY_TRACES_SAMPLE_RATE": "1.0", "SENTRY_TUNNEL": "http://localhost:3031/", + "SENTRY_TRACE_LIFECYCLE": "static", }, "assets": { "binding": "ASSETS", diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index 4b4a54ef24f1..7171e66872c0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -77,6 +77,7 @@ export class LocalLambdaStack extends Stack { Variables: { SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, + SENTRY_TRACE_LIFECYCLE: 'static', NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, // We only set SENTRY_DSN if not running TunnelNoDsn, because there // we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing. diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index cb3f4db7d0ec..9375ac6f0835 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -112,6 +112,7 @@ export class LocalLambdaStack extends Stack { SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, + SENTRY_TRACE_LIFECYCLE: 'static', NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, }, }, diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts index 6c1a647cb1fb..e8ed1a0de537 100644 --- a/packages/browser/src/integrations/spanstreaming.ts +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -14,16 +14,6 @@ export const spanStreamingIntegration = defineIntegration(() => { return { name: 'SpanStreaming' as const, - beforeSetup(client) { - // If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them. - // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK. - const clientOptions = client.getOptions(); - if (!clientOptions.traceLifecycle) { - DEBUG_BUILD && debug.log('[SpanStreaming] setting `traceLifecycle` to "stream"'); - clientOptions.traceLifecycle = 'stream'; - } - }, - setup(client) { const initialMessage = 'SpanStreaming integration requires'; const fallbackMsg = 'Falling back to static trace lifecycle.'; diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index f9ee4938ace1..d3bf3c960d6e 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -19,6 +19,7 @@ import { globalHandlersIntegration } from './integrations/globalhandlers'; import { httpContextIntegration } from './integrations/httpcontext'; import { linkedErrorsIntegration } from './integrations/linkederrors'; import { spotlightBrowserIntegration } from './integrations/spotlight'; +import { spanStreamingIntegration } from './integrations/spanstreaming'; import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; @@ -110,14 +111,22 @@ export function init(options: BrowserOptions = {}): Client | undefined { } /*! rollup-include-development-only-end */ + const integrations = getIntegrationsToSetup({ + integrations: options.integrations, + defaultIntegrations, + }); + + options.traceLifecycle ??= 'stream'; + + if (options.traceLifecycle === 'stream' && !integrations.some(integration => integration.name === 'SpanStreaming')) { + integrations.push(spanStreamingIntegration()); + } + const clientOptions: BrowserClientOptions = { ...options, enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), - integrations: getIntegrationsToSetup({ - integrations: options.integrations, - defaultIntegrations, - }), + integrations, transport: options.transport || makeFetchTransport, }; diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts index dd5268dab4ec..db4209a823e8 100644 --- a/packages/browser/test/integrations/spanstreaming.test.ts +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -37,24 +37,9 @@ describe('spanStreamingIntegration', () => { const integration = spanStreamingIntegration(); expect(integration.name).toBe('SpanStreaming'); // eslint-disable-next-line @typescript-eslint/unbound-method - expect(integration.beforeSetup).toBeDefined(); - // eslint-disable-next-line @typescript-eslint/unbound-method expect(integration.setup).toBeDefined(); }); - it('sets traceLifecycle to "stream" if not set', () => { - const client = new BrowserClient({ - ...getDefaultBrowserClientOptions(), - dsn: 'https://username@domain/123', - integrations: [spanStreamingIntegration()], - }); - - SentryCore.setCurrentClient(client); - client.init(); - - expect(client.getOptions().traceLifecycle).toBe('stream'); - }); - it.each(['static', 'somethingElse'])( 'logs a warning if traceLifecycle is not set to "stream" but to %s', traceLifecycle => { diff --git a/packages/browser/test/sdk.test.ts b/packages/browser/test/sdk.test.ts index 3d84da69e565..744025bc1b1d 100644 --- a/packages/browser/test/sdk.test.ts +++ b/packages/browser/test/sdk.test.ts @@ -61,6 +61,32 @@ describe('init', () => { expect(optionsPassed?.integrations.length).toBeGreaterThan(0); }); + it('installs spanStreamingIntegration by default', () => { + // @ts-expect-error this is fine for testing + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {}); + const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, defaultIntegrations: undefined }); + + init(options); + + const optionsPassed = initAndBindSpy.mock.calls[0]?.[1]; + expect(optionsPassed?.integrations.some(integration => integration.name === 'SpanStreaming')).toBe(true); + }); + + it('does not install spanStreamingIntegration when traceLifecycle is static', () => { + // @ts-expect-error this is fine for testing + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {}); + const options = getDefaultBrowserOptions({ + dsn: PUBLIC_DSN, + defaultIntegrations: undefined, + traceLifecycle: 'static', + }); + + init(options); + + const optionsPassed = initAndBindSpy.mock.calls[0]?.[1]; + expect(optionsPassed?.integrations.some(integration => integration.name === 'SpanStreaming')).toBe(false); + }); + test("doesn't install default integrations if told not to", () => { const DEFAULT_INTEGRATIONS: Integration[] = [ new MockIntegration('MockIntegration 0.3'), @@ -73,6 +99,17 @@ describe('init', () => { expect(DEFAULT_INTEGRATIONS[1]!.setupOnce as Mock).toHaveBeenCalledTimes(0); }); + it('installs spanStreamingIntegration with defaultIntegrations disabled', () => { + // @ts-expect-error this is fine for testing + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {}); + const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, defaultIntegrations: false }); + + init(options); + + const optionsPassed = initAndBindSpy.mock.calls[0]?.[1]; + expect(optionsPassed?.integrations.some(integration => integration.name === 'SpanStreaming')).toBe(true); + }); + it('installs merged default integrations, with overrides provided through options', () => { const DEFAULT_INTEGRATIONS = [ new MockIntegration('MockIntegration 1.1'), diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts index afeedda2f87c..5615ac3e8f6f 100644 --- a/packages/bundler-plugins/src/core/sentry/telemetry.ts +++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts @@ -27,6 +27,7 @@ export function createSentryInstance( dsn: 'https://4c2bae7d9fbc413e8f7385f55c515d51@o1.ingest.sentry.io/6690737', tracesSampleRate: 1, + traceLifecycle: 'static', sampleRate: 1, release: LIB_VERSION, diff --git a/packages/cloudflare/src/options.ts b/packages/cloudflare/src/options.ts index 947d6d921892..a90a236e4bf1 100644 --- a/packages/cloudflare/src/options.ts +++ b/packages/cloudflare/src/options.ts @@ -70,6 +70,7 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow tracesSampleRate: isFinite(tracesSampleRate) ? tracesSampleRate : undefined, debug: userOptions.debug ?? envToBool(getEnvVar(env, 'SENTRY_DEBUG')), tunnel: userOptions.tunnel ?? getEnvVar(env, 'SENTRY_TUNNEL'), + traceLifecycle: userOptions.traceLifecycle ?? getTraceLifecycleFromEnv(getEnvVar(env, 'SENTRY_TRACE_LIFECYCLE')), /*! rollup-include-development-only */ spotlight, /*! rollup-include-development-only-end */ @@ -102,3 +103,7 @@ function getSpotlightFromEnv( ? (envUrl ?? true) // true: use env URL if present, otherwise true : (envBool ?? envUrl); // undefined: use env var (bool or URL) } + +function getTraceLifecycleFromEnv(envVar: string | undefined): 'static' | 'stream' | undefined { + return envVar === 'stream' || envVar === 'static' ? envVar : undefined; +} diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 09efdd96f3d6..643c9f4462fb 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -22,10 +22,9 @@ describe('init', () => { expect(client).toBeInstanceOf(CloudflareClient); }); - test('installs SpanStreaming integration when traceLifecycle is "stream"', () => { + test('installs SpanStreaming integration by default', () => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - traceLifecycle: 'stream', }); const client = getClient(); @@ -36,8 +35,8 @@ describe('init', () => { ); }); - test("does not install SpanStreaming integration when traceLifecycle is not 'stream'", () => { - init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + test("does not install SpanStreaming integration when traceLifecycle is 'static'", () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', traceLifecycle: 'static' }); const client = getClient(); expect(client?.getOptions()).toEqual( diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8171a53c02c4..6a11959e9983 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -234,7 +234,7 @@ export abstract class Client { * @param options Options for the client. */ protected constructor(options: O) { - this._options = { attachStacktrace: true, ...options }; + this._options = { attachStacktrace: true, traceLifecycle: 'stream', ...options }; this._integrations = {}; this._numProcessing = 0; this._outcomes = {}; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 9b1c2374e50a..ec10c516ef9b 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,6 +40,8 @@ export class ServerRuntimeClient< public constructor(options: O) { addUserAgentToTransportHeaders(options); + options.traceLifecycle ??= 'stream'; + // When span streaming is enabled (`traceLifecycle: 'stream'`), the `spanStreamingIntegration` // is required to flush spans. We add it here so the individual server SDKs don't have to. // A user-provided `spanStreamingIntegration` always takes precedence over the one we add. diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index b96c3159dad6..4aa93e6ac12e 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -543,7 +543,7 @@ export interface ClientOptions { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, test: true }); const client = new TestClient(options); - expect(client.getOptions()).toEqual({ attachStacktrace: true, ...options }); + expect(client.getOptions()).toEqual({ attachStacktrace: true, traceLifecycle: 'stream', ...options }); + }); + + test('defaults traceLifecycle to stream', () => { + const options = getDefaultTestClientOptions(); + delete options.traceLifecycle; + const client = new TestClient(options); + + expect(client.getOptions().traceLifecycle).toBe('stream'); + }); + + test('preserves an explicit static traceLifecycle', () => { + const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'static' })); + + expect(client.getOptions().traceLifecycle).toBe('static'); }); }); diff --git a/packages/deno/test/sdk.test.ts b/packages/deno/test/sdk.test.ts index eac8f4d7997e..5d0d8973a075 100644 --- a/packages/deno/test/sdk.test.ts +++ b/packages/deno/test/sdk.test.ts @@ -8,8 +8,8 @@ Deno.test('init() should return client', () => { assertNotEquals(init({}), undefined); }); -Deno.test('adds spanStreamingIntegration when traceLifecycle is "stream"', () => { - const client = init({ traceLifecycle: 'stream' }); +Deno.test('adds spanStreamingIntegration by default', () => { + const client = init({}); const integrations = client.getOptions().integrations; assertArrayIncludes( integrations.map(i => i.name), @@ -17,8 +17,8 @@ Deno.test('adds spanStreamingIntegration when traceLifecycle is "stream"', () => ); }); -Deno.test('doesn\'t add spanStreamingIntegration when traceLifecycle is not "stream"', () => { - const client = init({}); +Deno.test('doesn\'t add spanStreamingIntegration when traceLifecycle is "static"', () => { + const client = init({ traceLifecycle: 'static' }); const integrations = client.getOptions().integrations; assert(!integrations.some(i => i.name === 'SpanStreaming')); }); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index c519b4408269..fe5df56cd5c1 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -300,6 +300,7 @@ function getClientOptions( const spotlight = getSpotlightConfig(options.spotlight); const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); + const traceLifecycle = getTraceLifecycle(options.traceLifecycle); const mergedOptions = { ...options, @@ -311,6 +312,7 @@ function getClientOptions( release, tracesSampleRate, spotlight, + traceLifecycle, debug: envToBool(options.debug ?? process.env.SENTRY_DEBUG), }; @@ -355,6 +357,20 @@ function getTracesSampleRate(tracesSampleRate: NodeOptions['tracesSampleRate']): return isFinite(parsed) ? parsed : undefined; } +function getTraceLifecycle(traceLifecycle: NodeOptions['traceLifecycle']): 'stream' | 'static' { + if (traceLifecycle !== undefined) { + return traceLifecycle; + } + + const lifecycleFromEnv = process.env.SENTRY_TRACE_LIFECYCLE; + + if (lifecycleFromEnv === 'stream' || lifecycleFromEnv === 'static') { + return lifecycleFromEnv; + } + + return 'stream'; +} + /** * Update scope and propagation context based on environmental variables. * diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index c4b0550098fa..c6c845ac4eba 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -59,16 +59,12 @@ describe('init()', () => { }); describe('integrations', () => { - it("doesn't install default integrations if told not to", () => { + it('only installs the required spanStreaming integration if default integrations are disabled', () => { init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); const client = getClient(); - expect(client?.getOptions()).toEqual( - expect.objectContaining({ - integrations: [], - }), - ); + expect(client?.getOptions().integrations.map(integration => integration.name)).toEqual(['SpanStreaming']); expect(mockAutoPerformanceIntegrations).toHaveBeenCalledTimes(0); }); @@ -169,8 +165,8 @@ describe('init()', () => { ); }); - it('installs spanStreaming integration when traceLifecycle is "stream"', () => { - init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' }); + it('installs spanStreaming integration by default', () => { + init({ dsn: PUBLIC_DSN }); const client = getClient(); expect(client?.getOptions()).toEqual( @@ -180,8 +176,8 @@ describe('init()', () => { ); }); - it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => { - init({ dsn: PUBLIC_DSN }); + it("doesn't install spanStreaming integration when traceLifecycle is 'static'", () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'static' }); const client = getClient(); expect(client?.getOptions()).toEqual( @@ -192,7 +188,7 @@ describe('init()', () => { }); it('installs spanStreaming integration even with custom defaultIntegrations', () => { - init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] }); + init({ dsn: PUBLIC_DSN, defaultIntegrations: [] }); const client = getClient(); expect(client?.getOptions()).toEqual( diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 43bdecdf3931..a845a9d77886 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -89,6 +89,8 @@ export function init(options: VercelEdgeOptions = {}): Client { options.environment = options.environment || process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV; + options.traceLifecycle = options.traceLifecycle ?? getTraceLifecycleFromEnv(process.env.SENTRY_TRACE_LIFECYCLE); + const client = new VercelEdgeClient({ ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), @@ -258,3 +260,7 @@ export function getSentryRelease(fallback?: string): string | undefined { fallback ); } + +function getTraceLifecycleFromEnv(envVar: string | undefined): 'static' | 'stream' | undefined { + return envVar === 'stream' || envVar === 'static' ? envVar : undefined; +} diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts index d5a67edff718..0da12197a5f4 100644 --- a/packages/vercel-edge/test/sdk.test.ts +++ b/packages/vercel-edge/test/sdk.test.ts @@ -3,22 +3,22 @@ import { init, spanStreamingIntegration } from '../src'; import type { Integration } from '@sentry/core'; describe('init', () => { - it('adds spanStreamingIntegration when traceLifecycle is "stream"', () => { - const client = init({ traceLifecycle: 'stream' }); + it('adds spanStreamingIntegration by default', () => { + const client = init({}); const integrations = client?.getOptions().integrations; expect(integrations?.map(i => i.name)).toContain('SpanStreaming'); }); - it('doesn\'t add spanStreamingIntegration when traceLifecycle is not "stream"', () => { - const client = init({}); + it('doesn\'t add spanStreamingIntegration when traceLifecycle is "static"', () => { + const client = init({ traceLifecycle: 'static' }); const integrations = client?.getOptions().integrations; expect(integrations?.map(i => i.name)).not.toContain('SpanStreaming'); }); it('adds spanStreaming integration even with custom defaultIntegrations', () => { - const client = init({ traceLifecycle: 'stream', defaultIntegrations: [] }); + const client = init({ defaultIntegrations: [] }); const integrations = client?.getOptions().integrations; expect(integrations?.map(i => i.name)).toContain('SpanStreaming');