Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions packages/browser/src/integrations/spanstreaming.ts
Original file line number Diff line number Diff line change
@@ -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;
154 changes: 154 additions & 0 deletions packages/browser/test/integrations/spanstreaming.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 29 additions & 3 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -499,6 +499,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
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
Expand Down Expand Up @@ -609,6 +613,18 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
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.
*/
Expand Down Expand Up @@ -892,12 +908,22 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
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;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tracing/spans/captureSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts
Original file line number Diff line number Diff line change
@@ -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';
}
1 change: 1 addition & 0 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading
Loading