diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index efa204cac5a3..feb939ca682f 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -77,6 +77,105 @@ function setMetricAttribute( } } +/** + * Validates and processes the sample_rate for a metric. + * + * @param metric - The metric containing the sample_rate to validate. + * @param client - The client to record dropped events with. + * @returns true if the sample_rate is valid, false if the metric should be dropped. + */ +function validateAndProcessSampleRate(metric: Metric, client: Client): boolean { + if (metric.sample_rate !== undefined) { + if (metric.sample_rate <= 0 || metric.sample_rate > 1.0) { + client.recordDroppedEvent('invalid_sample_rate', 'metric'); + return false; + } + } + return true; +} + +/** + * Checks if a metric should be sampled based on the sample rate and scope's sampleRand (random based on trace). + * + * @param metric - The metric containing the sample_rate. + * @param scope - The scope containing the sampleRand. + * @returns An object with sampled (boolean) and samplingPerformed (boolean) flags. + */ +function shouldSampleMetric(metric: Metric, scope: Scope): { sampled: boolean; samplingPerformed: boolean } { + if (metric.sample_rate === undefined) { + return { sampled: true, samplingPerformed: false }; + } + + const propagationContext = scope.getPropagationContext(); + if (!propagationContext || propagationContext.sampleRand === undefined) { + return { sampled: true, samplingPerformed: false }; + } + + const sampleRand = propagationContext.sampleRand; + return { sampled: sampleRand < metric.sample_rate, samplingPerformed: true }; +} + +/** + * Adds the sample_rate attribute to the metric attributes if sampling was actually performed. + * + * @param metric - The metric containing the sample_rate. + * @param attributes - The attributes object to modify. + * @param samplingPerformed - Whether sampling was actually performed. + */ +function addSampleRateAttribute(metric: Metric, attributes: Record, samplingPerformed: boolean): void { + if (metric.sample_rate !== undefined && metric.sample_rate !== 1.0 && samplingPerformed) { + setMetricAttribute(attributes, 'sentry.client_sample_rate', metric.sample_rate); + } +} + +/** + * Processes and enriches metric attributes with context data. + * + * @param beforeMetric - The original metric. + * @param currentScope - The current scope. + * @param client - The client. + * @param samplingPerformed - Whether sampling was actually performed. + * @returns The processed metric attributes. + */ +function processMetricAttributes(beforeMetric: Metric, currentScope: Scope, client: Client, samplingPerformed: boolean): Record { + const processedMetricAttributes = { + ...beforeMetric.attributes, + }; + + addSampleRateAttribute(beforeMetric, processedMetricAttributes, samplingPerformed); + + 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); + + const { release, environment } = client.getOptions(); + 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< + 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') { + setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', true); + } + + return processedMetricAttributes; +} + /** * Captures a serialized metric event and adds it to the metric buffer for the given client. * @@ -133,48 +232,24 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal return; } - const { release, environment, _experiments } = client.getOptions(); + const { _experiments } = client.getOptions(); if (!_experiments?.enableMetrics) { DEBUG_BUILD && debug.warn('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< - Integration & { - getReplayId: (onlyIfSampled?: boolean) => string; - getRecordingMode: () => 'session' | 'buffer' | undefined; - } - >('Replay'); - - const replayId = replay?.getReplayId(true); - - setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replayId); + if (!validateAndProcessSampleRate(beforeMetric, client)) { + return; + } - 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', true); + const { sampled, samplingPerformed } = shouldSampleMetric(beforeMetric, currentScope); + if (!sampled) { + return; } + const [, traceContext] = _getTraceInfoFromScope(client, currentScope); + const processedMetricAttributes = processMetricAttributes(beforeMetric, currentScope, client, samplingPerformed); + const metric: Metric = { ...beforeMetric, attributes: processedMetricAttributes, diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts index e508fcb9e6d0..d17e6153416f 100644 --- a/packages/core/src/metrics/public-api.ts +++ b/packages/core/src/metrics/public-api.ts @@ -20,6 +20,11 @@ export interface MetricOptions { * The scope to capture the metric with. */ scope?: Scope; + + /** + * The sample rate for the metric. Must be a float between 0 (exclusive) and 1 (inclusive). + */ + sample_rate?: number; } /** @@ -32,7 +37,7 @@ export interface MetricOptions { */ function captureMetric(type: MetricType, name: string, value: number | string, options?: MetricOptions): void { _INTERNAL_captureMetric( - { type, name, value, unit: options?.unit, attributes: options?.attributes }, + { type, name, value, unit: options?.unit, attributes: options?.attributes, sample_rate: options?.sample_rate }, { scope: options?.scope }, ); } @@ -43,6 +48,7 @@ function captureMetric(type: MetricType, name: string, value: number | string, o * @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. + * @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0). * * @example * @@ -56,14 +62,15 @@ function captureMetric(type: MetricType, name: string, value: number | string, o * }); * ``` * - * @example With custom value + * @example With custom value and sample rate * * ``` * Sentry.metrics.count('items.processed', 5, { * attributes: { * processor: 'batch-processor', * queue: 'high-priority' - * } + * }, + * sample_rate: 0.1 * }); * ``` */ @@ -77,6 +84,7 @@ export function count(name: string, value: number = 1, options?: MetricOptions): * @param name - The name of the gauge metric. * @param value - The current value of the gauge. * @param options - Options for capturing the metric. + * @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0). * * @example * @@ -90,14 +98,15 @@ export function count(name: string, value: number = 1, options?: MetricOptions): * }); * ``` * - * @example Without unit + * @example With sample rate * * ``` * Sentry.metrics.gauge('active.connections', 42, { * attributes: { * server: 'api-1', * protocol: 'websocket' - * } + * }, + * sample_rate: 0.5 * }); * ``` */ @@ -111,6 +120,7 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi * @param name - The name of the distribution metric. * @param value - The value to record in the distribution. * @param options - Options for capturing the metric. + * @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0). * * @example * @@ -124,14 +134,15 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi * }); * ``` * - * @example Without unit + * @example With sample rate * * ``` * Sentry.metrics.distribution('batch.size', 100, { * attributes: { * processor: 'batch-1', * type: 'async' - * } + * }, + * sample_rate: 0.25 * }); * ``` */ diff --git a/packages/core/src/types-hoist/clientreport.ts b/packages/core/src/types-hoist/clientreport.ts index 069adec43c62..a29e95f333dd 100644 --- a/packages/core/src/types-hoist/clientreport.ts +++ b/packages/core/src/types-hoist/clientreport.ts @@ -9,7 +9,8 @@ export type EventDropReason = | 'sample_rate' | 'send_error' | 'internal_sdk_error' - | 'buffer_overflow'; + | 'buffer_overflow' + | 'invalid_sample_rate'; export type Outcome = { reason: EventDropReason; diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 9201243c4a38..fb8b8d9b20cb 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -25,6 +25,11 @@ export interface Metric { * Arbitrary structured data that stores information about the metric. */ attributes?: Record; + + /** + * The sample rate for the metric. Must be a float between 0 (exclusive) and 1 (inclusive). + */ + sample_rate?: number; } export type SerializedMetricAttributeValue = diff --git a/packages/core/test/lib/metrics/public-api.test.ts b/packages/core/test/lib/metrics/public-api.test.ts index 42fe7c41ae4a..116ee994e3ca 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 } from 'vitest'; +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'; @@ -119,6 +119,150 @@ describe('Metrics Public API', () => { expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); }); + + it('captures a counter metric with sample_rate when sampled', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '86f39e84263a4de99c326acab3bfe3bd', + sampleRand: 0.25, + }); + + count('api.requests', 1, { + scope, + sample_rate: 0.5, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'api.requests', + type: 'counter', + value: 1, + attributes: { + 'sentry.client_sample_rate': { + value: 0.5, + type: 'double', + }, + }, + }), + ); + }); + + it('drops counter metric with sample_rate when not sampled', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '86f39e84263a4de99c326acab3bfe3bd', + sampleRand: 0.75, + }); + + count('api.requests', 1, { + scope, + sample_rate: 0.5, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toBeUndefined(); + }); + + it('captures a counter metric with sample_rate and existing attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '86f39e84263a4de99c326acab3bfe3bd', + sampleRand: 0.1, + }); + + count('api.requests', 1, { + scope, + sample_rate: 0.25, + attributes: { + endpoint: '/api/users', + method: 'GET', + }, + }); + + 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', + }, + 'sentry.client_sample_rate': { + value: 0.25, + type: 'double', + }, + }, + }), + ); + }); + + it('drops metrics with sample_rate above 1', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', 1, { + scope, + sample_rate: 1.5, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toBeUndefined(); + }); + + it('drops metrics with sample_rate at or below 0', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + count('api.requests', 1, { + scope, + sample_rate: 0, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toBeUndefined(); + }); + + it('records dropped event for invalid sample_rate values', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + count('api.requests', 1, { scope, sample_rate: 0 }); + count('api.requests', 1, { scope, sample_rate: -0.5 }); + count('api.requests', 1, { scope, sample_rate: 1.5 }); + + expect(recordDroppedEventSpy).toHaveBeenCalledTimes(3); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('invalid_sample_rate', 'metric'); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toBeUndefined(); + }); }); describe('gauge', () => { @@ -209,6 +353,38 @@ describe('Metrics Public API', () => { expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); }); + + it('captures a gauge metric with sample_rate', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '86f39e84263a4de99c326acab3bfe3bd', + sampleRand: 0.5, + }); + + gauge('memory.usage', 1024, { + scope, + sample_rate: 0.75, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'memory.usage', + type: 'gauge', + value: 1024, + attributes: { + 'sentry.client_sample_rate': { + value: 0.75, + type: 'double', + }, + }, + }), + ); + }); }); describe('distribution', () => { @@ -299,6 +475,38 @@ describe('Metrics Public API', () => { expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined(); }); + + it('captures a distribution metric with sample_rate', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setPropagationContext({ + traceId: '86f39e84263a4de99c326acab3bfe3bd', + sampleRand: 0.05, + }); + + distribution('task.duration', 500, { + scope, + sample_rate: 0.1, + }); + + const metricBuffer = _INTERNAL_getMetricBuffer(client); + expect(metricBuffer).toHaveLength(1); + expect(metricBuffer?.[0]).toEqual( + expect.objectContaining({ + name: 'task.duration', + type: 'distribution', + value: 500, + attributes: { + 'sentry.client_sample_rate': { + value: 0.1, + type: 'double', + }, + }, + }), + ); + }); }); describe('mixed metric types', () => {