Skip to content

Commit 19e28cc

Browse files
committed
feat(metrics): Add timing method to metrics
1 parent 8007c13 commit 19e28cc

File tree

10 files changed

+452
-10
lines changed

10 files changed

+452
-10
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: 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 & 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 { MetricData, Metrics, DurationUnit } 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: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Client, MetricData, Metrics, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
1+
import type { Client, MetricData, Metrics, DurationUnit, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
22
import { MetricsAggregator } from './aggregator';
33
import { metrics as metricsCore } from './exports';
44

@@ -38,6 +38,26 @@ function gauge(name: string, value: number, data?: MetricData): void {
3838
metricsCore.gauge(MetricsAggregator, name, value, data);
3939
}
4040

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(MetricsAggregator, name, value, unit, data);
59+
}
60+
4161
/**
4262
* Returns the metrics aggregator for a given client.
4363
*/
@@ -52,7 +72,7 @@ export const metricsDefault: Metrics & {
5272
distribution,
5373
set,
5474
gauge,
55-
75+
timing,
5676
/**
5777
* @ignore This is for internal use only.
5878
*/

packages/core/src/metrics/exports.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { Client, MetricData, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
2-
import { getGlobalSingleton, logger } from '@sentry/utils';
1+
import type { Client, DurationUnit, MetricData, MetricsAggregator as MetricsAggregatorInterface } from '@sentry/types';
2+
import { getGlobalSingleton, logger, timestampInSeconds } from '@sentry/utils';
33
import { getClient } from '../currentScopes';
44
import { DEBUG_BUILD } from '../debug-build';
5+
import { startInactiveSpan } from '../tracing';
6+
import { handleCallbackErrors } from '../utils/handleCallbackErrors';
57
import { getActiveSpan, getRootSpan, spanToJSON } from '../utils/spanUtils';
68
import { COUNTER_METRIC_TYPE, DISTRIBUTION_METRIC_TYPE, GAUGE_METRIC_TYPE, SET_METRIC_TYPE } from './constants';
79
import type { MetricType } from './types';
@@ -90,6 +92,50 @@ function distribution(aggregator: MetricsAggregatorConstructor, name: string, va
9092
addToMetricsAggregator(aggregator, DISTRIBUTION_METRIC_TYPE, name, ensureNumber(value), data);
9193
}
9294

95+
/**
96+
* Adds a timing metric.
97+
* The metric is added as a distribution metric.
98+
*
99+
* You can either directly capture a numeric `value`, or wrap a callback function in `timing`.
100+
* In the latter case, the duration of the callback execution will be captured as a span & a metric.
101+
*
102+
* @experimental This API is experimental and might have breaking changes in the future.
103+
*/
104+
function timing<T = void>(
105+
aggregator: MetricsAggregatorConstructor,
106+
name: string,
107+
value: number | (() => T),
108+
unit: DurationUnit = 'second',
109+
data?: Omit<MetricData, 'unit'>,
110+
): T | void {
111+
// callback form
112+
if (typeof value === 'function') {
113+
const startTime = timestampInSeconds();
114+
const span = startInactiveSpan({
115+
op: 'metrics.timing',
116+
name,
117+
startTime,
118+
onlyIfParent: true,
119+
});
120+
121+
return handleCallbackErrors(
122+
() => value(),
123+
() => {
124+
// no special error handling necessary
125+
},
126+
() => {
127+
const endTime = timestampInSeconds();
128+
const timeDiff = endTime - startTime;
129+
distribution(aggregator, name, timeDiff, { ...data, unit: 'second' });
130+
span.end(endTime);
131+
},
132+
);
133+
}
134+
135+
// value form
136+
distribution(aggregator, name, value, { ...data, unit });
137+
}
138+
93139
/**
94140
* Adds a value to a set metric. Value must be a string or integer.
95141
*
@@ -113,6 +159,7 @@ export const metrics = {
113159
distribution,
114160
set,
115161
gauge,
162+
timing,
116163
/**
117164
* @ignore This is for internal use only.
118165
*/

0 commit comments

Comments
 (0)