From a834536387e22c80ff2ac3f8ed980150efead1f1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Aug 2026 15:10:56 +0200 Subject: [PATCH 1/5] ref(browser): Tree-shake span streaming out of error-only bundles `init()` no longer pushes `spanStreamingIntegration()`. That single reference was the only thing retaining `SpanBuffer`, `captureSpan`, `estimateSize` and `scopeContextAttributes` in every browser bundle, including bundles for apps that never use tracing. Instead, browser-facing packages import their span-start APIs from `@sentry/core/browser`, which installs the integration on the client before the first span starts. A span can only end if it was started, so the `afterSpanEnd` listener is always registered in time. `init` alone drops from 28.52 kB to 26.95 kB gzipped, within 0.1 kB of the `__SENTRY_TRACING__: false` floor. Tracing users pay nothing extra. Both variants share their names, so a stray plain import compiles fine and silently stops sending spans. `sdk/no-unguarded-span-apis` is the only thing that catches that, hence it lands here rather than later. Co-Authored-By: Claude Opus 5 (1M context) --- .oxlintrc.base.json | 9 ++ .size-limit.js | 11 +- .../browser-utils/src/performance/utils.ts | 3 +- .../browser-utils/src/web-vitals/spans.ts | 2 +- .../test/web-vitals/spans.test.ts | 69 +++++---- .../integrations/fetchStreamPerformance.ts | 2 +- packages/browser/src/sdk.ts | 13 +- .../src/tracing/browserTracingIntegration.ts | 6 + packages/browser/test/sdk.test.ts | 36 ++--- .../test/tracing/spanStreamingWiring.test.ts | 114 +++++++++++++++ packages/effect/src/tracer.ts | 12 +- packages/effect/test/layer.test.ts | 8 +- packages/effect/test/tracer.test.ts | 13 +- packages/eslint-plugin-sdk/src/index.js | 1 + .../src/rules/no-unguarded-span-apis.js | 121 ++++++++++++++++ .../lib/rules/no-unguarded-span-apis.test.ts | 131 ++++++++++++++++++ packages/nextjs/src/client/index.ts | 6 +- packages/nextjs/src/index.types.ts | 6 + .../src/client/createClientInstrumentation.ts | 2 +- .../createClientInstrumentation.test.ts | 40 +++--- packages/svelte/src/performance.ts | 3 +- packages/sveltekit/src/client/load.ts | 2 +- packages/sveltekit/test/client/load.test.ts | 4 +- 23 files changed, 500 insertions(+), 114 deletions(-) create mode 100644 packages/browser/test/tracing/spanStreamingWiring.test.ts create mode 100644 packages/eslint-plugin-sdk/src/rules/no-unguarded-span-apis.js create mode 100644 packages/eslint-plugin-sdk/test/lib/rules/no-unguarded-span-apis.test.ts diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 7c9302092965..9db79820a5e8 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -68,6 +68,15 @@ "sdk/no-unfiltered-url-attributes": "error" } }, + { + // The rule itself decides which files are browser-facing (see `no-unguarded-span-apis.js`); + // oxlint resolves `files` globs relative to each package's own config, so scoping it to + // individual packages here is not possible. + "files": ["**/src/**/*.ts", "**/src/**/*.tsx", "**/addon/**/*.ts"], + "rules": { + "sdk/no-unguarded-span-apis": "error" + } + }, { "files": ["**/*.ts", "**/*.tsx", "**/*.d.ts"], "rules": { diff --git a/.size-limit.js b/.size-limit.js index 30422d018896..ad6e4daca85e 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -3,12 +3,15 @@ const nodePrefixedBuiltinModules = builtinModules.map(m => `node:${m}`); module.exports = [ // Browser SDK (ESM) + // The three `init`-only entries below are kept deliberately tight (< 1 KB headroom): an `init` + // that references `spanStreamingIntegration` again - directly or through a transitive import - + // costs ~1.6 KB here and would otherwise land unnoticed. { name: '@sentry/browser', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '36 KB', + limit: '29.5 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -16,7 +19,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '34 KB', + limit: '28 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -36,11 +39,13 @@ module.exports = [ }, }, { + // Now that `init` no longer pulls in span streaming, this entry is within ~0.1 KB of the entry + // above. It stays as the floor: if the two ever diverge again, tracing code leaked back in. name: '@sentry/browser - with treeshaking flags tracing without tracing', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '32 KB', + limit: '28 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); diff --git a/packages/browser-utils/src/performance/utils.ts b/packages/browser-utils/src/performance/utils.ts index 6a089c762fd2..a9a9759e7ed5 100644 --- a/packages/browser-utils/src/performance/utils.ts +++ b/packages/browser-utils/src/performance/utils.ts @@ -1,5 +1,6 @@ import type { SentrySpan, Span, SpanTimeInput, StartSpanOptions } from '@sentry/core'; -import { spanToStaticSpanJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import { spanToStaticSpanJSON, withActiveSpan } from '@sentry/core'; +import { startInactiveSpan } from '@sentry/core/browser'; import { WINDOW } from '../types'; /** diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index f6ad68b7ec1c..b0a42383bb03 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -11,9 +11,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, - startInactiveSpan, timestampInSeconds, } from '@sentry/core'; +import { startInactiveSpan } from '@sentry/core/browser'; 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 d23949c3307d..17f848364c3e 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -1,4 +1,5 @@ import * as SentryCore from '@sentry/core'; +import * as SentryCoreBrowser from '@sentry/core/browser'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import * as inpModule from '../../src/web-vitals/inp'; @@ -20,13 +21,21 @@ vi.mock('@sentry/core', async () => { timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), getClient: vi.fn(), - startInactiveSpan: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), spanToJSON: vi.fn(), }; }); +// `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'); + return { + ...actual, + startInactiveSpan: vi.fn(), + }; +}); + vi.mock('../../src/htmlTreeAsString', () => ({ htmlTreeAsString: vi.fn(), })); @@ -62,7 +71,7 @@ describe('_emitWebVitalSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); - vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any); vi.mocked(SentryCore.getClient).mockReturnValue({ getIntegrationByName: () => undefined } as any); }); @@ -81,7 +90,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.5, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith({ name: 'Test Vital', attributes: { 'sentry.origin': 'auto.http.browser.lcp', @@ -96,7 +105,7 @@ describe('_emitWebVitalSpan', () => { }); // No standalone flag - expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith( expect.objectContaining({ experimental: expect.anything() }), ); @@ -114,7 +123,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ experimental: { standalone: true } }), ); }); @@ -134,7 +143,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.replay_id': 'replay-123', @@ -159,7 +168,7 @@ describe('_emitWebVitalSpan', () => { standalone: true, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry._internal.replay_is_buffering': true }), }), @@ -180,7 +189,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.5, }); - const attributes = vi.mocked(SentryCore.startInactiveSpan).mock.calls[0]![0].attributes!; + const attributes = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0].attributes!; expect(attributes['sentry.replay_id']).toBeUndefined(); }); @@ -200,7 +209,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'sentry.pageload.span_id': 'abc123', @@ -226,7 +235,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.not.objectContaining({ 'sentry.pageload.span_id': expect.anything(), @@ -246,7 +255,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'browser.web_vital.cls.report_event': 'pagehide', @@ -266,7 +275,7 @@ describe('_emitWebVitalSpan', () => { startTime: 1.0, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ 'custom.attr': 'value', @@ -276,7 +285,7 @@ describe('_emitWebVitalSpan', () => { }); it('handles when startInactiveSpan returns undefined', () => { - vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(undefined as any); expect(() => { _emitWebVitalSpan({ @@ -306,7 +315,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(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, } as any); @@ -331,7 +340,7 @@ describe('_sendLcpSpan', () => { _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide'); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: '', attributes: expect.objectContaining({ @@ -361,7 +370,7 @@ describe('_sendLcpSpan', () => { it('sends a streamed LCP span without entry data', () => { _sendLcpSpan(250, undefined); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Largest contentful paint', startTime: 1, // timeOrigin: 1000 / 1000 @@ -373,7 +382,7 @@ describe('_sendLcpSpan', () => { _sendLcpSpan(0, undefined); _sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined); - expect(SentryCore.startInactiveSpan).not.toHaveBeenCalled(); + expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalled(); }); }); @@ -393,7 +402,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(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, } as any); @@ -429,7 +438,7 @@ describe('_sendClsSpan', () => { _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation'); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: '
', attributes: expect.objectContaining({ @@ -451,7 +460,7 @@ describe('_sendClsSpan', () => { _sendClsSpan(0, undefined); expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Layout shift', startTime: 1.5, @@ -475,7 +484,7 @@ describe('_sendInpSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(htmlTreeAsString).mockReturnValue('