Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.38 KB',
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand DownExpand Up@@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.33 KB',
limit: '43.5 KB',
},
// Vue SDK (ESM)
{
Expand All@@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.2 KB',
limit: '43.3 KB',
},
// Svelte SDK (ESM)
{
Expand All@@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand DownExpand Up@@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '51.1 KB',
},
// Node SDK (ESM)
{
Expand Down
44 changes: 33 additions & 11 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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';
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event';
import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event';
import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
Expand All@@ -43,6 +44,7 @@ import { merge } from './utils/merge';
import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
Expand DownExpand Up@@ -201,6 +203,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
// eslint-disable-next-line @typescript-eslint/ban-types
private _hooks: Record<string, Set<Function>>;

private _promiseBuffer: PromiseBuffer<unknown>;

/**
* Initializes this client instance.
*
Expand All@@ -213,6 +217,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand DownExpand Up@@ -275,9 +280,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

this._process(
this.eventFromException(exception, hintWithEventId).then(event =>
this._captureEvent(event, hintWithEventId, scope),
),
() =>
this.eventFromException(exception, hintWithEventId)
.then(event => this._captureEvent(event, hintWithEventId, scope))
.then(res => res),
'error',
);

return hintWithEventId.event_id;
Expand All@@ -300,12 +307,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
};

const eventMessage = isParameterizedString(message) ? message : String(message);

const promisedEvent = isPrimitive(message)
const isMessage = isPrimitive(message);
const promisedEvent = isMessage
? this.eventFromMessage(eventMessage, level, hintWithEventId)
: this.eventFromException(message, hintWithEventId);

this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)));
this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web


return hintWithEventId.event_id;
}
Expand All@@ -332,9 +342,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
const sdkProcessingMetadata = event.sdkProcessingMetadata || {};
const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope;
const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope;
const dataCategory = getDataCategoryByType(event.type);

this._process(
this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
() => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope),
dataCategory,
);

return hintWithEventId.event_id;
Expand DownExpand Up@@ -1252,7 +1264,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
);
}

const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory;
const dataCategory = getDataCategoryByType(event.type);

return this._prepareEvent(event, hint, currentScope, isolationScope)
.then(prepared => {
Expand DownExpand Up@@ -1335,15 +1347,21 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/**
* Occupies the client with processing and event
*/
protected _process<T>(promise: PromiseLike<T>): void {
protected _process<T>(taskProducer: () => PromiseLike<T>, dataCategory: DataCategory): void {
this._numProcessing++;
void promise.then(

void this._promiseBuffer.add(taskProducer).then(
value => {
this._numProcessing--;
return value;
},
reason => {
this._numProcessing--;

if (reason === SENTRY_BUFFER_FULL_ERROR) {
this.recordDroppedEvent('queue_overflow', dataCategory);
}

return reason;
},
);
Expand DownExpand Up@@ -1408,6 +1426,10 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
): PromiseLike<Event>;
}

function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory {
return type === 'replay_event' ? 'replay' : type || 'error';
}

/**
* Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so.
*/
Expand Down
65 changes: 64 additions & 1 deletion packages/core/test/lib/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
import * as integrationModule from '../../src/integration';
import { _INTERNAL_captureLog } from '../../src/logs/internal';
import { _INTERNAL_captureMetric } from '../../src/metrics/internal';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base';
import type { Envelope } from '../../src/types-hoist/envelope';
import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event';
import type { SpanJSON } from '../../src/types-hoist/span';
Expand All@@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc';
import * as stringModule from '../../src/utils/string';
import * as timeModule from '../../src/utils/time';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { AdHocIntegration, TestIntegration } from '../mocks/integration';
import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration';
import { makeFakeTransport } from '../mocks/transport';
import { clearGlobalScope } from '../testutils';

Expand DownExpand Up@@ -2935,4 +2936,66 @@ describe('Client', () => {
expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
});
});

describe('promise buffer usage', () => {
it('respects the default value of the buffer size', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => {
client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻'));
});

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]);
});

it('records queue_overflow when promise buffer is full', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]);
});

it('records different types of dropped events', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new AsyncTestIntegration());

client.captureException(new Error('first')); // error
client.captureException(new Error('second')); // error
client.captureMessage('third'); // unknown
client.captureEvent({ message: 'fourth' }); // error
client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay
client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction

expect(client._clearOutcomes()).toEqual([
{ reason: 'queue_overflow', category: 'error', quantity: 2 },
{ reason: 'queue_overflow', category: 'unknown', quantity: 1 },
{ reason: 'queue_overflow', category: 'replay', quantity: 1 },
{ reason: 'queue_overflow', category: 'transaction', quantity: 1 },
]);
});

it('should skip the promise buffer with sync integrations', async () => {
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } });
const client = new TestClient(options);

client.addIntegration(new TestIntegration());

client.captureException(new Error('first'));
client.captureException(new Error('second'));
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

});
});
10 changes: 10 additions & 0 deletions packages/core/test/mocks/integration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,16 @@ export class TestIntegration implements Integration {
}
}

export class AsyncTestIntegration implements Integration {
public static id: string = 'AsyncTestIntegration';

public name: string = 'AsyncTestIntegration';

processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web


export class AddAttachmentTestIntegration implements Integration {
public static id: string = 'AddAttachmentTestIntegration';

Expand Down
Loading