Skip to content

Commit d63d7c6

Browse files
authored
feat(metrics): Add timings method to metrics (#12226)
This introduces a new method, `metrics.timing()`, which can be used in two ways: 1. With a numeric value, to simplify creating a distribution metric. This will default to `second` as unit: ```js Sentry.metrics.timing('myMetric', 100); ``` 2. With a callback, which will wrap the duration of the callback. This can accept a sync or async callback. It will create an inactive span around the callback and at the end emit a metric with the duration of the span in seconds: ```js const returnValue = Sentry.metrics.timing('myMetric', measureThisFunction); ``` Closes #12215
1 parent 253c610 commit d63d7c6

File tree

12 files changed

+505
-24
lines changed

12 files changed

+505
-24
lines changed

dev-packages/browser-integration-tests/suites/metrics/metricsEvent/init.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,20 @@ Sentry.metrics.gauge('gauge', 5);
1515
Sentry.metrics.gauge('gauge', '15');
1616
Sentry.metrics.set('set', 'nope');
1717
Sentry.metrics.set('set', 'another');
18+
19+
Sentry.metrics.timing('timing', 99, 'hour');
20+
Sentry.metrics.timing('timingSync', () => {
21+
sleepSync(200);
22+
});
23+
Sentry.metrics.timing('timingAsync', async () => {
24+
await new Promise(resolve => setTimeout(resolve, 200));
25+
});
26+
27+
function sleepSync(milliseconds) {
28+
var start = new Date().getTime();
29+
for (var i = 0; i < 1e7; i++) {
30+
if (new Date().getTime() - start > milliseconds) {
31+
break;
32+
}
33+
}
34+
}

dev-packages/browser-integration-tests/suites/metrics/metricsEvent/test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,18 @@ sentryTest('collects metrics', async ({ getLocalTestUrl, page }) => {
1717
const statsdBuffer = await getFirstSentryEnvelopeRequest<Uint8Array>(page, url, properEnvelopeRequestParser);
1818
const statsdString = new TextDecoder().decode(statsdBuffer);
1919
// Replace all the Txxxxxx to remove the timestamps
20-
const normalisedStatsdString = statsdString.replace(/T\d+\n?/g, 'T000000');
20+
const normalisedStatsdString = statsdString.replace(/T\d+\n?/g, 'T000000').trim();
2121

22-
expect(normalisedStatsdString).toEqual(
23-
'increment@none:6|c|T000000distribution@none:42:45|d|T000000gauge@none:15:5:15:20:2|g|T000000set@none:3387254:3443787523|s|T000000',
24-
);
22+
const parts = normalisedStatsdString.split('T000000');
23+
24+
expect(parts).toEqual([
25+
'increment@none:6|c|',
26+
'distribution@none:42:45|d|',
27+
'gauge@none:15:5:15:20:2|g|',
28+
'set@none:3387254:3443787523|s|',
29+
'timing@hour:99|d|',
30+
expect.stringMatching(/timingSync@second:0.(\d+)\|d\|/),
31+
expect.stringMatching(/timingAsync@second:0.(\d+)\|d\|/),
32+
'', // trailing element
33+
]);
2534
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1.0,
8+
release: '1.0.0',
9+
autoSessionTracking: false,
10+
});
11+
12+
window.timingSync = () => {
13+
// Ensure we always have a wrapping span
14+
return Sentry.startSpan({ name: 'manual span' }, () => {
15+
return Sentry.metrics.timing('timingSync', () => {
16+
sleepSync(200);
17+
return 'sync done';
18+
});
19+
});
20+
};
21+
22+
window.timingAsync = () => {
23+
// Ensure we always have a wrapping span
24+
return Sentry.startSpan({ name: 'manual span' }, () => {
25+
return Sentry.metrics.timing('timingAsync', async () => {
26+
await new Promise(resolve => setTimeout(resolve, 200));
27+
return 'async done';
28+
});
29+
});
30+
};
31+
32+
function sleepSync(milliseconds) {
33+
var start = new Date().getTime();
34+
for (var i = 0; i < 1e7; i++) {
35+
if (new Date().getTime() - start > milliseconds) {
36+
break;
37+
}
38+
}
39+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import {
5+
envelopeRequestParser,
6+
properEnvelopeRequestParser,
7+
shouldSkipTracingTest,
8+
waitForTransactionRequest,
9+
} from '../../../utils/helpers';
10+
11+
sentryTest('allows to wrap sync methods with a timing metric', async ({ getLocalTestUrl, page }) => {
12+
if (shouldSkipTracingTest()) {
13+
sentryTest.skip();
14+
}
15+
16+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
17+
return route.fulfill({
18+
status: 200,
19+
contentType: 'application/json',
20+
body: JSON.stringify({ id: 'test-id' }),
21+
});
22+
});
23+
24+
const url = await getLocalTestUrl({ testDir: __dirname });
25+
26+
const beforeTime = Math.floor(Date.now() / 1000);
27+
28+
const metricsPromiseReq = page.waitForRequest(req => {
29+
const postData = req.postData();
30+
if (!postData) {
31+
return false;
32+
}
33+
34+
try {
35+
// this implies this is a metrics envelope
36+
return typeof envelopeRequestParser(req) === 'string';
37+
} catch {
38+
return false;
39+
}
40+
});
41+
42+
const transactionPromise = waitForTransactionRequest(page);
43+
44+
await page.goto(url);
45+
await page.waitForFunction('typeof window.timingSync === "function"');
46+
const response = await page.evaluate('window.timingSync()');
47+
48+
expect(response).toBe('sync done');
49+
50+
const statsdString = envelopeRequestParser<string>(await metricsPromiseReq);
51+
const transactionEvent = properEnvelopeRequestParser(await transactionPromise);
52+
53+
expect(typeof statsdString).toEqual('string');
54+
55+
const parsedStatsd = /timingSync@second:(0\.\d+)\|d\|#(.+)\|T(\d+)/.exec(statsdString);
56+
57+
expect(parsedStatsd).toBeTruthy();
58+
59+
const duration = parseFloat(parsedStatsd![1]);
60+
const tags = parsedStatsd![2];
61+
const timestamp = parseInt(parsedStatsd![3], 10);
62+
63+
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
64+
expect(tags).toEqual('release:1.0.0,transaction:manual span');
65+
expect(duration).toBeGreaterThan(0.2);
66+
expect(duration).toBeLessThan(1);
67+
68+
expect(transactionEvent).toBeDefined();
69+
expect(transactionEvent.transaction).toEqual('manual span');
70+
71+
const spans = transactionEvent.spans || [];
72+
73+
expect(spans.length).toBe(1);
74+
const span = spans[0];
75+
expect(span.op).toEqual('metrics.timing');
76+
expect(span.description).toEqual('timingSync');
77+
expect(span.timestamp! - span.start_timestamp).toEqual(duration);
78+
expect(span._metrics_summary).toEqual({
79+
'd:timingSync@second': [
80+
{
81+
count: 1,
82+
max: duration,
83+
min: duration,
84+
sum: duration,
85+
tags: {
86+
release: '1.0.0',
87+
transaction: 'manual span',
88+
},
89+
},
90+
],
91+
});
92+
});
93+
94+
sentryTest('allows to wrap async methods with a timing metric', async ({ getLocalTestUrl, page }) => {
95+
if (shouldSkipTracingTest()) {
96+
sentryTest.skip();
97+
}
98+
99+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
100+
return route.fulfill({
101+
status: 200,
102+
contentType: 'application/json',
103+
body: JSON.stringify({ id: 'test-id' }),
104+
});
105+
});
106+
107+
const url = await getLocalTestUrl({ testDir: __dirname });
108+
109+
const beforeTime = Math.floor(Date.now() / 1000);
110+
111+
const metricsPromiseReq = page.waitForRequest(req => {
112+
const postData = req.postData();
113+
if (!postData) {
114+
return false;
115+
}
116+
117+
try {
118+
// this implies this is a metrics envelope
119+
return typeof envelopeRequestParser(req) === 'string';
120+
} catch {
121+
return false;
122+
}
123+
});
124+
125+
const transactionPromise = waitForTransactionRequest(page);
126+
127+
await page.goto(url);
128+
await page.waitForFunction('typeof window.timingAsync === "function"');
129+
const response = await page.evaluate('window.timingAsync()');
130+
131+
expect(response).toBe('async done');
132+
133+
const statsdString = envelopeRequestParser<string>(await metricsPromiseReq);
134+
const transactionEvent = properEnvelopeRequestParser(await transactionPromise);
135+
136+
expect(typeof statsdString).toEqual('string');
137+
138+
const parsedStatsd = /timingAsync@second:(0\.\d+)\|d\|#(.+)\|T(\d+)/.exec(statsdString);
139+
140+
expect(parsedStatsd).toBeTruthy();
141+
142+
const duration = parseFloat(parsedStatsd![1]);
143+
const tags = parsedStatsd![2];
144+
const timestamp = parseInt(parsedStatsd![3], 10);
145+
146+
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
147+
expect(tags).toEqual('release:1.0.0,transaction:manual span');
148+
expect(duration).toBeGreaterThan(0.2);
149+
expect(duration).toBeLessThan(1);
150+
151+
expect(transactionEvent).toBeDefined();
152+
expect(transactionEvent.transaction).toEqual('manual span');
153+
154+
const spans = transactionEvent.spans || [];
155+
156+
expect(spans.length).toBe(1);
157+
const span = spans[0];
158+
expect(span.op).toEqual('metrics.timing');
159+
expect(span.description).toEqual('timingAsync');
160+
expect(span.timestamp! - span.start_timestamp).toEqual(duration);
161+
expect(span._metrics_summary).toEqual({
162+
'd:timingAsync@second': [
163+
{
164+
count: 1,
165+
max: duration,
166+
min: duration,
167+
sum: duration,
168+
tags: {
169+
release: '1.0.0',
170+
transaction: 'manual span',
171+
},
172+
},
173+
],
174+
});
175+
});

dev-packages/rollup-utils/plugins/bundlePlugins.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ export function makeTerserPlugin() {
121121
// These are used by instrument.ts in utils for identifying HTML elements & events
122122
'_sentryCaptured',
123123
'_sentryId',
124+
// Keeps the frozen DSC on a Sentry Span
124125
'_frozenDsc',
126+
// This keeps metrics summary on spans
127+
'_metrics_summary',
125128
// These are used to keep span & scope relationships
126129
'_sentryRootSpan',
127130
'_sentryChildSpans',

packages/browser/src/metrics.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BrowserMetricsAggregator, metrics as metricsCore } from '@sentry/core';
2-
import type { MetricData, Metrics } from '@sentry/types';
2+
import type { DurationUnit, MetricData, Metrics } from '@sentry/types';
33

44
/**
55
* Adds a value to a counter metric
@@ -37,9 +37,30 @@ function gauge(name: string, value: number, data?: MetricData): void {
3737
metricsCore.gauge(BrowserMetricsAggregator, name, value, data);
3838
}
3939

40+
/**
41+
* Adds a timing metric.
42+
* The metric is added as a distribution metric.
43+
*
44+
* You can either directly capture a numeric `value`, or wrap a callback function in `timing`.
45+
* In the latter case, the duration of the callback execution will be captured as a span & a metric.
46+
*
47+
* @experimental This API is experimental and might have breaking changes in the future.
48+
*/
49+
function timing(name: string, value: number, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): void;
50+
function timing<T>(name: string, callback: () => T, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): T;
51+
function timing<T = void>(
52+
name: string,
53+
value: number | (() => T),
54+
unit: DurationUnit = 'second',
55+
data?: Omit<MetricData, 'unit'>,
56+
): T | void {
57+
return metricsCore.timing(BrowserMetricsAggregator, name, value, unit, data);
58+
}
59+
4060
export const metrics: Metrics = {
4161
increment,
4262
distribution,
4363
set,
4464
gauge,
65+
timing,
4566
};

packages/core/src/metrics/exports-default.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { Client, MetricData, Metrics, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
1+
import type {
2+
Client,
3+
DurationUnit,
4+
MetricData,
5+
Metrics,
6+
MetricsAggregator as MetricsAggregatorInterface,
7+
} from '@sentry/types';
28
import { MetricsAggregator } from './aggregator';
39
import { metrics as metricsCore } from './exports';
410

@@ -38,6 +44,26 @@ function gauge(name: string, value: number, data?: MetricData): void {
3844
metricsCore.gauge(MetricsAggregator, name, value, data);
3945
}
4046

47+
/**
48+
* Adds a timing metric.
49+
* The metric is added as a distribution metric.
50+
*
51+
* You can either directly capture a numeric `value`, or wrap a callback function in `timing`.
52+
* In the latter case, the duration of the callback execution will be captured as a span & a metric.
53+
*
54+
* @experimental This API is experimental and might have breaking changes in the future.
55+
*/
56+
function timing(name: string, value: number, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): void;
57+
function timing<T>(name: string, callback: () => T, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): T;
58+
function timing<T = void>(
59+
name: string,
60+
value: number | (() => T),
61+
unit: DurationUnit = 'second',
62+
data?: Omit<MetricData, 'unit'>,
63+
): T | void {
64+
return metricsCore.timing(MetricsAggregator, name, value, unit, data);
65+
}
66+
4167
/**
4268
* Returns the metrics aggregator for a given client.
4369
*/
@@ -52,7 +78,7 @@ export const metricsDefault: Metrics & {
5278
distribution,
5379
set,
5480
gauge,
55-
81+
timing,
5682
/**
5783
* @ignore This is for internal use only.
5884
*/

0 commit comments

Comments
 (0)