Skip to content

Commit

Permalink
feat(browser): Add browser metrics sdk (#9794)
Browse files Browse the repository at this point in the history
This PR introduces functionality for a browser metrics SDK in an alpha
state. Via the newly introduced APIs, you can now flush metrics directly
to Sentry in the browser.

To enable capturing metrics, you first need to add the `Metrics`
integration. This is done for treeshaking purposes.

```js
Sentry.init({
  dsn: '__DSN__',
  integrations: [
    new Sentry.metrics.MetricsAggregator(),
  ],
});
```

Then you'll be able to add `counters`, `sets`, `distributions`, and
`gauges` under the `Sentry.metrics` namespace.

```js
// Add 4 to a counter named `hits`
Sentry.metrics.increment('hits', 4);

// Add 2 to gauge named `parallel_requests`, tagged with `happy: "no"`
Sentry.metrics.gauge('parallel_requests', 2, { tags: { happy: 'no' } });

// Add 4.6 to a distribution named `response_time` with unit seconds
Sentry.metrics.distribution('response_time', 4.6, { unit: 'seconds' });

// Add 2 to a set named `valuable.ids`
Sentry.metrics.set('valuable.ids', 2);
```

Under the hood, adding the `Metrics` integration adds a
`SimpleMetricsAggregator`. This is a bundle-sized efficient metrics
aggregator that aggregates metrics and flushes them out every 5 seconds.
This does not use any weight based logic - this will be implemented in
the `MetricsAggregator` that is used for server runtimes (node, deno,
vercel-edge).

To make metrics conditional, it is defined on the client as `public
metricsAggregator: MetricsAggregator | undefined`.

Next step is to add some integration tests for this functionality, and
then add server-runtime logic for it.

Many of the decisions taken for this aggregator + flushing
implementation was to try to be as bundle size efficient as possible,
happy to answer any specific questions.
  • Loading branch information
AbhiPrasad authored Dec 14, 2023
1 parent 8800d5b commit 6c000b6
Show file tree
Hide file tree
Showing 18 changed files with 636 additions and 68 deletions.
1 change: 1 addition & 0 deletions packages/browser/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
withScope,
FunctionToString,
InboundFilters,
metrics,
} from '@sentry/core';

export { WINDOW } from './helpers';
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/baseclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
FeedbackEvent,
Integration,
IntegrationClass,
MetricBucketItem,
MetricsAggregator,
Outcome,
PropagationContext,
SdkMetadata,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -88,6 +91,13 @@ const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been ca
* }
*/
export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
/**
* 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;

Expand Down Expand Up @@ -264,6 +274,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
public flush(timeout?: number): PromiseLike<boolean> {
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);
});
Expand All @@ -278,6 +291,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
public close(timeout?: number): PromiseLike<boolean> {
return this.flush(timeout).then(result => {
this.getOptions().enabled = false;
if (this.metricsAggregator) {
this.metricsAggregator.close();
}
return result;
});
}
Expand Down Expand Up @@ -383,6 +399,19 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
}
}

/**
* @inheritDoc
*/
public captureAggregateMetrics(metricBucketItems: Array<MetricBucketItem>): 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 */

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,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 };
30 changes: 30 additions & 0 deletions packages/core/src/metrics/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 40 additions & 0 deletions packages/core/src/metrics/envelope.ts
Original file line number Diff line number Diff line change
@@ -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<MetricBucketItem>,
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<StatsdEnvelope>(headers, [item]);
}

function createMetricEnvelopeItem(metricBucketItems: Array<MetricBucketItem>): StatsdItem {
const payload = serializeMetricBuckets(metricBucketItems);
const metricHeaders: StatsdItem[0] = {
type: 'statsd',
length: payload.length,
};
return [metricHeaders, payload];
}
92 changes: 92 additions & 0 deletions packages/core/src/metrics/exports.ts
Original file line number Diff line number Diff line change
@@ -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<string, Primitive>;
timestamp?: number;
}

function addToMetricsAggregator(
metricType: MetricType,
name: string,
value: number | string,
data: MetricData = {},
): void {
const hub = getCurrentHub();
const client = hub.getClient() as BaseClient<ClientOptions>;
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<string, string> = {};
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,
};
43 changes: 0 additions & 43 deletions packages/core/src/metrics/index.ts

This file was deleted.

Loading

0 comments on commit 6c000b6

Please sign in to comment.