Skip to content

Commit 7b3a22d

Browse files
cleptricAbhiPrasad
andcommitted
feat(core): Add metric summaries to spans (#10554)
Co-authored-by: Abhijeet Prasad <aprasad@sentry.io>
1 parent ac7cb33 commit 7b3a22d

File tree

12 files changed

+286
-17
lines changed

12 files changed

+286
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
2+
const Sentry = require('@sentry/node');
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
_experiments: {
10+
metricsAggregator: true,
11+
},
12+
});
13+
14+
// Stop the process from exiting before the transaction is sent
15+
setInterval(() => {}, 1000);
16+
17+
Sentry.startSpan(
18+
{
19+
name: 'Test Transaction',
20+
op: 'transaction',
21+
},
22+
() => {
23+
Sentry.metrics.increment('root-counter', 1, {
24+
tags: {
25+
email: 'jon.doe@example.com',
26+
},
27+
});
28+
Sentry.metrics.increment('root-counter', 1, {
29+
tags: {
30+
email: 'jane.doe@example.com',
31+
},
32+
});
33+
34+
Sentry.startSpan(
35+
{
36+
name: 'Some other span',
37+
op: 'transaction',
38+
},
39+
() => {
40+
Sentry.metrics.increment('root-counter');
41+
Sentry.metrics.increment('root-counter');
42+
Sentry.metrics.increment('root-counter', 2);
43+
44+
Sentry.metrics.set('root-set', 'some-value');
45+
Sentry.metrics.set('root-set', 'another-value');
46+
Sentry.metrics.set('root-set', 'another-value');
47+
48+
Sentry.metrics.gauge('root-gauge', 42);
49+
Sentry.metrics.gauge('root-gauge', 20);
50+
51+
Sentry.metrics.distribution('root-distribution', 42);
52+
Sentry.metrics.distribution('root-distribution', 20);
53+
},
54+
);
55+
},
56+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createRunner } from '../../../utils/runner';
2+
3+
const EXPECTED_TRANSACTION = {
4+
transaction: 'Test Transaction',
5+
_metrics_summary: {
6+
'c:root-counter@none': [
7+
{
8+
min: 1,
9+
max: 1,
10+
count: 1,
11+
sum: 1,
12+
tags: {
13+
release: '1.0',
14+
transaction: 'Test Transaction',
15+
email: 'jon.doe@example.com',
16+
},
17+
},
18+
{
19+
min: 1,
20+
max: 1,
21+
count: 1,
22+
sum: 1,
23+
tags: {
24+
release: '1.0',
25+
transaction: 'Test Transaction',
26+
email: 'jane.doe@example.com',
27+
},
28+
},
29+
],
30+
},
31+
spans: expect.arrayContaining([
32+
expect.objectContaining({
33+
description: 'Some other span',
34+
op: 'transaction',
35+
_metrics_summary: {
36+
'c:root-counter@none': [
37+
{
38+
min: 1,
39+
max: 2,
40+
count: 3,
41+
sum: 4,
42+
tags: {
43+
release: '1.0',
44+
transaction: 'Test Transaction',
45+
},
46+
},
47+
],
48+
's:root-set@none': [
49+
{
50+
min: 0,
51+
max: 1,
52+
count: 3,
53+
sum: 2,
54+
tags: {
55+
release: '1.0',
56+
transaction: 'Test Transaction',
57+
},
58+
},
59+
],
60+
'g:root-gauge@none': [
61+
{
62+
min: 20,
63+
max: 42,
64+
count: 2,
65+
sum: 62,
66+
tags: {
67+
release: '1.0',
68+
transaction: 'Test Transaction',
69+
},
70+
},
71+
],
72+
'd:root-distribution@none': [
73+
{
74+
min: 20,
75+
max: 42,
76+
count: 2,
77+
sum: 62,
78+
tags: {
79+
release: '1.0',
80+
transaction: 'Test Transaction',
81+
},
82+
},
83+
],
84+
},
85+
}),
86+
]),
87+
};
88+
89+
test('Should add metric summaries to spans', done => {
90+
createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start(done);
91+
});

packages/core/src/metrics/aggregator.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import type {
66
Primitive,
77
} from '@sentry/types';
88
import { timestampInSeconds } from '@sentry/utils';
9-
import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, NAME_AND_TAG_KEY_NORMALIZATION_REGEX } from './constants';
9+
import { DEFAULT_FLUSH_INTERVAL, MAX_WEIGHT, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants';
1010
import { METRIC_MAP } from './instance';
11+
import { updateMetricSummaryOnActiveSpan } from './metric-summary';
1112
import type { MetricBucket, MetricType } from './types';
1213
import { getBucketKey, sanitizeTags } from './utils';
1314

@@ -62,7 +63,11 @@ export class MetricsAggregator implements MetricsAggregatorBase {
6263
const tags = sanitizeTags(unsanitizedTags);
6364

6465
const bucketKey = getBucketKey(metricType, name, unit, tags);
66+
6567
let bucketItem = this._buckets.get(bucketKey);
68+
// If this is a set metric, we need to calculate the delta from the previous weight.
69+
const previousWeight = bucketItem && metricType === SET_METRIC_TYPE ? bucketItem.metric.weight : 0;
70+
6671
if (bucketItem) {
6772
bucketItem.metric.add(value);
6873
// TODO(abhi): Do we need this check?
@@ -82,6 +87,10 @@ export class MetricsAggregator implements MetricsAggregatorBase {
8287
this._buckets.set(bucketKey, bucketItem);
8388
}
8489

90+
// If value is a string, it's a set metric so calculate the delta from the previous weight.
91+
const val = typeof value === 'string' ? bucketItem.metric.weight - previousWeight : value;
92+
updateMetricSummaryOnActiveSpan(metricType, name, val, unit, unsanitizedTags, bucketKey);
93+
8594
// We need to keep track of the total weight of the buckets so that we can
8695
// flush them when we exceed the max weight.
8796
this._bucketsTotalWeight += bucketItem.metric.weight;

packages/core/src/metrics/browser-aggregator.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import type {
2-
Client,
3-
ClientOptions,
4-
MeasurementUnit,
5-
MetricBucketItem,
6-
MetricsAggregator,
7-
Primitive,
8-
} from '@sentry/types';
1+
import type { Client, ClientOptions, MeasurementUnit, MetricsAggregator, Primitive } from '@sentry/types';
92
import { timestampInSeconds } from '@sentry/utils';
10-
import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX } from './constants';
3+
import { DEFAULT_BROWSER_FLUSH_INTERVAL, NAME_AND_TAG_KEY_NORMALIZATION_REGEX, SET_METRIC_TYPE } from './constants';
114
import { METRIC_MAP } from './instance';
5+
import { updateMetricSummaryOnActiveSpan } from './metric-summary';
126
import type { MetricBucket, MetricType } from './types';
137
import { getBucketKey, sanitizeTags } from './utils';
148

@@ -46,24 +40,33 @@ export class BrowserMetricsAggregator implements MetricsAggregator {
4640
const tags = sanitizeTags(unsanitizedTags);
4741

4842
const bucketKey = getBucketKey(metricType, name, unit, tags);
49-
const bucketItem: MetricBucketItem | undefined = this._buckets.get(bucketKey);
43+
44+
let bucketItem = this._buckets.get(bucketKey);
45+
// If this is a set metric, we need to calculate the delta from the previous weight.
46+
const previousWeight = bucketItem && metricType === SET_METRIC_TYPE ? bucketItem.metric.weight : 0;
47+
5048
if (bucketItem) {
5149
bucketItem.metric.add(value);
5250
// TODO(abhi): Do we need this check?
5351
if (bucketItem.timestamp < timestamp) {
5452
bucketItem.timestamp = timestamp;
5553
}
5654
} else {
57-
this._buckets.set(bucketKey, {
55+
bucketItem = {
5856
// @ts-expect-error we don't need to narrow down the type of value here, saves bundle size.
5957
metric: new METRIC_MAP[metricType](value),
6058
timestamp,
6159
metricType,
6260
name,
6361
unit,
6462
tags,
65-
});
63+
};
64+
this._buckets.set(bucketKey, bucketItem);
6665
}
66+
67+
// If value is a string, it's a set metric so calculate the delta from the previous weight.
68+
const val = typeof value === 'string' ? bucketItem.metric.weight - previousWeight : value;
69+
updateMetricSummaryOnActiveSpan(metricType, name, val, unit, unsanitizedTags, bucketKey);
6770
}
6871

6972
/**

packages/core/src/metrics/constants.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const NAME_AND_TAG_KEY_NORMALIZATION_REGEX = /[^a-zA-Z0-9_/.-]+/g;
2121
*
2222
* See: https://develop.sentry.dev/sdk/metrics/#normalization
2323
*/
24-
export const TAG_VALUE_NORMALIZATION_REGEX = /[^\w\d_:/@.{}[\]$-]+/g;
24+
export const TAG_VALUE_NORMALIZATION_REGEX = /[^\w\d\s_:/@.{}[\]$-]+/g;
2525

2626
/**
2727
* This does not match spec in https://develop.sentry.dev/sdk/metrics
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { MeasurementUnit, Span } from '@sentry/types';
2+
import type { MetricSummary } from '@sentry/types';
3+
import type { Primitive } from '@sentry/types';
4+
import { dropUndefinedKeys } from '@sentry/utils';
5+
import { getActiveSpan } from '../tracing';
6+
import type { MetricType } from './types';
7+
8+
/**
9+
* key: bucketKey
10+
* value: [exportKey, MetricSummary]
11+
*/
12+
type MetricSummaryStorage = Map<string, [string, MetricSummary]>;
13+
14+
let SPAN_METRIC_SUMMARY: WeakMap<Span, MetricSummaryStorage> | undefined;
15+
16+
function getMetricStorageForSpan(span: Span): MetricSummaryStorage | undefined {
17+
return SPAN_METRIC_SUMMARY ? SPAN_METRIC_SUMMARY.get(span) : undefined;
18+
}
19+
20+
/**
21+
* Fetches the metric summary if it exists for the passed span
22+
*/
23+
export function getMetricSummaryJsonForSpan(span: Span): Record<string, Array<MetricSummary>> | undefined {
24+
const storage = getMetricStorageForSpan(span);
25+
26+
if (!storage) {
27+
return undefined;
28+
}
29+
const output: Record<string, Array<MetricSummary>> = {};
30+
31+
for (const [, [exportKey, summary]] of storage) {
32+
if (!output[exportKey]) {
33+
output[exportKey] = [];
34+
}
35+
36+
output[exportKey].push(dropUndefinedKeys(summary));
37+
}
38+
39+
return output;
40+
}
41+
42+
/**
43+
* Updates the metric summary on the currently active span
44+
*/
45+
export function updateMetricSummaryOnActiveSpan(
46+
metricType: MetricType,
47+
sanitizedName: string,
48+
value: number,
49+
unit: MeasurementUnit,
50+
tags: Record<string, Primitive>,
51+
bucketKey: string,
52+
): void {
53+
const span = getActiveSpan();
54+
if (span) {
55+
const storage = getMetricStorageForSpan(span) || new Map<string, [string, MetricSummary]>();
56+
57+
const exportKey = `${metricType}:${sanitizedName}@${unit}`;
58+
const bucketItem = storage.get(bucketKey);
59+
60+
if (bucketItem) {
61+
const [, summary] = bucketItem;
62+
storage.set(bucketKey, [
63+
exportKey,
64+
{
65+
min: Math.min(summary.min, value),
66+
max: Math.max(summary.max, value),
67+
count: (summary.count += 1),
68+
sum: (summary.sum += value),
69+
tags: summary.tags,
70+
},
71+
]);
72+
} else {
73+
storage.set(bucketKey, [
74+
exportKey,
75+
{
76+
min: value,
77+
max: value,
78+
count: 1,
79+
sum: value,
80+
tags,
81+
},
82+
]);
83+
}
84+
85+
if (!SPAN_METRIC_SUMMARY) {
86+
SPAN_METRIC_SUMMARY = new WeakMap();
87+
}
88+
89+
SPAN_METRIC_SUMMARY.set(span, storage);
90+
}
91+
}

packages/core/src/metrics/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function sanitizeTags(unsanitizedTags: Record<string, Primitive>): Record
6262
for (const key in unsanitizedTags) {
6363
if (Object.prototype.hasOwnProperty.call(unsanitizedTags, key)) {
6464
const sanitizedKey = key.replace(NAME_AND_TAG_KEY_NORMALIZATION_REGEX, '_');
65-
tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, '_');
65+
tags[sanitizedKey] = String(unsanitizedTags[key]).replace(TAG_VALUE_NORMALIZATION_REGEX, '');
6666
}
6767
}
6868
return tags;

packages/core/src/tracing/span.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/utils';
1717

1818
import { DEBUG_BUILD } from '../debug-build';
19+
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
1920
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
2021
import { getRootSpan } from '../utils/getRootSpan';
2122
import {
@@ -624,6 +625,7 @@ export class Span implements SpanInterface {
624625
timestamp: this._endTime,
625626
trace_id: this._traceId,
626627
origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
628+
_metrics_summary: getMetricSummaryJsonForSpan(this),
627629
});
628630
}
629631

packages/core/src/tracing/transaction.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { dropUndefinedKeys, logger } from '@sentry/utils';
1515
import { DEBUG_BUILD } from '../debug-build';
1616
import type { Hub } from '../hub';
1717
import { getCurrentHub } from '../hub';
18+
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
1819
import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes';
1920
import { spanTimeInputToSeconds, spanToJSON, spanToTraceContext } from '../utils/spanUtils';
2021
import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
@@ -331,6 +332,7 @@ export class Transaction extends SpanClass implements TransactionInterface {
331332
capturedSpanIsolationScope,
332333
dynamicSamplingContext: getDynamicSamplingContextFromSpan(this),
333334
},
335+
_metrics_summary: getMetricSummaryJsonForSpan(this),
334336
...(source && {
335337
transaction_info: {
336338
source,

packages/types/src/event.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { Request } from './request';
1111
import type { CaptureContext } from './scope';
1212
import type { SdkInfo } from './sdkinfo';
1313
import type { Severity, SeverityLevel } from './severity';
14-
import type { Span, SpanJSON } from './span';
14+
import type { MetricSummary, Span, SpanJSON } from './span';
1515
import type { Thread } from './thread';
1616
import type { TransactionSource } from './transaction';
1717
import type { User } from './user';
@@ -73,6 +73,7 @@ export interface ErrorEvent extends Event {
7373
}
7474
export interface TransactionEvent extends Event {
7575
type: 'transaction';
76+
_metrics_summary?: Record<string, Array<MetricSummary>>;
7677
}
7778

7879
/** JSDoc */

0 commit comments

Comments
 (0)