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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '83 KB',
limit: '84 KB',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bundle size increases in browser packages

Low Severity

Multiple browser package size limits were increased by 0.5–1 KB across several entries (ESM bundles and CDN bundles). This is flagged because the project rules require flagging large bundle size increases in browser packages even when they're unavoidable. The increases stem from adding cleanup infrastructure (registerCleanup, _disposeCallbacks, unsubscribe return values) that primarily benefits server-side runtimes like Cloudflare.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 295ad22. Configure here.

},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand Down Expand Up @@ -138,7 +138,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'metrics', 'logger'),
gzip: true,
limit: '28 KB',
limit: '29 KB',
},
// React SDK (ESM)
{
Expand Down Expand Up @@ -191,13 +191,13 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '46.5 KB',
limit: '47 KB',
},
{
name: 'CDN Bundle (incl. Logs, Metrics)',
path: createCDNPath('bundle.logs.metrics.min.js'),
gzip: true,
limit: '30 KB',
limit: '31 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Logs, Metrics)',
Expand All @@ -209,19 +209,19 @@ module.exports = [
name: 'CDN Bundle (incl. Replay, Logs, Metrics)',
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
gzip: true,
limit: '69 KB',
limit: '70 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: true,
limit: '83.5 KB',
limit: '84 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: true,
limit: '84.5 KB',
limit: '85 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
Expand Down Expand Up @@ -283,21 +283,21 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '258.5 KB',
limit: '259 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed',
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
gzip: false,
brotli: false,
limit: '268 KB',
limit: '269 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed',
path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '271.5 KB',
limit: '272 KB',
},
// Next.js SDK (ESM)
{
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {

protected _promiseBuffer: PromiseBuffer<unknown>;

/**
* Cleanup functions to call on dispose.
*
* NOTE: These callbacks are only invoked by subclasses whose `dispose()` implementation runs them
* (currently only `ServerRuntimeClient`). The base `Client.dispose()` is a no-op and will not run them.
*/
protected _disposeCallbacks: (() => void)[];

/**
* Initializes this client instance.
*
Expand All @@ -224,6 +232,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._outcomes = {};
this._hooks = {};
this._eventProcessors = [];
this._disposeCallbacks = [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

q: This will only be cleaned up in the server runtime client, should we at least document this here? Might be confusing otherwise

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point of adding it there. I'll add it there

this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);

if (options.dsn) {
Expand Down Expand Up @@ -1169,10 +1178,26 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return {};
}

/**
* Register a cleanup function to be called when the client is disposed.
* This is useful for integrations that need to clean up global state.
*
* NOTE: Registered callbacks are only executed by subclasses whose `dispose()` implementation
* runs them. At the moment that is only `ServerRuntimeClient` (and clients extending it). On the
* base `Client` (e.g. the browser client), `dispose()` is a no-op, so callbacks registered here
* will never be invoked.
*/
public registerCleanup(callback: () => void): void {
this._disposeCallbacks.push(callback);
}

/**
* Disposes of the client and releases all resources.
*
* Subclasses should override this method to clean up their own resources.
* Subclasses should override this method to clean up their own resources, including invoking
* any callbacks registered via {@link Client.registerCleanup}. The base implementation is a
* no-op and does NOT execute registered cleanup callbacks.
*
* After calling dispose(), the client should not be used anymore.
*/
public dispose(): void {
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/instrument/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import { addHandler, maybeInstrument, triggerHandlers } from './handlers';

/**
* Add an instrumentation handler for when a console.xxx method is called.
* Returns a function to remove the handler.
*
* Use at your own risk, this might break without changelog notice, only used internally.
* @hidden
*/
export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): void {
export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): () => void {
const type = 'console';
addHandler(type, handler);
const removeHandler = addHandler(type, handler);
maybeInstrument(type, instrumentConsole);
return removeHandler;
}

function instrumentConsole(): void {
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/instrument/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,35 @@ type FetchResource = string | { toString(): string } | { url: string };
* Add an instrumentation handler for when a fetch request happens.
* The handler function is called once when the request starts and once when it ends,
* which can be identified by checking if it has an `endTimestamp`.
* Returns a function to remove the handler.
*
* Use at your own risk, this might break without changelog notice, only used internally.
* @hidden
*/
export function addFetchInstrumentationHandler(
handler: (data: HandlerDataFetch) => void,
skipNativeFetchCheck?: boolean,
): void {
): () => void {
const type = 'fetch';
addHandler(type, handler);
const removeHandler = addHandler(type, handler);
maybeInstrument(type, () => instrumentFetch(undefined, skipNativeFetchCheck));
return removeHandler;
}

/**
* Add an instrumentation handler for long-lived fetch requests, like consuming server-sent events (SSE) via fetch.
* The handler will resolve the request body and emit the actual `endTimestamp`, so that the
* span can be updated accordingly.
* Returns a function to remove the handler.
*
* Only used internally
* @hidden
*/
export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): void {
export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): () => void {
const type = 'fetch-body-resolved';
addHandler(type, handler);
const removeHandler = addHandler(type, handler);
maybeInstrument(type, () => instrumentFetch(streamHandler));
return removeHandler;
}

function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void {
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/instrument/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@ export type InstrumentHandlerCallback = (data: any) => void;
const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {};
const instrumented: { [key in InstrumentHandlerType]?: boolean } = {};

/** Add a handler function. */
export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void {
/** Add a handler function. Returns a function to remove the handler. */
export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): () => void {
handlers[type] = handlers[type] || [];
handlers[type].push(handler);

return () => {
const typeHandlers = handlers[type];
if (typeHandlers) {
const index = typeHandlers.indexOf(handler);
if (index !== -1) {
typeHandlers.splice(index, 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.

Array splice during for...of iteration may skip handlers

Low Severity

The new unsubscribe function uses splice to remove handlers from the live handlers[type] array. If an unsubscribe is ever called while triggerHandlers is iterating over the same array with for...of, the in-place mutation can cause the iterator to skip the next handler. JavaScript's ArrayIterator tracks position by index — when splice removes an element at or before the current position, subsequent elements shift down and the next one is silently skipped. While unlikely in current usage (unsubscribe is called from dispose(), not during handler execution), this creates a latent hazard if future code paths ever trigger disposal from within a handler callback.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit a589811. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It shouldn't happen that it is called from somewhere else.

}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/integrations/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ export const consoleIntegration = defineIntegration((options: Partial<ConsoleInt
return {
name: INTEGRATION_NAME,
setup(client) {
addConsoleInstrumentationHandler(({ args, level }) => {
const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => {
if (getClient() !== client || !levels.has(level)) {
return;
}

addConsoleBreadcrumb(level, args);
});

client.registerCleanup(unsubscribe);
},
};
});
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/integrations/conversationId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const _conversationIdIntegration = (() => {
return {
name: INTEGRATION_NAME,
setup(client: Client) {
client.on('spanStart', (span: Span) => {
const unsubscribe = client.on('spanStart', (span: Span) => {
const scopeData = getCurrentScope().getScopeData();
const isolationScopeData = getIsolationScope().getScopeData();

Expand All @@ -32,6 +32,8 @@ const _conversationIdIntegration = (() => {
span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, conversationId);
}
});

client.registerCleanup(unsubscribe);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant cleanup registration for per-client hooks

Low Severity

The conversationId integration registers the client.on('spanStart') unsubscribe via client.registerCleanup(), but this is unnecessary. Unlike global handlers (used by console instrumentation), client hooks live in this._hooks which ServerRuntimeClient.dispose() already clears unconditionally via this._hooks[hookName]?.clear(). For non-server clients, hooks are GC'd with the client. This adds confusion about when registerCleanup is actually needed vs. when dispose() already handles it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 295ad22. Configure here.

},
};
}) satisfies IntegrationFn;
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/logs/console-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const _consoleLoggingIntegration = ((options: Partial<CaptureConsoleOptions> = {
return;
}

addConsoleInstrumentationHandler(({ args, level }) => {
const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => {
if (getClient() !== client || !levels.includes(level)) {
return;
}
Expand Down Expand Up @@ -66,6 +66,8 @@ const _consoleLoggingIntegration = ((options: Partial<CaptureConsoleOptions> = {
attributes,
});
});

client.registerCleanup(unsubscribe);
},
};
}) satisfies IntegrationFn;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/server-runtime-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ export class ServerRuntimeClient<
public override dispose(): void {
DEBUG_BUILD && debug.log('Disposing client...');

// Run all registered cleanup callbacks
for (const callback of this._disposeCallbacks) {
try {
callback();
} catch {
// Ignore errors in cleanup callbacks
}
}
this._disposeCallbacks.length = 0;

for (const hookName of Object.keys(this._hooks)) {
this._hooks[hookName]?.clear();
}
Expand Down
99 changes: 97 additions & 2 deletions packages/core/test/lib/instrument/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, test } from 'vitest';
import { maybeInstrument } from '../../../src/instrument/handlers';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
addHandler,
maybeInstrument,
resetInstrumentationHandlers,
triggerHandlers,
} from '../../../src/instrument/handlers';

afterEach(() => {
resetInstrumentationHandlers();
});

describe('maybeInstrument', () => {
test('does not throw when instrumenting fails', () => {
Expand All @@ -12,3 +21,89 @@ describe('maybeInstrument', () => {
maybeInstrument('xhr', undefined as any);
});
});

describe('addHandler', () => {
test('returns an unsubscribe function', () => {
const handler = vi.fn();
const unsubscribe = addHandler('fetch', handler);

expect(typeof unsubscribe).toBe('function');
});

test('handler is called when triggerHandlers is invoked', () => {
const handler = vi.fn();
addHandler('fetch', handler);

triggerHandlers('fetch', { url: 'https://example.com' });

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ url: 'https://example.com' });
});

test('unsubscribe removes the handler', () => {
const handler = vi.fn();
const unsubscribe = addHandler('fetch', handler);

triggerHandlers('fetch', { test: 1 });
expect(handler).toHaveBeenCalledTimes(1);

unsubscribe();

triggerHandlers('fetch', { test: 2 });
expect(handler).toHaveBeenCalledTimes(1);
});

test('unsubscribe only removes the specific handler', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();

const unsubscribe1 = addHandler('fetch', handler1);
addHandler('fetch', handler2);

triggerHandlers('fetch', { test: 1 });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);

unsubscribe1();

triggerHandlers('fetch', { test: 2 });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(2);
});

test('calling unsubscribe multiple times is safe', () => {
const handler = vi.fn();
const unsubscribe = addHandler('fetch', handler);

unsubscribe();
expect(() => unsubscribe()).not.toThrow();
expect(() => unsubscribe()).not.toThrow();
});

test('unsubscribe works with different handler types', () => {
const consoleHandler = vi.fn();
const fetchHandler = vi.fn();

const unsubscribeConsole = addHandler('console', consoleHandler);
const unsubscribeFetch = addHandler('fetch', fetchHandler);

triggerHandlers('console', { level: 'log' });
triggerHandlers('fetch', { url: 'test' });

expect(consoleHandler).toHaveBeenCalledTimes(1);
expect(fetchHandler).toHaveBeenCalledTimes(1);

unsubscribeConsole();

triggerHandlers('console', { level: 'warn' });
triggerHandlers('fetch', { url: 'test2' });

expect(consoleHandler).toHaveBeenCalledTimes(1);
expect(fetchHandler).toHaveBeenCalledTimes(2);

unsubscribeFetch();

triggerHandlers('fetch', { url: 'test3' });
expect(fetchHandler).toHaveBeenCalledTimes(2);
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing integration or E2E test for feat PR

Low Severity

This feat PR only includes unit tests for addHandler unsubscribe behavior and registerCleanup/dispose(). The project rules require at least one integration or E2E test for feature PRs. An integration test verifying the full lifecycle — client creation, integration setup, handler registration, dispose, and confirmation that global handlers are actually cleaned up — would help catch regressions in the memory-leak fix this PR targets (especially relevant for the Cloudflare per-request client scenario).

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 295ad22. Configure here.

Loading
Loading