diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 0804dbe49ac0..499bb12c5fc9 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -56,6 +56,7 @@ export { withScope, FunctionToString, InboundFilters, + metrics, } from '@sentry/core'; export { WINDOW } from './helpers'; diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 0ea80e229c58..a6e0575a2e67 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -16,6 +16,8 @@ import type { FeedbackEvent, Integration, IntegrationClass, + MetricBucketItem, + MetricsAggregator, Outcome, PropagationContext, SdkMetadata, @@ -49,6 +51,7 @@ import { createEventEnvelope, createSessionEnvelope } from './envelope'; import { getCurrentHub } from './hub'; import type { IntegrationIndex } from './integration'; import { setupIntegration, setupIntegrations } from './integration'; +import { createMetricEnvelope } from './metrics/envelope'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromClient } from './tracing/dynamicSamplingContext'; @@ -88,6 +91,13 @@ const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been ca * } */ export abstract class BaseClient implements Client { + /** + * A reference to a metrics aggregator + * + * @experimental Note this is alpha API. It may experience breaking changes in the future. + */ + public metricsAggregator?: MetricsAggregator; + /** Options passed to the SDK. */ protected readonly _options: O; @@ -264,6 +274,9 @@ export abstract class BaseClient implements Client { public flush(timeout?: number): PromiseLike { const transport = this._transport; if (transport) { + if (this.metricsAggregator) { + this.metricsAggregator.flush(); + } return this._isClientDoneProcessing(timeout).then(clientFinished => { return transport.flush(timeout).then(transportFlushed => clientFinished && transportFlushed); }); @@ -278,6 +291,9 @@ export abstract class BaseClient implements Client { public close(timeout?: number): PromiseLike { return this.flush(timeout).then(result => { this.getOptions().enabled = false; + if (this.metricsAggregator) { + this.metricsAggregator.close(); + } return result; }); } @@ -383,6 +399,19 @@ export abstract class BaseClient implements Client { } } + /** + * @inheritDoc + */ + public captureAggregateMetrics(metricBucketItems: Array): void { + const metricsEnvelope = createMetricEnvelope( + metricBucketItems, + this._dsn, + this._options._metadata, + this._options.tunnel, + ); + void this._sendEnvelope(metricsEnvelope); + } + // Keep on() & emit() signatures in sync with types' client.ts interface /* eslint-disable @typescript-eslint/unified-signatures */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07871633f16c..48ba2b4a362a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -63,5 +63,6 @@ export { DEFAULT_ENVIRONMENT } from './constants'; export { ModuleMetadata } from './integrations/metadata'; export { RequestData } from './integrations/requestdata'; import * as Integrations from './integrations'; +export { metrics } from './metrics/exports'; export { Integrations }; diff --git a/packages/core/src/metrics/constants.ts b/packages/core/src/metrics/constants.ts new file mode 100644 index 000000000000..f29ac323c2ee --- /dev/null +++ b/packages/core/src/metrics/constants.ts @@ -0,0 +1,30 @@ +export const COUNTER_METRIC_TYPE = 'c' as const; +export const GAUGE_METRIC_TYPE = 'g' as const; +export const SET_METRIC_TYPE = 's' as const; +export const DISTRIBUTION_METRIC_TYPE = 'd' as const; + +/** + * Normalization regex for metric names and metric tag names. + * + * This enforces that names and tag keys only contain alphanumeric characters, + * underscores, forward slashes, periods, and dashes. + * + * See: https://develop.sentry.dev/sdk/metrics/#normalization + */ +export const NAME_AND_TAG_KEY_NORMALIZATION_REGEX = /[^a-zA-Z0-9_/.-]+/g; + +/** + * Normalization regex for metric tag values. + * + * This enforces that values only contain words, digits, or the following + * special characters: _:/@.{}[\]$- + * + * See: https://develop.sentry.dev/sdk/metrics/#normalization + */ +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; diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts new file mode 100644 index 000000000000..c7c65674b736 --- /dev/null +++ b/packages/core/src/metrics/envelope.ts @@ -0,0 +1,40 @@ +import type { DsnComponents, MetricBucketItem, SdkMetadata, StatsdEnvelope, StatsdItem } from '@sentry/types'; +import { createEnvelope, dsnToString } from '@sentry/utils'; +import { serializeMetricBuckets } from './utils'; + +/** + * Create envelope from a metric aggregate. + */ +export function createMetricEnvelope( + metricBucketItems: Array, + dsn?: DsnComponents, + metadata?: SdkMetadata, + tunnel?: string, +): StatsdEnvelope { + const headers: StatsdEnvelope[0] = { + sent_at: new Date().toISOString(), + }; + + if (metadata && metadata.sdk) { + headers.sdk = { + name: metadata.sdk.name, + version: metadata.sdk.version, + }; + } + + if (!!tunnel && dsn) { + headers.dsn = dsnToString(dsn); + } + + const item = createMetricEnvelopeItem(metricBucketItems); + return createEnvelope(headers, [item]); +} + +function createMetricEnvelopeItem(metricBucketItems: Array): StatsdItem { + const payload = serializeMetricBuckets(metricBucketItems); + const metricHeaders: StatsdItem[0] = { + type: 'statsd', + length: payload.length, + }; + return [metricHeaders, payload]; +} diff --git a/packages/core/src/metrics/exports.ts b/packages/core/src/metrics/exports.ts new file mode 100644 index 000000000000..c27e76cf79b1 --- /dev/null +++ b/packages/core/src/metrics/exports.ts @@ -0,0 +1,92 @@ +import type { ClientOptions, MeasurementUnit, Primitive } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import type { BaseClient } from '../baseclient'; +import { DEBUG_BUILD } from '../debug-build'; +import { getCurrentHub } from '../hub'; +import { COUNTER_METRIC_TYPE, DISTRIBUTION_METRIC_TYPE, GAUGE_METRIC_TYPE, SET_METRIC_TYPE } from './constants'; +import { MetricsAggregator } from './integration'; +import type { MetricType } from './types'; + +interface MetricData { + unit?: MeasurementUnit; + tags?: Record; + timestamp?: number; +} + +function addToMetricsAggregator( + metricType: MetricType, + name: string, + value: number | string, + data: MetricData = {}, +): void { + const hub = getCurrentHub(); + const client = hub.getClient() as BaseClient; + const scope = hub.getScope(); + if (client) { + if (!client.metricsAggregator) { + DEBUG_BUILD && + logger.warn('No metrics aggregator enabled. Please add the MetricsAggregator integration to use metrics APIs'); + return; + } + const { unit, tags, timestamp } = data; + const { release, environment } = client.getOptions(); + const transaction = scope.getTransaction(); + const metricTags: Record = {}; + if (release) { + metricTags.release = release; + } + if (environment) { + metricTags.environment = environment; + } + if (transaction) { + metricTags.transaction = transaction.name; + } + + DEBUG_BUILD && logger.log(`Adding value of ${value} to ${metricType} metric ${name}`); + client.metricsAggregator.add(metricType, name, value, unit, { ...metricTags, ...tags }, timestamp); + } +} + +/** + * Adds a value to a counter metric + * + * @experimental This API is experimental and might having breaking changes in the future. + */ +export function increment(name: string, value: number = 1, data?: MetricData): void { + addToMetricsAggregator(COUNTER_METRIC_TYPE, name, value, data); +} + +/** + * Adds a value to a distribution metric + * + * @experimental This API is experimental and might having breaking changes in the future. + */ +export function distribution(name: string, value: number, data?: MetricData): void { + addToMetricsAggregator(DISTRIBUTION_METRIC_TYPE, name, value, data); +} + +/** + * 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. + */ +export function set(name: string, value: number | string, data?: MetricData): void { + addToMetricsAggregator(SET_METRIC_TYPE, name, value, data); +} + +/** + * Adds a value to a gauge metric + * + * @experimental This API is experimental and might having breaking changes in the future. + */ +export function gauge(name: string, value: number, data?: MetricData): void { + addToMetricsAggregator(GAUGE_METRIC_TYPE, name, value, data); +} + +export const metrics = { + increment, + distribution, + set, + gauge, + MetricsAggregator, +}; diff --git a/packages/core/src/metrics/index.ts b/packages/core/src/metrics/index.ts deleted file mode 100644 index 3cfeae37e03f..000000000000 --- a/packages/core/src/metrics/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { DsnComponents, DynamicSamplingContext, SdkMetadata, StatsdEnvelope, StatsdItem } from '@sentry/types'; -import { createEnvelope, dropUndefinedKeys, dsnToString } from '@sentry/utils'; - -/** - * Create envelope from a metric aggregate. - */ -export function createMetricEnvelope( - // TODO(abhi): Add type for this - metricAggregate: string, - dynamicSamplingContext?: Partial, - metadata?: SdkMetadata, - tunnel?: string, - dsn?: DsnComponents, -): StatsdEnvelope { - const headers: StatsdEnvelope[0] = { - sent_at: new Date().toISOString(), - }; - - if (metadata && metadata.sdk) { - headers.sdk = { - name: metadata.sdk.name, - version: metadata.sdk.version, - }; - } - - if (!!tunnel && !!dsn) { - headers.dsn = dsnToString(dsn); - } - - if (dynamicSamplingContext) { - headers.trace = dropUndefinedKeys(dynamicSamplingContext) as DynamicSamplingContext; - } - - const item = createMetricEnvelopeItem(metricAggregate); - return createEnvelope(headers, [item]); -} - -function createMetricEnvelopeItem(metricAggregate: string): StatsdItem { - const metricHeaders: StatsdItem[0] = { - type: 'statsd', - }; - return [metricHeaders, metricAggregate]; -} diff --git a/packages/core/src/metrics/instance.ts b/packages/core/src/metrics/instance.ts new file mode 100644 index 000000000000..f071006c96ca --- /dev/null +++ b/packages/core/src/metrics/instance.ts @@ -0,0 +1,110 @@ +import type { MetricInstance } from '@sentry/types'; +import { COUNTER_METRIC_TYPE, DISTRIBUTION_METRIC_TYPE, GAUGE_METRIC_TYPE, SET_METRIC_TYPE } from './constants'; +import { simpleHash } from './utils'; + +/** + * A metric instance representing a counter. + */ +export class CounterMetric implements MetricInstance { + public constructor(private _value: number) {} + + /** @inheritdoc */ + public add(value: number): void { + this._value += value; + } + + /** @inheritdoc */ + public toString(): string { + return `${this._value}`; + } +} + +/** + * A metric instance representing a gauge. + */ +export class GaugeMetric implements MetricInstance { + private _last: number; + private _min: number; + private _max: number; + private _sum: number; + private _count: number; + + public constructor(value: number) { + this._last = value; + this._min = value; + this._max = value; + this._sum = value; + this._count = 1; + } + + /** @inheritdoc */ + public add(value: number): void { + this._last = value; + if (value < this._min) { + this._min = value; + } + if (value > this._max) { + this._max = value; + } + this._sum += value; + this._count++; + } + + /** @inheritdoc */ + public toString(): string { + return `${this._last}:${this._min}:${this._max}:${this._sum}:${this._count}`; + } +} + +/** + * A metric instance representing a distribution. + */ +export class DistributionMetric implements MetricInstance { + private _value: number[]; + + public constructor(first: number) { + this._value = [first]; + } + + /** @inheritdoc */ + public add(value: number): void { + this._value.push(value); + } + + /** @inheritdoc */ + public toString(): string { + return this._value.join(':'); + } +} + +/** + * A metric instance representing a set. + */ +export class SetMetric implements MetricInstance { + private _value: Set; + + public constructor(public first: number | string) { + this._value = new Set([first]); + } + + /** @inheritdoc */ + public add(value: number | string): void { + this._value.add(value); + } + + /** @inheritdoc */ + public toString(): string { + return `${Array.from(this._value) + .map(val => (typeof val === 'string' ? simpleHash(val) : val)) + .join(':')}`; + } +} + +export type Metric = CounterMetric | GaugeMetric | DistributionMetric | SetMetric; + +export const METRIC_MAP = { + [COUNTER_METRIC_TYPE]: CounterMetric, + [GAUGE_METRIC_TYPE]: GaugeMetric, + [DISTRIBUTION_METRIC_TYPE]: DistributionMetric, + [SET_METRIC_TYPE]: SetMetric, +}; diff --git a/packages/core/src/metrics/integration.ts b/packages/core/src/metrics/integration.ts new file mode 100644 index 000000000000..9ce7f836ca8c --- /dev/null +++ b/packages/core/src/metrics/integration.ts @@ -0,0 +1,38 @@ +import type { ClientOptions, Integration } from '@sentry/types'; +import type { BaseClient } from '../baseclient'; +import { SimpleMetricsAggregator } from './simpleaggregator'; + +/** + * Enables Sentry metrics monitoring. + * + * @experimental This API is experimental and might having breaking changes in the future. + */ +export class MetricsAggregator implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'MetricsAggregator'; + + /** + * @inheritDoc + */ + public name: string; + + public constructor() { + this.name = MetricsAggregator.id; + } + + /** + * @inheritDoc + */ + public setupOnce(): void { + // Do nothing + } + + /** + * @inheritDoc + */ + public setup(client: BaseClient): void { + client.metricsAggregator = new SimpleMetricsAggregator(client); + } +} diff --git a/packages/core/src/metrics/simpleaggregator.ts b/packages/core/src/metrics/simpleaggregator.ts new file mode 100644 index 000000000000..a628a3b5a406 --- /dev/null +++ b/packages/core/src/metrics/simpleaggregator.ts @@ -0,0 +1,91 @@ +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 new file mode 100644 index 000000000000..de6032f811b8 --- /dev/null +++ b/packages/core/src/metrics/types.ts @@ -0,0 +1,10 @@ +import type { MetricBucketItem } from '@sentry/types'; +import type { COUNTER_METRIC_TYPE, DISTRIBUTION_METRIC_TYPE, GAUGE_METRIC_TYPE, SET_METRIC_TYPE } from './constants'; + +export type MetricType = + | typeof COUNTER_METRIC_TYPE + | typeof GAUGE_METRIC_TYPE + | typeof SET_METRIC_TYPE + | typeof DISTRIBUTION_METRIC_TYPE; + +export type SimpleMetricBucket = Map; diff --git a/packages/core/src/metrics/utils.ts b/packages/core/src/metrics/utils.ts new file mode 100644 index 000000000000..27c49d144523 --- /dev/null +++ b/packages/core/src/metrics/utils.ts @@ -0,0 +1,57 @@ +import type { MeasurementUnit, MetricBucketItem } from '@sentry/types'; +import { dropUndefinedKeys } from '@sentry/utils'; +import type { MetricType, SimpleMetricBucket } from './types'; + +/** + * Generate bucket key from metric properties. + */ +export function getBucketKey( + metricType: MetricType, + name: string, + unit: MeasurementUnit, + tags: Record, +): string { + const stringifiedTags = Object.entries(dropUndefinedKeys(tags)).sort((a, b) => a[0].localeCompare(b[0])); + return `${metricType}${name}${unit}${stringifiedTags}`; +} + +/* eslint-disable no-bitwise */ +/** + * Simple hash function for strings. + */ +export function simpleHash(s: string): number { + let rv = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + rv = (rv << 5) - rv + c; + rv &= rv; + } + return rv >>> 0; +} +/* eslint-enable no-bitwise */ + +/** + * Serialize metrics buckets into a string based on statsd format. + * + * Example of format: + * metric.name@second:1:1.2|d|#a:value,b:anothervalue|T12345677 + * Segments: + * name: metric.name + * unit: second + * value: [1, 1.2] + * type of metric: d (distribution) + * tags: { a: value, b: anothervalue } + * timestamp: 12345677 + */ +export function serializeMetricBuckets(metricBucketItems: Array): 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`; + } + return out; +} diff --git a/packages/core/test/lib/metrics/simpleaggregator.test.ts b/packages/core/test/lib/metrics/simpleaggregator.test.ts new file mode 100644 index 000000000000..cafc78d1e018 --- /dev/null +++ b/packages/core/test/lib/metrics/simpleaggregator.test.ts @@ -0,0 +1,61 @@ +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', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); + const testClient = new TestClient(options); + + it('adds items to buckets', () => { + const aggregator = new SimpleMetricsAggregator(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', {}]); + }); + + it('groups same items together', () => { + const aggregator = new SimpleMetricsAggregator(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); + }); + + it('differentiates based on tag value', () => { + const aggregator = new SimpleMetricsAggregator(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 SimpleMetricsAggregator(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'); + }); + }); +}); diff --git a/packages/core/test/lib/metrics/utils.test.ts b/packages/core/test/lib/metrics/utils.test.ts new file mode 100644 index 000000000000..fe96404b72ea --- /dev/null +++ b/packages/core/test/lib/metrics/utils.test.ts @@ -0,0 +1,21 @@ +import { + COUNTER_METRIC_TYPE, + DISTRIBUTION_METRIC_TYPE, + GAUGE_METRIC_TYPE, + SET_METRIC_TYPE, +} from '../../../src/metrics/constants'; +import { getBucketKey } from '../../../src/metrics/utils'; + +describe('getBucketKey', () => { + it.each([ + [COUNTER_METRIC_TYPE, 'requests', 'none', {}, 'crequestsnone'], + [GAUGE_METRIC_TYPE, 'cpu', 'none', {}, 'gcpunone'], + [DISTRIBUTION_METRIC_TYPE, 'lcp', 'second', { a: 'value', b: 'anothervalue' }, 'dlcpseconda,value,b,anothervalue'], + [DISTRIBUTION_METRIC_TYPE, 'lcp', 'second', { b: 'anothervalue', a: 'value' }, 'dlcpseconda,value,b,anothervalue'], + [DISTRIBUTION_METRIC_TYPE, 'lcp', 'second', { a: '1', b: '2', c: '3' }, 'dlcpseconda,1,b,2,c,3'], + [DISTRIBUTION_METRIC_TYPE, 'lcp', 'second', { numericKey: '2' }, 'dlcpsecondnumericKey,2'], + [SET_METRIC_TYPE, 'important_org_ids', 'none', { numericKey: '2' }, 'simportant_org_idsnonenumericKey,2'], + ])('should return', (metricType, name, unit, tags, expected) => { + expect(getBucketKey(metricType, name, unit, tags)).toEqual(expected); + }); +}); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 33fa749eb379..bfb657d135fa 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -8,6 +8,7 @@ import type { Event, EventHint } from './event'; import type { EventProcessor } from './eventprocessor'; import type { FeedbackEvent } from './feedback'; import type { Integration, IntegrationClass } from './integration'; +import type { MetricBucketItem } from './metrics'; import type { ClientOptions } from './options'; import type { Scope } from './scope'; import type { SdkMetadata } from './sdkmetadata'; @@ -179,6 +180,13 @@ export interface Client { */ recordDroppedEvent(reason: EventDropReason, dataCategory: DataCategory, event?: Event): void; + /** + * Captures serialized metrics and sends them to Sentry. + * + * @experimental This API is experimental and might experience breaking changes + */ + captureAggregateMetrics?(metricBucketItems: Array): void; + // HOOKS // TODO(v8): Make the hooks non-optional. /* eslint-disable @typescript-eslint/unified-signatures */ diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index 8d47206f4217..c07853c377d9 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -77,7 +77,7 @@ type ClientReportItemHeaders = { type: 'client_report' }; type ReplayEventItemHeaders = { type: 'replay_event' }; type ReplayRecordingItemHeaders = { type: 'replay_recording'; length: number }; type CheckInItemHeaders = { type: 'check_in' }; -type StatsdItemHeaders = { type: 'statsd' }; +type StatsdItemHeaders = { type: 'statsd'; length: number }; type ProfileItemHeaders = { type: 'profile' }; export type EventItem = BaseEnvelopeItem; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5603854170f5..7d3531599aa3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -139,4 +139,4 @@ export type { export type { BrowserClientReplayOptions, BrowserClientProfilingOptions } from './browseroptions'; export type { CheckIn, MonitorConfig, FinishedCheckIn, InProgressCheckIn, SerializedCheckIn } from './checkin'; -export type { Metric, CounterMetric, GaugeMetric, DistributionMetric, SetMetric } from './metrics'; +export type { MetricsAggregator, MetricBucketItem, MetricInstance } from './metrics'; diff --git a/packages/types/src/metrics.ts b/packages/types/src/metrics.ts index b55096950b2f..18943ee3997e 100644 --- a/packages/types/src/metrics.ts +++ b/packages/types/src/metrics.ts @@ -1,32 +1,54 @@ import type { MeasurementUnit } from './measurement'; import type { Primitive } from './misc'; -export interface BaseMetric { - name: string; - timestamp: number; - unit?: MeasurementUnit; - tags?: { [key: string]: Primitive }; +export interface MetricInstance { + /** + * Adds a value to a metric. + */ + add(value: number | string): void; + /** + * Serializes the metric into a statsd format string. + */ + toString(): string; } -export interface CounterMetric extends BaseMetric { - value: number; -} +export type MetricBucketItem = [ + metric: MetricInstance, + timestamp: number, + metricType: 'c' | 'g' | 's' | 'd', + name: string, + unit: MeasurementUnit, + tags: { [key: string]: string }, +]; -export interface GaugeMetric extends BaseMetric { - value: number; - first: number; - min: number; - max: number; - sum: number; - count: number; -} +/** + * A metrics aggregator that aggregates metrics in memory and flushes them periodically. + */ +export interface MetricsAggregator { + /** + * Add a metric to the aggregator. + */ + add( + metricType: 'c' | 'g' | 's' | 'd', + name: string, + value: number | string, + unit?: MeasurementUnit, + tags?: Record, + timestamp?: number, + ): void; -export interface DistributionMetric extends BaseMetric { - value: number[]; -} + /** + * Flushes the current metrics to the transport via the transport. + */ + flush(): void; -export interface SetMetric extends BaseMetric { - value: Set; -} + /** + * Shuts down metrics aggregator and clears all metrics. + */ + close(): void; -export type Metric = CounterMetric | GaugeMetric | DistributionMetric | SetMetric; + /** + * Returns a string representation of the aggregator. + */ + toString(): string; +}