From 4a476a2d9d6fade202149333df4fbe89c9e7ac3d Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 7 Oct 2025 09:56:31 -0400 Subject: [PATCH 01/14] feat(tracemetrics): Add trace metrics behind an experiments flag This allows the js sdk to send in new trace metric protocol items, although this code is experimental since the schema may still change. Most of this has been copied from logs so some parts may need to be modified / removed later (eg. buffer) but this should allow us to start on UI work by sending in larger amounts of data from sentry js app to test grouping / aggregations etc. --- .../suites/public-api/metrics/init.js | 11 + .../public-api/metrics/simple/subject.js | 12 + .../suites/public-api/metrics/simple/test.ts | 104 +++++++ .../utils/assertions.ts | 10 + .../node-integration-tests/utils/runner.ts | 22 ++ packages/browser/src/client.ts | 25 +- packages/browser/src/exports.ts | 1 + packages/core/src/carrier.ts | 7 + packages/core/src/client.ts | 25 ++ packages/core/src/index.ts | 15 + packages/core/src/metrics/envelope.ts | 58 ++++ packages/core/src/metrics/internal.ts | 267 ++++++++++++++++++ packages/core/src/metrics/public-api.ts | 140 +++++++++ packages/core/src/server-runtime-client.ts | 72 +++++ packages/core/src/types-hoist/datacategory.ts | 2 + packages/core/src/types-hoist/envelope.ts | 19 +- packages/core/src/types-hoist/metric.ts | 80 ++++++ packages/core/src/types-hoist/options.ts | 24 ++ packages/core/src/utils/envelope.ts | 1 + packages/node/src/index.ts | 2 + 20 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/init.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts create mode 100644 packages/core/src/metrics/envelope.ts create mode 100644 packages/core/src/metrics/internal.ts create mode 100644 packages/core/src/metrics/public-api.ts create mode 100644 packages/core/src/types-hoist/metric.ts diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js new file mode 100644 index 000000000000..900f806f3482 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + _enableTraceMetrics: true, + release: '1.0.0', + environment: 'test', + autoSessionTracking: false, // Was causing session envelopes to be sent +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js new file mode 100644 index 000000000000..0b8fced8d6e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js @@ -0,0 +1,12 @@ +Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); +Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); +Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } }); + +Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { + Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } }); +}); + +Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); +Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + +Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts new file mode 100644 index 000000000000..8f58e70adfd3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test'; +import type { MetricEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; + +sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE || ''; + if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); + const envelopeItems = event[1]; + + expect(envelopeItems[0]).toEqual([ + { + type: 'metric', + item_count: 5, + content_type: 'application/vnd.sentry.items.trace-metric+json', + }, + { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: { value: 'test-1', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.distribution', + type: 'distribution', + unit: 'second', + value: 200, + attributes: { + priority: { value: 'high', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.span.counter', + type: 'counter', + value: 1, + attributes: { + operation: { value: 'test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.user.counter', + type: 'counter', + value: 1, + attributes: { + action: { value: 'click', type: 'string' }, + 'user.id': { value: 'user-123', type: 'string' }, + 'user.email': { value: 'test@example.com', type: 'string' }, + 'user.name': { value: 'testuser', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + ]); +}); diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts index 296bdc608bb4..8d9fb5f2251f 100644 --- a/dev-packages/node-integration-tests/utils/assertions.ts +++ b/dev-packages/node-integration-tests/utils/assertions.ts @@ -4,6 +4,7 @@ import type { Event, SerializedCheckIn, SerializedLogContainer, + SerializedMetricContainer, SerializedSession, SessionAggregates, TransactionEvent, @@ -76,6 +77,15 @@ export function assertSentryLogContainer( }); } +export function assertSentryMetricContainer( + actual: SerializedMetricContainer, + expected: Partial, +): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { expect(actual).toEqual({ event_id: expect.any(String), diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index b0c6467fd75a..2f6390e35dc3 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -7,6 +7,7 @@ import type { EventEnvelope, SerializedCheckIn, SerializedLogContainer, + SerializedMetricContainer, SerializedSession, SessionAggregates, TransactionEvent, @@ -25,6 +26,7 @@ import { assertSentryClientReport, assertSentryEvent, assertSentryLogContainer, + assertSentryMetricContainer, assertSentrySession, assertSentrySessions, assertSentryTransaction, @@ -130,6 +132,7 @@ type ExpectedSessions = Partial | ((event: SessionAggregates) type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void); type ExpectedClientReport = Partial | ((event: ClientReport) => void); type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); +type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void); type Expected = | { @@ -152,6 +155,9 @@ type Expected = } | { log: ExpectedLogContainer; + } + | { + metric: ExpectedMetricContainer; }; type ExpectedEnvelopeHeader = @@ -380,6 +386,11 @@ export function createRunner(...paths: string[]) { expectedEnvelopeHeaders.push(expected); return this; }, + expectMetricEnvelope: function () { + // Unignore metric envelopes + ignored.delete('metric'); + return this; + }, withEnv: function (env: Record) { withEnv = env; return this; @@ -514,6 +525,9 @@ export function createRunner(...paths: string[]) { } else if ('log' in expected) { expectLog(item[1] as SerializedLogContainer, expected.log); expectCallbackCalled(); + } else if ('metric' in expected) { + expectMetric(item[1] as SerializedMetricContainer, expected.metric); + expectCallbackCalled(); } else { throw new Error( `Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`, @@ -769,6 +783,14 @@ function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer) } } +function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricContainer): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryMetricContainer(item, expected); + } +} + /** * Converts ESM import statements to CommonJS require statements * @param content The content of an ESM file diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index b4e4f24d3b90..e874a7ebde53 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -11,6 +11,7 @@ import type { } from '@sentry/core'; import { _INTERNAL_flushLogsBuffer, + _INTERNAL_flushMetricsBuffer, addAutoIpAddressToSession, applySdkMetadata, Client, @@ -85,6 +86,7 @@ export type BrowserClientOptions = ClientOptions & Brow */ export class BrowserClient extends Client { private _logFlushIdleTimeout: ReturnType | undefined; + private _metricFlushIdleTimeout: ReturnType | undefined; /** * Creates a new Browser SDK instance. * @@ -106,9 +108,9 @@ export class BrowserClient extends Client { super(opts); - const { sendDefaultPii, sendClientReports, enableLogs } = this._options; + const { sendDefaultPii, sendClientReports, enableLogs, _enableTraceMetrics } = this._options; - if (WINDOW.document && (sendClientReports || enableLogs)) { + if (WINDOW.document && (sendClientReports || enableLogs || _enableTraceMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { if (sendClientReports) { @@ -117,6 +119,9 @@ export class BrowserClient extends Client { if (enableLogs) { _INTERNAL_flushLogsBuffer(this); } + if (_enableTraceMetrics) { + _INTERNAL_flushMetricsBuffer(this); + } } }); } @@ -137,6 +142,22 @@ export class BrowserClient extends Client { }); } + if (_enableTraceMetrics) { + 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); } diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 2a45880de82b..50223e4b9fd9 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -65,6 +65,7 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + metrics, } from '@sentry/core'; export { diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 201e79cb4514..992c30681924 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -3,6 +3,7 @@ import type { AsyncContextStrategy } from './asyncContext/types'; import type { Client } from './client'; import type { Scope } from './scope'; import type { SerializedLog } from './types-hoist/log'; +import type { SerializedMetric } from './types-hoist/metric'; import { SDK_VERSION } from './utils/version'; import { GLOBAL_OBJ } from './utils/worldwide'; @@ -32,6 +33,12 @@ export interface SentryCarrier { */ clientToLogBufferMap?: WeakMap>; + /** + * A map of Sentry clients to their metric buffers. + * This is used to store metrics that are sent to Sentry. + */ + clientToMetricBufferMap?: WeakMap>; + /** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */ encodePolyfill?: (input: string) => Uint8Array; /** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */ diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1de223b327c0..5a271bba1981 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -24,6 +24,7 @@ import type { EventProcessor } from './types-hoist/eventprocessor'; 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 { ClientOptions } from './types-hoist/options'; import type { ParameterizedString } from './types-hoist/parameterize'; import type { SdkMetadata } from './types-hoist/sdkmetadata'; @@ -687,6 +688,20 @@ export abstract class Client { */ public on(hook: 'flushLogs', callback: () => void): () => void; + /** + * A hook that is called after capturing a metric. This hooks runs after `beforeSendMetric` is fired. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'afterCaptureMetric', callback: (metric: Metric) => void): () => void; + + /** + * A hook that is called when the client is flushing metrics + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'flushMetrics', callback: () => void): () => void; + /** * Register a hook on this client. */ @@ -875,6 +890,16 @@ export abstract class Client { */ public emit(hook: 'flushLogs'): void; + /** + * Emit a hook event for client after capturing a metric. + */ + public emit(hook: 'afterCaptureMetric', metric: Metric): void; + + /** + * Emit a hook event for client flush metrics + */ + public emit(hook: 'flushMetrics'): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0daefd54d76..06be19c86774 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,13 @@ export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/internal'; export * as logger from './logs/public-api'; export { consoleLoggingIntegration } from './logs/console-integration'; +export { + _INTERNAL_captureMetric, + _INTERNAL_flushMetricsBuffer, + _INTERNAL_captureSerializedMetric, +} from './metrics/internal'; +export * as metrics from './metrics/public-api'; +export type { MetricOptions } from './metrics/public-api'; export { createConsolaReporter } from './integrations/consola'; export { addVercelAiProcessors } from './utils/vercel-ai'; export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils'; @@ -355,6 +362,7 @@ export type { SpanEnvelope, SpanItem, LogEnvelope, + MetricEnvelope, } from './types-hoist/envelope'; export type { ExtendedError } from './types-hoist/error'; export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent } from './types-hoist/event'; @@ -416,6 +424,13 @@ export type { } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; export type { Log, LogSeverityLevel } from './types-hoist/log'; +export type { + Metric, + MetricType, + SerializedMetric, + SerializedMetricContainer, + SerializedMetricAttributeValue, +} from './types-hoist/metric'; export type { TimedEvent } from './types-hoist/timedEvent'; export type { StackFrame } from './types-hoist/stackframe'; export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './types-hoist/stacktrace'; diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts new file mode 100644 index 000000000000..fb68f43c9c75 --- /dev/null +++ b/packages/core/src/metrics/envelope.ts @@ -0,0 +1,58 @@ +import type { DsnComponents } from '../types-hoist/dsn'; +import type { MetricContainerItem, MetricEnvelope } from '../types-hoist/envelope'; +import type { SerializedMetric } from '../types-hoist/metric'; +import type { SdkMetadata } from '../types-hoist/sdkmetadata'; +import { dsnToString } from '../utils/dsn'; +import { createEnvelope } from '../utils/envelope'; + +/** + * Creates a metric container envelope item for a list of metrics. + * + * @param items - The metrics to include in the envelope. + * @returns The created metric container envelope item. + */ +export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem { + return [ + { + type: 'metric', + item_count: items.length, + content_type: 'application/vnd.sentry.items.trace-metric+json', + } as MetricContainerItem[0], + { + items, + }, + ]; +} + +/** + * Creates an envelope for a list of metrics. + * + * Metrics from multiple traces can be included in the same envelope. + * + * @param metrics - The metrics to include in the envelope. + * @param metadata - The metadata to include in the envelope. + * @param tunnel - The tunnel to include in the envelope. + * @param dsn - The DSN to include in the envelope. + * @returns The created envelope. + */ +export function createMetricEnvelope( + metrics: Array, + metadata?: SdkMetadata, + tunnel?: string, + dsn?: DsnComponents, +): MetricEnvelope { + const headers: MetricEnvelope[0] = {}; + + if (metadata?.sdk) { + headers.sdk = { + name: metadata.sdk.name, + version: metadata.sdk.version, + }; + } + + if (!!tunnel && !!dsn) { + headers.dsn = dsnToString(dsn); + } + + return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics)]); +} diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts new file mode 100644 index 000000000000..e329eeab719a --- /dev/null +++ b/packages/core/src/metrics/internal.ts @@ -0,0 +1,267 @@ +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'; +import type { Integration } from '../types-hoist/integration'; +import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric'; +import { mergeScopeData } from '../utils/applyScopeDataToEvent'; +import { consoleSandbox, debug } from '../utils/debug-logger'; +import { _getSpanForScope } from '../utils/spanOnScope'; +import { timestampInSeconds } from '../utils/time'; +import { createMetricEnvelope } from './envelope'; + +const MAX_METRIC_BUFFER_SIZE = 100; + +/** + * Converts a metric attribute to a serialized metric attribute. + * + * @param value - The value of the metric attribute. + * @returns The serialized metric attribute. + */ +export function metricAttributeToSerializedMetricAttribute(value: unknown): SerializedMetricAttributeValue { + switch (typeof value) { + case 'number': + if (Number.isInteger(value)) { + return { + value, + type: 'integer', + }; + } + return { + value, + type: 'double', + }; + case 'boolean': + return { + value, + type: 'boolean', + }; + case 'string': + return { + value, + type: 'string', + }; + default: { + let stringValue = ''; + try { + stringValue = JSON.stringify(value) ?? ''; + } catch { + // Do nothing + } + return { + value: stringValue, + type: 'string', + }; + } + } +} + +/** + * Sets a metric attribute if the value exists and the attribute key is not already present. + * + * @param metricAttributes - The metric attributes object to modify. + * @param key - The attribute key to set. + * @param value - The value to set (only sets if truthy and key not present). + * @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true. + */ +function setMetricAttribute( + metricAttributes: Record, + key: string, + value: unknown, + setEvenIfPresent = true, +): void { + if (value && (setEvenIfPresent || !(key in metricAttributes))) { + metricAttributes[key] = value; + } +} + +/** + * Captures a serialized metric event and adds it to the metric buffer for the given client. + * + * @param client - A client. Uses the current client if not provided. + * @param serializedMetric - The serialized metric event to capture. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + */ +export function _INTERNAL_captureSerializedMetric(client: Client, serializedMetric: SerializedMetric): void { + const bufferMap = _getBufferMap(); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + if (metricBuffer === undefined) { + bufferMap.set(client, [serializedMetric]); + } else { + bufferMap.set(client, [...metricBuffer, serializedMetric]); + if (metricBuffer.length >= MAX_METRIC_BUFFER_SIZE) { + _INTERNAL_flushMetricsBuffer(client, metricBuffer); + } + } +} + +/** + * Options for capturing a metric internally. + */ +export interface InternalCaptureMetricOptions { + /** + * The scope to capture the metric with. + */ + scope?: Scope; + + /** + * A function to capture the serialized metric. + */ + captureSerializedMetric?: (client: Client, metric: SerializedMetric) => void; +} + +/** + * Captures a metric event and sends it to Sentry. + * + * @param metric - The metric event to capture. + * @param options - Options for capturing the metric. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + */ +export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: InternalCaptureMetricOptions): void { + const currentScope = options?.scope ?? getCurrentScope(); + const captureSerializedMetric = options?.captureSerializedMetric ?? _INTERNAL_captureSerializedMetric; + const client = currentScope?.getClient() ?? getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No client available to capture metric.'); + return; + } + + const { release, environment, _enableTraceMetrics = false, beforeSendMetric } = client.getOptions(); + if (!_enableTraceMetrics) { + DEBUG_BUILD && debug.warn('trace metrics option not enabled, metric will not be captured.'); + return; + } + + const [, traceContext] = _getTraceInfoFromScope(client, currentScope); + + const processedMetricAttributes = { + ...beforeMetric.attributes, + }; + + const { + user: { id, email, username }, + } = getMergedScopeData(currentScope); + setMetricAttribute(processedMetricAttributes, 'user.id', id, false); + setMetricAttribute(processedMetricAttributes, 'user.email', email, false); + setMetricAttribute(processedMetricAttributes, 'user.name', username, false); + + setMetricAttribute(processedMetricAttributes, 'sentry.release', release); + setMetricAttribute(processedMetricAttributes, 'sentry.environment', environment); + + const { name, version } = client.getSdkMetadata()?.sdk ?? {}; + setMetricAttribute(processedMetricAttributes, 'sentry.sdk.name', name); + setMetricAttribute(processedMetricAttributes, 'sentry.sdk.version', version); + + const replay = client.getIntegrationByName string }>('Replay'); + setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replay?.getReplayId()); + + const metric: Metric = { + ...beforeMetric, + attributes: processedMetricAttributes, + }; + + // Run beforeSendMetric callback + const processedMetric = beforeSendMetric ? beforeSendMetric(metric) : metric; + + if (!processedMetric) { + DEBUG_BUILD && debug.log('An event processor returned `null`, will not send metric.'); + return; + } + + const serializedAttributes: Record = {}; + for (const key in processedMetric.attributes) { + if (processedMetric.attributes[key] !== undefined) { + serializedAttributes[key] = metricAttributeToSerializedMetricAttribute(processedMetric.attributes[key]); + } + } + + const span = _getSpanForScope(currentScope); + const traceId = span ? span.spanContext().traceId : traceContext?.trace_id; + const spanId = span ? span.spanContext().spanId : undefined; + + const serializedMetric: SerializedMetric = { + timestamp: timestampInSeconds(), + trace_id: traceId, + span_id: spanId, + name: processedMetric.name, + type: processedMetric.type, + unit: processedMetric.unit, + value: processedMetric.value, + attributes: serializedAttributes, + }; + + consoleSandbox(() => { + // eslint-disable-next-line no-console + DEBUG_BUILD && console.log('[Metric]', serializedMetric); + }); + + captureSerializedMetric(client, serializedMetric); + + client.emit('afterCaptureMetric', metric); +} + +/** + * Flushes the metrics buffer to Sentry. + * + * @param client - A client. + * @param maybeMetricBuffer - A metric buffer. Uses the metric buffer for the given client if not provided. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + */ +export function _INTERNAL_flushMetricsBuffer(client: Client, maybeMetricBuffer?: Array): void { + const metricBuffer = maybeMetricBuffer ?? _INTERNAL_getMetricBuffer(client) ?? []; + if (metricBuffer.length === 0) { + return; + } + + const clientOptions = client.getOptions(); + const envelope = createMetricEnvelope(metricBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + + // Clear the metric buffer after envelopes have been constructed. + _getBufferMap().set(client, []); + + client.emit('flushMetrics'); + + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.sendEnvelope(envelope); +} + +/** + * Returns the metric buffer for a given client. + * + * Exported for testing purposes. + * + * @param client - The client to get the metric buffer for. + * @returns The metric buffer for the given client. + */ +export function _INTERNAL_getMetricBuffer(client: Client): Array | undefined { + return _getBufferMap().get(client); +} + +/** + * Get the scope data for the current scope after merging with the + * global scope and isolation scope. + * + * @param currentScope - The current scope. + * @returns The scope data. + */ +function getMergedScopeData(currentScope: Scope): ScopeData { + const scopeData = getGlobalScope().getScopeData(); + mergeScopeData(scopeData, getIsolationScope().getScopeData()); + mergeScopeData(scopeData, currentScope.getScopeData()); + return scopeData; +} + +function _getBufferMap(): WeakMap> { + // The reference to the Client <> MetricBuffer map is stored on the carrier to ensure it's always the same + return getGlobalSingleton('clientToMetricBufferMap', () => new WeakMap>()); +} diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts new file mode 100644 index 000000000000..f2766c0b2498 --- /dev/null +++ b/packages/core/src/metrics/public-api.ts @@ -0,0 +1,140 @@ +import type { Scope } from '../scope'; +import type { Metric, MetricType } from '../types-hoist/metric'; +import { _INTERNAL_captureMetric } from './internal'; + +/** + * Options for capturing a metric. + */ +export interface MetricOptions { + /** + * The unit of the metric value. + */ + unit?: string; + + /** + * Arbitrary structured data that stores information about the metric. + */ + attributes?: Metric['attributes']; + + /** + * The scope to capture the metric with. + */ + scope?: Scope; +} + +/** + * Capture a metric with the given type, name, and value. + * + * @param type - The type of the metric. + * @param name - The name of the metric. + * @param value - The value of the metric. + * @param options - Options for capturing the metric. + */ +function captureMetric(type: MetricType, name: string, value: number | string, options?: MetricOptions): void { + _INTERNAL_captureMetric( + { type, name, value, unit: options?.unit, attributes: options?.attributes }, + { scope: options?.scope }, + ); +} + +/** + * @summary Increment a counter metric. Requires the `_enableTraceMetrics` option to be enabled. + * + * @param name - The name of the counter metric. + * @param value - The value to increment by (defaults to 1). + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.count('api.requests', 1, { + * attributes: { + * endpoint: '/api/users', + * method: 'GET', + * status: 200 + * } + * }); + * ``` + * + * @example With custom value + * + * ``` + * Sentry.metrics.count('items.processed', 5, { + * attributes: { + * processor: 'batch-processor', + * queue: 'high-priority' + * } + * }); + * ``` + */ +export function count(name: string, value: number = 1, options?: MetricOptions): void { + captureMetric('counter', name, value, options); +} + +/** + * @summary Set a gauge metric to a specific value. Requires the `_enableTraceMetrics` option to be enabled. + * + * @param name - The name of the gauge metric. + * @param value - The current value of the gauge. + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.gauge('memory.usage', 1024, { + * unit: 'megabyte', + * attributes: { + * process: 'web-server', + * region: 'us-east-1' + * } + * }); + * ``` + * + * @example Without unit + * + * ``` + * Sentry.metrics.gauge('active.connections', 42, { + * attributes: { + * server: 'api-1', + * protocol: 'websocket' + * } + * }); + * ``` + */ +export function gauge(name: string, value: number, options?: MetricOptions): void { + captureMetric('gauge', name, value, options); +} + +/** + * @summary Record a value in a distribution metric. Requires the `_enableTraceMetrics` option to be enabled. + * + * @param name - The name of the distribution metric. + * @param value - The value to record in the distribution. + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.distribution('task.duration', 500, { + * unit: 'millisecond', + * attributes: { + * task: 'data-processing', + * priority: 'high' + * } + * }); + * ``` + * + * @example Without unit + * + * ``` + * Sentry.metrics.distribution('batch.size', 100, { + * attributes: { + * processor: 'batch-1', + * type: 'async' + * } + * }); + * ``` + */ +export function distribution(name: string, value: number, options?: MetricOptions): void { + captureMetric('distribution', name, value, options); +} diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 44e608925535..3d17260d6700 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -3,11 +3,13 @@ import { _getTraceInfoFromScope, Client } from './client'; import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import { _INTERNAL_flushLogsBuffer } from './logs/internal'; +import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; import type { Event, EventHint } from './types-hoist/event'; 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'; @@ -36,6 +38,8 @@ export class ServerRuntimeClient< > extends Client { private _logFlushIdleTimeout: ReturnType | undefined; private _logWeight: number; + private _metricFlushIdleTimeout: ReturnType | undefined; + private _metricWeight: number; /** * Creates a new Edge SDK instance. @@ -48,6 +52,7 @@ export class ServerRuntimeClient< super(options); this._logWeight = 0; + this._metricWeight = 0; if (this._options.enableLogs) { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -78,6 +83,36 @@ export class ServerRuntimeClient< _INTERNAL_flushLogsBuffer(client); }); } + + if (this._options._enableTraceMetrics) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const client = this; + + client.on('flushMetrics', () => { + client._metricWeight = 0; + clearTimeout(client._metricFlushIdleTimeout); + }); + + client.on('afterCaptureMetric', metric => { + client._metricWeight += estimateMetricSizeInBytes(metric); + + // We flush the metrics buffer if it exceeds 0.8 MB + // The metric weight is a rough estimate, so we flush way before + // the payload gets too big. + if (client._metricWeight >= 800_000) { + _INTERNAL_flushMetricsBuffer(client); + } else { + // start an idle timeout to flush the metrics buffer if no metrics are captured for a while + client._metricFlushIdleTimeout = setTimeout(() => { + _INTERNAL_flushMetricsBuffer(client); + }, DEFAULT_LOG_FLUSH_INTERVAL); + } + }); + + client.on('flush', () => { + _INTERNAL_flushMetricsBuffer(client); + }); + } } /** @@ -233,6 +268,43 @@ function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { } } +/** + * 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 the value + if (typeof metric.value === 'string') { + weight += metric.value.length * 2; + } else { + weight += 8; // number + } + + if (metric.attributes) { + Object.values(metric.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; +} + /** * Estimate the size of a log in bytes. * diff --git a/packages/core/src/types-hoist/datacategory.ts b/packages/core/src/types-hoist/datacategory.ts index 2e636b605fcf..ad1e61732816 100644 --- a/packages/core/src/types-hoist/datacategory.ts +++ b/packages/core/src/types-hoist/datacategory.ts @@ -32,5 +32,7 @@ export type DataCategory = | 'log_item' // Log bytes stored (unused for rate limiting) | 'log_byte' + // Metric event + | 'metric' // Unknown data category | 'unknown'; diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 58671c1eba70..95353b1883f8 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -6,6 +6,7 @@ import type { DsnComponents } from './dsn'; import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; import type { SerializedLogContainer } from './log'; +import type { SerializedMetricContainer } from './metric'; import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; @@ -46,6 +47,7 @@ export type EnvelopeItemType = | 'check_in' | 'span' | 'log' + | 'metric' | 'raw_security'; export type BaseEnvelopeHeaders = { @@ -99,6 +101,17 @@ type LogContainerItemHeaders = { */ content_type: 'application/vnd.sentry.items.log+json'; }; +type MetricContainerItemHeaders = { + type: 'metric'; + /** + * The number of metric items in the container. This must be the same as the number of metric items in the payload. + */ + item_count: number; + /** + * The content type of the metric items. This must be `application/vnd.sentry.items.trace-metric+json`. + */ + content_type: 'application/vnd.sentry.items.trace-metric+json'; +}; type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string }; export type EventItem = BaseEnvelopeItem; @@ -116,6 +129,7 @@ export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; export type LogContainerItem = BaseEnvelopeItem; +export type MetricContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: Partial }; @@ -125,6 +139,7 @@ type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; +type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< EventEnvelopeHeaders, EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem @@ -137,6 +152,7 @@ export type SpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; +export type MetricEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope @@ -147,5 +163,6 @@ export type Envelope = | CheckInEnvelope | SpanEnvelope | RawSecurityEnvelope - | LogEnvelope; + | LogEnvelope + | MetricEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts new file mode 100644 index 000000000000..9201243c4a38 --- /dev/null +++ b/packages/core/src/types-hoist/metric.ts @@ -0,0 +1,80 @@ +export type MetricType = 'counter' | 'gauge' | 'distribution'; + +export interface Metric { + /** + * The name of the metric. + */ + name: string; + + /** + * The value of the metric. + */ + value: number | string; + + /** + * The type of metric. + */ + type: MetricType; + + /** + * The unit of the metric value. + */ + unit?: string; + + /** + * Arbitrary structured data that stores information about the metric. + */ + attributes?: Record; +} + +export type SerializedMetricAttributeValue = + | { value: string; type: 'string' } + | { value: number; type: 'integer' } + | { value: number; type: 'double' } + | { value: boolean; type: 'boolean' }; + +export interface SerializedMetric { + /** + * Timestamp in seconds (epoch time) indicating when the metric was recorded. + */ + timestamp: number; + + /** + * The trace ID for this metric. + */ + trace_id?: string; + + /** + * The span ID for this metric. + */ + span_id?: string; + + /** + * The name of the metric. + */ + name: string; + + /** + * The type of metric. + */ + type: MetricType; + + /** + * The unit of the metric value. + */ + unit?: string; + + /** + * The value of the metric. + */ + value: number | string; + + /** + * Arbitrary structured data that stores information about the metric. + */ + attributes?: Record; +} + +export type SerializedMetricContainer = { + items: Array; +}; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 92603bb0242d..05429b265baf 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -3,6 +3,7 @@ import type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; import type { ErrorEvent, EventHint, TransactionEvent } from './event'; import type { Integration } from './integration'; import type { Log } from './log'; +import type { Metric } from './metric'; import type { TracesSamplerSamplingContext } from './samplingcontext'; import type { SdkMetadata } from './sdkmetadata'; import type { SpanJSON } from './span'; @@ -379,6 +380,29 @@ export interface ClientOptions Log | null; + /** + * If trace metrics support should be enabled. + * + * @default false + * @experimental + */ + _enableTraceMetrics?: boolean; + + /** + * An event-processing callback for metrics, guaranteed to be invoked after all other metric + * processors. This allows a metric to be modified or dropped before it's sent. + * + * Note that you must return a valid metric from this callback. If you do not wish to modify the metric, simply return + * it at the end. Returning `null` will cause the metric to be dropped. + * + * @default undefined + * @experimental + * + * @param metric The metric generated by the SDK. + * @returns A new metric that will be sent | null. + */ + beforeSendMetric?: (metric: Metric) => Metric | null; + /** * Function to compute tracing sample rate dynamically and filter unwanted traces. * diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index ffda9434d886..40ed1a9bd066 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -221,6 +221,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { span: 'span', raw_security: 'security', log: 'log_item', + metric: 'metric', }; /** diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 67e00660c2a1..e843fd16badf 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -133,6 +133,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + metrics, } from '@sentry/core'; export type { @@ -152,6 +153,7 @@ export type { Thread, User, Span, + Metric, FeatureFlagsIntegration, } from '@sentry/core'; From ab95d3fd4282977dda077c19687dfb421ba967ec Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 11:26:07 +0200 Subject: [PATCH 02/14] use _experiments --- packages/browser/src/client.ts | 8 ++-- packages/core/src/metrics/internal.ts | 6 +-- packages/core/src/metrics/public-api.ts | 6 +-- packages/core/src/server-runtime-client.ts | 2 +- packages/core/src/types-hoist/options.ts | 46 +++++++++++----------- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index e874a7ebde53..096df187f68d 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -108,9 +108,9 @@ export class BrowserClient extends Client { super(opts); - const { sendDefaultPii, sendClientReports, enableLogs, _enableTraceMetrics } = this._options; + const { sendDefaultPii, sendClientReports, enableLogs, _experiments } = this._options; - if (WINDOW.document && (sendClientReports || enableLogs || _enableTraceMetrics)) { + if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableTraceMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { if (sendClientReports) { @@ -119,7 +119,7 @@ export class BrowserClient extends Client { if (enableLogs) { _INTERNAL_flushLogsBuffer(this); } - if (_enableTraceMetrics) { + if (_experiments?.enableTraceMetrics) { _INTERNAL_flushMetricsBuffer(this); } } @@ -142,7 +142,7 @@ export class BrowserClient extends Client { }); } - if (_enableTraceMetrics) { + if (_experiments?.enableTraceMetrics) { this.on('flush', () => { _INTERNAL_flushMetricsBuffer(this); }); diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index e329eeab719a..48514e144105 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -133,8 +133,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal return; } - const { release, environment, _enableTraceMetrics = false, beforeSendMetric } = client.getOptions(); - if (!_enableTraceMetrics) { + const { release, environment, _experiments } = client.getOptions(); + if (!_experiments?.enableTraceMetrics) { DEBUG_BUILD && debug.warn('trace metrics option not enabled, metric will not be captured.'); return; } @@ -168,7 +168,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal }; // Run beforeSendMetric callback - const processedMetric = beforeSendMetric ? beforeSendMetric(metric) : metric; + const processedMetric = _experiments?.beforeSendMetric ? _experiments.beforeSendMetric(metric) : metric; if (!processedMetric) { DEBUG_BUILD && debug.log('An event processor returned `null`, will not send metric.'); diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts index f2766c0b2498..c0a6737d350a 100644 --- a/packages/core/src/metrics/public-api.ts +++ b/packages/core/src/metrics/public-api.ts @@ -38,7 +38,7 @@ function captureMetric(type: MetricType, name: string, value: number | string, o } /** - * @summary Increment a counter metric. Requires the `_enableTraceMetrics` option to be enabled. + * @summary Increment a counter metric. Requires the `_experiments.enableTraceMetrics` option to be enabled. * * @param name - The name of the counter metric. * @param value - The value to increment by (defaults to 1). @@ -72,7 +72,7 @@ export function count(name: string, value: number = 1, options?: MetricOptions): } /** - * @summary Set a gauge metric to a specific value. Requires the `_enableTraceMetrics` option to be enabled. + * @summary Set a gauge metric to a specific value. Requires the `_experiments.enableTraceMetrics` option to be enabled. * * @param name - The name of the gauge metric. * @param value - The current value of the gauge. @@ -106,7 +106,7 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi } /** - * @summary Record a value in a distribution metric. Requires the `_enableTraceMetrics` option to be enabled. + * @summary Record a value in a distribution metric. Requires the `_experiments.enableTraceMetrics` option to be enabled. * * @param name - The name of the distribution metric. * @param value - The value to record in the distribution. diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 3d17260d6700..4321c0ec3733 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -84,7 +84,7 @@ export class ServerRuntimeClient< }); } - if (this._options._enableTraceMetrics) { + if (this._options._experiments?.enableTraceMetrics) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index eae4aad93378..5dc199e35714 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -283,6 +283,29 @@ export interface ClientOptions Metric | null; }; /** @@ -380,29 +403,6 @@ export interface ClientOptions Log | null; - /** - * If trace metrics support should be enabled. - * - * @default false - * @experimental - */ - _enableTraceMetrics?: boolean; - - /** - * An event-processing callback for metrics, guaranteed to be invoked after all other metric - * processors. This allows a metric to be modified or dropped before it's sent. - * - * Note that you must return a valid metric from this callback. If you do not wish to modify the metric, simply return - * it at the end. Returning `null` will cause the metric to be dropped. - * - * @default undefined - * @experimental - * - * @param metric The metric generated by the SDK. - * @returns A new metric that will be sent | null. - */ - beforeSendMetric?: (metric: Metric) => Metric | null; - /** * Function to compute tracing sample rate dynamically and filter unwanted traces. * From 90ca1471a0c78c62bdbf1bf727b144f6ba32fd3e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 11:28:49 +0200 Subject: [PATCH 03/14] update test --- .../suites/public-api/metrics/init.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js index 900f806f3482..9095bf62f34d 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js @@ -4,7 +4,9 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - _enableTraceMetrics: true, + _experiments: { + enableTraceMetrics: true, + }, release: '1.0.0', environment: 'test', autoSessionTracking: false, // Was causing session envelopes to be sent From 9cb84429300e7e91655a4adea40b6c5f6ed15c1f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 11:32:16 +0200 Subject: [PATCH 04/14] update debug log --- packages/core/src/metrics/internal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 48514e144105..ff3c5acfff42 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -171,7 +171,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal const processedMetric = _experiments?.beforeSendMetric ? _experiments.beforeSendMetric(metric) : metric; if (!processedMetric) { - DEBUG_BUILD && debug.log('An event processor returned `null`, will not send metric.'); + DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.'); return; } From ee8c08f638aae20d408ea698f706df2dbe6ef6cc Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 11:36:43 +0200 Subject: [PATCH 05/14] update replayId attaching logic --- packages/core/src/metrics/internal.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index ff3c5acfff42..f4f7061f7836 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -159,8 +159,21 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal setMetricAttribute(processedMetricAttributes, 'sentry.sdk.name', name); setMetricAttribute(processedMetricAttributes, 'sentry.sdk.version', version); - const replay = client.getIntegrationByName string }>('Replay'); - setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replay?.getReplayId()); + const replay = client.getIntegrationByName< + Integration & { + getReplayId: (onlyIfSampled?: boolean) => string; + getRecordingMode: () => 'session' | 'buffer' | undefined; + } + >('Replay'); + + const replayId = replay?.getReplayId(true); + + setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replayId); + + if (replayId && replay?.getRecordingMode() === 'buffer') { + // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry + setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', replayId); + } const metric: Metric = { ...beforeMetric, From cb2102b6edf8b6c45deceac816dba6acd70c1920 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 11:53:36 +0200 Subject: [PATCH 06/14] bump size limit --- .size-limit.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 32d5d19e1495..59ad29c3ccf8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -96,7 +96,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -150,13 +150,13 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '26 KB', + limit: '27 KB', }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '41 KB', + limit: '42 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -183,7 +183,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '120 KB', + limit: '123 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', From 0929feb00a697cbed76b1143e8d4de2cdbc7c690 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 12:00:39 +0200 Subject: [PATCH 07/14] add unit tests --- .../core/test/lib/metrics/envelope.test.ts | 173 +++ .../core/test/lib/metrics/internal.test.ts | 1082 +++++++++++++++++ .../core/test/lib/metrics/public-api.test.ts | 338 +++++ 3 files changed, 1593 insertions(+) create mode 100644 packages/core/test/lib/metrics/envelope.test.ts create mode 100644 packages/core/test/lib/metrics/internal.test.ts create mode 100644 packages/core/test/lib/metrics/public-api.test.ts diff --git a/packages/core/test/lib/metrics/envelope.test.ts b/packages/core/test/lib/metrics/envelope.test.ts new file mode 100644 index 000000000000..d3a19fd7a916 --- /dev/null +++ b/packages/core/test/lib/metrics/envelope.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMetricContainerEnvelopeItem, createMetricEnvelope } from '../../../src/metrics/envelope'; +import type { DsnComponents } from '../../../src/types-hoist/dsn'; +import type { SerializedMetric } from '../../../src/types-hoist/metric'; +import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; +import * as utilsDsn from '../../../src/utils/dsn'; +import * as utilsEnvelope from '../../../src/utils/envelope'; + +vi.mock('../../../src/utils/dsn', () => ({ + dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), +})); +vi.mock('../../../src/utils/envelope', () => ({ + createEnvelope: vi.fn((_headers, items) => [_headers, items]), +})); + +describe('createMetricContainerEnvelopeItem', () => { + it('creates an envelope item with correct structure', () => { + const mockMetric: SerializedMetric = { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }; + + const result = createMetricContainerEnvelopeItem([mockMetric, mockMetric]); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'metric', + item_count: 2, + content_type: 'application/vnd.sentry.items.trace-metric+json', + }); + expect(result[1]).toEqual({ items: [mockMetric, mockMetric] }); + }); +}); + +describe('createMetricEnvelope', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01T12:00:00Z')); + + // Reset mocks + vi.mocked(utilsEnvelope.createEnvelope).mockClear(); + vi.mocked(utilsDsn.dsnToString).mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates an envelope with basic headers', () => { + const mockMetrics: SerializedMetric[] = [ + { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }, + ]; + + const result = createMetricEnvelope(mockMetrics); + + expect(result[0]).toEqual({}); + + expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array)); + }); + + it('includes SDK info when metadata is provided', () => { + const mockMetrics: SerializedMetric[] = [ + { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }, + ]; + + const metadata: SdkMetadata = { + sdk: { + name: 'sentry.javascript.node', + version: '10.0.0', + }, + }; + + const result = createMetricEnvelope(mockMetrics, metadata); + + expect(result[0]).toEqual({ + sdk: { + name: 'sentry.javascript.node', + version: '10.0.0', + }, + }); + }); + + it('includes DSN when tunnel and DSN are provided', () => { + const mockMetrics: SerializedMetric[] = [ + { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'test.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }, + ]; + + const dsn: DsnComponents = { + host: 'example.sentry.io', + path: '/', + projectId: '123', + port: '', + protocol: 'https', + publicKey: 'abc123', + }; + + const result = createMetricEnvelope(mockMetrics, undefined, 'https://tunnel.example.com', dsn); + + expect(result[0]).toHaveProperty('dsn'); + expect(utilsDsn.dsnToString).toHaveBeenCalledWith(dsn); + }); + + it('maps each metric to an envelope item', () => { + const mockMetrics: SerializedMetric[] = [ + { + timestamp: 1713859200, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'first.metric', + type: 'counter', + value: 1, + unit: 'count', + attributes: {}, + }, + { + timestamp: 1713859201, + trace_id: '3d9355f71e9c444b81161599adac6e29', + span_id: '8b5f5e5e5e5e5e5e', + name: 'second.metric', + type: 'gauge', + value: 42, + unit: 'bytes', + attributes: {}, + }, + ]; + + createMetricEnvelope(mockMetrics); + + // Check that createEnvelope was called with a single container item containing all metrics + expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.arrayContaining([ + { type: 'metric', item_count: 2, content_type: 'application/vnd.sentry.items.trace-metric+json' }, + { items: mockMetrics }, + ]), + ]), + ); + }); +}); diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts new file mode 100644 index 000000000000..ea5dbff33752 --- /dev/null +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -0,0 +1,1082 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Scope } from '../../../src'; +import { + _INTERNAL_captureMetric, + _INTERNAL_flushMetricsBuffer, + _INTERNAL_getMetricBuffer, + metricAttributeToSerializedMetricAttribute, +} from '../../../src/metrics/internal'; +import type { Metric } from '../../../src/types-hoist/metric'; +import * as loggerModule from '../../../src/utils/debug-logger'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('metricAttributeToSerializedMetricAttribute', () => { + it('serializes integer values', () => { + const result = metricAttributeToSerializedMetricAttribute(42); + expect(result).toEqual({ + value: 42, + type: 'integer', + }); + }); + + it('serializes double values', () => { + const result = metricAttributeToSerializedMetricAttribute(42.34); + expect(result).toEqual({ + value: 42.34, + type: 'double', + }); + }); + + it('serializes boolean values', () => { + const result = metricAttributeToSerializedMetricAttribute(true); + expect(result).toEqual({ + value: true, + type: 'boolean', + }); + }); + + it('serializes string values', () => { + const result = metricAttributeToSerializedMetricAttribute('endpoint'); + expect(result).toEqual({ + value: 'endpoint', + type: 'string', + }); + }); + + it('serializes object values as JSON strings', () => { + const obj = { name: 'John', age: 30 }; + const result = metricAttributeToSerializedMetricAttribute(obj); + expect(result).toEqual({ + value: JSON.stringify(obj), + type: 'string', + }); + }); + + it('serializes array values as JSON strings', () => { + const array = [1, 2, 3, 'test']; + const result = metricAttributeToSerializedMetricAttribute(array); + expect(result).toEqual({ + value: JSON.stringify(array), + type: 'string', + }); + }); + + it('serializes undefined values as empty strings', () => { + const result = metricAttributeToSerializedMetricAttribute(undefined); + expect(result).toEqual({ + value: '', + type: 'string', + }); + }); + + it('serializes null values as JSON strings', () => { + const result = metricAttributeToSerializedMetricAttribute(null); + expect(result).toEqual({ + value: 'null', + type: 'string', + }); + }); +}); + +describe('_INTERNAL_captureMetric', () => { + it('captures and sends metrics', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(1); + expect(_INTERNAL_getMetricBuffer(client)?.[0]).toEqual( + expect.objectContaining({ + name: 'test.metric', + type: 'counter', + value: 1, + timestamp: expect.any(Number), + trace_id: expect.any(String), + attributes: {}, + }), + ); + }); + + it('does not capture metrics when enableTraceMetrics is not enabled', () => { + const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(logWarnSpy).toHaveBeenCalledWith('trace metrics option not enabled, metric will not be captured.'); + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + + logWarnSpy.mockRestore(); + }); + + it('includes trace context when available', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '3d9355f71e9c444b81161599adac6e29', + sampleRand: 1, + }); + + _INTERNAL_captureMetric({ type: 'gauge', name: 'test.gauge', value: 42 }, { scope }); + + expect(_INTERNAL_getMetricBuffer(client)?.[0]).toEqual( + expect.objectContaining({ + trace_id: '3d9355f71e9c444b81161599adac6e29', + }), + ); + }); + + it('includes release and environment in metric attributes when available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + }); + }); + + it('includes SDK metadata in metric attributes when available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + vi.spyOn(client, 'getSdkMetadata').mockReturnValue({ + sdk: { + name: 'sentry.javascript.node', + version: '10.0.0', + }, + }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.sdk.name': { + value: 'sentry.javascript.node', + type: 'string', + }, + 'sentry.sdk.version': { + value: '10.0.0', + type: 'string', + }, + }); + }); + + it('does not include SDK metadata in metric attributes when not available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + // Mock getSdkMetadata to return no SDK info + vi.spyOn(client, 'getSdkMetadata').mockReturnValue({}); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'sentry.sdk.name' }), + expect.objectContaining({ key: 'sentry.sdk.version' }), + ]), + ); + }); + + it('includes custom attributes in metric', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { endpoint: '/api/users', method: 'GET' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + endpoint: { + value: '/api/users', + type: 'string', + }, + method: { + value: 'GET', + type: 'string', + }, + }); + }); + + it('flushes metrics buffer when it reaches max size', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Fill the buffer to max size (100 is the MAX_METRIC_BUFFER_SIZE constant) + for (let i = 0; i < 100; i++) { + _INTERNAL_captureMetric({ type: 'counter', name: `metric.${i}`, value: i }, { scope }); + } + + expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(100); + + // Add one more to trigger flush + _INTERNAL_captureMetric({ type: 'counter', name: 'trigger.flush', value: 999 }, { scope }); + + expect(_INTERNAL_getMetricBuffer(client)).toEqual([]); + }); + + it('does not flush metrics buffer when it is empty', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + + const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); + _INTERNAL_flushMetricsBuffer(client); + expect(mockSendEnvelope).not.toHaveBeenCalled(); + }); + + it('processes metrics through beforeSendMetric when provided', () => { + const beforeSendMetric = vi.fn().mockImplementation(metric => ({ + ...metric, + name: `modified.${metric.name}`, + attributes: { ...metric.attributes, processed: true }, + })); + + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true, beforeSendMetric }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'original.metric', + value: 1, + attributes: { original: true }, + }, + { scope }, + ); + + expect(beforeSendMetric).toHaveBeenCalledWith({ + type: 'counter', + name: 'original.metric', + value: 1, + attributes: { original: true }, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toBeDefined(); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'modified.original.metric', + attributes: { + processed: { + value: true, + type: 'boolean', + }, + original: { + value: true, + type: 'boolean', + }, + }, + }), + ); + }); + + it('drops metrics when beforeSendMetric returns null', () => { + const beforeSendMetric = vi.fn().mockReturnValue(null); + const loggerWarnSpy = vi.spyOn(loggerModule.debug, 'log').mockImplementation(() => undefined); + + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true, beforeSendMetric }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + }, + { scope }, + ); + + expect(beforeSendMetric).toHaveBeenCalled(); + expect(loggerWarnSpy).toHaveBeenCalledWith('`beforeSendMetric` returned `null`, will not send metric.'); + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + + loggerWarnSpy.mockRestore(); + }); + + it('emits afterCaptureMetric event', () => { + const afterCaptureMetricSpy = vi.spyOn(TestClient.prototype, 'emit'); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const metric: Metric = { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: {}, + }; + + _INTERNAL_captureMetric(metric, { scope }); + + expect(afterCaptureMetricSpy).toHaveBeenCalledWith('afterCaptureMetric', expect.objectContaining(metric)); + afterCaptureMetricSpy.mockRestore(); + }); + + describe('replay integration with onlyIfSampled', () => { + it('includes replay ID for sampled sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with sampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id'; + }), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.replay_id': { + value: 'sampled-replay-id', + type: 'string', + }, + }); + }); + + it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with unsampled session + const mockReplayIntegration = { + getReplayId: vi.fn((onlyIfSampled?: boolean) => { + return onlyIfSampled ? undefined : 'unsampled-replay-id'; + }), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + }); + + it('includes replay ID for buffer mode sessions', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode session + const mockReplayIntegration = { + getReplayId: vi.fn((_onlyIfSampled?: boolean) => { + return 'buffer-replay-id'; + }), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: 'buffer-replay-id', + type: 'string', + }, + }); + }); + + it('handles missing replay integration gracefully', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + }); + + it('combines replay ID with other metric attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'test-replay-id'), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { endpoint: '/api/users', method: 'GET' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + endpoint: { + value: '/api/users', + type: 'string', + }, + method: { + value: 'GET', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'test-replay-id', + type: 'string', + }, + }); + }); + + it('does not set replay ID attribute when getReplayId returns null or undefined', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const testCases = [null, undefined]; + + testCases.forEach(returnValue => { + // Clear buffer for each test + _INTERNAL_getMetricBuffer(client)?.splice(0); + + const mockReplayIntegration = { + getReplayId: vi.fn(() => returnValue), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + expect(metricAttributes).not.toHaveProperty('sentry.replay_id'); + }); + }); + + it('sets replay_is_buffering attribute when replay is in buffer mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: 'buffer-replay-id', + type: 'string', + }, + }); + }); + + it('does not set replay_is_buffering attribute when replay is in session mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with session mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'session-replay-id'), + getRecordingMode: vi.fn(() => 'session'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.replay_id': { + value: 'session-replay-id', + type: 'string', + }, + }); + expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay is undefined mode', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with undefined mode (replay stopped/disabled) + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'stopped-replay-id'), + getRecordingMode: vi.fn(() => undefined), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled(); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'sentry.replay_id': { + value: 'stopped-replay-id', + type: 'string', + }, + }); + expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when no replay ID is available', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration that returns no replay ID but has buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => undefined), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true); + // getRecordingMode should not be called if there's no replay ID + expect(mockReplayIntegration.getRecordingMode).not.toHaveBeenCalled(); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + expect(metricAttributes).not.toHaveProperty('sentry.replay_id'); + expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('does not set replay_is_buffering attribute when replay integration is missing', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock no replay integration found + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + expect(metricAttributes).not.toHaveProperty('sentry.replay_id'); + expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering'); + }); + + it('combines replay_is_buffering with other replay attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + // Mock replay integration with buffer mode + const mockReplayIntegration = { + getReplayId: vi.fn(() => 'buffer-replay-id'), + getRecordingMode: vi.fn(() => 'buffer'), + }; + + vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { endpoint: '/api/users', method: 'GET' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + endpoint: { + value: '/api/users', + type: 'string', + }, + method: { + value: 'GET', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + 'sentry.replay_id': { + value: 'buffer-replay-id', + type: 'string', + }, + 'sentry._internal.replay_is_buffering': { + value: 'buffer-replay-id', + type: 'string', + }, + }); + }); + }); + + describe('user functionality', () => { + it('includes user data in metric attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.id': { + value: '123', + type: 'string', + }, + 'user.email': { + value: 'user@example.com', + type: 'string', + }, + 'user.name': { + value: 'testuser', + type: 'string', + }, + }); + }); + + it('includes partial user data when only some fields are available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: '123', + // email and username are missing + }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.id': { + value: '123', + type: 'string', + }, + }); + }); + + it('includes user email and username without id', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + email: 'user@example.com', + username: 'testuser', + // id is missing + }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.email': { + value: 'user@example.com', + type: 'string', + }, + 'user.name': { + value: 'testuser', + type: 'string', + }, + }); + }); + + it('does not include user data when user object is empty', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({}); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({}); + }); + + it('combines user data with other metric attributes', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + }); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { endpoint: '/api/users', method: 'GET' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + endpoint: { + value: '/api/users', + type: 'string', + }, + method: { + value: 'GET', + type: 'string', + }, + 'user.id': { + value: '123', + type: 'string', + }, + 'user.email': { + value: 'user@example.com', + type: 'string', + }, + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + }); + }); + + it('handles user data with non-string values', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: 123, + email: 'user@example.com', + username: undefined, + }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.id': { + value: 123, + type: 'integer', + }, + 'user.email': { + value: 'user@example.com', + type: 'string', + }, + }); + }); + + it('preserves existing user attributes in metric and does not override them', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + }); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { + 'user.id': 'existing-id', + 'user.custom': 'custom-value', + }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.custom': { + value: 'custom-value', + type: 'string', + }, + 'user.id': { + value: 'existing-id', + type: 'string', + }, + 'user.email': { + value: 'user@example.com', + type: 'string', + }, + }); + }); + + it('only adds scope user data for attributes that do not already exist', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setUser({ + id: 'scope-id', + email: 'scope@example.com', + username: 'scope-user', + }); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { + 'user.email': 'existing@example.com', + 'other.attr': 'value', + }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'other.attr': { + value: 'value', + type: 'string', + }, + 'user.email': { + value: 'existing@example.com', + type: 'string', + }, + 'user.id': { + value: 'scope-id', + type: 'string', + }, + 'user.name': { + value: 'scope-user', + type: 'string', + }, + }); + }); + }); + + it('overrides user-provided system attributes with SDK values', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableTraceMetrics: true }, + release: 'sdk-release-1.0.0', + environment: 'sdk-environment', + }); + const client = new TestClient(options); + vi.spyOn(client, 'getSdkMetadata').mockReturnValue({ + sdk: { + name: 'sentry.javascript.node', + version: '10.0.0', + }, + }); + + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { + 'sentry.release': 'user-release-2.0.0', + 'sentry.environment': 'user-environment', + 'sentry.sdk.name': 'user-sdk-name', + 'sentry.sdk.version': 'user-sdk-version', + 'user.custom': 'preserved-value', + }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'user.custom': { + value: 'preserved-value', + type: 'string', + }, + 'sentry.release': { + value: 'sdk-release-1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'sdk-environment', + type: 'string', + }, + 'sentry.sdk.name': { + value: 'sentry.javascript.node', + type: 'string', + }, + 'sentry.sdk.version': { + value: '10.0.0', + type: 'string', + }, + }); + }); +}); diff --git a/packages/core/test/lib/metrics/public-api.test.ts b/packages/core/test/lib/metrics/public-api.test.ts new file mode 100644 index 000000000000..e51d7a888b17 --- /dev/null +++ b/packages/core/test/lib/metrics/public-api.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Scope } from '../../../src'; +import { _INTERNAL_getMetricBuffer } from '../../../src/metrics/internal'; +import { count, distribution, gauge } from '../../../src/metrics/public-api'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('Metrics Public API', () => { + describe('count', () => { + it('captures a counter metric with default value of 1', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', undefined, { scope }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'api.requests', + type: 'counter', + value: 1, + }), + ); + }); + + it('captures a counter metric with custom value', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('items.processed', 5, { scope }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'items.processed', + type: 'counter', + value: 5, + }), + ); + }); + + it('captures a counter metric with attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', 1, { + scope, + attributes: { + endpoint: '/api/users', + method: 'GET', + status: 200, + }, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'api.requests', + type: 'counter', + value: 1, + attributes: { + endpoint: { + value: '/api/users', + type: 'string', + }, + method: { + value: 'GET', + type: 'string', + }, + status: { + value: 200, + type: 'integer', + }, + }, + }), + ); + }); + + it('captures a counter metric with unit', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('data.uploaded', 1024, { + scope, + unit: 'byte', + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'data.uploaded', + type: 'counter', + value: 1024, + unit: 'byte', + }), + ); + }); + + it('does not capture counter when enableTraceMetrics is not enabled', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', 1, { scope }); + + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + }); + }); + + describe('gauge', () => { + it('captures a gauge metric', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + gauge('memory.usage', 1024, { scope }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'memory.usage', + type: 'gauge', + value: 1024, + }), + ); + }); + + it('captures a gauge metric with unit', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + gauge('memory.usage', 1024, { + scope, + unit: 'megabyte', + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'memory.usage', + type: 'gauge', + value: 1024, + unit: 'megabyte', + }), + ); + }); + + it('captures a gauge metric with attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + gauge('active.connections', 42, { + scope, + attributes: { + server: 'api-1', + protocol: 'websocket', + }, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'active.connections', + type: 'gauge', + value: 42, + attributes: { + server: { + value: 'api-1', + type: 'string', + }, + protocol: { + value: 'websocket', + type: 'string', + }, + }, + }), + ); + }); + + it('does not capture gauge when enableTraceMetrics is not enabled', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + gauge('memory.usage', 1024, { scope }); + + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + }); + }); + + describe('distribution', () => { + it('captures a distribution metric', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + distribution('task.duration', 500, { scope }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'task.duration', + type: 'distribution', + value: 500, + }), + ); + }); + + it('captures a distribution metric with unit', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + distribution('task.duration', 500, { + scope, + unit: 'millisecond', + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'task.duration', + type: 'distribution', + value: 500, + unit: 'millisecond', + }), + ); + }); + + it('captures a distribution metric with attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + distribution('batch.size', 100, { + scope, + attributes: { + processor: 'batch-1', + type: 'async', + }, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'batch.size', + type: 'distribution', + value: 100, + attributes: { + processor: { + value: 'batch-1', + type: 'string', + }, + type: { + value: 'async', + type: 'string', + }, + }, + }), + ); + }); + + it('does not capture distribution when enableTraceMetrics is not enabled', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + distribution('task.duration', 500, { scope }); + + expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); + }); + }); + + describe('mixed metric types', () => { + it('captures multiple different metric types', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', 1, { scope }); + gauge('memory.usage', 1024, { scope }); + distribution('task.duration', 500, { scope }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(3); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'api.requests', + type: 'counter', + }), + ); + expect(metricBuffer?.[1]).toEqual( + expect.objectContaining({ + name: 'memory.usage', + type: 'gauge', + }), + ); + expect(metricBuffer?.[2]).toEqual( + expect.objectContaining({ + name: 'task.duration', + type: 'distribution', + }), + ); + }); + }); +}); + From 350567f2b8eb27cc4d46c6d437621fac4e6ca83f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 12:04:02 +0200 Subject: [PATCH 08/14] . --- packages/core/test/lib/metrics/public-api.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/test/lib/metrics/public-api.test.ts b/packages/core/test/lib/metrics/public-api.test.ts index e51d7a888b17..3837ec47324b 100644 --- a/packages/core/test/lib/metrics/public-api.test.ts +++ b/packages/core/test/lib/metrics/public-api.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Scope } from '../../../src'; import { _INTERNAL_getMetricBuffer } from '../../../src/metrics/internal'; import { count, distribution, gauge } from '../../../src/metrics/public-api'; @@ -335,4 +335,3 @@ describe('Metrics Public API', () => { }); }); }); - From caa59ea1fbc34e95a25b8778b420a152a8b6e863 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 13:14:20 +0200 Subject: [PATCH 09/14] filter browser session integration to unflake test --- .../suites/public-api/metrics/init.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js index 9095bf62f34d..8aa862f18606 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js @@ -9,5 +9,7 @@ Sentry.init({ }, release: '1.0.0', environment: 'test', - autoSessionTracking: false, // Was causing session envelopes to be sent + integrations: integrations => { + return integrations.filter(integration => integration.name !== 'BrowserSession'); + }, }); From b36d517dd83c62fc97fe0b16a271363019b90f28 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 13:43:44 +0200 Subject: [PATCH 10/14] export from node-core --- packages/node-core/src/index.ts | 1 + packages/node/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 0f976bd23436..7557d73c74a2 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -135,6 +135,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + metrics, } from '@sentry/core'; export type { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bd3c9f44e5e7..54a90dbfcd09 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -133,7 +133,6 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, - metrics, } from '@sentry/core'; export type { @@ -159,6 +158,7 @@ export type { export { logger, + metrics, httpServerIntegration, httpServerSpansIntegration, nodeContextIntegration, From dac2aca09bb7759e69e0a5efd05db1c5849ff7ad Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 13:43:51 +0200 Subject: [PATCH 11/14] add node integration tests --- .../suites/public-api/metrics/scenario.ts | 35 +++++++ .../suites/public-api/metrics/test.ts | 97 +++++++++++++++++++ .../utils/assertions.ts | 10 ++ .../utils/runner.ts | 17 ++++ .../suites/public-api/metrics/scenario.ts | 32 ++++++ .../suites/public-api/metrics/test.ts | 96 ++++++++++++++++++ 6 files changed, 287 insertions(+) create mode 100644 dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts create mode 100644 dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/test.ts diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts new file mode 100644 index 000000000000..1b14344e4687 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + _experiments: { + enableTraceMetrics: true, + }, + transport: loggingTransport, +}); + +setupOtel(client); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); + + Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } }); + + await Sentry.startSpan({ name: 'test-span', op: 'test' }, async () => { + Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } }); + }); + + Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); + Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts new file mode 100644 index 000000000000..520d4835fe7d --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts @@ -0,0 +1,97 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('metrics', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should capture all metric types', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .unignore('metric') + .expect({ + metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: { value: 'test-1', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.distribution', + type: 'distribution', + unit: 'second', + value: 200, + attributes: { + priority: { value: 'high', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.span.counter', + type: 'counter', + value: 1, + attributes: { + operation: { value: 'test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.user.counter', + type: 'counter', + value: 1, + attributes: { + action: { value: 'click', type: 'string' }, + 'user.id': { value: 'user-123', type: 'string' }, + 'user.email': { value: 'test@example.com', type: 'string' }, + 'user.name': { value: 'testuser', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/utils/assertions.ts b/dev-packages/node-core-integration-tests/utils/assertions.ts index 296bdc608bb4..8d9fb5f2251f 100644 --- a/dev-packages/node-core-integration-tests/utils/assertions.ts +++ b/dev-packages/node-core-integration-tests/utils/assertions.ts @@ -4,6 +4,7 @@ import type { Event, SerializedCheckIn, SerializedLogContainer, + SerializedMetricContainer, SerializedSession, SessionAggregates, TransactionEvent, @@ -76,6 +77,15 @@ export function assertSentryLogContainer( }); } +export function assertSentryMetricContainer( + actual: SerializedMetricContainer, + expected: Partial, +): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { expect(actual).toEqual({ event_id: expect.any(String), diff --git a/dev-packages/node-core-integration-tests/utils/runner.ts b/dev-packages/node-core-integration-tests/utils/runner.ts index da6184dcbb42..4e973954806d 100644 --- a/dev-packages/node-core-integration-tests/utils/runner.ts +++ b/dev-packages/node-core-integration-tests/utils/runner.ts @@ -7,6 +7,7 @@ import type { EventEnvelope, SerializedCheckIn, SerializedLogContainer, + SerializedMetricContainer, SerializedSession, SessionAggregates, TransactionEvent, @@ -22,6 +23,7 @@ import { assertSentryClientReport, assertSentryEvent, assertSentryLogContainer, + assertSentryMetricContainer, assertSentrySession, assertSentrySessions, assertSentryTransaction, @@ -122,6 +124,7 @@ type ExpectedSessions = Partial | ((event: SessionAggregates) type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void); type ExpectedClientReport = Partial | ((event: ClientReport) => void); type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); +type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void); type Expected = | { @@ -144,6 +147,9 @@ type Expected = } | { log: ExpectedLogContainer; + } + | { + metric: ExpectedMetricContainer; }; type ExpectedEnvelopeHeader = @@ -403,6 +409,9 @@ export function createRunner(...paths: string[]) { } else if ('log' in expected) { expectLog(item[1] as SerializedLogContainer, expected.log); expectCallbackCalled(); + } else if ('metric' in expected) { + expectMetric(item[1] as SerializedMetricContainer, expected.metric); + expectCallbackCalled(); } else { throw new Error( `Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`, @@ -649,6 +658,14 @@ function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer) } } +function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricContainer): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryMetricContainer(item, expected); + } +} + /** * Converts ESM import statements to CommonJS require statements * @param content The content of an ESM file diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts new file mode 100644 index 000000000000..a529be66c8ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + _experiments: { + enableTraceMetrics: true, + }, + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } }); + + Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } }); + + await Sentry.startSpan({ name: 'test-span', op: 'test' }, async () => { + Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } }); + }); + + Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); + Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts new file mode 100644 index 000000000000..78c9fa23b4bd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -0,0 +1,96 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('metrics', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should capture all metric types', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.gauge', + type: 'gauge', + unit: 'millisecond', + value: 42, + attributes: { + server: { value: 'test-1', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.distribution', + type: 'distribution', + unit: 'second', + value: 200, + attributes: { + priority: { value: 'high', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.span.counter', + type: 'counter', + value: 1, + attributes: { + operation: { value: 'test', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.user.counter', + type: 'counter', + value: 1, + attributes: { + action: { value: 'click', type: 'string' }, + 'user.id': { value: 'user-123', type: 'string' }, + 'user.email': { value: 'test@example.com', type: 'string' }, + 'user.name': { value: 'testuser', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); From 8e12b0db3004bfb309fed7ea7d68476b60059605 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 8 Oct 2025 13:58:14 +0200 Subject: [PATCH 12/14] ignore node exports for now --- .../node-exports-test-app/scripts/consistentExports.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 17c6f714c499..ee4b7ac35421 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -41,6 +41,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Astro 'setupFastifyErrorHandler', + // Todo(metrics): Add metrics exports for beta + 'metrics', ], }, { @@ -54,6 +56,8 @@ const DEPENDENTS: Dependent[] = [ 'childProcessIntegration', 'systemErrorIntegration', 'pinoIntegration', + // Todo(metrics): Add metrics exports for beta + 'metrics', ], }, { @@ -75,6 +79,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + // Todo(metrics): Add metrics exports for beta + 'metrics', ], }, { @@ -84,6 +90,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + // Todo(metrics): Add metrics exports for beta + 'metrics', ], }, { From 0d7323ccf50070d7c0af63fc3669777d67372fcc Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 8 Oct 2025 11:58:28 -0400 Subject: [PATCH 13/14] We changed envelope to 'trace_metric' to avoid ambiguity --- .../suites/public-api/metrics/simple/test.ts | 2 +- packages/core/src/metrics/envelope.ts | 2 +- packages/core/src/types-hoist/envelope.ts | 9 ++------- packages/core/src/utils/envelope.ts | 1 + packages/core/test/lib/metrics/envelope.test.ts | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts index 8f58e70adfd3..3a8ac97f8408 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -16,7 +16,7 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) expect(envelopeItems[0]).toEqual([ { - type: 'metric', + type: 'trace_metric', item_count: 5, content_type: 'application/vnd.sentry.items.trace-metric+json', }, diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts index fb68f43c9c75..71ef0832667b 100644 --- a/packages/core/src/metrics/envelope.ts +++ b/packages/core/src/metrics/envelope.ts @@ -14,7 +14,7 @@ import { createEnvelope } from '../utils/envelope'; export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem { return [ { - type: 'metric', + type: 'trace_metric', item_count: items.length, content_type: 'application/vnd.sentry.items.trace-metric+json', } as MetricContainerItem[0], diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 95353b1883f8..272f8cde9f62 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -48,6 +48,7 @@ export type EnvelopeItemType = | 'span' | 'log' | 'metric' + | 'trace_metric' | 'raw_security'; export type BaseEnvelopeHeaders = { @@ -102,14 +103,8 @@ type LogContainerItemHeaders = { content_type: 'application/vnd.sentry.items.log+json'; }; type MetricContainerItemHeaders = { - type: 'metric'; - /** - * The number of metric items in the container. This must be the same as the number of metric items in the payload. - */ + type: 'trace_metric'; item_count: number; - /** - * The content type of the metric items. This must be `application/vnd.sentry.items.trace-metric+json`. - */ content_type: 'application/vnd.sentry.items.trace-metric+json'; }; type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string }; diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index 40ed1a9bd066..8f21a00dc590 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -222,6 +222,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { raw_security: 'security', log: 'log_item', metric: 'metric', + trace_metric: 'metric', }; /** diff --git a/packages/core/test/lib/metrics/envelope.test.ts b/packages/core/test/lib/metrics/envelope.test.ts index d3a19fd7a916..03918f353064 100644 --- a/packages/core/test/lib/metrics/envelope.test.ts +++ b/packages/core/test/lib/metrics/envelope.test.ts @@ -30,7 +30,7 @@ describe('createMetricContainerEnvelopeItem', () => { expect(result).toHaveLength(2); expect(result[0]).toEqual({ - type: 'metric', + type: 'trace_metric', item_count: 2, content_type: 'application/vnd.sentry.items.trace-metric+json', }); From 6fc9e189e14a7bd2a5cf4c32fa45df6473f0544c Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 9 Oct 2025 02:27:22 -0400 Subject: [PATCH 14/14] Replace 'tracemetric' in public facing api with 'metric' (eg. enableMetrics) since trace metric is an internal dataset detail --- .../suites/public-api/metrics/init.js | 2 +- .../suites/public-api/metrics/scenario.ts | 2 +- .../suites/public-api/metrics/scenario.ts | 2 +- packages/browser/src/client.ts | 6 +- packages/core/src/metrics/internal.ts | 4 +- packages/core/src/metrics/public-api.ts | 6 +- packages/core/src/server-runtime-client.ts | 2 +- packages/core/src/types-hoist/options.ts | 4 +- .../core/test/lib/metrics/internal.test.ts | 66 +++++++++---------- .../core/test/lib/metrics/public-api.test.ts | 28 ++++---- 10 files changed, 61 insertions(+), 61 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js index 8aa862f18606..df4fda70e4c7 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js @@ -5,7 +5,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', _experiments: { - enableTraceMetrics: true, + enableMetrics: true, }, release: '1.0.0', environment: 'test', diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts index 1b14344e4687..9ab9fed7d22b 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts @@ -7,7 +7,7 @@ const client = Sentry.init({ release: '1.0.0', environment: 'test', _experiments: { - enableTraceMetrics: true, + enableMetrics: true, }, transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts index a529be66c8ae..9c776eb14d59 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts @@ -6,7 +6,7 @@ Sentry.init({ release: '1.0.0', environment: 'test', _experiments: { - enableTraceMetrics: true, + enableMetrics: true, }, transport: loggingTransport, }); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 096df187f68d..af7a1d6ee2ec 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -110,7 +110,7 @@ export class BrowserClient extends Client { const { sendDefaultPii, sendClientReports, enableLogs, _experiments } = this._options; - if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableTraceMetrics)) { + if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { if (sendClientReports) { @@ -119,7 +119,7 @@ export class BrowserClient extends Client { if (enableLogs) { _INTERNAL_flushLogsBuffer(this); } - if (_experiments?.enableTraceMetrics) { + if (_experiments?.enableMetrics) { _INTERNAL_flushMetricsBuffer(this); } } @@ -142,7 +142,7 @@ export class BrowserClient extends Client { }); } - if (_experiments?.enableTraceMetrics) { + if (_experiments?.enableMetrics) { this.on('flush', () => { _INTERNAL_flushMetricsBuffer(this); }); diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index f4f7061f7836..0f16d98b790e 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -134,8 +134,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal } const { release, environment, _experiments } = client.getOptions(); - if (!_experiments?.enableTraceMetrics) { - DEBUG_BUILD && debug.warn('trace metrics option not enabled, metric will not be captured.'); + if (!_experiments?.enableMetrics) { + DEBUG_BUILD && debug.warn('metrics option not enabled, metric will not be captured.'); return; } diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts index c0a6737d350a..e508fcb9e6d0 100644 --- a/packages/core/src/metrics/public-api.ts +++ b/packages/core/src/metrics/public-api.ts @@ -38,7 +38,7 @@ function captureMetric(type: MetricType, name: string, value: number | string, o } /** - * @summary Increment a counter metric. Requires the `_experiments.enableTraceMetrics` option to be enabled. + * @summary Increment a counter metric. Requires the `_experiments.enableMetrics` option to be enabled. * * @param name - The name of the counter metric. * @param value - The value to increment by (defaults to 1). @@ -72,7 +72,7 @@ export function count(name: string, value: number = 1, options?: MetricOptions): } /** - * @summary Set a gauge metric to a specific value. Requires the `_experiments.enableTraceMetrics` option to be enabled. + * @summary Set a gauge metric to a specific value. Requires the `_experiments.enableMetrics` option to be enabled. * * @param name - The name of the gauge metric. * @param value - The current value of the gauge. @@ -106,7 +106,7 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi } /** - * @summary Record a value in a distribution metric. Requires the `_experiments.enableTraceMetrics` option to be enabled. + * @summary Record a value in a distribution metric. Requires the `_experiments.enableMetrics` option to be enabled. * * @param name - The name of the distribution metric. * @param value - The value to record in the distribution. diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 4321c0ec3733..761d4aca7cd7 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -84,7 +84,7 @@ export class ServerRuntimeClient< }); } - if (this._options._experiments?.enableTraceMetrics) { + if (this._options._experiments?.enableMetrics) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 5dc199e35714..1f172aaa1f4a 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -285,12 +285,12 @@ export interface ClientOptions { describe('_INTERNAL_captureMetric', () => { it('captures and sends metrics', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -101,7 +101,7 @@ describe('_INTERNAL_captureMetric', () => { ); }); - it('does not capture metrics when enableTraceMetrics is not enabled', () => { + it('does not capture metrics when enableMetrics is not enabled', () => { const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); @@ -117,7 +117,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('includes trace context when available', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -138,7 +138,7 @@ describe('_INTERNAL_captureMetric', () => { it('includes release and environment in metric attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, release: '1.0.0', environment: 'test', }); @@ -164,7 +164,7 @@ describe('_INTERNAL_captureMetric', () => { it('includes SDK metadata in metric attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -195,7 +195,7 @@ describe('_INTERNAL_captureMetric', () => { it('does not include SDK metadata in metric attributes when not available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -215,7 +215,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('includes custom attributes in metric', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -244,7 +244,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('flushes metrics buffer when it reaches max size', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -263,7 +263,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not flush metrics buffer when it is empty', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); @@ -280,7 +280,7 @@ describe('_INTERNAL_captureMetric', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true, beforeSendMetric }, + _experiments: { enableMetrics: true, beforeSendMetric }, }); const client = new TestClient(options); const scope = new Scope(); @@ -328,7 +328,7 @@ describe('_INTERNAL_captureMetric', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true, beforeSendMetric }, + _experiments: { enableMetrics: true, beforeSendMetric }, }); const client = new TestClient(options); const scope = new Scope(); @@ -352,7 +352,7 @@ describe('_INTERNAL_captureMetric', () => { it('emits afterCaptureMetric event', () => { const afterCaptureMetricSpy = vi.spyOn(TestClient.prototype, 'emit'); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -372,7 +372,7 @@ describe('_INTERNAL_captureMetric', () => { describe('replay integration with onlyIfSampled', () => { it('includes replay ID for sampled sessions', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -401,7 +401,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -424,7 +424,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('includes replay ID for buffer mode sessions', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -457,7 +457,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('handles missing replay integration gracefully', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -474,7 +474,7 @@ describe('_INTERNAL_captureMetric', () => { it('combines replay ID with other metric attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, release: '1.0.0', environment: 'test', }); @@ -526,7 +526,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not set replay ID attribute when getReplayId returns null or undefined', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -552,7 +552,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('sets replay_is_buffering attribute when replay is in buffer mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -584,7 +584,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not set replay_is_buffering attribute when replay is in session mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -613,7 +613,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not set replay_is_buffering attribute when replay is undefined mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -642,7 +642,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not set replay_is_buffering attribute when no replay ID is available', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -668,7 +668,7 @@ describe('_INTERNAL_captureMetric', () => { }); it('does not set replay_is_buffering attribute when replay integration is missing', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -687,7 +687,7 @@ describe('_INTERNAL_captureMetric', () => { it('combines replay_is_buffering with other replay attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, release: '1.0.0', environment: 'test', }); @@ -747,7 +747,7 @@ describe('_INTERNAL_captureMetric', () => { it('includes user data in metric attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -780,7 +780,7 @@ describe('_INTERNAL_captureMetric', () => { it('includes partial user data when only some fields are available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -804,7 +804,7 @@ describe('_INTERNAL_captureMetric', () => { it('includes user email and username without id', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -833,7 +833,7 @@ describe('_INTERNAL_captureMetric', () => { it('does not include user data when user object is empty', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -849,7 +849,7 @@ describe('_INTERNAL_captureMetric', () => { it('combines user data with other metric attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, release: '1.0.0', environment: 'test', }); @@ -903,7 +903,7 @@ describe('_INTERNAL_captureMetric', () => { it('handles user data with non-string values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -932,7 +932,7 @@ describe('_INTERNAL_captureMetric', () => { it('preserves existing user attributes in metric and does not override them', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -975,7 +975,7 @@ describe('_INTERNAL_captureMetric', () => { it('only adds scope user data for attributes that do not already exist', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, }); const client = new TestClient(options); const scope = new Scope(); @@ -1024,7 +1024,7 @@ describe('_INTERNAL_captureMetric', () => { it('overrides user-provided system attributes with SDK values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableTraceMetrics: true }, + _experiments: { enableMetrics: true }, release: 'sdk-release-1.0.0', environment: 'sdk-environment', }); diff --git a/packages/core/test/lib/metrics/public-api.test.ts b/packages/core/test/lib/metrics/public-api.test.ts index 3837ec47324b..42fe7c41ae4a 100644 --- a/packages/core/test/lib/metrics/public-api.test.ts +++ b/packages/core/test/lib/metrics/public-api.test.ts @@ -9,7 +9,7 @@ const PUBLIC_DSN = 'https://username@domain/123'; describe('Metrics Public API', () => { describe('count', () => { it('captures a counter metric with default value of 1', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -28,7 +28,7 @@ describe('Metrics Public API', () => { }); it('captures a counter metric with custom value', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -47,7 +47,7 @@ describe('Metrics Public API', () => { }); it('captures a counter metric with attributes', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -87,7 +87,7 @@ describe('Metrics Public API', () => { }); it('captures a counter metric with unit', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -109,7 +109,7 @@ describe('Metrics Public API', () => { ); }); - it('does not capture counter when enableTraceMetrics is not enabled', () => { + it('does not capture counter when enableMetrics is not enabled', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); @@ -123,7 +123,7 @@ describe('Metrics Public API', () => { describe('gauge', () => { it('captures a gauge metric', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -142,7 +142,7 @@ describe('Metrics Public API', () => { }); it('captures a gauge metric with unit', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -165,7 +165,7 @@ describe('Metrics Public API', () => { }); it('captures a gauge metric with attributes', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -199,7 +199,7 @@ describe('Metrics Public API', () => { ); }); - it('does not capture gauge when enableTraceMetrics is not enabled', () => { + it('does not capture gauge when enableMetrics is not enabled', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); @@ -213,7 +213,7 @@ describe('Metrics Public API', () => { describe('distribution', () => { it('captures a distribution metric', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -232,7 +232,7 @@ describe('Metrics Public API', () => { }); it('captures a distribution metric with unit', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -255,7 +255,7 @@ describe('Metrics Public API', () => { }); it('captures a distribution metric with attributes', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -289,7 +289,7 @@ describe('Metrics Public API', () => { ); }); - it('does not capture distribution when enableTraceMetrics is not enabled', () => { + it('does not capture distribution when enableMetrics is not enabled', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); @@ -303,7 +303,7 @@ describe('Metrics Public API', () => { describe('mixed metric types', () => { it('captures multiple different metric types', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableTraceMetrics: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client);