Skip to content
Open
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
16 changes: 8 additions & 8 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 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: '21 KB',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Size limit typo: 21 KB instead of ~82 KB

High Severity

The gzip size limit for "CDN Bundle (incl. Tracing, Replay, Logs, Metrics)" was changed from 81 KB to 21 KB, which is almost certainly a typo (82). A bundle including Tracing, Replay, Logs, AND Metrics cannot be smaller than the "CDN Bundle (incl. Tracing, Replay)" at 81 KB. The uncompressed version of this same bundle is 250 KB, making 21 KB an impossible gzip ratio. This will either always fail CI or silently mask real size regressions.

Fix in Cursor Fix in Web

},
{
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);
});
});
32 changes: 29 additions & 3 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base';
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
Expand All @@ -34,7 +35,6 @@ 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 { createClientReportEnvelope } from './utils/clientreport';
import { debug } from './utils/debug-logger';
import { dsnToString, makeDsn } from './utils/dsn';
Expand Down Expand Up @@ -503,6 +503,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 @@ -613,6 +617,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: (immutableSegmentSpan: Readonly<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: (immutableSegmentSpan: Readonly<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 @@ -896,12 +912,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 after a span ends and the `spanEnd` hook has run.
*/
public emit(hook: 'afterSpanEnd', immutableSpan: Readonly<Span>): void;

/**
* Fire a hook event after a segment span ends and the `spanEnd` hook has run.
*/
public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly<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
@@ -1,6 +1,7 @@
import type { Client } from './client';
import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext';
import type { SentrySpan } from './tracing/sentrySpan';
import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
import type { LegacyCSPReport } from './types-hoist/csp';
import type { DsnComponents } from './types-hoist/dsn';
import type {
Expand All @@ -18,7 +19,6 @@ 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 { dsnToString } from './utils/dsn';
import {
createEnvelope,
Expand Down
Loading
Loading