Skip to content

Commit 4c19afd

Browse files
committed
feat: add server runtime metrics aggregator
1 parent f1a677f commit 4c19afd

File tree

14 files changed

+242
-63
lines changed

14 files changed

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

packages/core/src/metrics/simpleaggregator.ts renamed to packages/core/src/metrics/browser-aggregator.ts

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,33 @@
1-
import type { Client, ClientOptions, MeasurementUnit, MetricsAggregator, Primitive } from '@sentry/types';
1+
import type {
2+
Client,
3+
ClientOptions,
4+
MeasurementUnit,
5+
MetricBucketItem,
6+
MetricsAggregator,
7+
Primitive
8+
} from '@sentry/types';
29
import { timestampInSeconds } from '@sentry/utils';
310
import {
4-
DEFAULT_FLUSH_INTERVAL,
11+
DEFAULT_BROWSER_FLUSH_INTERVAL,
512
NAME_AND_TAG_KEY_NORMALIZATION_REGEX,
6-
TAG_VALUE_NORMALIZATION_REGEX,
713
} from './constants';
814
import { METRIC_MAP } from './instance';
9-
import type { MetricType, SimpleMetricBucket } from './types';
10-
import { getBucketKey } from './utils';
15+
import type { MetricType, MetricBucket } from './types';
16+
import { getBucketKey, sanitizeTags } from './utils';
1117

1218
/**
1319
* A simple metrics aggregator that aggregates metrics in memory and flushes them periodically.
1420
* Default flush interval is 5 seconds.
1521
*
1622
* @experimental This API is experimental and might change in the future.
1723
*/
18-
export class SimpleMetricsAggregator implements MetricsAggregator {
19-
private _buckets: SimpleMetricBucket;
24+
export class BrowserMetricsAggregator implements MetricsAggregator {
25+
private _buckets: MetricBucket;
2026
private readonly _interval: ReturnType<typeof setInterval>;
2127

2228
public constructor(private readonly _client: Client<ClientOptions>) {
2329
this._buckets = new Map();
24-
this._interval = setInterval(() => this.flush(), DEFAULT_FLUSH_INTERVAL);
30+
this._interval = setInterval(() => this.flush(), DEFAULT_BROWSER_FLUSH_INTERVAL);
2531
}
2632

2733
/**
@@ -31,27 +37,33 @@ export class SimpleMetricsAggregator implements MetricsAggregator {
3137
metricType: MetricType,
3238
unsanitizedName: string,
3339
value: number | string,
34-
unit: MeasurementUnit = 'none',
35-
unsanitizedTags: Record<string, Primitive> = {},
36-
maybeFloatTimestamp = timestampInSeconds(),
40+
unit: MeasurementUnit | undefined = 'none',
41+
unsanitizedTags: Record<string, Primitive> | undefined = {},
42+
maybeFloatTimestamp: number | undefined = timestampInSeconds(),
3743
): void {
3844
const timestamp = Math.floor(maybeFloatTimestamp);
3945
const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_');
4046
const tags = sanitizeTags(unsanitizedTags);
4147

4248
const bucketKey = getBucketKey(metricType, name, unit, tags);
43-
const bucketItem = this._buckets.get(bucketKey);
49+
const bucketItem: MetricBucketItem | undefined = this._buckets.get(bucketKey);
4450
if (bucketItem) {
45-
const [bucketMetric, bucketTimestamp] = bucketItem;
46-
bucketMetric.add(value);
51+
bucketItem.metric.add(value);
4752
// TODO(abhi): Do we need this check?
48-
if (bucketTimestamp < timestamp) {
49-
bucketItem[1] = timestamp;
53+
if (bucketItem.timestamp < timestamp) {
54+
bucketItem.timestamp = timestamp;
5055
}
5156
} else {
52-
// @ts-expect-error we don't need to narrow down the type of value here, saves bundle size.
53-
const newMetric = new METRIC_MAP[metricType](value);
54-
this._buckets.set(bucketKey, [newMetric, timestamp, metricType, name, unit, tags]);
57+
this._buckets.set(bucketKey, {
58+
// @ts-expect-error we don't need to narrow down the type of value here, saves bundle size.
59+
metric: new METRIC_MAP[metricType](value),
60+
timestamp,
61+
metricType,
62+
name,
63+
unit,
64+
tags,
65+
});
66+
5567
}
5668
}
5769

@@ -78,14 +90,3 @@ export class SimpleMetricsAggregator implements MetricsAggregator {
7890
this.flush();
7991
}
8092
}
81-
82-
function sanitizeTags(unsanitizedTags: Record<string, Primitive>): Record<string, string> {
83-
const tags: Record<string, string> = {};
84-
for (const key in unsanitizedTags) {
85-
if (Object.prototype.hasOwnProperty.call(unsanitizedTags, key)) {
86-
const sanitizedKey = key.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_');
87-
tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, '_');
88-
}
89-
}
90-
return tags;
91-
}

packages/core/src/metrics/constants.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,11 @@ 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;
37+

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
}

packages/core/src/metrics/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ export type MetricType =
77
| typeof SET_METRIC_TYPE
88
| typeof DISTRIBUTION_METRIC_TYPE;
99

10-
export type SimpleMetricBucket = Map<string, MetricBucketItem>;
10+
export type MetricBucket = Map<string, MetricBucketItem>;

0 commit comments

Comments
 (0)