From fd4ffbc4d8f8e676bac9bdd366677f7589edc81b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 12:24:28 +0100 Subject: [PATCH 1/4] feat(core): Add `SpanBuffer` implementation --- packages/core/src/index.ts | 3 + .../src/tracing/dynamicSamplingContext.ts | 5 +- .../core/src/tracing/spans/captureSpan.ts | 2 +- packages/core/src/tracing/spans/spanBuffer.ts | 164 +++++++++++ .../test/lib/tracing/spans/spanBuffer.test.ts | 262 ++++++++++++++++++ 5 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tracing/spans/spanBuffer.ts create mode 100644 packages/core/test/lib/tracing/spans/spanBuffer.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd75b251a5ee..d4531a895056 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -174,6 +174,9 @@ export type { GoogleGenAIOptions, GoogleGenAIIstrumentedMethod, } from './tracing/google-genai/types'; + +export { SpanBuffer } from './tracing/spans/spanBuffer'; + export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 47d5657a7d87..219d7deddcd7 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -6,6 +6,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, } from '../semanticAttributes'; import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { Span } from '../types-hoist/span'; @@ -119,7 +120,9 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly>; + + private _flushIntervalId: ReturnType | null; + private _client: Client; + private _maxSpanLimit: number; + private _flushInterval: number; + + public constructor(client: Client, options?: SpanBufferOptions) { + this._traceMap = new Map(); + this._client = client; + + const { maxSpanLimit, flushInterval } = options ?? {}; + + this._maxSpanLimit = + maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE + ? maxSpanLimit + : MAX_SPANS_PER_ENVELOPE; + this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000; + + this._flushIntervalId = null; + this._debounceFlushInterval(); + + this._client.on('flush', () => { + this.drain(); + }); + } + + /** + * Add a span to the buffer. + */ + public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void { + const traceId = spanJSON.trace_id; + let traceBucket = this._traceMap.get(traceId); + if (traceBucket) { + traceBucket.add(spanJSON); + } else { + traceBucket = new Set([spanJSON]); + this._traceMap.set(traceId, traceBucket); + } + + if (traceBucket.size >= this._maxSpanLimit) { + this.flush(traceId); + this._debounceFlushInterval(); + } + } + + /** + * Drain and flush all buffered traces. + */ + public drain(): void { + if (!this._traceMap.size) { + return; + } + + DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceMap.size} traces`); + + this._traceMap.forEach((_, traceId) => { + this.flush(traceId); + }); + this._debounceFlushInterval(); + } + + /** + * Flush spans of a specific trace. + * In contrast to {@link SpanBuffer.flush}, this method does not flush all traces, but only the one with the given traceId. + */ + public flush(traceId: string): void { + const traceBucket = this._traceMap.get(traceId); + if (!traceBucket) { + return; + } + + if (!traceBucket.size) { + // we should never get here, given we always add a span when we create a new bucket + // and delete the bucket once we flush out the trace + this._traceMap.delete(traceId); + return; + } + + const spans = Array.from(traceBucket); + + const segmentSpan = spans[0]?._segmentSpan; + if (!segmentSpan) { + DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC'); + this._traceMap.delete(traceId); + return; + } + + const dsc = getDynamicSamplingContextFromSpan(segmentSpan); + + const cleanedSpans: SerializedStreamedSpan[] = Array.from(traceBucket).map(spanJSON => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _segmentSpan, ...cleanSpanJSON } = spanJSON; + return cleanSpanJSON; + }); + + const envelope = createStreamedSpanEnvelope(cleanedSpans, dsc, this._client); + + DEBUG_BUILD && debug.log(`Sending span envelope for trace ${traceId} with ${cleanedSpans.length} spans`); + + this._client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason); + }); + + this._traceMap.delete(traceId); + } + + private _debounceFlushInterval(): void { + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._flushIntervalId = safeUnref( + setInterval(() => { + this.drain(); + }, this._flushInterval), + ); + } +} diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts new file mode 100644 index 000000000000..1b654cd400e6 --- /dev/null +++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Client, StreamedSpanEnvelope } from '../../../../src'; +import { SentrySpan, setCurrentClient, SpanBuffer } from '../../../../src'; +import type { SerializedStreamedSpanWithSegmentSpan } from '../../../../src/tracing/spans/captureSpan'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('SpanBuffer', () => { + let client: TestClient; + let sendEnvelopeSpy: ReturnType; + + let sentEnvelopes: Array = []; + + beforeEach(() => { + vi.useFakeTimers(); + sentEnvelopes = []; + sendEnvelopeSpy = vi.fn().mockImplementation(e => { + sentEnvelopes.push(e); + return Promise.resolve(); + }); + + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1.0, + }), + ); + client.sendEnvelope = sendEnvelopeSpy; + client.init(); + setCurrentClient(client as Client); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('flushes all traces on drain()', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace123' }); + const segmentSpan2 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace456' }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace456', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sentEnvelopes).toHaveLength(2); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123'); + expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456'); + }); + + it('drains on interval', () => { + const buffer = new SpanBuffer(client, { flushInterval: 1000 }); + + const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true }); + const span1 = { + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }; + + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + const span2 = { + trace_id: 'trace123', + span_id: 'span2', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }; + + buffer.add(span1 as SerializedStreamedSpanWithSegmentSpan); + buffer.add(span2 as SerializedStreamedSpanWithSegmentSpan); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // since the buffer is now empty, it should not send anything anymore + vi.advanceTimersByTime(1000); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes when maxSpanLimit is reached', () => { + const buffer = new SpanBuffer(client, { maxSpanLimit: 2 }); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span3', + name: 'test span 3', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + // we added another span after flushing but neither limit nor time interval should have been reached + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + + // draining will flush out the remaining span + buffer.drain(); + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes on client flush event', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan = new SentrySpan({ name: 'segment', sampled: true }); + + buffer.add({ + trace_id: 'trace123', + span_id: 'span1', + name: 'test span', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan, + }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('groups spans by traceId', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.drain(); + + // Should send 2 envelopes, one for each trace + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + }); + + it('flushes a specific trace on flush(traceId)', () => { + const buffer = new SpanBuffer(client); + + const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true }); + const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true }); + + buffer.add({ + trace_id: 'trace1', + span_id: 'span1', + name: 'test span 1', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan1, + }); + + buffer.add({ + trace_id: 'trace2', + span_id: 'span2', + name: 'test span 2', + start_timestamp: Date.now() / 1000, + end_timestamp: Date.now() / 1000, + status: 'ok', + is_segment: false, + _segmentSpan: segmentSpan2, + }); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace1'); + }); + + it('handles flushing a non-existing trace', () => { + const buffer = new SpanBuffer(client); + + buffer.flush('trace1'); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); +}); From 471048de869fad44b553ef20586159ec5679664d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 14:37:59 +0100 Subject: [PATCH 2/4] adjust sentry.span.source to changes --- packages/core/src/tracing/dynamicSamplingContext.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 219d7deddcd7..7cf79e53d07b 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -6,7 +6,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_SENTRY_SPAN_SOURCE, } from '../semanticAttributes'; import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { Span } from '../types-hoist/span'; @@ -120,9 +119,8 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly Date: Fri, 6 Feb 2026 16:33:40 +0100 Subject: [PATCH 3/4] listen to 'close' --- packages/core/src/tracing/spans/spanBuffer.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts index 9285fe8a8403..761ba076e4d2 100644 --- a/packages/core/src/tracing/spans/spanBuffer.ts +++ b/packages/core/src/tracing/spans/spanBuffer.ts @@ -69,6 +69,15 @@ export class SpanBuffer { this._client.on('flush', () => { this.drain(); }); + + this._client.on('close', () => { + // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()` + // which already invokes the `flush` hook and thus drains the buffer. + if (this._flushIntervalId) { + clearInterval(this._flushIntervalId); + } + this._traceMap.clear(); + }); } /** @@ -134,7 +143,7 @@ export class SpanBuffer { const dsc = getDynamicSamplingContextFromSpan(segmentSpan); - const cleanedSpans: SerializedStreamedSpan[] = Array.from(traceBucket).map(spanJSON => { + const cleanedSpans: SerializedStreamedSpan[] = spans.map(spanJSON => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _segmentSpan, ...cleanSpanJSON } = spanJSON; return cleanSpanJSON; From a2947549411b97c54f24bc1feff11dbecd627dca Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 6 Feb 2026 18:00:30 +0100 Subject: [PATCH 4/4] 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', () => {