diff --git a/packages/browser-utils/src/ensureBrowserSpanStreaming.ts b/packages/browser-utils/src/ensureBrowserSpanStreaming.ts new file mode 100644 index 000000000000..21c53530d459 --- /dev/null +++ b/packages/browser-utils/src/ensureBrowserSpanStreaming.ts @@ -0,0 +1,29 @@ +import type { Client } from '@sentry/core'; +import { getClient, hasSpanStreamingEnabled, spanStreamingIntegration } from '@sentry/core'; + +/** + * The span streaming integration is only reachable from code that can actually start a span. + * `@sentry/browser`'s `init()` deliberately doesn't reference it, so error-only apps tree-shake the + * entire span streaming graph away without needing the `__SENTRY_TRACING__` flag. The guarded browser + * span-start APIs call this before starting a span. + */ + +const clientsWithIntegration = new WeakSet(); + +/** + * Lazily install the browser span streaming integration. + * + * Defaults to the current client; pass one explicitly from integration hooks, where the client being + * set up isn't necessarily the current one. + * + */ +export function ensureBrowserSpanStreaming(client: Client | undefined = getClient()): void { + // The `WeakSet` is an allocation optimization, not a semantic gate — `addIntegration()` is already + // idempotent by integration name, including against a user-supplied instance. + if (!client || clientsWithIntegration.has(client) || !hasSpanStreamingEnabled(client)) { + return; + } + + clientsWithIntegration.add(client); + client.addIntegration(spanStreamingIntegration()); +} diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 5e20876bcffc..ed8482931031 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -9,6 +9,8 @@ export { export { addPerformanceEntries, startTrackingLongTasks, startTrackingLongAnimationFrames } from './performance/entries'; +export { startSpan, startInactiveSpan, startSpanManual } from './spanApi'; + export { addWebVitalsToSpan, // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts index 067b549a707f..35302f7d3fad 100644 --- a/packages/browser-utils/src/performance/interactions.ts +++ b/packages/browser-utils/src/performance/interactions.ts @@ -18,6 +18,7 @@ import { } from '@sentry/core'; import { startIdleSpan } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build'; +import { ensureBrowserSpanStreaming } from '../ensureBrowserSpanStreaming'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { addPerformanceInstrumentationHandler } from '../instrumentation/performanceObserver'; import { isBotUserAgent } from '../isBotUserAgent'; @@ -66,6 +67,10 @@ const _interactionsIntegration = ((options: InteractionsOptions = {}) => { return; } + // Interaction spans are started through `startIdleSpan`, which - unlike the guarded `startSpan` + // APIs - does not install span streaming itself, so we ensure it here. + ensureBrowserSpanStreaming(client); + const latestRoute: RouteInfo = { name: undefined, source: undefined }; // The pageload/navigation span that is currently in progress, if any. Clicks that happen while one // is open are not turned into interaction spans, as they'd compete with the route span for children. diff --git a/packages/browser-utils/src/performance/utils.ts b/packages/browser-utils/src/performance/utils.ts index a9a9759e7ed5..d371a04e9b4e 100644 --- a/packages/browser-utils/src/performance/utils.ts +++ b/packages/browser-utils/src/performance/utils.ts @@ -1,6 +1,6 @@ import type { SentrySpan, Span, SpanTimeInput, StartSpanOptions } from '@sentry/core'; import { spanToStaticSpanJSON, withActiveSpan } from '@sentry/core'; -import { startInactiveSpan } from '@sentry/core/browser'; +import { startInactiveSpan } from '../spanApi'; import { WINDOW } from '../types'; /** diff --git a/packages/core/src/tracing/browserSpanApi.ts b/packages/browser-utils/src/spanApi.ts similarity index 51% rename from packages/core/src/tracing/browserSpanApi.ts rename to packages/browser-utils/src/spanApi.ts index 202e5f859dea..cefee1c26826 100644 --- a/packages/core/src/tracing/browserSpanApi.ts +++ b/packages/browser-utils/src/spanApi.ts @@ -1,14 +1,14 @@ -import type { Client } from '../client'; -import { getClient } from '../currentScopes'; -import { spanStreamingIntegration } from '../integrations/spanStreaming'; -import type { Span } from '../types/span'; -import type { StartSpanOptions } from '../types/startSpanOptions'; -import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; +import type { Span, StartSpanOptions } from '@sentry/core'; +/* oxlint-disable sdk/no-unguarded-span-apis -- This module IS the guarded browser variant: each wrapper + installs `spanStreamingIntegration` via `ensureBrowserSpanStreaming` before delegating to the + plain core API, which is exactly what browser-facing code is meant to go through. */ import { startInactiveSpan as coreStartInactiveSpan, startSpan as coreStartSpan, startSpanManual as coreStartSpanManual, -} from './trace'; +} from '@sentry/core'; +/* oxlint-enable sdk/no-unguarded-span-apis */ +import { ensureBrowserSpanStreaming } from './ensureBrowserSpanStreaming'; /** * Browser variants of the span-start APIs. @@ -19,34 +19,13 @@ import { * the `__SENTRY_TRACING__` flag. */ -const clientsWithIntegration = new WeakSet(); - -/** - * Lazily install the browser span streaming integration. - * - * Defaults to the current client; pass one explicitly from integration hooks, where the client being - * set up isn't necessarily the current one. - * - * @internal - */ -export function _INTERNAL_ensureBrowserSpanStreaming(client: Client | undefined = getClient()): void { - // The `WeakSet` is an allocation optimization, not a semantic gate — `addIntegration()` is already - // idempotent by integration name, including against a user-supplied instance. - if (!client || clientsWithIntegration.has(client) || !hasSpanStreamingEnabled(client)) { - return; - } - - clientsWithIntegration.add(client); - client.addIntegration(spanStreamingIntegration()); -} - /** * Wraps a function with a span and finishes the span after the function is done. * * See {@link startSpan} in `@sentry/core` for details. */ export function startSpan(options: StartSpanOptions, callback: (span: Span) => T): T { - _INTERNAL_ensureBrowserSpanStreaming(); + ensureBrowserSpanStreaming(); return coreStartSpan(options, callback); } @@ -56,7 +35,7 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = * See {@link startSpanManual} in `@sentry/core` for details. */ export function startSpanManual(options: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T { - _INTERNAL_ensureBrowserSpanStreaming(); + ensureBrowserSpanStreaming(); return coreStartSpanManual(options, callback); } @@ -66,6 +45,6 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S * See {@link startInactiveSpan} in `@sentry/core` for details. */ export function startInactiveSpan(options: StartSpanOptions): Span { - _INTERNAL_ensureBrowserSpanStreaming(); + ensureBrowserSpanStreaming(); return coreStartInactiveSpan(options); } diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 456a07701b3c..b6716fbf0089 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -13,7 +13,7 @@ import { spanToJSON, timestampInSeconds, } from '@sentry/core'; -import { startInactiveSpan } from '@sentry/core/browser'; +import { startInactiveSpan } from '../spanApi'; import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index 802add7e1c7b..9e585373f41c 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -1,5 +1,5 @@ import * as SentryCore from '@sentry/core'; -import * as SentryCoreBrowser from '@sentry/core/browser'; +import * as SpanApi from '../../src/spanApi'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import * as inpModule from '../../src/web-vitals/inp'; @@ -27,9 +27,9 @@ vi.mock('@sentry/core', async () => { }; }); -// `startInactiveSpan` comes from `@sentry/core/browser`, not the root entry - see `browserSpanApi.ts`. -vi.mock('@sentry/core/browser', async () => { - const actual = await vi.importActual('@sentry/core/browser'); +// `startInactiveSpan` is the guarded browser variant from `../../src/spanApi`, not the root entry. +vi.mock('../../src/spanApi', async () => { + const actual = await vi.importActual('../../src/spanApi'); return { ...actual, startInactiveSpan: vi.fn(), @@ -71,7 +71,7 @@ describe('_emitWebVitalSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); - vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SpanApi.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any); // A root span is its own root, which is what the web vital spans are parented to. vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span); @@ -92,7 +92,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.5, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith({ + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith({ name: 'Test Vital', attributes: { 'sentry.origin': 'auto.http.browser.lcp', @@ -107,7 +107,7 @@ describe('_emitWebVitalSpan', () => { }); // No standalone flag - expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).not.toHaveBeenCalledWith( expect.objectContaining({ experimental: expect.anything() }), ); @@ -129,7 +129,7 @@ describe('_emitWebVitalSpan', () => { parentSpan, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.segment.name': 'Pageload', @@ -150,7 +150,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ experimental: { standalone: true } }), ); }); @@ -170,7 +170,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.replay_id': 'replay-123', @@ -195,7 +195,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry._internal.replay_is_buffering': true }), }), @@ -216,7 +216,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.5, }); - const attributes = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0].attributes!; + const attributes = vi.mocked(SpanApi.startInactiveSpan).mock.calls[0]![0].attributes!; expect(attributes['sentry.replay_id']).toBeUndefined(); }); @@ -238,7 +238,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.pageload.span_id': 'abc123', @@ -264,7 +264,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.not.objectContaining({ 'sentry.pageload.span_id': expect.anything(), @@ -284,7 +284,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'browser.web_vital.cls.report_event': 'pagehide', @@ -304,7 +304,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'custom.attr': 'value', @@ -314,7 +314,7 @@ describe('_emitWebVitalSpan', () => { }); it('handles when startInactiveSpan returns undefined', () => { - vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(undefined as any); + vi.mocked(SpanApi.startInactiveSpan).mockReturnValue(undefined as any); expect(() => { _emitWebVitalSpan({ @@ -344,7 +344,7 @@ describe('_sendLcpSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); - vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SpanApi.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ // The web vital span takes its segment name off the pageload span it is parented to. name: 'test-route', @@ -371,7 +371,7 @@ describe('_sendLcpSpan', () => { _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide'); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: '', attributes: expect.objectContaining({ @@ -401,7 +401,7 @@ describe('_sendLcpSpan', () => { it('sends a streamed LCP span without entry data', () => { _sendLcpSpan(250, undefined); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Largest contentful paint', startTime: 1, // timeOrigin: 1000 / 1000 @@ -413,7 +413,7 @@ describe('_sendLcpSpan', () => { _sendLcpSpan(0, undefined); _sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined); - expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); + expect(SpanApi.startInactiveSpan).not.toHaveBeenCalled(); }); }); @@ -433,7 +433,7 @@ describe('_sendClsSpan', () => { vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); - vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SpanApi.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ // The web vital span takes its segment name off the pageload span it is parented to. name: 'test-route', @@ -471,7 +471,7 @@ describe('_sendClsSpan', () => { _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation'); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: '
', attributes: expect.objectContaining({ @@ -493,7 +493,7 @@ describe('_sendClsSpan', () => { _sendClsSpan(0, undefined); expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); - expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( + expect(SpanApi.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Layout shift', startTime: 1.5, @@ -517,7 +517,7 @@ describe('_sendInpSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(htmlTreeAsString).mockReturnValue('