Skip to content

Commit d8b0eb3

Browse files
committed
feat(metrics): Add metrics.timing() method
1 parent 3cf3ff1 commit d8b0eb3

File tree

8 files changed

+401
-22
lines changed

8 files changed

+401
-22
lines changed

dev-packages/browser-integration-tests/suites/metrics/init.js renamed to 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+
}
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import { expect } from '@playwright/test';
22

3-
import { sentryTest } from '../../utils/fixtures';
4-
import { getFirstSentryEnvelopeRequest, properEnvelopeRequestParser } from '../../utils/helpers';
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, properEnvelopeRequestParser } from '../../../utils/helpers';
55

66
sentryTest('collects metrics', async ({ getLocalTestUrl, page }) => {
77
const url = await getLocalTestUrl({ testDir: __dirname });
88

99
const statsdBuffer = await getFirstSentryEnvelopeRequest<Uint8Array>(page, url, properEnvelopeRequestParser);
1010
const statsdString = new TextDecoder().decode(statsdBuffer);
1111
// Replace all the Txxxxxx to remove the timestamps
12-
const normalisedStatsdString = statsdString.replace(/T\d+\n?/g, 'T000000');
12+
const normalisedStatsdString = statsdString.replace(/T\d+\n?/g, 'T000000').trim();
1313

14-
expect(normalisedStatsdString).toEqual(
15-
'increment@none:6|c|T000000distribution@none:42:45|d|T000000gauge@none:15:5:15:20:2|g|T000000set@none:3387254:3443787523|s|T000000',
16-
);
14+
const parts = normalisedStatsdString.split('T000000');
15+
16+
expect(parts).toEqual([
17+
'increment@none:6|c|',
18+
'distribution@none:42:45|d|',
19+
'gauge@none:15:5:15:20:2|g|',
20+
'set@none:3387254:3443787523|s|',
21+
'timing@hour:99|d|',
22+
expect.stringMatching(/timingSync@second:0.(\d+)\|d\|/),
23+
expect.stringMatching(/timingAsync@second:0.(\d+)\|d\|/),
24+
'', // trailing element
25+
]);
1726
});
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://public@dsn.ingest.sentry.io/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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
// @ts-expect-error this is fine...
71+
expect(transactionEvent._metrics_summary).toEqual({
72+
'd:timingSync@second': [
73+
{
74+
count: 1,
75+
max: duration,
76+
min: duration,
77+
sum: duration,
78+
tags: {
79+
release: '1.0.0',
80+
transaction: 'manual span',
81+
},
82+
},
83+
],
84+
});
85+
});
86+
87+
sentryTest('allows to wrap async methods with a timing metric', async ({ getLocalTestUrl, page }) => {
88+
if (shouldSkipTracingTest()) {
89+
sentryTest.skip();
90+
}
91+
92+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
93+
return route.fulfill({
94+
status: 200,
95+
contentType: 'application/json',
96+
body: JSON.stringify({ id: 'test-id' }),
97+
});
98+
});
99+
100+
const url = await getLocalTestUrl({ testDir: __dirname });
101+
102+
const beforeTime = Math.floor(Date.now() / 1000);
103+
104+
const metricsPromiseReq = page.waitForRequest(req => {
105+
const postData = req.postData();
106+
if (!postData) {
107+
return false;
108+
}
109+
110+
try {
111+
// this implies this is a metrics envelope
112+
return typeof envelopeRequestParser(req) === 'string';
113+
} catch {
114+
return false;
115+
}
116+
});
117+
118+
const transactionPromise = waitForTransactionRequest(page);
119+
120+
await page.goto(url);
121+
await page.waitForFunction('typeof window.timingAsync === "function"');
122+
const response = await page.evaluate('window.timingAsync()');
123+
124+
expect(response).toBe('async done');
125+
126+
const statsdString = envelopeRequestParser<string>(await metricsPromiseReq);
127+
const transactionEvent = properEnvelopeRequestParser(await transactionPromise);
128+
129+
expect(typeof statsdString).toEqual('string');
130+
131+
const parsedStatsd = /timingAsync@second:(0\.\d+)\|d\|#(.+)\|T(\d+)/.exec(statsdString);
132+
133+
expect(parsedStatsd).toBeTruthy();
134+
135+
const duration = parseFloat(parsedStatsd![1]);
136+
const tags = parsedStatsd![2];
137+
const timestamp = parseInt(parsedStatsd![3], 10);
138+
139+
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
140+
expect(tags).toEqual('release:1.0.0,transaction:manual span');
141+
expect(duration).toBeGreaterThan(0.2);
142+
expect(duration).toBeLessThan(1);
143+
144+
expect(transactionEvent).toBeDefined();
145+
expect(transactionEvent.transaction).toEqual('manual span');
146+
// @ts-expect-error this is fine...
147+
expect(transactionEvent._metrics_summary).toEqual({
148+
'd:timingAsync@second': [
149+
{
150+
count: 1,
151+
max: duration,
152+
min: duration,
153+
sum: duration,
154+
tags: {
155+
release: '1.0.0',
156+
transaction: 'manual span',
157+
},
158+
},
159+
],
160+
});
161+
});

packages/browser/src/metrics.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MetricData } from '@sentry/core';
22
import { BrowserMetricsAggregator, metrics as metricsCore } from '@sentry/core';
3+
import type { DurationUnit } from '@sentry/types';
34

45
/**
56
* Adds a value to a counter metric
@@ -37,9 +38,30 @@ function gauge(name: string, value: number, data?: MetricData): void {
3738
metricsCore.gauge(BrowserMetricsAggregator, name, value, data);
3839
}
3940

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

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Client, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
1+
import type { Client, DurationUnit, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
22
import { MetricsAggregator } from './aggregator';
33
import type { MetricData } from './exports';
44
import { metrics as metricsCore } from './exports';
@@ -39,6 +39,26 @@ function gauge(name: string, value: number, data?: MetricData): void {
3939
metricsCore.gauge(MetricsAggregator, name, value, data);
4040
}
4141

42+
/**
43+
* Adds a timing metric.
44+
* The metric is added as a distribution metric.
45+
*
46+
* You can either directly capture a numeric `value`, or wrap a callback function in `timing`.
47+
* In the latter case, the duration of the callback execution will be captured as a span & a metric.
48+
*
49+
* @experimental This API is experimental and might have breaking changes in the future.
50+
*/
51+
function timing(name: string, value: number, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): void;
52+
function timing<T>(name: string, callback: () => T, unit?: DurationUnit, data?: Omit<MetricData, 'unit'>): T;
53+
function timing<T = void>(
54+
name: string,
55+
value: number | (() => T),
56+
unit: DurationUnit = 'second',
57+
data?: Omit<MetricData, 'unit'>,
58+
): T | void {
59+
return metricsCore.timing(MetricsAggregator, name, value, unit, data);
60+
}
61+
4262
/**
4363
* Returns the metrics aggregator for a given client.
4464
*/
@@ -51,6 +71,7 @@ export const metricsDefault = {
5171
distribution,
5272
set,
5373
gauge,
74+
timing,
5475
/**
5576
* @ignore This is for internal use only.
5677
*/

packages/core/src/metrics/exports.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,20 +105,15 @@ function distribution(aggregator: MetricsAggregatorConstructor, name: string, va
105105
addToMetricsAggregator(aggregator, DISTRIBUTION_METRIC_TYPE, name, ensureNumber(value), data);
106106
}
107107

108-
function timing(
109-
aggregator: MetricsAggregatorConstructor,
110-
name: string,
111-
value: number,
112-
unit?: DurationUnit,
113-
data?: Omit<MetricData, 'unit'>,
114-
): void;
115-
function timing<T>(
116-
aggregator: MetricsAggregatorConstructor,
117-
name: string,
118-
value: () => T,
119-
unit?: DurationUnit,
120-
data?: Omit<MetricData, 'unit'>,
121-
): T;
108+
/**
109+
* Adds a timing metric.
110+
* The metric is added as a distribution metric.
111+
*
112+
* You can either directly capture a numeric `value`, or wrap a callback function in `timing`.
113+
* In the latter case, the duration of the callback execution will be captured as a span & a metric.
114+
*
115+
* @experimental This API is experimental and might have breaking changes in the future.
116+
*/
122117
function timing<T = void>(
123118
aggregator: MetricsAggregatorConstructor,
124119
name: string,
@@ -129,7 +124,12 @@ function timing<T = void>(
129124
// callback form
130125
if (typeof value === 'function') {
131126
const startTime = timestampInSeconds();
132-
const span = startInactiveSpan({ op: 'metrics.timing', name, startTime });
127+
const span = startInactiveSpan({
128+
op: 'metrics.timing',
129+
name,
130+
startTime,
131+
onlyIfParent: true,
132+
});
133133

134134
return handleCallbackErrors(
135135
() => value(),

0 commit comments

Comments
 (0)