Skip to content

Commit cec2081

Browse files
committed
feat(tracemetrics): Add sample_rate parameter to metric API calls
This allows for a sample_rate (0, 1.0] to be sent on a per metric basis. Refs LOGS-497
1 parent 428186c commit cec2081

File tree

6 files changed

+237
-10
lines changed

6 files changed

+237
-10
lines changed

packages/core/src/metrics/internal.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ function setMetricAttribute(
7777
}
7878
}
7979

80+
/**
81+
* Validates and processes the sample_rate for a metric.
82+
*
83+
* @param metric - The metric containing the sample_rate to validate.
84+
* @param client - The client to record dropped events with.
85+
* @returns true if the sample_rate is valid, false if the metric should be dropped.
86+
*/
87+
function validateAndProcessSampleRate(metric: Metric, client: Client): boolean {
88+
if (metric.sample_rate !== undefined) {
89+
if (metric.sample_rate <= 0 || metric.sample_rate > 1.0) {
90+
// Invalid sample rate - drop the metric entirely and record the lost event
91+
client.recordDroppedEvent('invalid_sample_rate', 'metric');
92+
return false;
93+
}
94+
}
95+
return true;
96+
}
97+
98+
/**
99+
* Adds the sample_rate attribute to the metric attributes if needed.
100+
*
101+
* @param metric - The metric containing the sample_rate.
102+
* @param attributes - The attributes object to modify.
103+
*/
104+
function addSampleRateAttribute(metric: Metric, attributes: Record<string, unknown>): void {
105+
if (metric.sample_rate !== undefined && metric.sample_rate !== 1.0) {
106+
setMetricAttribute(attributes, 'sentry.client_sample_rate', metric.sample_rate);
107+
}
108+
}
109+
80110
/**
81111
* Captures a serialized metric event and adds it to the metric buffer for the given client.
82112
*
@@ -133,6 +163,10 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
133163
return;
134164
}
135165

166+
if (!validateAndProcessSampleRate(beforeMetric, client)) {
167+
return;
168+
}
169+
136170
const { release, environment, _experiments } = client.getOptions();
137171
if (!_experiments?.enableMetrics) {
138172
DEBUG_BUILD && debug.warn('metrics option not enabled, metric will not be captured.');
@@ -145,6 +179,9 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal
145179
...beforeMetric.attributes,
146180
};
147181

182+
183+
addSampleRateAttribute(beforeMetric, processedMetricAttributes);
184+
148185
const {
149186
user: { id, email, username },
150187
} = getMergedScopeData(currentScope);

packages/core/src/metrics/public-api.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export interface MetricOptions {
2020
* The scope to capture the metric with.
2121
*/
2222
scope?: Scope;
23+
24+
/**
25+
* The sample rate for the metric. Must be a float between 0 (exclusive) and 1 (inclusive).
26+
*/
27+
sample_rate?: number;
2328
}
2429

2530
/**
@@ -32,7 +37,7 @@ export interface MetricOptions {
3237
*/
3338
function captureMetric(type: MetricType, name: string, value: number | string, options?: MetricOptions): void {
3439
_INTERNAL_captureMetric(
35-
{ type, name, value, unit: options?.unit, attributes: options?.attributes },
40+
{ type, name, value, unit: options?.unit, attributes: options?.attributes, sample_rate: options?.sample_rate },
3641
{ scope: options?.scope },
3742
);
3843
}
@@ -43,6 +48,7 @@ function captureMetric(type: MetricType, name: string, value: number | string, o
4348
* @param name - The name of the counter metric.
4449
* @param value - The value to increment by (defaults to 1).
4550
* @param options - Options for capturing the metric.
51+
* @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0).
4652
*
4753
* @example
4854
*
@@ -56,14 +62,15 @@ function captureMetric(type: MetricType, name: string, value: number | string, o
5662
* });
5763
* ```
5864
*
59-
* @example With custom value
65+
* @example With custom value and sample rate
6066
*
6167
* ```
6268
* Sentry.metrics.count('items.processed', 5, {
6369
* attributes: {
6470
* processor: 'batch-processor',
6571
* queue: 'high-priority'
66-
* }
72+
* },
73+
* sample_rate: 0.1
6774
* });
6875
* ```
6976
*/
@@ -77,6 +84,7 @@ export function count(name: string, value: number = 1, options?: MetricOptions):
7784
* @param name - The name of the gauge metric.
7885
* @param value - The current value of the gauge.
7986
* @param options - Options for capturing the metric.
87+
* @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0).
8088
*
8189
* @example
8290
*
@@ -90,14 +98,15 @@ export function count(name: string, value: number = 1, options?: MetricOptions):
9098
* });
9199
* ```
92100
*
93-
* @example Without unit
101+
* @example With sample rate
94102
*
95103
* ```
96104
* Sentry.metrics.gauge('active.connections', 42, {
97105
* attributes: {
98106
* server: 'api-1',
99107
* protocol: 'websocket'
100-
* }
108+
* },
109+
* sample_rate: 0.5
101110
* });
102111
* ```
103112
*/
@@ -111,6 +120,7 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi
111120
* @param name - The name of the distribution metric.
112121
* @param value - The value to record in the distribution.
113122
* @param options - Options for capturing the metric.
123+
* @param options.sample_rate - Sample rate for the metric (0 < sample_rate <= 1.0).
114124
*
115125
* @example
116126
*
@@ -124,14 +134,15 @@ export function gauge(name: string, value: number, options?: MetricOptions): voi
124134
* });
125135
* ```
126136
*
127-
* @example Without unit
137+
* @example With sample rate
128138
*
129139
* ```
130140
* Sentry.metrics.distribution('batch.size', 100, {
131141
* attributes: {
132142
* processor: 'batch-1',
133143
* type: 'async'
134-
* }
144+
* },
145+
* sample_rate: 0.25
135146
* });
136147
* ```
137148
*/

packages/core/src/types-hoist/clientreport.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export type EventDropReason =
99
| 'sample_rate'
1010
| 'send_error'
1111
| 'internal_sdk_error'
12-
| 'buffer_overflow';
12+
| 'buffer_overflow'
13+
| 'invalid_sample_rate';
1314

1415
export type Outcome = {
1516
reason: EventDropReason;

packages/core/src/types-hoist/metric.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface Metric {
2525
* Arbitrary structured data that stores information about the metric.
2626
*/
2727
attributes?: Record<string, unknown>;
28+
29+
/**
30+
* The sample rate for the metric. Must be a float between 0 (exclusive) and 1 (inclusive).
31+
*/
32+
sample_rate?: number;
2833
}
2934

3035
export type SerializedMetricAttributeValue =

packages/core/src/utils/lru.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class LRUMap<K, V> {
2828
if (this._cache.size >= this._maxSize) {
2929
// keys() returns an iterator in insertion order so keys().next() gives us the oldest key
3030
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
31-
const nextKey = this._cache.keys().next().value!;
31+
const nextKey = this._cache.keys().next().value;
3232
this._cache.delete(nextKey);
3333
}
3434
this._cache.set(key, value);

packages/core/test/lib/metrics/public-api.test.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
22
import { Scope } from '../../../src';
33
import { _INTERNAL_getMetricBuffer } from '../../../src/metrics/internal';
44
import { count, distribution, gauge } from '../../../src/metrics/public-api';
@@ -119,6 +119,123 @@ describe('Metrics Public API', () => {
119119

120120
expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
121121
});
122+
123+
it('captures a counter metric with sample_rate', () => {
124+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
125+
const client = new TestClient(options);
126+
const scope = new Scope();
127+
scope.setClient(client);
128+
129+
count('api.requests', 1, {
130+
scope,
131+
sample_rate: 0.5,
132+
});
133+
134+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
135+
expect(metricBuffer).toHaveLength(1);
136+
expect(metricBuffer?.[0]).toEqual(
137+
expect.objectContaining({
138+
name: 'api.requests',
139+
type: 'counter',
140+
value: 1,
141+
attributes: {
142+
'sentry.client_sample_rate': {
143+
value: 0.5,
144+
type: 'double',
145+
},
146+
},
147+
}),
148+
);
149+
});
150+
151+
it('captures a counter metric with sample_rate and existing attributes', () => {
152+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
153+
const client = new TestClient(options);
154+
const scope = new Scope();
155+
scope.setClient(client);
156+
157+
count('api.requests', 1, {
158+
scope,
159+
sample_rate: 0.25,
160+
attributes: {
161+
endpoint: '/api/users',
162+
method: 'GET',
163+
},
164+
});
165+
166+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
167+
expect(metricBuffer).toHaveLength(1);
168+
expect(metricBuffer?.[0]).toEqual(
169+
expect.objectContaining({
170+
name: 'api.requests',
171+
type: 'counter',
172+
value: 1,
173+
attributes: {
174+
endpoint: {
175+
value: '/api/users',
176+
type: 'string',
177+
},
178+
method: {
179+
value: 'GET',
180+
type: 'string',
181+
},
182+
'sentry.client_sample_rate': {
183+
value: 0.25,
184+
type: 'double',
185+
},
186+
},
187+
}),
188+
);
189+
});
190+
191+
it('drops metrics with sample_rate above 1', () => {
192+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
193+
const client = new TestClient(options);
194+
const scope = new Scope();
195+
scope.setClient(client);
196+
197+
count('api.requests', 1, {
198+
scope,
199+
sample_rate: 1.5,
200+
});
201+
202+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
203+
expect(metricBuffer).toBeUndefined();
204+
});
205+
206+
it('drops metrics with sample_rate at or below 0', () => {
207+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
208+
const client = new TestClient(options);
209+
const scope = new Scope();
210+
scope.setClient(client);
211+
212+
count('api.requests', 1, {
213+
scope,
214+
sample_rate: 0,
215+
});
216+
217+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
218+
expect(metricBuffer).toBeUndefined();
219+
});
220+
221+
it('records dropped event for invalid sample_rate values', () => {
222+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
223+
const client = new TestClient(options);
224+
const scope = new Scope();
225+
scope.setClient(client);
226+
227+
const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent');
228+
229+
count('api.requests', 1, { scope, sample_rate: 0 });
230+
count('api.requests', 1, { scope, sample_rate: -0.5 });
231+
count('api.requests', 1, { scope, sample_rate: 1.5 });
232+
233+
expect(recordDroppedEventSpy).toHaveBeenCalledTimes(3);
234+
expect(recordDroppedEventSpy).toHaveBeenCalledWith('invalid_sample_rate', 'metric');
235+
236+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
237+
expect(metricBuffer).toBeUndefined();
238+
});
122239
});
123240

124241
describe('gauge', () => {
@@ -209,6 +326,34 @@ describe('Metrics Public API', () => {
209326

210327
expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
211328
});
329+
330+
it('captures a gauge metric with sample_rate', () => {
331+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
332+
const client = new TestClient(options);
333+
const scope = new Scope();
334+
scope.setClient(client);
335+
336+
gauge('memory.usage', 1024, {
337+
scope,
338+
sample_rate: 0.75,
339+
});
340+
341+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
342+
expect(metricBuffer).toHaveLength(1);
343+
expect(metricBuffer?.[0]).toEqual(
344+
expect.objectContaining({
345+
name: 'memory.usage',
346+
type: 'gauge',
347+
value: 1024,
348+
attributes: {
349+
'sentry.client_sample_rate': {
350+
value: 0.75,
351+
type: 'double',
352+
},
353+
},
354+
}),
355+
);
356+
});
212357
});
213358

214359
describe('distribution', () => {
@@ -299,6 +444,34 @@ describe('Metrics Public API', () => {
299444

300445
expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
301446
});
447+
448+
it('captures a distribution metric with sample_rate', () => {
449+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
450+
const client = new TestClient(options);
451+
const scope = new Scope();
452+
scope.setClient(client);
453+
454+
distribution('task.duration', 500, {
455+
scope,
456+
sample_rate: 0.1,
457+
});
458+
459+
const metricBuffer = _INTERNAL_getMetricBuffer(client);
460+
expect(metricBuffer).toHaveLength(1);
461+
expect(metricBuffer?.[0]).toEqual(
462+
expect.objectContaining({
463+
name: 'task.duration',
464+
type: 'distribution',
465+
value: 500,
466+
attributes: {
467+
'sentry.client_sample_rate': {
468+
value: 0.1,
469+
type: 'double',
470+
},
471+
},
472+
}),
473+
);
474+
});
302475
});
303476

304477
describe('mixed metric types', () => {

0 commit comments

Comments
 (0)