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
18 changes: 9 additions & 9 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init'),
gzip: true,
limit: '24.1 KB',
limit: '25 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');

Expand Down Expand Up @@ -82,7 +82,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '86 KB',
limit: '87 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand All @@ -103,7 +103,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'sendFeedback'),
gzip: true,
limit: '31 KB',
limit: '32 KB',
},
{
name: '@sentry/browser (incl. FeedbackAsync)',
Expand Down Expand Up @@ -190,7 +190,7 @@ module.exports = [
name: 'CDN Bundle (incl. Logs, Metrics)',
path: createCDNPath('bundle.logs.metrics.min.js'),
gzip: true,
limit: '29 KB',
limit: '30 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Logs, Metrics)',
Expand All @@ -214,7 +214,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: true,
limit: '81 KB',
limit: '82 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
Expand All @@ -241,7 +241,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
limit: '128 KB',
limit: '129 KB',
},
{
name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed',
Expand All @@ -255,7 +255,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '131 KB',
limit: '133 KB',
},
{
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',
Expand All @@ -269,7 +269,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: false,
brotli: false,
limit: '245 KB',
limit: '247 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
Expand Down Expand Up @@ -308,7 +308,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
limit: '43 KB',
limit: '44 KB',
},
// Node-Core SDK (ESM)
{
Expand Down
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
55 changes: 55 additions & 0 deletions packages/browser/src/integrations/spanstreaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 = 'SpanStreaming integration 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_BUILD &&
debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${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;
167 changes: 167 additions & 0 deletions packages/browser/test/integrations/spanstreaming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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(
'SpanStreaming integration 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(
'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.',
);
debugSpy.mockRestore();

expect(client.getOptions().traceLifecycle).toBe('static');
});

it('does nothing if traceLifecycle set to "stream"', () => {
const client = new BrowserClient({
...getDefaultBrowserClientOptions(),
dsn: 'https://username@domain/123',
integrations: [spanStreamingIntegration()],
traceLifecycle: 'stream',
});

SentryCore.setCurrentClient(client);
client.init();

expect(client.getOptions().traceLifecycle).toBe('stream');
});

it('enqueues a span into the buffer when the span ends', () => {
const client = new BrowserClient({
...getDefaultBrowserClientOptions(),
dsn: 'https://username@domain/123',
integrations: [spanStreamingIntegration()],
});

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);
});
});
Loading