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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 1 addition & 36 deletions packages/browser/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ import type { BrowserTransportOptions } from './transports/types';
*/
declare const __SENTRY_RELEASE__: string | undefined;

const DEFAULT_FLUSH_INTERVAL = 5000;

type BrowserSpecificOptions = BrowserClientReplayOptions &
BrowserClientProfilingOptions & {
/** If configured, this URL will be used as base URL for lazy loading integration. */
Expand Down Expand Up @@ -85,8 +83,6 @@ export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> & Brow
* @see SentryClient for usage documentation.
*/
export class BrowserClient extends Client<BrowserClientOptions> {
private _logFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
private _metricFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* Creates a new Browser SDK instance.
*
Expand All @@ -110,6 +106,7 @@ export class BrowserClient extends Client<BrowserClientOptions> {

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

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

if (enableLogs) {
this.on('flush', () => {
_INTERNAL_flushLogsBuffer(this);
});

this.on('afterCaptureLog', () => {
if (this._logFlushIdleTimeout) {
clearTimeout(this._logFlushIdleTimeout);
}

this._logFlushIdleTimeout = setTimeout(() => {
_INTERNAL_flushLogsBuffer(this);
}, DEFAULT_FLUSH_INTERVAL);
});
}

if (_experiments?.enableMetrics) {
this.on('flush', () => {
_INTERNAL_flushMetricsBuffer(this);
});

this.on('afterCaptureMetric', () => {
if (this._metricFlushIdleTimeout) {
clearTimeout(this._metricFlushIdleTimeout);
}

this._metricFlushIdleTimeout = setTimeout(() => {
_INTERNAL_flushMetricsBuffer(this);
}, DEFAULT_FLUSH_INTERVAL);
});
}

if (sendDefaultPii) {
this.on('beforeSendSession', addAutoIpAddressToSession);
}
Expand Down
54 changes: 0 additions & 54 deletions packages/browser/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ vi.mock('@sentry/core', async requireActual => {

describe('BrowserClient', () => {
let client: BrowserClient;
const DEFAULT_FLUSH_INTERVAL = 5000;

afterEach(() => {
vi.useRealTimers();
Expand Down Expand Up @@ -77,59 +76,6 @@ describe('BrowserClient', () => {
expect(flushOutcomesSpy).toHaveBeenCalled();
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
});

it('flushes logs on flush event', () => {
const scope = new Scope();
scope.setClient(client);

// Add some logs
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope);
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope);

// Trigger flush event
client.emit('flush');

expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
});

it('flushes logs after idle timeout', () => {
const scope = new Scope();
scope.setClient(client);

// Add a log which will trigger afterCaptureLog event
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log' }, scope);

// Fast forward the idle timeout
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL);

expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
});

it('resets idle timeout when new logs are captured', () => {
const scope = new Scope();
scope.setClient(client);

// Add initial log
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope);

// Fast forward part of the idle timeout
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2);

// Add another log which should reset the timeout
sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope);

// Fast forward the remaining time
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2);

// Should not have flushed yet since timeout was reset
expect(sentryCore._INTERNAL_flushLogsBuffer).not.toHaveBeenCalled();

// Fast forward the full timeout
vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL);

// Now should have flushed both logs
expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client);
});
});
});

Expand Down
172 changes: 151 additions & 21 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
/* eslint-disable max-lines */
import { getEnvelopeEndpointWithUrlEncodedAuth } from './api';
import { DEFAULT_ENVIRONMENT } from './constants';
import { getCurrentScope, getIsolationScope, getTraceContextFromScope, withScope } from './currentScopes';
import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes';
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,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
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 { TraceContext } from './types-hoist/context';
import type { DataCategory } from './types-hoist/datacategory';
import type { DsnComponents } from './types-hoist/dsn';
import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope';
Expand All @@ -25,6 +23,7 @@ import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
import type { Log } from './types-hoist/log';
import type { Metric } from './types-hoist/metric';
import type { Primitive } from './types-hoist/misc';
import type { ClientOptions } from './types-hoist/options';
import type { ParameterizedString } from './types-hoist/parameterize';
import type { RequestEventData } from './types-hoist/request';
Expand All @@ -45,7 +44,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span';
import { getActiveSpan, showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';

Expand All @@ -55,6 +54,9 @@ 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;

interface InternalError {
message: string;
[INTERNAL_ERROR_SYMBOL]: true;
Expand Down Expand Up @@ -87,6 +89,57 @@ function _isDoNotSendEventError(error: unknown): error is DoNotSendEventError {
return !!error && typeof error === 'object' && 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 idle timeout if no new items arrive
*
* 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<typeof setTimeout> | undefined;

// @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);
});

// @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 {
clearTimeout(flushTimeout);
flushTimeout = setTimeout(() => {
flushFn(client);
}, DEFAULT_FLUSH_INTERVAL);
}
});

client.on('flush', () => {
flushFn(client);
});
}

/**
* Base implementation for all JavaScript SDK clients.
*
Expand Down Expand Up @@ -173,6 +226,22 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
url,
});
}

// Setup log flushing with weight and timeout tracking
if (this._options.enableLogs) {
setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer);
}

// Setup metric flushing with weight and timeout tracking
if (this._options._experiments?.enableMetrics) {
setupWeightBasedFlushing(
this,
'afterCaptureMetric',
'flushMetrics',
estimateMetricSizeInBytes,
_INTERNAL_flushMetricsBuffer,
);
}
}

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

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
/**
* 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;
}

return withScope(scope, () => {
const span = getActiveSpan();
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
// Add weight for the value
if (typeof metric.value === 'string') {
weight += metric.value.length * 2;
} else {
weight += 8; // number
}

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<string, unknown> | 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;
}
2 changes: 1 addition & 1 deletion packages/core/src/logs/internal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { getGlobalSingleton } from '../carrier';
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import type { Scope, ScopeData } from '../scope';
Expand All @@ -11,6 +10,7 @@ import { consoleSandbox, debug } from '../utils/debug-logger';
import { isParameterizedString } from '../utils/is';
import { _getSpanForScope } from '../utils/spanOnScope';
import { timestampInSeconds } from '../utils/time';
import { _getTraceInfoFromScope } from '../utils/trace-info';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import { createLogEnvelope } from './envelope';

Expand Down
Loading
Loading