Skip to content

Commit ac57cec

Browse files
authored
ref(core): Add weight tracking logic to browser logs/metrics (#17901)
We've seen some cases where our browser logs are hitting size limits. I suspect this is because we don't have any robust size tracking mechanisms in the browser sdk. <img width="1251" height="513" alt="image" src="https://github.com/user-attachments/assets/2364b984-2b53-4c6a-89e5-0a0e20fa3246" /> This refactors our log flushing mechanisms in the SDK to unify everything between the browser client and server runtime client. This also means the browser SDK gets a weight tracking mechanism for buffering, which should help with making sure we don't run into size issues with logs. Given metrics has the same issue, I included it in this refactor.
1 parent 245e91b commit ac57cec

File tree

11 files changed

+398
-381
lines changed

11 files changed

+398
-381
lines changed

.size-limit.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = [
3838
path: 'packages/browser/build/npm/esm/index.js',
3939
import: createImport('init', 'browserTracingIntegration'),
4040
gzip: true,
41-
limit: '40.7 KB',
41+
limit: '41 KB',
4242
},
4343
{
4444
name: '@sentry/browser (incl. Tracing, Replay)',
@@ -82,7 +82,7 @@ module.exports = [
8282
path: 'packages/browser/build/npm/esm/index.js',
8383
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'),
8484
gzip: true,
85-
limit: '96 KB',
85+
limit: '97 KB',
8686
},
8787
{
8888
name: '@sentry/browser (incl. Feedback)',
@@ -128,7 +128,7 @@ module.exports = [
128128
path: 'packages/vue/build/esm/index.js',
129129
import: createImport('init'),
130130
gzip: true,
131-
limit: '29 KB',
131+
limit: '30 KB',
132132
},
133133
{
134134
name: '@sentry/vue (incl. Tracing)',

packages/browser/src/client.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ import type { BrowserTransportOptions } from './transports/types';
2626
*/
2727
declare const __SENTRY_RELEASE__: string | undefined;
2828

29-
const DEFAULT_FLUSH_INTERVAL = 5000;
30-
3129
type BrowserSpecificOptions = BrowserClientReplayOptions &
3230
BrowserClientProfilingOptions & {
3331
/** If configured, this URL will be used as base URL for lazy loading integration. */
@@ -85,8 +83,6 @@ export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> & Brow
8583
* @see SentryClient for usage documentation.
8684
*/
8785
export class BrowserClient extends Client<BrowserClientOptions> {
88-
private _logFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
89-
private _metricFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
9086
/**
9187
* Creates a new Browser SDK instance.
9288
*
@@ -110,6 +106,7 @@ export class BrowserClient extends Client<BrowserClientOptions> {
110106

111107
const { sendDefaultPii, sendClientReports, enableLogs, _experiments } = this._options;
112108

109+
// Flush logs and metrics when page becomes hidden (e.g., tab switch, navigation)
113110
if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableMetrics)) {
114111
WINDOW.document.addEventListener('visibilitychange', () => {
115112
if (WINDOW.document.visibilityState === 'hidden') {
@@ -126,38 +123,6 @@ export class BrowserClient extends Client<BrowserClientOptions> {
126123
});
127124
}
128125

129-
if (enableLogs) {
130-
this.on('flush', () => {
131-
_INTERNAL_flushLogsBuffer(this);
132-
});
133-
134-
this.on('afterCaptureLog', () => {
135-
if (this._logFlushIdleTimeout) {
136-
clearTimeout(this._logFlushIdleTimeout);
137-
}
138-
139-
this._logFlushIdleTimeout = setTimeout(() => {
140-
_INTERNAL_flushLogsBuffer(this);
141-
}, DEFAULT_FLUSH_INTERVAL);
142-
});
143-
}
144-
145-
if (_experiments?.enableMetrics) {
146-
this.on('flush', () => {
147-
_INTERNAL_flushMetricsBuffer(this);
148-
});
149-
150-
this.on('afterCaptureMetric', () => {
151-
if (this._metricFlushIdleTimeout) {
152-
clearTimeout(this._metricFlushIdleTimeout);
153-
}
154-
155-
this._metricFlushIdleTimeout = setTimeout(() => {
156-
_INTERNAL_flushMetricsBuffer(this);
157-
}, DEFAULT_FLUSH_INTERVAL);
158-
});
159-
}
160-
161126
if (sendDefaultPii) {
162127
this.on('beforeSendSession', addAutoIpAddressToSession);
163128
}

packages/browser/test/client.test.ts

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ vi.mock('@sentry/core', async requireActual => {
1818

1919
describe('BrowserClient', () => {
2020
let client: BrowserClient;
21-
const DEFAULT_FLUSH_INTERVAL = 5000;
2221

2322
afterEach(() => {
2423
vi.useRealTimers();
@@ -77,59 +76,6 @@ describe('BrowserClient', () => {
7776
expect(flushOutcomesSpy).toHaveBeenCalled();
7877
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
7978
});
80-
81-
it('flushes logs on flush event', () => {
82-
const scope = new Scope();
83-
scope.setClient(client);
84-
85-
// Add some logs
86-
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope);
87-
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope);
88-
89-
// Trigger flush event
90-
client.emit('flush');
91-
92-
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
93-
});
94-
95-
it('flushes logs after idle timeout', () => {
96-
const scope = new Scope();
97-
scope.setClient(client);
98-
99-
// Add a log which will trigger afterCaptureLog event
100-
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log' }, scope);
101-
102-
// Fast forward the idle timeout
103-
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL);
104-
105-
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
106-
});
107-
108-
it('resets idle timeout when new logs are captured', () => {
109-
const scope = new Scope();
110-
scope.setClient(client);
111-
112-
// Add initial log
113-
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope);
114-
115-
// Fast forward part of the idle timeout
116-
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2);
117-
118-
// Add another log which should reset the timeout
119-
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope);
120-
121-
// Fast forward the remaining time
122-
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2);
123-
124-
// Should not have flushed yet since timeout was reset
125-
expect(sentryCore._INTERNAL_flushLogsBuffer).not.toHaveBeenCalled();
126-
127-
// Fast forward the full timeout
128-
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL);
129-
130-
// Now should have flushed both logs
131-
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
132-
});
13379
});
13480
});
13581

packages/core/src/client.ts

Lines changed: 151 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
/* eslint-disable max-lines */
22
import { getEnvelopeEndpointWithUrlEncodedAuth } from './api';
33
import { DEFAULT_ENVIRONMENT } from './constants';
4-
import { getCurrentScope, getIsolationScope, getTraceContextFromScope, withScope } from './currentScopes';
4+
import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes';
55
import { DEBUG_BUILD } from './debug-build';
66
import { createEventEnvelope, createSessionEnvelope } from './envelope';
77
import type { IntegrationIndex } from './integration';
88
import { afterSetupIntegrations, setupIntegration, setupIntegrations } from './integration';
9+
import { _INTERNAL_flushLogsBuffer } from './logs/internal';
10+
import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
911
import type { Scope } from './scope';
1012
import { updateSession } from './session';
11-
import {
12-
getDynamicSamplingContextFromScope,
13-
getDynamicSamplingContextFromSpan,
14-
} from './tracing/dynamicSamplingContext';
13+
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
1514
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
1615
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
1716
import type { EventDropReason, Outcome } from './types-hoist/clientreport';
18-
import type { TraceContext } from './types-hoist/context';
1917
import type { DataCategory } from './types-hoist/datacategory';
2018
import type { DsnComponents } from './types-hoist/dsn';
2119
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
@@ -25,6 +23,7 @@ import type { FeedbackEvent } from './types-hoist/feedback';
2523
import type { Integration } from './types-hoist/integration';
2624
import type { Log } from './types-hoist/log';
2725
import type { Metric } from './types-hoist/metric';
26+
import type { Primitive } from './types-hoist/misc';
2827
import type { ClientOptions } from './types-hoist/options';
2928
import type { ParameterizedString } from './types-hoist/parameterize';
3029
import type { RequestEventData } from './types-hoist/request';
@@ -45,7 +44,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
4544
import { parseSampleRate } from './utils/parseSampleRate';
4645
import { prepareEvent } from './utils/prepareEvent';
4746
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
48-
import { getActiveSpan, showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
47+
import { showSpanDropWarning } from './utils/spanUtils';
4948
import { rejectedSyncPromise } from './utils/syncpromise';
5049
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
5150

@@ -55,6 +54,9 @@ const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing
5554
const INTERNAL_ERROR_SYMBOL = Symbol.for('SentryInternalError');
5655
const DO_NOT_SEND_EVENT_SYMBOL = Symbol.for('SentryDoNotSendEventError');
5756

57+
// Default interval for flushing logs and metrics (5 seconds)
58+
const DEFAULT_FLUSH_INTERVAL = 5000;
59+
5860
interface InternalError {
5961
message: string;
6062
[INTERNAL_ERROR_SYMBOL]: true;
@@ -87,6 +89,57 @@ function _isDoNotSendEventError(error: unknown): error is DoNotSendEventError {
8789
return !!error && typeof error === 'object' && DO_NOT_SEND_EVENT_SYMBOL in error;
8890
}
8991

92+
/**
93+
* Sets up weight-based flushing for logs or metrics.
94+
* This helper function encapsulates the common pattern of:
95+
* 1. Tracking accumulated weight of items
96+
* 2. Flushing when weight exceeds threshold (800KB)
97+
* 3. Flushing after idle timeout if no new items arrive
98+
*
99+
* Uses closure variables to track weight and timeout state.
100+
*/
101+
function setupWeightBasedFlushing<
102+
T,
103+
AfterCaptureHook extends 'afterCaptureLog' | 'afterCaptureMetric',
104+
FlushHook extends 'flushLogs' | 'flushMetrics',
105+
>(
106+
client: Client,
107+
afterCaptureHook: AfterCaptureHook,
108+
flushHook: FlushHook,
109+
estimateSizeFn: (item: T) => number,
110+
flushFn: (client: Client) => void,
111+
): void {
112+
// Track weight and timeout in closure variables
113+
let weight = 0;
114+
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
115+
116+
// @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe
117+
client.on(flushHook, () => {
118+
weight = 0;
119+
clearTimeout(flushTimeout);
120+
});
121+
122+
// @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe
123+
client.on(afterCaptureHook, (item: T) => {
124+
weight += estimateSizeFn(item);
125+
126+
// We flush the buffer if it exceeds 0.8 MB
127+
// The weight is a rough estimate, so we flush way before the payload gets too big.
128+
if (weight >= 800_000) {
129+
flushFn(client);
130+
} else {
131+
clearTimeout(flushTimeout);
132+
flushTimeout = setTimeout(() => {
133+
flushFn(client);
134+
}, DEFAULT_FLUSH_INTERVAL);
135+
}
136+
});
137+
138+
client.on('flush', () => {
139+
flushFn(client);
140+
});
141+
}
142+
90143
/**
91144
* Base implementation for all JavaScript SDK clients.
92145
*
@@ -173,6 +226,22 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
173226
url,
174227
});
175228
}
229+
230+
// Setup log flushing with weight and timeout tracking
231+
if (this._options.enableLogs) {
232+
setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer);
233+
}
234+
235+
// Setup metric flushing with weight and timeout tracking
236+
if (this._options._experiments?.enableMetrics) {
237+
setupWeightBasedFlushing(
238+
this,
239+
'afterCaptureMetric',
240+
'flushMetrics',
241+
estimateMetricSizeInBytes,
242+
_INTERNAL_flushMetricsBuffer,
243+
);
244+
}
176245
}
177246

178247
/**
@@ -1438,21 +1507,82 @@ function isTransactionEvent(event: Event): event is TransactionEvent {
14381507
return event.type === 'transaction';
14391508
}
14401509

1441-
/** Extract trace information from scope */
1442-
export function _getTraceInfoFromScope(
1443-
client: Client,
1444-
scope: Scope | undefined,
1445-
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
1446-
if (!scope) {
1447-
return [undefined, undefined];
1510+
/**
1511+
* Estimate the size of a metric in bytes.
1512+
*
1513+
* @param metric - The metric to estimate the size of.
1514+
* @returns The estimated size of the metric in bytes.
1515+
*/
1516+
function estimateMetricSizeInBytes(metric: Metric): number {
1517+
let weight = 0;
1518+
1519+
// Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16.
1520+
if (metric.name) {
1521+
weight += metric.name.length * 2;
14481522
}
14491523

1450-
return withScope(scope, () => {
1451-
const span = getActiveSpan();
1452-
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
1453-
const dynamicSamplingContext = span
1454-
? getDynamicSamplingContextFromSpan(span)
1455-
: getDynamicSamplingContextFromScope(client, scope);
1456-
return [dynamicSamplingContext, traceContext];
1524+
// Add weight for the value
1525+
if (typeof metric.value === 'string') {
1526+
weight += metric.value.length * 2;
1527+
} else {
1528+
weight += 8; // number
1529+
}
1530+
1531+
return weight + estimateAttributesSizeInBytes(metric.attributes);
1532+
}
1533+
1534+
/**
1535+
* Estimate the size of a log in bytes.
1536+
*
1537+
* @param log - The log to estimate the size of.
1538+
* @returns The estimated size of the log in bytes.
1539+
*/
1540+
function estimateLogSizeInBytes(log: Log): number {
1541+
let weight = 0;
1542+
1543+
// Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16.
1544+
if (log.message) {
1545+
weight += log.message.length * 2;
1546+
}
1547+
1548+
return weight + estimateAttributesSizeInBytes(log.attributes);
1549+
}
1550+
1551+
/**
1552+
* Estimate the size of attributes in bytes.
1553+
*
1554+
* @param attributes - The attributes object to estimate the size of.
1555+
* @returns The estimated size of the attributes in bytes.
1556+
*/
1557+
function estimateAttributesSizeInBytes(attributes: Record<string, unknown> | undefined): number {
1558+
if (!attributes) {
1559+
return 0;
1560+
}
1561+
1562+
let weight = 0;
1563+
1564+
Object.values(attributes).forEach(value => {
1565+
if (Array.isArray(value)) {
1566+
weight += value.length * estimatePrimitiveSizeInBytes(value[0]);
1567+
} else if (isPrimitive(value)) {
1568+
weight += estimatePrimitiveSizeInBytes(value);
1569+
} else {
1570+
// For objects values, we estimate the size of the object as 100 bytes
1571+
weight += 100;
1572+
}
14571573
});
1574+
1575+
return weight;
1576+
}
1577+
1578+
function estimatePrimitiveSizeInBytes(value: Primitive): number {
1579+
if (typeof value === 'string') {
1580+
return value.length * 2;
1581+
} else if (typeof value === 'number') {
1582+
return 8;
1583+
} else if (typeof value === 'boolean') {
1584+
return 4;
1585+
}
1586+
1587+
return 0;
14581588
}

0 commit comments

Comments
 (0)