Skip to content

Commit 29c6cf7

Browse files
committed
feat: add server runtime metrics aggregator
1 parent f1a677f commit 29c6cf7

File tree

15 files changed

+297
-126
lines changed

15 files changed

+297
-126
lines changed

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export {
6767
startInactiveSpan,
6868
startSpanManual,
6969
continueTrace,
70+
metrics,
7071
} from '@sentry/core';
7172
export type { SpanStatusType } from '@sentry/core';
7273
export { autoDiscoverNodePerformanceMonitoringIntegrations } from '@sentry/node';
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type {
2+
Client,
3+
ClientOptions,
4+
MeasurementUnit,
5+
MetricsAggregator as MetricsAggregatorBase,
6+
Primitive,
7+
} from '@sentry/types';
8+
import { timestampInSeconds } from '@sentry/utils';
9+
import { DEFAULT_FLUSH_INTERVAL } from './constants';
10+
import type { MetricBucket, MetricType } from './types';
11+
12+
/**
13+
* A metrics aggregator that aggregates metrics in memory and flushes them periodically.
14+
*/
15+
export class MetricsAggregator implements MetricsAggregatorBase {
16+
private _buckets: MetricBucket;
17+
private _bucketsTotalWeight;
18+
private readonly _interval: ReturnType<typeof setInterval>;
19+
private readonly _flushShift: number;
20+
private _forceFlush: boolean;
21+
22+
public constructor(private readonly _client: Client<ClientOptions>) {
23+
this._buckets = new Map();
24+
this._bucketsTotalWeight = 0;
25+
this._interval = setInterval(() => this._flush(), DEFAULT_FLUSH_INTERVAL);
26+
27+
// SDKs are required to shift the flush interval by random() * rollup_in_seconds.
28+
// That shift is determined once per startup to create jittering.
29+
this._flushShift = Math.random() * DEFAULT_FLUSH_INTERVAL;
30+
31+
this._forceFlush = false;
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public add(
38+
metricType: MetricType,
39+
unsanitizedName: string,
40+
value: number | string,
41+
unit: MeasurementUnit = 'none',
42+
unsanitizedTags: Record<string, Primitive> = {},
43+
maybeFloatTimestamp = timestampInSeconds(),
44+
): void {
45+
// Do nothing
46+
}
47+
48+
/**
49+
* Flushes the current metrics to the transport via the transport.
50+
*/
51+
public flush(): void {
52+
this._forceFlush = true;
53+
this._flush();
54+
}
55+
56+
/**
57+
* Shuts down metrics aggregator and clears all metrics.
58+
*/
59+
public close(): void {
60+
this._forceFlush = true;
61+
clearInterval(this._interval);
62+
this._flush();
63+
}
64+
65+
/**
66+
* Returns a string representation of the aggregator.
67+
*/
68+
public toString(): string {
69+
return '';
70+
}
71+
72+
/**
73+
* Flushes the buckets according to the internal state of the aggregator.
74+
* If it is a force flush, which happens on shutdown, it will flush all buckets.
75+
* Otherwise, it will only flush buckets that are older than the flush interval,
76+
* and according to the flush shift.
77+
*
78+
* This function mutates `_forceFlush` and `_bucketsTotalWeight` properties.
79+
*/
80+
private _flush(): void {
81+
// This path eliminates the need for checking for timestamps since we're forcing a flush.
82+
// Remember to reset the flag, or it will always flush all metrics.
83+
if (this._forceFlush) {
84+
this._forceFlush = false;
85+
this._bucketsTotalWeight = 0;
86+
this._captureMetrics(this._buckets);
87+
this._buckets.clear();
88+
return;
89+
}
90+
const cutoffSeconds = timestampInSeconds() - DEFAULT_FLUSH_INTERVAL - this._flushShift;
91+
// TODO(@anonrig): Optimization opportunity.
92+
// Convert this map to an array and store key in the bucketItem.
93+
const flushedBuckets: MetricBucket = new Map();
94+
for (const [key, bucket] of this._buckets) {
95+
if (bucket.timestamp < cutoffSeconds) {
96+
flushedBuckets.set(key, bucket);
97+
this._bucketsTotalWeight -= bucket.metric.weight;
98+
}
99+
}
100+
101+
for (const [key] of flushedBuckets) {
102+
this._buckets.delete(key);
103+
}
104+
105+
this._captureMetrics(flushedBuckets);
106+
}
107+
108+
/**
109+
* Only captures a subset of the buckets passed to this function.
110+
* @param flushedBuckets
111+
*/
112+
private _captureMetrics(flushedBuckets: MetricBucket): void {
113+
if (flushedBuckets.size > 0 && this._client.captureAggregateMetrics) {
114+
// TODO(@anonrig): This copy operation can be avoided if we store the key in the bucketItem.
115+
const buckets = Array.from(flushedBuckets).map(([, bucketItem]) => bucketItem);
116+
this._client.captureAggregateMetrics(buckets);
117+
}
118+
}
119+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type {
2+
Client,
3+
ClientOptions,
4+
MeasurementUnit,
5+
MetricBucketItem,
6+
MetricsAggregator,
7+
Primitive,
8+
} from '@sentry/types';
9+
import { timestampInSeconds } from '@sentry/utils';
10+
import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX } from './constants';
11+
import { METRIC_MAP } from './instance';
12+
import type { MetricBucket, MetricType } from './types';
13+
import { getBucketKey, sanitizeTags } from './utils';
14+
15+
/**
16+
* A simple metrics aggregator that aggregates metrics in memory and flushes them periodically.
17+
* Default flush interval is 5 seconds.
18+
*
19+
* @experimental This API is experimental and might change in the future.
20+
*/
21+
export class BrowserMetricsAggregator implements MetricsAggregator {
22+
private _buckets: MetricBucket;
23+
private readonly _interval: ReturnType<typeof setInterval>;
24+
25+
public constructor(private readonly _client: Client<ClientOptions>) {
26+
this._buckets = new Map();
27+
this._interval = setInterval(() => this.flush(), DEFAULT_BROWSER_FLUSH_INTERVAL);
28+
}
29+
30+
/**
31+
* @inheritDoc
32+
*/
33+
public add(
34+
metricType: MetricType,
35+
unsanitizedName: string,
36+
value: number | string,
37+
unit: MeasurementUnit | undefined = 'none',
38+
unsanitizedTags: Record<string, Primitive> | undefined = {},
39+
maybeFloatTimestamp: number | undefined = timestampInSeconds(),
40+
): void {
41+
const timestamp = Math.floor(maybeFloatTimestamp);
42+
const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_');
43+
const tags = sanitizeTags(unsanitizedTags);
44+
45+
const bucketKey = getBucketKey(metricType, name, unit, tags);
46+
const bucketItem: MetricBucketItem | undefined = this._buckets.get(bucketKey);
47+
if (bucketItem) {
48+
bucketItem.metric.add(value);
49+
// TODO(abhi): Do we need this check?
50+
if (bucketItem.timestamp < timestamp) {
51+
bucketItem.timestamp = timestamp;
52+
}
53+
} else {
54+
this._buckets.set(bucketKey, {
55+
// @ts-expect-error we don't need to narrow down the type of value here, saves bundle size.
56+
metric: new METRIC_MAP[metricType](value),
57+
timestamp,
58+
metricType,
59+
name,
60+
unit,
61+
tags,
62+
});
63+
}
64+
}
65+
66+
/**
67+
* @inheritDoc
68+
*/
69+
public flush(): void {
70+
// short circuit if buckets are empty.
71+
if (this._buckets.size === 0) {
72+
return;
73+
}
74+
if (this._client.captureAggregateMetrics) {
75+
const metricBuckets = Array.from(this._buckets).map(([, bucketItem]) => bucketItem);
76+
this._client.captureAggregateMetrics(metricBuckets);
77+
}
78+
this._buckets.clear();
79+
}
80+
81+
/**
82+
* @inheritDoc
83+
*/
84+
public close(): void {
85+
clearInterval(this._interval);
86+
this.flush();
87+
}
88+
}

packages/core/src/metrics/constants.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ export const TAG_VALUE_NORMALIZATION_REGEX = /[^\w\d_:/@.{}[\]$-]+/g;
2727
* This does not match spec in https://develop.sentry.dev/sdk/metrics
2828
* but was chosen to optimize for the most common case in browser environments.
2929
*/
30-
export const DEFAULT_FLUSH_INTERVAL = 5000;
30+
export const DEFAULT_BROWSER_FLUSH_INTERVAL = 5000;
31+
32+
/**
33+
* SDKs are required to bucket into 10 second intervals (rollup in seconds)
34+
* which is the current lower bound of metric accuracy.
35+
*/
36+
export const DEFAULT_FLUSH_INTERVAL = 10000;

packages/core/src/metrics/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function createMetricEnvelope(
3030
return createEnvelope<StatsdEnvelope>(headers, [item]);
3131
}
3232

33-
function createMetricEnvelopeItem(metricBucketItems: Array<MetricBucketItem>): StatsdItem {
33+
function createMetricEnvelopeItem(metricBucketItems: MetricBucketItem[]): StatsdItem {
3434
const payload = serializeMetricBuckets(metricBucketItems);
3535
const metricHeaders: StatsdItem[0] = {
3636
type: 'statsd',

packages/core/src/metrics/exports.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function addToMetricsAggregator(
1717
metricType: MetricType,
1818
name: string,
1919
value: number | string,
20-
data: MetricData = {},
20+
data: MetricData | undefined = {},
2121
): void {
2222
const hub = getCurrentHub();
2323
const client = hub.getClient() as BaseClient<ClientOptions>;
@@ -50,7 +50,7 @@ function addToMetricsAggregator(
5050
/**
5151
* Adds a value to a counter metric
5252
*
53-
* @experimental This API is experimental and might having breaking changes in the future.
53+
* @experimental This API is experimental and might have breaking changes in the future.
5454
*/
5555
export function increment(name: string, value: number = 1, data?: MetricData): void {
5656
addToMetricsAggregator(COUNTER_METRIC_TYPE, name, value, data);
@@ -59,7 +59,7 @@ export function increment(name: string, value: number = 1, data?: MetricData): v
5959
/**
6060
* Adds a value to a distribution metric
6161
*
62-
* @experimental This API is experimental and might having breaking changes in the future.
62+
* @experimental This API is experimental and might have breaking changes in the future.
6363
*/
6464
export function distribution(name: string, value: number, data?: MetricData): void {
6565
addToMetricsAggregator(DISTRIBUTION_METRIC_TYPE, name, value, data);
@@ -68,7 +68,7 @@ export function distribution(name: string, value: number, data?: MetricData): vo
6868
/**
6969
* Adds a value to a set metric. Value must be a string or integer.
7070
*
71-
* @experimental This API is experimental and might having breaking changes in the future.
71+
* @experimental This API is experimental and might have breaking changes in the future.
7272
*/
7373
export function set(name: string, value: number | string, data?: MetricData): void {
7474
addToMetricsAggregator(SET_METRIC_TYPE, name, value, data);
@@ -77,7 +77,7 @@ export function set(name: string, value: number | string, data?: MetricData): vo
7777
/**
7878
* Adds a value to a gauge metric
7979
*
80-
* @experimental This API is experimental and might having breaking changes in the future.
80+
* @experimental This API is experimental and might have breaking changes in the future.
8181
*/
8282
export function gauge(name: string, value: number, data?: MetricData): void {
8383
addToMetricsAggregator(GAUGE_METRIC_TYPE, name, value, data);

packages/core/src/metrics/instance.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { simpleHash } from './utils';
88
export class CounterMetric implements MetricInstance {
99
public constructor(private _value: number) {}
1010

11+
/** @inheritDoc */
12+
public get weight(): number {
13+
return 1;
14+
}
15+
1116
/** @inheritdoc */
1217
public add(value: number): void {
1318
this._value += value;
@@ -37,6 +42,11 @@ export class GaugeMetric implements MetricInstance {
3742
this._count = 1;
3843
}
3944

45+
/** @inheritDoc */
46+
public get weight(): number {
47+
return 5;
48+
}
49+
4050
/** @inheritdoc */
4151
public add(value: number): void {
4252
this._last = value;
@@ -66,6 +76,11 @@ export class DistributionMetric implements MetricInstance {
6676
this._value = [first];
6777
}
6878

79+
/** @inheritDoc */
80+
public get weight(): number {
81+
return this._value.length;
82+
}
83+
6984
/** @inheritdoc */
7085
public add(value: number): void {
7186
this._value.push(value);
@@ -87,21 +102,24 @@ export class SetMetric implements MetricInstance {
87102
this._value = new Set([first]);
88103
}
89104

105+
/** @inheritDoc */
106+
public get weight(): number {
107+
return this._value.size;
108+
}
109+
90110
/** @inheritdoc */
91111
public add(value: number | string): void {
92112
this._value.add(value);
93113
}
94114

95115
/** @inheritdoc */
96116
public toString(): string {
97-
return `${Array.from(this._value)
117+
return Array.from(this._value)
98118
.map(val => (typeof val === 'string' ? simpleHash(val) : val))
99-
.join(':')}`;
119+
.join(':');
100120
}
101121
}
102122

103-
export type Metric = CounterMetric | GaugeMetric | DistributionMetric | SetMetric;
104-
105123
export const METRIC_MAP = {
106124
[COUNTER_METRIC_TYPE]: CounterMetric,
107125
[GAUGE_METRIC_TYPE]: GaugeMetric,

packages/core/src/metrics/integration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ClientOptions, Integration } from '@sentry/types';
22
import type { BaseClient } from '../baseclient';
3-
import { SimpleMetricsAggregator } from './simpleaggregator';
3+
import { BrowserMetricsAggregator } from './browser-aggregator';
44

55
/**
66
* Enables Sentry metrics monitoring.
@@ -33,6 +33,6 @@ export class MetricsAggregator implements Integration {
3333
* @inheritDoc
3434
*/
3535
public setup(client: BaseClient<ClientOptions>): void {
36-
client.metricsAggregator = new SimpleMetricsAggregator(client);
36+
client.metricsAggregator = new BrowserMetricsAggregator(client);
3737
}
3838
}

0 commit comments

Comments
 (0)