From 1dae77ee1224410fdef61fe8b6564d077f7fec72 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:32:16 +0000 Subject: [PATCH 1/2] ref(core): Set up log and metric flushing on first capture The client constructor always set up weight-based flushing, which pulled the log and metric buffer and envelope code into every bundle. Flushing is now set up when a client's log or metric buffer is first created. Closes #26145 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/core/src/client.ts | 159 ------------------ packages/core/src/logs/internal.ts | 3 + packages/core/src/metrics/internal.ts | 9 + .../core/src/utils/weightBasedFlushing.ts | 149 ++++++++++++++++ packages/core/test/lib/client.test.ts | 53 +++++- 5 files changed, 206 insertions(+), 167 deletions(-) create mode 100644 packages/core/src/utils/weightBasedFlushing.ts diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 54f0c2dae0d2..e9f8b0e31404 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -6,8 +6,6 @@ import { DEBUG_BUILD } from './debug-build'; import { createEventEnvelope, createSessionEnvelope } from './envelope'; import type { IntegrationIndex } from './integration'; import { afterSetupIntegrations, setupIntegration, setupIntegrations } from './integration'; -import { _INTERNAL_flushLogsBuffer } from './logs/internal'; -import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; @@ -26,7 +24,6 @@ import type { FeedbackEvent } from './types/feedback'; import type { Integration } from './types/integration'; import type { Log } from './types/log'; import type { Metric } from './types/metric'; -import type { Primitive } from './types/misc'; import type { ClientOptions } from './types/options'; import type { ParameterizedString } from './types/parameterize'; import type { ReplayEndEvent, ReplayStartEvent } from './types/replay'; @@ -53,7 +50,6 @@ import { safeMathRandom } from './utils/randomSafeContext'; import { safeCallback } from './utils/safeCallback'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { rejectedSyncPromise } from './utils/syncpromise'; -import { safeUnref } from './utils/timer'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; import { maybeWarnAboutIgnoredTransactionOptions } from './utils/warnAboutIgnoredTransactionOptions'; import { resolveDataCollectionOptions } from './utils/data-collection/resolveDataCollectionOptions'; @@ -65,9 +61,6 @@ const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing const INTERNAL_ERROR_SYMBOL = Symbol.for('SentryInternalError'); const DO_NOT_SEND_EVENT_SYMBOL = Symbol.for('SentryDoNotSendEventError'); -// Default interval for flushing logs and metrics (5 seconds) -const DEFAULT_FLUSH_INTERVAL = 5000; - // Clones of a scope keep their own reference to its client, so unbinding a // closed client from one scope does not reach the others. The mark lives on // the client under a `Symbol.for` key, so every copy of the SDK can read it. @@ -120,70 +113,6 @@ function _isDoNotSendEventError(error: unknown): error is DoNotSendEventError { return isObjectLike(error) && DO_NOT_SEND_EVENT_SYMBOL in error; } -/** - * Sets up weight-based flushing for logs or metrics. - * This helper function encapsulates the common pattern of: - * 1. Tracking accumulated weight of items - * 2. Flushing when weight exceeds threshold (800KB) - * 3. Flushing after timeout period from the first item - * - * Uses closure variables to track weight and timeout state. - */ -function setupWeightBasedFlushing< - T, - AfterCaptureHook extends 'afterCaptureLog' | 'afterCaptureMetric', - FlushHook extends 'flushLogs' | 'flushMetrics', ->( - client: Client, - afterCaptureHook: AfterCaptureHook, - flushHook: FlushHook, - estimateSizeFn: (item: T) => number, - flushFn: (client: Client) => void, -): void { - // Track weight and timeout in closure variables - let weight = 0; - let flushTimeout: ReturnType | undefined; - let isTimerActive = false; - - // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe - client.on(flushHook, () => { - weight = 0; - clearTimeout(flushTimeout); - isTimerActive = false; - }); - - // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe - client.on(afterCaptureHook, (item: T) => { - weight += estimateSizeFn(item); - - // We flush the buffer if it exceeds 0.8 MB - // The weight is a rough estimate, so we flush way before the payload gets too big. - if (weight >= 800_000) { - flushFn(client); - } else if (!isTimerActive) { - const flushInterval = client.getOptions()._flushInterval ?? DEFAULT_FLUSH_INTERVAL; - if (flushInterval > 0) { - // Only start timer if one isn't already running. - // This prevents flushing being delayed by items that arrive close to the timeout limit - // and thus resetting the flushing timeout and delaying items being flushed. - isTimerActive = true; - // Use safeUnref so the timer doesn't prevent the process from exiting - flushTimeout = safeUnref( - setTimeout(() => { - flushFn(client); - // Note: isTimerActive is reset by the flushHook handler above, not here, - // to avoid race conditions when new items arrive during the flush. - }, flushInterval), - ); - } - } - }); - - client.on('flush', () => { - flushFn(client); - }); -} - /** * Base implementation for all JavaScript SDK clients. * @@ -313,18 +242,6 @@ export abstract class Client { url, }); } - - // Setup log flushing with weight and timeout tracking - setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer); - - // Setup metric flushing with weight and timeout tracking - setupWeightBasedFlushing( - this, - 'afterCaptureMetric', - 'flushMetrics', - estimateMetricSizeInBytes, - _INTERNAL_flushMetricsBuffer, - ); } /** @@ -1874,79 +1791,3 @@ function isErrorEvent(event: Event): event is ErrorEvent { function isTransactionEvent(event: Event): event is TransactionEvent { return event.type === 'transaction'; } - -/** - * Estimate the size of a metric in bytes. - * - * @param metric - The metric to estimate the size of. - * @returns The estimated size of the metric in bytes. - */ -function estimateMetricSizeInBytes(metric: Metric): number { - let weight = 0; - - // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. - if (metric.name) { - weight += metric.name.length * 2; - } - - // Add weight for number - weight += 8; - - return weight + estimateAttributesSizeInBytes(metric.attributes); -} - -/** - * Estimate the size of a log in bytes. - * - * @param log - The log to estimate the size of. - * @returns The estimated size of the log in bytes. - */ -function estimateLogSizeInBytes(log: Log): number { - let weight = 0; - - // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. - if (log.message) { - weight += log.message.length * 2; - } - - return weight + estimateAttributesSizeInBytes(log.attributes); -} - -/** - * Estimate the size of attributes in bytes. - * - * @param attributes - The attributes object to estimate the size of. - * @returns The estimated size of the attributes in bytes. - */ -function estimateAttributesSizeInBytes(attributes: Record | undefined): number { - if (!attributes) { - return 0; - } - - let weight = 0; - - Object.values(attributes).forEach(value => { - if (Array.isArray(value)) { - weight += value.length * estimatePrimitiveSizeInBytes(value[0]); - } else if (isPrimitive(value)) { - weight += estimatePrimitiveSizeInBytes(value); - } else { - // For objects values, we estimate the size of the object as 100 bytes - weight += 100; - } - }); - - return weight; -} - -function estimatePrimitiveSizeInBytes(value: Primitive): number { - if (typeof value === 'string') { - return value.length * 2; - } else if (typeof value === 'number') { - return 8; - } else if (typeof value === 'boolean') { - return 4; - } - - return 0; -} diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 652212746a5c..dd5cc98ee5fc 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -26,6 +26,7 @@ import { getActiveSpan } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; import { getSequenceAttribute } from '../utils/timestampSequence'; import { _getTraceInfoFromScope } from '../utils/trace-info'; +import { estimateLogSizeInBytes, setupWeightBasedFlushing } from '../utils/weightBasedFlushing'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; import { createLogEnvelope } from './envelope'; @@ -64,6 +65,8 @@ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: Se const logBuffer = _INTERNAL_getLogBuffer(client); if (logBuffer === undefined) { + // Set up flushing on the first log, so apps that never capture logs don't bundle the flush code. + setupWeightBasedFlushing(client, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer); bufferMap.set(client, [serializedLog]); } else { if (logBuffer.length >= MAX_LOG_BUFFER_SIZE) { diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 4d0635522bd6..6e4dd5881009 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -24,6 +24,7 @@ import { getActiveSpan } from '../utils/spanUtils'; import { timestampInSeconds } from '../utils/time'; import { getSequenceAttribute } from '../utils/timestampSequence'; import { _getTraceInfoFromScope } from '../utils/trace-info'; +import { estimateMetricSizeInBytes, setupWeightBasedFlushing } from '../utils/weightBasedFlushing'; import { createMetricEnvelope } from './envelope'; const MAX_METRIC_BUFFER_SIZE = 1000; @@ -61,6 +62,14 @@ export function _INTERNAL_captureSerializedMetric(client: Client, serializedMetr const metricBuffer = _INTERNAL_getMetricBuffer(client); if (metricBuffer === undefined) { + // Set up flushing on the first metric, so apps that never capture metrics don't bundle the flush code. + setupWeightBasedFlushing( + client, + 'afterCaptureMetric', + 'flushMetrics', + estimateMetricSizeInBytes, + _INTERNAL_flushMetricsBuffer, + ); bufferMap.set(client, [serializedMetric]); } else { if (metricBuffer.length >= MAX_METRIC_BUFFER_SIZE) { diff --git a/packages/core/src/utils/weightBasedFlushing.ts b/packages/core/src/utils/weightBasedFlushing.ts new file mode 100644 index 000000000000..7a50bd83fd0a --- /dev/null +++ b/packages/core/src/utils/weightBasedFlushing.ts @@ -0,0 +1,149 @@ +import type { Client } from '../client'; +import type { Log } from '../types/log'; +import type { Metric } from '../types/metric'; +import type { Primitive } from '../types/misc'; +import { isPrimitive } from './is'; +import { safeUnref } from './timer'; + +// Default interval for flushing logs and metrics (5 seconds) +const DEFAULT_FLUSH_INTERVAL = 5000; + +/** + * Sets up weight-based flushing for logs or metrics. + * This helper function encapsulates the common pattern of: + * 1. Tracking accumulated weight of items + * 2. Flushing when weight exceeds threshold (800KB) + * 3. Flushing after timeout period from the first item + * + * Uses closure variables to track weight and timeout state. + */ +export function setupWeightBasedFlushing< + T, + AfterCaptureHook extends 'afterCaptureLog' | 'afterCaptureMetric', + FlushHook extends 'flushLogs' | 'flushMetrics', +>( + client: Client, + afterCaptureHook: AfterCaptureHook, + flushHook: FlushHook, + estimateSizeFn: (item: T) => number, + flushFn: (client: Client) => void, +): void { + // Track weight and timeout in closure variables + let weight = 0; + let flushTimeout: ReturnType | undefined; + let isTimerActive = false; + + // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe + client.on(flushHook, () => { + weight = 0; + clearTimeout(flushTimeout); + isTimerActive = false; + }); + + // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe + client.on(afterCaptureHook, (item: T) => { + weight += estimateSizeFn(item); + + // We flush the buffer if it exceeds 0.8 MB + // The weight is a rough estimate, so we flush way before the payload gets too big. + if (weight >= 800_000) { + flushFn(client); + } else if (!isTimerActive) { + const flushInterval = client.getOptions()._flushInterval ?? DEFAULT_FLUSH_INTERVAL; + if (flushInterval > 0) { + // Only start timer if one isn't already running. + // This prevents flushing being delayed by items that arrive close to the timeout limit + // and thus resetting the flushing timeout and delaying items being flushed. + isTimerActive = true; + // Use safeUnref so the timer doesn't prevent the process from exiting + flushTimeout = safeUnref( + setTimeout(() => { + flushFn(client); + // Note: isTimerActive is reset by the flushHook handler above, not here, + // to avoid race conditions when new items arrive during the flush. + }, flushInterval), + ); + } + } + }); + + client.on('flush', () => { + flushFn(client); + }); +} + +/** + * Estimate the size of a metric in bytes. + * + * @param metric - The metric to estimate the size of. + * @returns The estimated size of the metric in bytes. + */ +export function estimateMetricSizeInBytes(metric: Metric): number { + let weight = 0; + + // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. + if (metric.name) { + weight += metric.name.length * 2; + } + + // Add weight for number + weight += 8; + + return weight + estimateAttributesSizeInBytes(metric.attributes); +} + +/** + * Estimate the size of a log in bytes. + * + * @param log - The log to estimate the size of. + * @returns The estimated size of the log in bytes. + */ +export function estimateLogSizeInBytes(log: Log): number { + let weight = 0; + + // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. + if (log.message) { + weight += log.message.length * 2; + } + + return weight + estimateAttributesSizeInBytes(log.attributes); +} + +/** + * Estimate the size of attributes in bytes. + * + * @param attributes - The attributes object to estimate the size of. + * @returns The estimated size of the attributes in bytes. + */ +function estimateAttributesSizeInBytes(attributes: Record | undefined): number { + if (!attributes) { + return 0; + } + + let weight = 0; + + Object.values(attributes).forEach(value => { + if (Array.isArray(value)) { + weight += value.length * estimatePrimitiveSizeInBytes(value[0]); + } else if (isPrimitive(value)) { + weight += estimatePrimitiveSizeInBytes(value); + } else { + // For objects values, we estimate the size of the object as 100 bytes + weight += 100; + } + }); + + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'number') { + return 8; + } else if (typeof value === 'boolean') { + return 4; + } + + return 0; +} diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 5d06b33e40d7..8039b54dd9f4 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -3144,19 +3144,20 @@ describe('Client', () => { test('close flushes the logs buffer', async () => { vi.useRealTimers(); - const flushLogsSpy = vi - .spyOn(logsInternalModule, '_INTERNAL_flushLogsBuffer') - .mockImplementation(() => undefined); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - await client.close(); + const flushLogsHandler = vi.fn(); + client.on('flushLogs', flushLogsHandler); + + _INTERNAL_captureLog({ message: 'test log', level: 'info' }, scope); - expect(flushLogsSpy).toHaveBeenCalledTimes(1); - expect(flushLogsSpy).toHaveBeenCalledWith(client); + await client.close(); - flushLogsSpy.mockRestore(); + expect(flushLogsHandler).toHaveBeenCalledTimes(1); + expect(logsInternalModule._INTERNAL_getLogBuffer(client)).toEqual([]); }); test('multiple concurrent flush calls should just work', async () => { @@ -4215,6 +4216,24 @@ describe('Client', () => { vi.useRealTimers(); }); + it('sets up log flushing on the first captured log only', () => { + const onSpy = vi.spyOn(TestClient.prototype, 'on'); + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const scope = new Scope(); + scope.setClient(client); + + expect(onSpy).not.toHaveBeenCalledWith('afterCaptureLog', expect.any(Function)); + + _INTERNAL_captureLog({ message: 'test log 1', level: 'info' }, scope); + expect(onSpy).toHaveBeenCalledWith('afterCaptureLog', expect.any(Function)); + + onSpy.mockClear(); + _INTERNAL_captureLog({ message: 'test log 2', level: 'info' }, scope); + expect(onSpy).not.toHaveBeenCalled(); + + onSpy.mockRestore(); + }); + it('flushes logs when weight exceeds 800KB', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, @@ -4398,6 +4417,24 @@ describe('Client', () => { vi.useRealTimers(); }); + it('sets up metric flushing on the first captured metric only', () => { + const onSpy = vi.spyOn(TestClient.prototype, 'on'); + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + const scope = new Scope(); + scope.setClient(client); + + expect(onSpy).not.toHaveBeenCalledWith('afterCaptureMetric', expect.any(Function)); + + _INTERNAL_captureMetric({ name: 'metric1', value: 1, type: 'counter', attributes: {} }, { scope }); + expect(onSpy).toHaveBeenCalledWith('afterCaptureMetric', expect.any(Function)); + + onSpy.mockClear(); + _INTERNAL_captureMetric({ name: 'metric2', value: 2, type: 'counter', attributes: {} }, { scope }); + expect(onSpy).not.toHaveBeenCalled(); + + onSpy.mockRestore(); + }); + it('flushes metrics when weight exceeds 800KB', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, From 196399a3f1b6919fd4e9aea0d56896e9726304a0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:44:11 +0000 Subject: [PATCH 2/2] test(core): Assert only the flush hook in first-capture tests --- packages/core/test/lib/client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 8039b54dd9f4..8d05b74cf9d8 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -4229,7 +4229,7 @@ describe('Client', () => { onSpy.mockClear(); _INTERNAL_captureLog({ message: 'test log 2', level: 'info' }, scope); - expect(onSpy).not.toHaveBeenCalled(); + expect(onSpy).not.toHaveBeenCalledWith('afterCaptureLog', expect.any(Function)); onSpy.mockRestore(); }); @@ -4430,7 +4430,7 @@ describe('Client', () => { onSpy.mockClear(); _INTERNAL_captureMetric({ name: 'metric2', value: 2, type: 'counter', attributes: {} }, { scope }); - expect(onSpy).not.toHaveBeenCalled(); + expect(onSpy).not.toHaveBeenCalledWith('afterCaptureMetric', expect.any(Function)); onSpy.mockRestore(); });