From a2947549411b97c54f24bc1feff11dbecd627dca Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 18:00:30 +0100 Subject: [PATCH] feat(browser): Add `spanStreamingIntegration` --- packages/browser/src/index.ts | 1 + .../browser/src/integrations/spanstreaming.ts | 54 ++++++ .../test/integrations/spanstreaming.test.ts | 154 ++++++++++++++++++ packages/core/src/client.ts | 32 +++- packages/core/src/envelope.ts | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/integration.ts | 6 + packages/core/src/tracing/sentrySpan.ts | 6 + .../spans}/beforeSendSpan.ts | 6 +- .../core/src/tracing/spans/captureSpan.ts | 2 +- .../tracing/spans/hasSpanStreamingEnabled.ts | 8 + packages/core/src/tracing/trace.ts | 1 + packages/core/src/types-hoist/integration.ts | 9 + .../test/lib/utils/beforeSendSpan.test.ts | 2 +- 14 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 packages/browser/src/integrations/spanstreaming.ts create mode 100644 packages/browser/test/integrations/spanstreaming.test.ts rename packages/core/src/{utils => tracing/spans}/beforeSendSpan.ts (89%) create mode 100644 packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6e7c54198edc..392feb7865d2 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -41,6 +41,7 @@ export { } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; +export { spanStreamingIntegration } from './integrations/spanstreaming'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts new file mode 100644 index 000000000000..768925a6ec1c --- /dev/null +++ b/packages/browser/src/integrations/spanstreaming.ts @@ -0,0 +1,54 @@ +import type { IntegrationFn } from '@sentry/core'; +import { + captureSpan, + debug, + defineIntegration, + hasSpanStreamingEnabled, + isStreamedBeforeSendSpanCallback, + SpanBuffer, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +export const spanStreamingIntegration = defineIntegration(() => { + return { + name: 'SpanStreaming', + + 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.warn('[SpanStreaming] set `traceLifecycle` to "stream"'); + clientOptions.traceLifecycle = 'stream'; + } + }, + + setup(client) { + const initialMessage = 'spanStreamingIntegration requires'; + const fallbackMsg = 'Falling back to static trace lifecycle.'; + + if (!hasSpanStreamingEnabled(client)) { + DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`); + return; + } + + const beforeSendSpan = client.getOptions().beforeSendSpan; + // If users misconfigure their SDK by opting into span streaming but + // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle. + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + client.getOptions().traceLifecycle = 'static'; + debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamSpan\`! ${fallbackMsg}`); + return; + } + + const buffer = new SpanBuffer(client); + + client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client))); + + // In addition to capturing the span, we also flush the trace when the segment + // span ends to ensure things are sent timely. We never know when the browser + // is closed, users navigate away, etc. + client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId)); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts new file mode 100644 index 000000000000..5fd6ddffee79 --- /dev/null +++ b/packages/browser/test/integrations/spanstreaming.test.ts @@ -0,0 +1,154 @@ +import * as SentryCore from '@sentry/core'; +import { debug } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { BrowserClient, spanStreamingIntegration } from '../../src'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +// Mock SpanBuffer as a class that can be instantiated +const mockSpanBufferInstance = vi.hoisted(() => ({ + flush: vi.fn(), + add: vi.fn(), + drain: vi.fn(), +})); + +const MockSpanBuffer = vi.hoisted(() => { + return vi.fn(() => mockSpanBufferInstance); +}); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + SpanBuffer: MockSpanBuffer, + }; +}); + +describe('spanStreamingIntegration', () => { + it('has the correct hooks', () => { + 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('logs a warning if traceLifecycle is not set to "stream"', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'static', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'spanStreamingIntegration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + beforeSendSpan: (span: Span) => span, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + expect(debugSpy).toHaveBeenCalledWith( + 'spanStreamingIntegration requires a beforeSendSpan callback using `withStreamSpan`! Falling back to static trace lifecycle.', + ); + debugSpy.mockRestore(); + + expect(client.getOptions().traceLifecycle).toBe('static'); + }); + + it('enqueues a span into the buffer when the span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSpanEnd', span); + + expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({ + _segmentSpan: span, + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'test', + start_timestamp: expect.any(Number), + status: 'ok', + attributes: { + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: span.spanContext().spanId, + }, + 'sentry.segment.name': { + type: 'string', + value: 'test', + }, + }, + }); + }); + + it('flushes the trace when the segment span ends', () => { + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + }); + + SentryCore.setCurrentClient(client); + client.init(); + + const span = new SentryCore.SentrySpan({ name: 'test' }); + client.emit('afterSegmentSpanEnd', span); + + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 2cd29502a823..ee2e8ec2c1a8 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -34,7 +34,7 @@ import type { SeverityLevel } from './types-hoist/severity'; import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span'; import type { StartSpanOptions } from './types-hoist/startSpanOptions'; import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport'; -import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; @@ -499,6 +499,10 @@ export abstract class Client { public addIntegration(integration: Integration): void { const isAlreadyInstalled = this._integrations[integration.name]; + if (!isAlreadyInstalled && integration.beforeSetup) { + integration.beforeSetup(this); + } + // This hook takes care of only installing if not already installed setupIntegration(this, integration, this._integrations); // Here we need to check manually to make sure to not run this multiple times @@ -609,6 +613,18 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** + * Register a callback for after a span is ended and the `spanEnd` hook has run. + * NOTE: The span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSpanEnd', callback: (span: Span) => void): () => void; + + /** + * Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run. + * NOTE: The segment span cannot be mutated anymore in this callback. + */ + public on(hook: 'afterSegmentSpanEnd', callback: (segmentSpan: Span) => void): () => void; + /** * Register a callback for when a span JSON is processed, to add some data to the span JSON. */ @@ -892,12 +908,22 @@ export abstract class Client { public emit(hook: 'spanEnd', span: Span): void; /** - * Register a callback for when a span JSON is processed, to add some data to the span JSON. + * Fire a hook event when a span ends. + */ + public emit(hook: 'afterSpanEnd', span: Span): void; + + /** + * Fire a hook event when a segment span ends. + */ + public emit(hook: 'afterSegmentSpanEnd', segmentSpan: Span): void; + + /** + * Fire a hook event when a span JSON is processed, to add some data to the span JSON. */ public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void; /** - * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON. + * Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON. */ public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void; diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index c7a46359260f..e46e71073f12 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -18,7 +18,7 @@ import type { Event } from './types-hoist/event'; import type { SdkInfo } from './types-hoist/sdkinfo'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; import type { Session, SessionAggregates } from './types-hoist/session'; -import { isStreamedBeforeSendSpanCallback } from './utils/beforeSendSpan'; +import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; import { dsnToString } from './utils/dsn'; import { createEnvelope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4531a895056..69a8311d94e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -67,7 +67,8 @@ export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; -export { withStreamedSpan } from './utils/beforeSendSpan'; +export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; +export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; @@ -176,6 +177,7 @@ export type { } from './tracing/google-genai/types'; export { SpanBuffer } from './tracing/spans/spanBuffer'; +export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; export type { FeatureFlag } from './utils/featureFlags'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 892228476824..b8e7240cf748 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -76,6 +76,12 @@ export function getIntegrationsToSetup( export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex { const integrationIndex: IntegrationIndex = {}; + integrations.forEach((integration: Integration | undefined) => { + if (integration?.beforeSetup) { + integration.beforeSetup(client); + } + }); + integrations.forEach((integration: Integration | undefined) => { // guard against empty provided integrations if (integration) { diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8bdae7129dba..73dcf5114277 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -45,6 +45,7 @@ import { timestampInSeconds } from '../utils/time'; import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; +import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { getCapturedScopesOnSpan } from './utils'; const MAX_SPAN_COUNT = 1000; @@ -315,6 +316,7 @@ export class SentrySpan implements Span { const client = getClient(); if (client) { client.emit('spanEnd', this); + client.emit('afterSpanEnd', this); } // A segment span is basically the root span of a local span tree. @@ -338,6 +340,10 @@ export class SentrySpan implements Span { } } return; + } else if (client && hasSpanStreamingEnabled(client)) { + // TODO (spans): Remove standalone span custom logic in favor of sending simple v2 web vital spans + client?.emit('afterSegmentSpanEnd', this); + return; } const transactionEvent = this._convertSpanToTransaction(); diff --git a/packages/core/src/utils/beforeSendSpan.ts b/packages/core/src/tracing/spans/beforeSendSpan.ts similarity index 89% rename from packages/core/src/utils/beforeSendSpan.ts rename to packages/core/src/tracing/spans/beforeSendSpan.ts index 68c4576d179d..84ec6a8a8b52 100644 --- a/packages/core/src/utils/beforeSendSpan.ts +++ b/packages/core/src/tracing/spans/beforeSendSpan.ts @@ -1,6 +1,6 @@ -import type { BeforeSendStramedSpanCallback, ClientOptions } from '../types-hoist/options'; -import type { StreamedSpanJSON } from '../types-hoist/span'; -import { addNonEnumerableProperty } from './object'; +import type { BeforeSendStramedSpanCallback, ClientOptions } from '../../types-hoist/options'; +import type { StreamedSpanJSON } from '../../types-hoist/span'; +import { addNonEnumerableProperty } from '../../utils/object'; /** * A wrapper to use the new span format in your `beforeSendSpan` callback. diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index a6d0df8725cf..3211e36f99d8 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -15,7 +15,7 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; -import { isStreamedBeforeSendSpanCallback } from '../../utils/beforeSendSpan'; +import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, diff --git a/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts new file mode 100644 index 000000000000..7d5fa2861c21 --- /dev/null +++ b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts @@ -0,0 +1,8 @@ +import type { Client } from '../../client'; + +/** + * Determines if span streaming is enabled for the given client + */ +export function hasSpanStreamingEnabled(client: Client): boolean { + return client.getOptions().traceLifecycle === 'stream'; +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..59b00bb018c1 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -492,6 +492,7 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp // If it has an endTimestamp, it's already ended if (spanArguments.endTimestamp) { client.emit('spanEnd', childSpan); + client.emit('afterSpanEnd', childSpan); } } diff --git a/packages/core/src/types-hoist/integration.ts b/packages/core/src/types-hoist/integration.ts index 120cb1acc884..fc80cf3f524a 100644 --- a/packages/core/src/types-hoist/integration.ts +++ b/packages/core/src/types-hoist/integration.ts @@ -14,6 +14,15 @@ export interface Integration { */ setupOnce?(): void; + /** + * Called before the `setup` hook of any integration is called. + * This is useful if an integration needs to e.g. modify client options prior to other integrations + * reading client options. + * + * @param client + */ + beforeSetup?(client: Client): void; + /** * Set up an integration for the given client. * Receives the client as argument. diff --git a/packages/core/test/lib/utils/beforeSendSpan.test.ts b/packages/core/test/lib/utils/beforeSendSpan.test.ts index 5e5bdc566889..90ae85931b25 100644 --- a/packages/core/test/lib/utils/beforeSendSpan.test.ts +++ b/packages/core/test/lib/utils/beforeSendSpan.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { withStreamedSpan } from '../../../src'; -import { isStreamedBeforeSendSpanCallback } from '../../../src/utils/beforeSendSpan'; +import { isStreamedBeforeSendSpanCallback } from '../../../src/tracing/spans/beforeSendSpan'; describe('beforeSendSpan for span streaming', () => { describe('withStreamedSpan', () => {