Skip to content

Commit 4a476a2

Browse files
committed
feat(tracemetrics): Add trace metrics behind an experiments flag
This allows the js sdk to send in new trace metric protocol items, although this code is experimental since the schema may still change. Most of this has been copied from logs so some parts may need to be modified / removed later (eg. buffer) but this should allow us to start on UI work by sending in larger amounts of data from sentry js app to test grouping / aggregations etc.
1 parent 863c169 commit 4a476a2

File tree

20 files changed

+894
-3
lines changed

20 files changed

+894
-3
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
_enableTraceMetrics: true,
8+
release: '1.0.0',
9+
environment: 'test',
10+
autoSessionTracking: false, // Was causing session envelopes to be sent
11+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } });
2+
Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } });
3+
Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } });
4+
5+
Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {
6+
Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } });
7+
});
8+
9+
Sentry.setUser({ id: 'user-123', email: '[email protected]', username: 'testuser' });
10+
Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } });
11+
12+
Sentry.flush();
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { expect } from '@playwright/test';
2+
import type { MetricEnvelope } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers';
5+
6+
sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) => {
7+
const bundle = process.env.PW_BUNDLE || '';
8+
if (bundle.startsWith('bundle') || bundle.startsWith('loader')) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestUrl({ testDir: __dirname });
13+
14+
const event = await getFirstSentryEnvelopeRequest<MetricEnvelope>(page, url, properFullEnvelopeRequestParser);
15+
const envelopeItems = event[1];
16+
17+
expect(envelopeItems[0]).toEqual([
18+
{
19+
type: 'metric',
20+
item_count: 5,
21+
content_type: 'application/vnd.sentry.items.trace-metric+json',
22+
},
23+
{
24+
items: [
25+
{
26+
timestamp: expect.any(Number),
27+
trace_id: expect.any(String),
28+
name: 'test.counter',
29+
type: 'counter',
30+
value: 1,
31+
attributes: {
32+
endpoint: { value: '/api/test', type: 'string' },
33+
'sentry.release': { value: '1.0.0', type: 'string' },
34+
'sentry.environment': { value: 'test', type: 'string' },
35+
'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
36+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
37+
},
38+
},
39+
{
40+
timestamp: expect.any(Number),
41+
trace_id: expect.any(String),
42+
name: 'test.gauge',
43+
type: 'gauge',
44+
unit: 'millisecond',
45+
value: 42,
46+
attributes: {
47+
server: { value: 'test-1', type: 'string' },
48+
'sentry.release': { value: '1.0.0', type: 'string' },
49+
'sentry.environment': { value: 'test', type: 'string' },
50+
'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
51+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
52+
},
53+
},
54+
{
55+
timestamp: expect.any(Number),
56+
trace_id: expect.any(String),
57+
name: 'test.distribution',
58+
type: 'distribution',
59+
unit: 'second',
60+
value: 200,
61+
attributes: {
62+
priority: { value: 'high', type: 'string' },
63+
'sentry.release': { value: '1.0.0', type: 'string' },
64+
'sentry.environment': { value: 'test', type: 'string' },
65+
'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
66+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
67+
},
68+
},
69+
{
70+
timestamp: expect.any(Number),
71+
trace_id: expect.any(String),
72+
span_id: expect.any(String),
73+
name: 'test.span.counter',
74+
type: 'counter',
75+
value: 1,
76+
attributes: {
77+
operation: { value: 'test', type: 'string' },
78+
'sentry.release': { value: '1.0.0', type: 'string' },
79+
'sentry.environment': { value: 'test', type: 'string' },
80+
'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
81+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
82+
},
83+
},
84+
{
85+
timestamp: expect.any(Number),
86+
trace_id: expect.any(String),
87+
name: 'test.user.counter',
88+
type: 'counter',
89+
value: 1,
90+
attributes: {
91+
action: { value: 'click', type: 'string' },
92+
'user.id': { value: 'user-123', type: 'string' },
93+
'user.email': { value: '[email protected]', type: 'string' },
94+
'user.name': { value: 'testuser', type: 'string' },
95+
'sentry.release': { value: '1.0.0', type: 'string' },
96+
'sentry.environment': { value: 'test', type: 'string' },
97+
'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
98+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
99+
},
100+
},
101+
],
102+
},
103+
]);
104+
});

dev-packages/node-integration-tests/utils/assertions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
Event,
55
SerializedCheckIn,
66
SerializedLogContainer,
7+
SerializedMetricContainer,
78
SerializedSession,
89
SessionAggregates,
910
TransactionEvent,
@@ -76,6 +77,15 @@ export function assertSentryLogContainer(
7677
});
7778
}
7879

80+
export function assertSentryMetricContainer(
81+
actual: SerializedMetricContainer,
82+
expected: Partial<SerializedMetricContainer>,
83+
): void {
84+
expect(actual).toMatchObject({
85+
...expected,
86+
});
87+
}
88+
7989
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial<Envelope[0]>): void {
8090
expect(actual).toEqual({
8191
event_id: expect.any(String),

dev-packages/node-integration-tests/utils/runner.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
EventEnvelope,
88
SerializedCheckIn,
99
SerializedLogContainer,
10+
SerializedMetricContainer,
1011
SerializedSession,
1112
SessionAggregates,
1213
TransactionEvent,
@@ -25,6 +26,7 @@ import {
2526
assertSentryClientReport,
2627
assertSentryEvent,
2728
assertSentryLogContainer,
29+
assertSentryMetricContainer,
2830
assertSentrySession,
2931
assertSentrySessions,
3032
assertSentryTransaction,
@@ -130,6 +132,7 @@ type ExpectedSessions = Partial<SessionAggregates> | ((event: SessionAggregates)
130132
type ExpectedCheckIn = Partial<SerializedCheckIn> | ((event: SerializedCheckIn) => void);
131133
type ExpectedClientReport = Partial<ClientReport> | ((event: ClientReport) => void);
132134
type ExpectedLogContainer = Partial<SerializedLogContainer> | ((event: SerializedLogContainer) => void);
135+
type ExpectedMetricContainer = Partial<SerializedMetricContainer> | ((event: SerializedMetricContainer) => void);
133136

134137
type Expected =
135138
| {
@@ -152,6 +155,9 @@ type Expected =
152155
}
153156
| {
154157
log: ExpectedLogContainer;
158+
}
159+
| {
160+
metric: ExpectedMetricContainer;
155161
};
156162

157163
type ExpectedEnvelopeHeader =
@@ -380,6 +386,11 @@ export function createRunner(...paths: string[]) {
380386
expectedEnvelopeHeaders.push(expected);
381387
return this;
382388
},
389+
expectMetricEnvelope: function () {
390+
// Unignore metric envelopes
391+
ignored.delete('metric');
392+
return this;
393+
},
383394
withEnv: function (env: Record<string, string>) {
384395
withEnv = env;
385396
return this;
@@ -514,6 +525,9 @@ export function createRunner(...paths: string[]) {
514525
} else if ('log' in expected) {
515526
expectLog(item[1] as SerializedLogContainer, expected.log);
516527
expectCallbackCalled();
528+
} else if ('metric' in expected) {
529+
expectMetric(item[1] as SerializedMetricContainer, expected.metric);
530+
expectCallbackCalled();
517531
} else {
518532
throw new Error(
519533
`Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`,
@@ -769,6 +783,14 @@ function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer)
769783
}
770784
}
771785

786+
function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricContainer): void {
787+
if (typeof expected === 'function') {
788+
expected(item);
789+
} else {
790+
assertSentryMetricContainer(item, expected);
791+
}
792+
}
793+
772794
/**
773795
* Converts ESM import statements to CommonJS require statements
774796
* @param content The content of an ESM file

packages/browser/src/client.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from '@sentry/core';
1212
import {
1313
_INTERNAL_flushLogsBuffer,
14+
_INTERNAL_flushMetricsBuffer,
1415
addAutoIpAddressToSession,
1516
applySdkMetadata,
1617
Client,
@@ -85,6 +86,7 @@ export type BrowserClientOptions = ClientOptions<BrowserTransportOptions> & Brow
8586
*/
8687
export class BrowserClient extends Client<BrowserClientOptions> {
8788
private _logFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
89+
private _metricFlushIdleTimeout: ReturnType<typeof setTimeout> | undefined;
8890
/**
8991
* Creates a new Browser SDK instance.
9092
*
@@ -106,9 +108,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
106108

107109
super(opts);
108110

109-
const { sendDefaultPii, sendClientReports, enableLogs } = this._options;
111+
const { sendDefaultPii, sendClientReports, enableLogs, _enableTraceMetrics } = this._options;
110112

111-
if (WINDOW.document && (sendClientReports || enableLogs)) {
113+
if (WINDOW.document && (sendClientReports || enableLogs || _enableTraceMetrics)) {
112114
WINDOW.document.addEventListener('visibilitychange', () => {
113115
if (WINDOW.document.visibilityState === 'hidden') {
114116
if (sendClientReports) {
@@ -117,6 +119,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
117119
if (enableLogs) {
118120
_INTERNAL_flushLogsBuffer(this);
119121
}
122+
if (_enableTraceMetrics) {
123+
_INTERNAL_flushMetricsBuffer(this);
124+
}
120125
}
121126
});
122127
}
@@ -137,6 +142,22 @@ export class BrowserClient extends Client<BrowserClientOptions> {
137142
});
138143
}
139144

145+
if (_enableTraceMetrics) {
146+
this.on('flush', () => {
147+
_INTERNAL_flushMetricsBuffer(this);
148+
});
149+
150+
this.on('afterCaptureMetric', () => {
151+
if (this._metricFlushIdleTimeout) {
152+
clearTimeout(this._metricFlushIdleTimeout);
153+
}
154+
155+
this._metricFlushIdleTimeout = setTimeout(() => {
156+
_INTERNAL_flushMetricsBuffer(this);
157+
}, DEFAULT_FLUSH_INTERVAL);
158+
});
159+
}
160+
140161
if (sendDefaultPii) {
141162
this.on('beforeSendSession', addAutoIpAddressToSession);
142163
}

packages/browser/src/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export {
6565
spanToTraceHeader,
6666
spanToBaggageHeader,
6767
updateSpanName,
68+
metrics,
6869
} from '@sentry/core';
6970

7071
export {

packages/core/src/carrier.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AsyncContextStrategy } from './asyncContext/types';
33
import type { Client } from './client';
44
import type { Scope } from './scope';
55
import type { SerializedLog } from './types-hoist/log';
6+
import type { SerializedMetric } from './types-hoist/metric';
67
import { SDK_VERSION } from './utils/version';
78
import { GLOBAL_OBJ } from './utils/worldwide';
89

@@ -32,6 +33,12 @@ export interface SentryCarrier {
3233
*/
3334
clientToLogBufferMap?: WeakMap<Client, Array<SerializedLog>>;
3435

36+
/**
37+
* A map of Sentry clients to their metric buffers.
38+
* This is used to store metrics that are sent to Sentry.
39+
*/
40+
clientToMetricBufferMap?: WeakMap<Client, Array<SerializedMetric>>;
41+
3542
/** Overwrites TextEncoder used in `@sentry/core`, need for `[email protected]` and older */
3643
encodePolyfill?: (input: string) => Uint8Array;
3744
/** Overwrites TextDecoder used in `@sentry/core`, need for `[email protected]` and older */

packages/core/src/client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { EventProcessor } from './types-hoist/eventprocessor';
2424
import type { FeedbackEvent } from './types-hoist/feedback';
2525
import type { Integration } from './types-hoist/integration';
2626
import type { Log } from './types-hoist/log';
27+
import type { Metric } from './types-hoist/metric';
2728
import type { ClientOptions } from './types-hoist/options';
2829
import type { ParameterizedString } from './types-hoist/parameterize';
2930
import type { SdkMetadata } from './types-hoist/sdkmetadata';
@@ -687,6 +688,20 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
687688
*/
688689
public on(hook: 'flushLogs', callback: () => void): () => void;
689690

691+
/**
692+
* A hook that is called after capturing a metric. This hooks runs after `beforeSendMetric` is fired.
693+
*
694+
* @returns {() => void} A function that, when executed, removes the registered callback.
695+
*/
696+
public on(hook: 'afterCaptureMetric', callback: (metric: Metric) => void): () => void;
697+
698+
/**
699+
* A hook that is called when the client is flushing metrics
700+
*
701+
* @returns {() => void} A function that, when executed, removes the registered callback.
702+
*/
703+
public on(hook: 'flushMetrics', callback: () => void): () => void;
704+
690705
/**
691706
* Register a hook on this client.
692707
*/
@@ -875,6 +890,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
875890
*/
876891
public emit(hook: 'flushLogs'): void;
877892

893+
/**
894+
* Emit a hook event for client after capturing a metric.
895+
*/
896+
public emit(hook: 'afterCaptureMetric', metric: Metric): void;
897+
898+
/**
899+
* Emit a hook event for client flush metrics
900+
*/
901+
public emit(hook: 'flushMetrics'): void;
902+
878903
/**
879904
* Emit a hook that was previously registered via `on()`.
880905
*/

packages/core/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export type { ReportDialogOptions } from './report-dialog';
126126
export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/internal';
127127
export * as logger from './logs/public-api';
128128
export { consoleLoggingIntegration } from './logs/console-integration';
129+
export {
130+
_INTERNAL_captureMetric,
131+
_INTERNAL_flushMetricsBuffer,
132+
_INTERNAL_captureSerializedMetric,
133+
} from './metrics/internal';
134+
export * as metrics from './metrics/public-api';
135+
export type { MetricOptions } from './metrics/public-api';
129136
export { createConsolaReporter } from './integrations/consola';
130137
export { addVercelAiProcessors } from './utils/vercel-ai';
131138
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils';
@@ -355,6 +362,7 @@ export type {
355362
SpanEnvelope,
356363
SpanItem,
357364
LogEnvelope,
365+
MetricEnvelope,
358366
} from './types-hoist/envelope';
359367
export type { ExtendedError } from './types-hoist/error';
360368
export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent } from './types-hoist/event';
@@ -416,6 +424,13 @@ export type {
416424
} from './types-hoist/span';
417425
export type { SpanStatus } from './types-hoist/spanStatus';
418426
export type { Log, LogSeverityLevel } from './types-hoist/log';
427+
export type {
428+
Metric,
429+
MetricType,
430+
SerializedMetric,
431+
SerializedMetricContainer,
432+
SerializedMetricAttributeValue,
433+
} from './types-hoist/metric';
419434
export type { TimedEvent } from './types-hoist/timedEvent';
420435
export type { StackFrame } from './types-hoist/stackframe';
421436
export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './types-hoist/stacktrace';

0 commit comments

Comments
 (0)