From 7f8eca7aa7695b32a5d5cd4b12ad3243c8e1f656 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 21 Dec 2023 15:07:55 -0500 Subject: [PATCH] feat: Add server runtime metrics aggregator (#9894) ## Implements - [x] 10 Second Bucketing: SDKs are required to bucket into 10 second intervals (rollup in seconds) which is the current lower bound of metric accuracy. - [x] Flush Shift: SDKs are required to shift the flush interval by random() * rollup_in_seconds. That shift is determined once per startup to create jittering. - [ ] Force flush: an SDK is required to perform force flushing ahead of scheduled time if the memory pressure is too high. There is no rule for this other than that SDKs should be tracking abstract aggregation complexity (eg: a counter only carries a single float, whereas a distribution is a float per emission). ## Caveats - Force flush requires Node.js 14+ support (FinalizationRegistry). I recommend leaving it after v8 release to make the implementation a lot easier. --------- Co-authored-by: Abhijeet Prasad --- package.json | 4 +- packages/bun/src/index.ts | 1 + packages/core/src/baseclient.ts | 1 + packages/core/src/metrics/aggregator.ts | 163 ++++++++++++++++++ .../core/src/metrics/browser-aggregator.ts | 92 ++++++++++ packages/core/src/metrics/constants.ts | 13 +- packages/core/src/metrics/envelope.ts | 2 +- packages/core/src/metrics/exports.ts | 10 +- packages/core/src/metrics/instance.ts | 26 ++- packages/core/src/metrics/integration.ts | 4 +- packages/core/src/metrics/simpleaggregator.ts | 91 ---------- packages/core/src/metrics/types.ts | 4 +- packages/core/src/metrics/utils.ts | 30 +++- packages/core/src/server-runtime-client.ts | 5 + .../core/test/lib/metrics/aggregator.test.ts | 157 +++++++++++++++++ ...tor.test.ts => browser-aggregator.test.ts} | 33 ++-- packages/deno/src/index.ts | 1 + packages/node/src/index.ts | 1 + packages/types/src/metrics.ts | 38 ++-- packages/vercel-edge/src/index.ts | 1 + 20 files changed, 540 insertions(+), 137 deletions(-) create mode 100644 packages/core/src/metrics/aggregator.ts create mode 100644 packages/core/src/metrics/browser-aggregator.ts delete mode 100644 packages/core/src/metrics/simpleaggregator.ts create mode 100644 packages/core/test/lib/metrics/aggregator.test.ts rename packages/core/test/lib/metrics/{simpleaggregator.test.ts => browser-aggregator.test.ts} (71%) diff --git a/package.json b/package.json index 4fa76ae58198..e1d8309637c6 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,12 @@ "clean:deps": "lerna clean --yes && rm -rf node_modules && yarn", "clean:all": "run-s clean:build clean:caches clean:deps", "codecov": "codecov", - "fix": "run-s fix:lerna fix:biome", + "fix": "run-p fix:lerna fix:biome", "fix:lerna": "lerna run fix", "fix:biome": "biome check --apply .", "changelog": "ts-node ./scripts/get-commit-list.ts", "link:yarn": "lerna exec yarn link", - "lint": "run-s lint:lerna lint:biome", + "lint": "run-p lint:lerna lint:biome", "lint:lerna": "lerna run lint", "lint:biome": "biome check .", "validate:es5": "lerna run validate:es5", diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 0166b43d137d..bc29dcd908b5 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -70,6 +70,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + metrics, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; export { autoDiscoverNodePerformanceMonitoringIntegrations } from '@sentry/node'; diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 865fdcbd14b5..75b736bbf803 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -407,6 +407,7 @@ export abstract class BaseClient implements Client { * @inheritDoc */ public captureAggregateMetrics(metricBucketItems: Array): void { + DEBUG_BUILD && logger.log(`Flushing aggregated metrics, number of metrics: ${metricBucketItems.length}`); const metricsEnvelope = createMetricEnvelope( metricBucketItems, this._dsn, diff --git a/packages/core/src/metrics/aggregator.ts b/packages/core/src/metrics/aggregator.ts new file mode 100644 index 000000000000..6a49fda5918b --- /dev/null +++ b/packages/core/src/metrics/aggregator.ts @@ -0,0 +1,163 @@ +import type { + Client, + ClientOptions, + MeasurementUnit, + MetricsAggregator as MetricsAggregatorBase, + Primitive, +} from '@sentry/types'; +import { timestampInSeconds } from '@sentry/utils'; +import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, NAME_AND_TAG_KEY_NORMALIZATION_REGEX } from './constants'; +import { METRIC_MAP } from './instance'; +import type { MetricBucket, MetricType } from './types'; +import { getBucketKey, sanitizeTags } from './utils'; + +/** + * A metrics aggregator that aggregates metrics in memory and flushes them periodically. + */ +export class MetricsAggregator implements MetricsAggregatorBase { + // TODO(@anonrig): Use FinalizationRegistry to have a proper way of flushing the buckets + // when the aggregator is garbage collected. + // Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry + private _buckets: MetricBucket; + + // Different metrics have different weights. We use this to limit the number of metrics + // that we store in memory. + private _bucketsTotalWeight; + + private readonly _interval: ReturnType; + + // SDKs are required to shift the flush interval by random() * rollup_in_seconds. + // That shift is determined once per startup to create jittering. + private readonly _flushShift: number; + + // An SDK is required to perform force flushing ahead of scheduled time if the memory + // pressure is too high. There is no rule for this other than that SDKs should be tracking + // abstract aggregation complexity (eg: a counter only carries a single float, whereas a + // distribution is a float per emission). + // + // Force flush is used on either shutdown, flush() or when we exceed the max weight. + private _forceFlush: boolean; + + public constructor(private readonly _client: Client) { + this._buckets = new Map(); + this._bucketsTotalWeight = 0; + this._interval = setInterval(() => this._flush(), DEFAULT_FLUSH_INTERVAL); + this._flushShift = Math.floor((Math.random() * DEFAULT_FLUSH_INTERVAL) / 1000); + this._forceFlush = false; + } + + /** + * @inheritDoc + */ + public add( + metricType: MetricType, + unsanitizedName: string, + value: number | string, + unit: MeasurementUnit = 'none', + unsanitizedTags: Record = {}, + maybeFloatTimestamp = timestampInSeconds(), + ): void { + const timestamp = Math.floor(maybeFloatTimestamp); + const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); + const tags = sanitizeTags(unsanitizedTags); + + const bucketKey = getBucketKey(metricType, name, unit, tags); + let bucketItem = this._buckets.get(bucketKey); + if (bucketItem) { + bucketItem.metric.add(value); + // TODO(abhi): Do we need this check? + if (bucketItem.timestamp < timestamp) { + bucketItem.timestamp = timestamp; + } + } else { + bucketItem = { + // @ts-expect-error we don't need to narrow down the type of value here, saves bundle size. + metric: new METRIC_MAP[metricType](value), + timestamp, + metricType, + name, + unit, + tags, + }; + this._buckets.set(bucketKey, bucketItem); + } + + // We need to keep track of the total weight of the buckets so that we can + // flush them when we exceed the max weight. + this._bucketsTotalWeight += bucketItem.metric.weight; + + if (this._bucketsTotalWeight >= MAX_WEIGHT) { + this.flush(); + } + } + + /** + * Flushes the current metrics to the transport via the transport. + */ + public flush(): void { + this._forceFlush = true; + this._flush(); + } + + /** + * Shuts down metrics aggregator and clears all metrics. + */ + public close(): void { + this._forceFlush = true; + clearInterval(this._interval); + this._flush(); + } + + /** + * Flushes the buckets according to the internal state of the aggregator. + * If it is a force flush, which happens on shutdown, it will flush all buckets. + * Otherwise, it will only flush buckets that are older than the flush interval, + * and according to the flush shift. + * + * This function mutates `_forceFlush` and `_bucketsTotalWeight` properties. + */ + private _flush(): void { + // TODO(@anonrig): Add Atomics for locking to avoid having force flush and regular flush + // running at the same time. + // Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics + + // This path eliminates the need for checking for timestamps since we're forcing a flush. + // Remember to reset the flag, or it will always flush all metrics. + if (this._forceFlush) { + this._forceFlush = false; + this._bucketsTotalWeight = 0; + this._captureMetrics(this._buckets); + this._buckets.clear(); + return; + } + const cutoffSeconds = Math.floor(timestampInSeconds()) - DEFAULT_FLUSH_INTERVAL / 1000 - this._flushShift; + // TODO(@anonrig): Optimization opportunity. + // Convert this map to an array and store key in the bucketItem. + const flushedBuckets: MetricBucket = new Map(); + for (const [key, bucket] of this._buckets) { + if (bucket.timestamp <= cutoffSeconds) { + flushedBuckets.set(key, bucket); + this._bucketsTotalWeight -= bucket.metric.weight; + } + } + + for (const [key] of flushedBuckets) { + this._buckets.delete(key); + } + + this._captureMetrics(flushedBuckets); + } + + /** + * Only captures a subset of the buckets passed to this function. + * @param flushedBuckets + */ + private _captureMetrics(flushedBuckets: MetricBucket): void { + if (flushedBuckets.size > 0 && this._client.captureAggregateMetrics) { + // TODO(@anonrig): Optimization opportunity. + // This copy operation can be avoided if we store the key in the bucketItem. + const buckets = Array.from(flushedBuckets).map(([, bucketItem]) => bucketItem); + this._client.captureAggregateMetrics(buckets); + } + } +} diff --git a/packages/core/src/metrics/browser-aggregator.ts b/packages/core/src/metrics/browser-aggregator.ts new file mode 100644 index 000000000000..5b5c81353024 --- /dev/null +++ b/packages/core/src/metrics/browser-aggregator.ts @@ -0,0 +1,92 @@ +import type { + Client, + ClientOptions, + MeasurementUnit, + MetricBucketItem, + MetricsAggregator, + Primitive, +} from '@sentry/types'; +import { timestampInSeconds } from '@sentry/utils'; +import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX } from './constants'; +import { METRIC_MAP } from './instance'; +import type { MetricBucket, MetricType } from './types'; +import { getBucketKey, sanitizeTags } from './utils'; + +/** + * A simple metrics aggregator that aggregates metrics in memory and flushes them periodically. + * Default flush interval is 5 seconds. + * + * @experimental This API is experimental and might change in the future. + */ +export class BrowserMetricsAggregator implements MetricsAggregator { + // TODO(@anonrig): Use FinalizationRegistry to have a proper way of flushing the buckets + // when the aggregator is garbage collected. + // Ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry + private _buckets: MetricBucket; + private readonly _interval: ReturnType; + + public constructor(private readonly _client: Client) { + this._buckets = new Map(); + this._interval = setInterval(() => this.flush(), DEFAULT_BROWSER_FLUSH_INTERVAL); + } + + /** + * @inheritDoc + */ + public add( + metricType: MetricType, + unsanitizedName: string, + value: number | string, + unit: MeasurementUnit | undefined = 'none', + unsanitizedTags: Record | undefined = {}, + maybeFloatTimestamp: number | undefined = timestampInSeconds(), + ): void { + const timestamp = Math.floor(maybeFloatTimestamp); + const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); + const tags = sanitizeTags(unsanitizedTags); + + const bucketKey = getBucketKey(metricType, name, unit, tags); + const bucketItem: MetricBucketItem | undefined = this._buckets.get(bucketKey); + if (bucketItem) { + bucketItem.metric.add(value); + // TODO(abhi): Do we need this check? + if (bucketItem.timestamp < timestamp) { + bucketItem.timestamp = timestamp; + } + } else { + this._buckets.set(bucketKey, { + // @ts-expect-error we don't need to narrow down the type of value here, saves bundle size. + metric: new METRIC_MAP[metricType](value), + timestamp, + metricType, + name, + unit, + tags, + }); + } + } + + /** + * @inheritDoc + */ + public flush(): void { + // short circuit if buckets are empty. + if (this._buckets.size === 0) { + return; + } + if (this._client.captureAggregateMetrics) { + // TODO(@anonrig): Use Object.values() when we support ES6+ + const metricBuckets = Array.from(this._buckets).map(([, bucketItem]) => bucketItem); + this._client.captureAggregateMetrics(metricBuckets); + } + this._buckets.clear(); + } + + /** + * @inheritDoc + */ + public close(): void { + clearInterval(this._interval); + this.flush(); + } +} diff --git a/packages/core/src/metrics/constants.ts b/packages/core/src/metrics/constants.ts index f29ac323c2ee..e89e0fd1562b 100644 --- a/packages/core/src/metrics/constants.ts +++ b/packages/core/src/metrics/constants.ts @@ -27,4 +27,15 @@ export const TAG_VALUE_NORMALIZATION_REGEX = /[^\w\d_:/@.{}[\]$-]+/g; * This does not match spec in https://develop.sentry.dev/sdk/metrics * but was chosen to optimize for the most common case in browser environments. */ -export const DEFAULT_FLUSH_INTERVAL = 5000; +export const DEFAULT_BROWSER_FLUSH_INTERVAL = 5000; + +/** + * SDKs are required to bucket into 10 second intervals (rollup in seconds) + * which is the current lower bound of metric accuracy. + */ +export const DEFAULT_FLUSH_INTERVAL = 10000; + +/** + * The maximum number of metrics that should be stored in memory. + */ +export const MAX_WEIGHT = 10000; diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts index c7c65674b736..95622e109740 100644 --- a/packages/core/src/metrics/envelope.ts +++ b/packages/core/src/metrics/envelope.ts @@ -30,7 +30,7 @@ export function createMetricEnvelope( return createEnvelope(headers, [item]); } -function createMetricEnvelopeItem(metricBucketItems: Array): StatsdItem { +function createMetricEnvelopeItem(metricBucketItems: MetricBucketItem[]): StatsdItem { const payload = serializeMetricBuckets(metricBucketItems); const metricHeaders: StatsdItem[0] = { type: 'statsd', diff --git a/packages/core/src/metrics/exports.ts b/packages/core/src/metrics/exports.ts index 22a5e83ffb3d..66074a7e846c 100644 --- a/packages/core/src/metrics/exports.ts +++ b/packages/core/src/metrics/exports.ts @@ -17,7 +17,7 @@ function addToMetricsAggregator( metricType: MetricType, name: string, value: number | string, - data: MetricData = {}, + data: MetricData | undefined = {}, ): void { const client = getClient>(); const scope = getCurrentScope(); @@ -49,7 +49,7 @@ function addToMetricsAggregator( /** * Adds a value to a counter metric * - * @experimental This API is experimental and might having breaking changes in the future. + * @experimental This API is experimental and might have breaking changes in the future. */ export function increment(name: string, value: number = 1, data?: MetricData): void { addToMetricsAggregator(COUNTER_METRIC_TYPE, name, value, data); @@ -58,7 +58,7 @@ export function increment(name: string, value: number = 1, data?: MetricData): v /** * Adds a value to a distribution metric * - * @experimental This API is experimental and might having breaking changes in the future. + * @experimental This API is experimental and might have breaking changes in the future. */ export function distribution(name: string, value: number, data?: MetricData): void { addToMetricsAggregator(DISTRIBUTION_METRIC_TYPE, name, value, data); @@ -67,7 +67,7 @@ export function distribution(name: string, value: number, data?: MetricData): vo /** * Adds a value to a set metric. Value must be a string or integer. * - * @experimental This API is experimental and might having breaking changes in the future. + * @experimental This API is experimental and might have breaking changes in the future. */ export function set(name: string, value: number | string, data?: MetricData): void { addToMetricsAggregator(SET_METRIC_TYPE, name, value, data); @@ -76,7 +76,7 @@ export function set(name: string, value: number | string, data?: MetricData): vo /** * Adds a value to a gauge metric * - * @experimental This API is experimental and might having breaking changes in the future. + * @experimental This API is experimental and might have breaking changes in the future. */ export function gauge(name: string, value: number, data?: MetricData): void { addToMetricsAggregator(GAUGE_METRIC_TYPE, name, value, data); diff --git a/packages/core/src/metrics/instance.ts b/packages/core/src/metrics/instance.ts index f071006c96ca..f7d37d8118ed 100644 --- a/packages/core/src/metrics/instance.ts +++ b/packages/core/src/metrics/instance.ts @@ -8,6 +8,11 @@ import { simpleHash } from './utils'; export class CounterMetric implements MetricInstance { public constructor(private _value: number) {} + /** @inheritDoc */ + public get weight(): number { + return 1; + } + /** @inheritdoc */ public add(value: number): void { this._value += value; @@ -37,6 +42,11 @@ export class GaugeMetric implements MetricInstance { this._count = 1; } + /** @inheritDoc */ + public get weight(): number { + return 5; + } + /** @inheritdoc */ public add(value: number): void { this._last = value; @@ -66,6 +76,11 @@ export class DistributionMetric implements MetricInstance { this._value = [first]; } + /** @inheritDoc */ + public get weight(): number { + return this._value.length; + } + /** @inheritdoc */ public add(value: number): void { this._value.push(value); @@ -87,6 +102,11 @@ export class SetMetric implements MetricInstance { this._value = new Set([first]); } + /** @inheritDoc */ + public get weight(): number { + return this._value.size; + } + /** @inheritdoc */ public add(value: number | string): void { this._value.add(value); @@ -94,14 +114,12 @@ export class SetMetric implements MetricInstance { /** @inheritdoc */ public toString(): string { - return `${Array.from(this._value) + return Array.from(this._value) .map(val => (typeof val === 'string' ? simpleHash(val) : val)) - .join(':')}`; + .join(':'); } } -export type Metric = CounterMetric | GaugeMetric | DistributionMetric | SetMetric; - export const METRIC_MAP = { [COUNTER_METRIC_TYPE]: CounterMetric, [GAUGE_METRIC_TYPE]: GaugeMetric, diff --git a/packages/core/src/metrics/integration.ts b/packages/core/src/metrics/integration.ts index 0c3fa626a9e5..531b0aa698b2 100644 --- a/packages/core/src/metrics/integration.ts +++ b/packages/core/src/metrics/integration.ts @@ -1,7 +1,7 @@ import type { ClientOptions, IntegrationFn } from '@sentry/types'; import type { BaseClient } from '../baseclient'; import { convertIntegrationFnToClass } from '../integration'; -import { SimpleMetricsAggregator } from './simpleaggregator'; +import { BrowserMetricsAggregator } from './browser-aggregator'; const INTEGRATION_NAME = 'MetricsAggregator'; @@ -9,7 +9,7 @@ const metricsAggregatorIntegration: IntegrationFn = () => { return { name: INTEGRATION_NAME, setup(client: BaseClient) { - client.metricsAggregator = new SimpleMetricsAggregator(client); + client.metricsAggregator = new BrowserMetricsAggregator(client); }, }; }; diff --git a/packages/core/src/metrics/simpleaggregator.ts b/packages/core/src/metrics/simpleaggregator.ts deleted file mode 100644 index a628a3b5a406..000000000000 --- a/packages/core/src/metrics/simpleaggregator.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Client, ClientOptions, MeasurementUnit, MetricsAggregator, Primitive } from '@sentry/types'; -import { timestampInSeconds } from '@sentry/utils'; -import { - DEFAULT_FLUSH_INTERVAL, - NAME_AND_TAG_KEY_NORMALIZATION_REGEX, - TAG_VALUE_NORMALIZATION_REGEX, -} from './constants'; -import { METRIC_MAP } from './instance'; -import type { MetricType, SimpleMetricBucket } from './types'; -import { getBucketKey } from './utils'; - -/** - * A simple metrics aggregator that aggregates metrics in memory and flushes them periodically. - * Default flush interval is 5 seconds. - * - * @experimental This API is experimental and might change in the future. - */ -export class SimpleMetricsAggregator implements MetricsAggregator { - private _buckets: SimpleMetricBucket; - private readonly _interval: ReturnType; - - public constructor(private readonly _client: Client) { - this._buckets = new Map(); - this._interval = setInterval(() => this.flush(), DEFAULT_FLUSH_INTERVAL); - } - - /** - * @inheritDoc - */ - public add( - metricType: MetricType, - unsanitizedName: string, - value: number | string, - unit: MeasurementUnit = 'none', - unsanitizedTags: Record = {}, - maybeFloatTimestamp = timestampInSeconds(), - ): void { - const timestamp = Math.floor(maybeFloatTimestamp); - const name = unsanitizedName.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); - const tags = sanitizeTags(unsanitizedTags); - - const bucketKey = getBucketKey(metricType, name, unit, tags); - const bucketItem = this._buckets.get(bucketKey); - if (bucketItem) { - const [bucketMetric, bucketTimestamp] = bucketItem; - bucketMetric.add(value); - // TODO(abhi): Do we need this check? - if (bucketTimestamp < timestamp) { - bucketItem[1] = timestamp; - } - } else { - // @ts-expect-error we don't need to narrow down the type of value here, saves bundle size. - const newMetric = new METRIC_MAP[metricType](value); - this._buckets.set(bucketKey, [newMetric, timestamp, metricType, name, unit, tags]); - } - } - - /** - * @inheritDoc - */ - public flush(): void { - // short circuit if buckets are empty. - if (this._buckets.size === 0) { - return; - } - if (this._client.captureAggregateMetrics) { - const metricBuckets = Array.from(this._buckets).map(([, bucketItem]) => bucketItem); - this._client.captureAggregateMetrics(metricBuckets); - } - this._buckets.clear(); - } - - /** - * @inheritDoc - */ - public close(): void { - clearInterval(this._interval); - this.flush(); - } -} - -function sanitizeTags(unsanitizedTags: Record): Record { - const tags: Record = {}; - for (const key in unsanitizedTags) { - if (Object.prototype.hasOwnProperty.call(unsanitizedTags, key)) { - const sanitizedKey = key.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); - tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, '_'); - } - } - return tags; -} diff --git a/packages/core/src/metrics/types.ts b/packages/core/src/metrics/types.ts index de6032f811b8..000c401e7a34 100644 --- a/packages/core/src/metrics/types.ts +++ b/packages/core/src/metrics/types.ts @@ -7,4 +7,6 @@ export type MetricType = | typeof SET_METRIC_TYPE | typeof DISTRIBUTION_METRIC_TYPE; -export type SimpleMetricBucket = Map; +// TODO(@anonrig): Convert this to WeakMap when we support ES6 and +// use FinalizationRegistry to flush the buckets when the aggregator is garbage collected. +export type MetricBucket = Map; diff --git a/packages/core/src/metrics/utils.ts b/packages/core/src/metrics/utils.ts index 6e4d75fee5f6..a6674bcf30e1 100644 --- a/packages/core/src/metrics/utils.ts +++ b/packages/core/src/metrics/utils.ts @@ -1,5 +1,6 @@ -import type { MeasurementUnit, MetricBucketItem } from '@sentry/types'; +import type { MeasurementUnit, MetricBucketItem, Primitive } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; +import { NAME_AND_TAG_KEY_NORMALIZATION_REGEX, TAG_VALUE_NORMALIZATION_REGEX } from './constants'; import type { MetricType } from './types'; /** @@ -43,15 +44,26 @@ export function simpleHash(s: string): number { * tags: { a: value, b: anothervalue } * timestamp: 12345677 */ -export function serializeMetricBuckets(metricBucketItems: Array): string { +export function serializeMetricBuckets(metricBucketItems: MetricBucketItem[]): string { let out = ''; - for (const [metric, timestamp, metricType, name, unit, tags] of metricBucketItems) { - const maybeTags = Object.keys(tags).length - ? `|#${Object.entries(tags) - .map(([key, value]) => `${key}:${String(value)}`) - .join(',')}` - : ''; - out += `${name}@${unit}:${metric}|${metricType}${maybeTags}|T${timestamp}\n`; + for (const item of metricBucketItems) { + const tagEntries = Object.entries(item.tags); + const maybeTags = tagEntries.length > 0 ? `|#${tagEntries.map(([key, value]) => `${key}:${value}`).join(',')}` : ''; + out += `${item.name}@${item.unit}:${item.metric}|${item.metricType}${maybeTags}|T${item.timestamp}\n`; } return out; } + +/** + * Sanitizes tags. + */ +export function sanitizeTags(unsanitizedTags: Record): Record { + const tags: Record = {}; + for (const key in unsanitizedTags) { + if (Object.prototype.hasOwnProperty.call(unsanitizedTags, key)) { + const sanitizedKey = key.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_'); + tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, '_'); + } + } + return tags; +} diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index f4abd134223f..66d846c23911 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -17,6 +17,7 @@ import { BaseClient } from './baseclient'; import { createCheckInEnvelope } from './checkin'; import { DEBUG_BUILD } from './debug-build'; import { getClient } from './exports'; +import { MetricsAggregator } from './metrics/aggregator'; import type { Scope } from './scope'; import { SessionFlusher } from './sessionflusher'; import { addTracingExtensions, getDynamicSamplingContextFromClient } from './tracing'; @@ -44,6 +45,10 @@ export class ServerRuntimeClient< addTracingExtensions(); super(options); + + if (options._experiments && options._experiments['metricsAggregator']) { + this.metricsAggregator = new MetricsAggregator(this); + } } /** diff --git a/packages/core/test/lib/metrics/aggregator.test.ts b/packages/core/test/lib/metrics/aggregator.test.ts new file mode 100644 index 000000000000..32396cfedcc2 --- /dev/null +++ b/packages/core/test/lib/metrics/aggregator.test.ts @@ -0,0 +1,157 @@ +import { MetricsAggregator } from '../../../src/metrics/aggregator'; +import { MAX_WEIGHT } from '../../../src/metrics/constants'; +import { CounterMetric } from '../../../src/metrics/instance'; +import { serializeMetricBuckets } from '../../../src/metrics/utils'; +import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; + +let testClient: TestClient; + +describe('MetricsAggregator', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); + + beforeEach(() => { + jest.useFakeTimers('legacy'); + testClient = new TestClient(options); + }); + + it('adds items to buckets', () => { + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 1); + expect(aggregator['_buckets'].size).toEqual(1); + + const firstValue = aggregator['_buckets'].values().next().value; + expect(firstValue).toEqual({ + metric: expect.any(CounterMetric), + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }); + }); + + it('groups same items together', () => { + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 1); + expect(aggregator['_buckets'].size).toEqual(1); + aggregator.add('c', 'requests', 1); + expect(aggregator['_buckets'].size).toEqual(1); + + const firstValue = aggregator['_buckets'].values().next().value; + expect(firstValue).toEqual({ + metric: expect.any(CounterMetric), + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }); + expect(firstValue.metric._value).toEqual(2); + }); + + it('differentiates based on tag value', () => { + const aggregator = new MetricsAggregator(testClient); + aggregator.add('g', 'cpu', 50); + expect(aggregator['_buckets'].size).toEqual(1); + aggregator.add('g', 'cpu', 55, undefined, { a: 'value' }); + expect(aggregator['_buckets'].size).toEqual(2); + }); + + describe('serializeBuckets', () => { + it('serializes ', () => { + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 8); + aggregator.add('g', 'cpu', 50); + aggregator.add('g', 'cpu', 55); + aggregator.add('g', 'cpu', 52); + aggregator.add('d', 'lcp', 1, 'second', { a: 'value', b: 'anothervalue' }); + aggregator.add('d', 'lcp', 1.2, 'second', { a: 'value', b: 'anothervalue' }); + aggregator.add('s', 'important_people', 'a', 'none', { numericKey: 2 }); + aggregator.add('s', 'important_people', 'b', 'none', { numericKey: 2 }); + + const metricBuckets = Array.from(aggregator['_buckets']).map(([, bucketItem]) => bucketItem); + const serializedBuckets = serializeMetricBuckets(metricBuckets); + + expect(serializedBuckets).toContain('requests@none:8|c|T'); + expect(serializedBuckets).toContain('cpu@none:52:50:55:157:3|g|T'); + expect(serializedBuckets).toContain('lcp@second:1:1.2|d|#a:value,b:anothervalue|T'); + expect(serializedBuckets).toContain('important_people@none:97:98|s|#numericKey:2|T'); + }); + }); + + describe('close', () => { + test('should flush immediately', () => { + const capture = jest.spyOn(testClient, 'captureAggregateMetrics'); + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 1); + aggregator.close(); + // It should clear the interval. + expect(clearInterval).toHaveBeenCalled(); + expect(capture).toBeCalled(); + expect(capture).toBeCalledTimes(1); + expect(capture).toBeCalledWith([ + { + metric: { _value: 1 }, + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }, + ]); + }); + }); + + describe('flush', () => { + test('should flush immediately', () => { + const capture = jest.spyOn(testClient, 'captureAggregateMetrics'); + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 1); + aggregator.flush(); + expect(capture).toBeCalled(); + expect(capture).toBeCalledTimes(1); + expect(capture).toBeCalledWith([ + { + metric: { _value: 1 }, + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }, + ]); + capture.mockReset(); + aggregator.close(); + // It should clear the interval. + expect(clearInterval).toHaveBeenCalled(); + + // It shouldn't be called since it's been already flushed. + expect(capture).toBeCalledTimes(0); + }); + + test('should not capture if empty', () => { + const capture = jest.spyOn(testClient, 'captureAggregateMetrics'); + const aggregator = new MetricsAggregator(testClient); + aggregator.add('c', 'requests', 1); + aggregator.flush(); + expect(capture).toBeCalledTimes(1); + capture.mockReset(); + aggregator.close(); + expect(capture).toBeCalledTimes(0); + }); + }); + + describe('add', () => { + test('it should respect the max weight and flush if exceeded', () => { + const capture = jest.spyOn(testClient, 'captureAggregateMetrics'); + const aggregator = new MetricsAggregator(testClient); + + for (let i = 0; i < MAX_WEIGHT; i++) { + aggregator.add('c', 'requests', 1); + } + + expect(capture).toBeCalledTimes(1); + aggregator.close(); + }); + }); +}); diff --git a/packages/core/test/lib/metrics/simpleaggregator.test.ts b/packages/core/test/lib/metrics/browser-aggregator.test.ts similarity index 71% rename from packages/core/test/lib/metrics/simpleaggregator.test.ts rename to packages/core/test/lib/metrics/browser-aggregator.test.ts index cafc78d1e018..669959a03e05 100644 --- a/packages/core/test/lib/metrics/simpleaggregator.test.ts +++ b/packages/core/test/lib/metrics/browser-aggregator.test.ts @@ -1,36 +1,49 @@ +import { BrowserMetricsAggregator } from '../../../src/metrics/browser-aggregator'; import { CounterMetric } from '../../../src/metrics/instance'; -import { SimpleMetricsAggregator } from '../../../src/metrics/simpleaggregator'; import { serializeMetricBuckets } from '../../../src/metrics/utils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; -describe('SimpleMetricsAggregator', () => { +describe('BrowserMetricsAggregator', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); const testClient = new TestClient(options); it('adds items to buckets', () => { - const aggregator = new SimpleMetricsAggregator(testClient); + const aggregator = new BrowserMetricsAggregator(testClient); aggregator.add('c', 'requests', 1); expect(aggregator['_buckets'].size).toEqual(1); const firstValue = aggregator['_buckets'].values().next().value; - expect(firstValue).toEqual([expect.any(CounterMetric), expect.any(Number), 'c', 'requests', 'none', {}]); + expect(firstValue).toEqual({ + metric: expect.any(CounterMetric), + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }); }); it('groups same items together', () => { - const aggregator = new SimpleMetricsAggregator(testClient); + const aggregator = new BrowserMetricsAggregator(testClient); aggregator.add('c', 'requests', 1); expect(aggregator['_buckets'].size).toEqual(1); aggregator.add('c', 'requests', 1); expect(aggregator['_buckets'].size).toEqual(1); const firstValue = aggregator['_buckets'].values().next().value; - expect(firstValue).toEqual([expect.any(CounterMetric), expect.any(Number), 'c', 'requests', 'none', {}]); - - expect(firstValue[0]._value).toEqual(2); + expect(firstValue).toEqual({ + metric: expect.any(CounterMetric), + metricType: 'c', + name: 'requests', + tags: {}, + timestamp: expect.any(Number), + unit: 'none', + }); + expect(firstValue.metric._value).toEqual(2); }); it('differentiates based on tag value', () => { - const aggregator = new SimpleMetricsAggregator(testClient); + const aggregator = new BrowserMetricsAggregator(testClient); aggregator.add('g', 'cpu', 50); expect(aggregator['_buckets'].size).toEqual(1); aggregator.add('g', 'cpu', 55, undefined, { a: 'value' }); @@ -39,7 +52,7 @@ describe('SimpleMetricsAggregator', () => { describe('serializeBuckets', () => { it('serializes ', () => { - const aggregator = new SimpleMetricsAggregator(testClient); + const aggregator = new BrowserMetricsAggregator(testClient); aggregator.add('c', 'requests', 8); aggregator.add('g', 'cpu', 50); aggregator.add('g', 'cpu', 55); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 4a57bb6f2cfd..fe0b5ee4b620 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -68,6 +68,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + metrics, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 46b1d6d742d4..36d2d8beac53 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -71,6 +71,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + metrics, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core'; export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing'; diff --git a/packages/types/src/metrics.ts b/packages/types/src/metrics.ts index 18943ee3997e..9bfb990461eb 100644 --- a/packages/types/src/metrics.ts +++ b/packages/types/src/metrics.ts @@ -1,25 +1,41 @@ import type { MeasurementUnit } from './measurement'; import type { Primitive } from './misc'; -export interface MetricInstance { +/** + * An abstract definition of the minimum required API + * for a metric instance. + */ +export abstract class MetricInstance { + /** + * Returns the weight of the metric. + */ + public get weight(): number { + return 1; + } + /** * Adds a value to a metric. */ - add(value: number | string): void; + public add(value: number | string): void { + // Override this. + } + /** * Serializes the metric into a statsd format string. */ - toString(): string; + public toString(): string { + return ''; + } } -export type MetricBucketItem = [ - metric: MetricInstance, - timestamp: number, - metricType: 'c' | 'g' | 's' | 'd', - name: string, - unit: MeasurementUnit, - tags: { [key: string]: string }, -]; +export interface MetricBucketItem { + metric: MetricInstance; + timestamp: number; + metricType: 'c' | 'g' | 's' | 'd'; + name: string; + unit: MeasurementUnit; + tags: Record; +} /** * A metrics aggregator that aggregates metrics in memory and flushes them periodically. diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index cbc7f6a89d7c..2ef1217ab117 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -69,6 +69,7 @@ export { startInactiveSpan, startSpanManual, continueTrace, + metrics, } from '@sentry/core'; export type { SpanStatusType } from '@sentry/core';