diff --git a/.cursor/rules/publishing_release.mdc b/.cursor/rules/publishing_release.mdc
index 01571f684581..4d6fecca5d2a 100644
--- a/.cursor/rules/publishing_release.mdc
+++ b/.cursor/rules/publishing_release.mdc
@@ -14,7 +14,7 @@ The release process is outlined in [publishing-a-release.md](mdc:docs/publishing
1. Make sure you are on the latest version of the `develop` branch. To confirm this, run `git pull origin develop` to get the latest changes from the repo.
2. Run `yarn changelog` on the `develop` branch and copy the output. You can use `yarn changelog | pbcopy` to copy the output of `yarn changelog` into your clipboard.
-3. Decide on a version for the release based on [semver](mdc:https://semver.org). The version should be decided based on what is in included in the release. For example, if the release includes a new feature, we should increment the minor version. If it includes only bug fixes, we should increment the patch version.
+3. Decide on a version for the release based on [semver](mdc:https://semver.org). The version should be decided based on what is in included in the release. For example, if the release includes a new feature, we should increment the minor version. If it includes only bug fixes, we should increment the patch version. You can find the latest version in [CHANGELOG.md](mdc:CHANGELOG.md) at the very top.
4. Create a branch `prepare-release/VERSION`, eg. `prepare-release/8.1.0`, off `develop`.
5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. If you remove changelog entries because they are not applicable, please let the user know.
6. Commit the changes to [CHANGELOG.md](mdc:CHANGELOG.md) with `meta(changelog): Update changelog for VERSION` where `VERSION` is the version of the release, e.g. `meta(changelog): Update changelog for 8.1.0`
diff --git a/.size-limit.js b/.size-limit.js
index 32d5d19e1495..59ad29c3ccf8 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -96,7 +96,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'sendFeedback'),
gzip: true,
- limit: '29 KB',
+ limit: '30 KB',
},
{
name: '@sentry/browser (incl. FeedbackAsync)',
@@ -150,13 +150,13 @@ module.exports = [
name: 'CDN Bundle',
path: createCDNPath('bundle.min.js'),
gzip: true,
- limit: '26 KB',
+ limit: '27 KB',
},
{
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
- limit: '41 KB',
+ limit: '42 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
@@ -183,7 +183,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
- limit: '120 KB',
+ limit: '123 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ae0eba65cec..b6d065c6c800 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 10.19.0
+
+- feat(tracemetrics): Add trace metrics behind an experiments flag ([#17883](https://github.com/getsentry/sentry-javascript/pull/17883))
+
+
+ Internal Changes
+
+- chore: add info latest release for the cursor release command ([#17876](https://github.com/getsentry/sentry-javascript/pull/17876))
+
+
+
## 10.18.0
### Important Changes
diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js
new file mode 100644
index 000000000000..df4fda70e4c7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ _experiments: {
+ enableMetrics: true,
+ },
+ release: '1.0.0',
+ environment: 'test',
+ integrations: integrations => {
+ return integrations.filter(integration => integration.name !== 'BrowserSession');
+ },
+});
diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js
new file mode 100644
index 000000000000..0b8fced8d6e3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js
@@ -0,0 +1,12 @@
+Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } });
+Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } });
+Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } });
+
+Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {
+ Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } });
+});
+
+Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' });
+Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } });
+
+Sentry.flush();
diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts
new file mode 100644
index 000000000000..3a8ac97f8408
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts
@@ -0,0 +1,104 @@
+import { expect } from '@playwright/test';
+import type { MetricEnvelope } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers';
+
+sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) => {
+ const bundle = process.env.PW_BUNDLE || '';
+ if (bundle.startsWith('bundle') || bundle.startsWith('loader')) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser);
+ const envelopeItems = event[1];
+
+ expect(envelopeItems[0]).toEqual([
+ {
+ type: 'trace_metric',
+ item_count: 5,
+ content_type: 'application/vnd.sentry.items.trace-metric+json',
+ },
+ {
+ items: [
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ endpoint: { value: '/api/test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.gauge',
+ type: 'gauge',
+ unit: 'millisecond',
+ value: 42,
+ attributes: {
+ server: { value: 'test-1', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.distribution',
+ type: 'distribution',
+ unit: 'second',
+ value: 200,
+ attributes: {
+ priority: { value: 'high', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ span_id: expect.any(String),
+ name: 'test.span.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ operation: { value: 'test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.user.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ action: { value: 'click', type: 'string' },
+ 'user.id': { value: 'user-123', type: 'string' },
+ 'user.email': { value: 'test@example.com', type: 'string' },
+ 'user.name': { value: 'testuser', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ ],
+ },
+ ]);
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts
index 17c6f714c499..ee4b7ac35421 100644
--- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts
+++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts
@@ -41,6 +41,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Astro
'setupFastifyErrorHandler',
+ // Todo(metrics): Add metrics exports for beta
+ 'metrics',
],
},
{
@@ -54,6 +56,8 @@ const DEPENDENTS: Dependent[] = [
'childProcessIntegration',
'systemErrorIntegration',
'pinoIntegration',
+ // Todo(metrics): Add metrics exports for beta
+ 'metrics',
],
},
{
@@ -75,6 +79,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Serverless
'setupFastifyErrorHandler',
+ // Todo(metrics): Add metrics exports for beta
+ 'metrics',
],
},
{
@@ -84,6 +90,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Serverless
'setupFastifyErrorHandler',
+ // Todo(metrics): Add metrics exports for beta
+ 'metrics',
],
},
{
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts
new file mode 100644
index 000000000000..9ab9fed7d22b
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts
@@ -0,0 +1,35 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+import { setupOtel } from '../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0.0',
+ environment: 'test',
+ _experiments: {
+ enableMetrics: true,
+ },
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+async function run(): Promise {
+ Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } });
+
+ Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } });
+
+ Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } });
+
+ await Sentry.startSpan({ name: 'test-span', op: 'test' }, async () => {
+ Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } });
+ });
+
+ Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' });
+ Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } });
+
+ await Sentry.flush();
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+void run();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts
new file mode 100644
index 000000000000..c89c8fb59e55
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts
@@ -0,0 +1,97 @@
+import { afterAll, describe, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
+
+describe('metrics', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ test('should capture all metric types', async () => {
+ const runner = createRunner(__dirname, 'scenario.ts')
+ .unignore('trace_metric')
+ .expect({
+ trace_metric: {
+ items: [
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ endpoint: { value: '/api/test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.gauge',
+ type: 'gauge',
+ unit: 'millisecond',
+ value: 42,
+ attributes: {
+ server: { value: 'test-1', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.distribution',
+ type: 'distribution',
+ unit: 'second',
+ value: 200,
+ attributes: {
+ priority: { value: 'high', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.span.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ operation: { value: 'test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.user.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ action: { value: 'click', type: 'string' },
+ 'user.id': { value: 'user-123', type: 'string' },
+ 'user.email': { value: 'test@example.com', type: 'string' },
+ 'user.name': { value: 'testuser', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node-core', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ ],
+ },
+ })
+ .start();
+
+ await runner.completed();
+ });
+});
diff --git a/dev-packages/node-core-integration-tests/utils/assertions.ts b/dev-packages/node-core-integration-tests/utils/assertions.ts
index 296bdc608bb4..8d9fb5f2251f 100644
--- a/dev-packages/node-core-integration-tests/utils/assertions.ts
+++ b/dev-packages/node-core-integration-tests/utils/assertions.ts
@@ -4,6 +4,7 @@ import type {
Event,
SerializedCheckIn,
SerializedLogContainer,
+ SerializedMetricContainer,
SerializedSession,
SessionAggregates,
TransactionEvent,
@@ -76,6 +77,15 @@ export function assertSentryLogContainer(
});
}
+export function assertSentryMetricContainer(
+ actual: SerializedMetricContainer,
+ expected: Partial,
+): void {
+ expect(actual).toMatchObject({
+ ...expected,
+ });
+}
+
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
expect(actual).toEqual({
event_id: expect.any(String),
diff --git a/dev-packages/node-core-integration-tests/utils/runner.ts b/dev-packages/node-core-integration-tests/utils/runner.ts
index da6184dcbb42..22a600efa63b 100644
--- a/dev-packages/node-core-integration-tests/utils/runner.ts
+++ b/dev-packages/node-core-integration-tests/utils/runner.ts
@@ -7,6 +7,7 @@ import type {
EventEnvelope,
SerializedCheckIn,
SerializedLogContainer,
+ SerializedMetricContainer,
SerializedSession,
SessionAggregates,
TransactionEvent,
@@ -22,6 +23,7 @@ import {
assertSentryClientReport,
assertSentryEvent,
assertSentryLogContainer,
+ assertSentryMetricContainer,
assertSentrySession,
assertSentrySessions,
assertSentryTransaction,
@@ -122,6 +124,7 @@ type ExpectedSessions = Partial | ((event: SessionAggregates)
type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void);
type ExpectedClientReport = Partial | ((event: ClientReport) => void);
type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void);
+type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void);
type Expected =
| {
@@ -144,6 +147,9 @@ type Expected =
}
| {
log: ExpectedLogContainer;
+ }
+ | {
+ trace_metric: ExpectedMetricContainer;
};
type ExpectedEnvelopeHeader =
@@ -403,6 +409,9 @@ export function createRunner(...paths: string[]) {
} else if ('log' in expected) {
expectLog(item[1] as SerializedLogContainer, expected.log);
expectCallbackCalled();
+ } else if ('trace_metric' in expected) {
+ expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric);
+ expectCallbackCalled();
} else {
throw new Error(
`Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`,
@@ -649,6 +658,14 @@ function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer)
}
}
+function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricContainer): void {
+ if (typeof expected === 'function') {
+ expected(item);
+ } else {
+ assertSentryMetricContainer(item, expected);
+ }
+}
+
/**
* Converts ESM import statements to CommonJS require statements
* @param content The content of an ESM file
diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts
new file mode 100644
index 000000000000..9c776eb14d59
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts
@@ -0,0 +1,32 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0.0',
+ environment: 'test',
+ _experiments: {
+ enableMetrics: true,
+ },
+ transport: loggingTransport,
+});
+
+async function run(): Promise {
+ Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } });
+
+ Sentry.metrics.gauge('test.gauge', 42, { unit: 'millisecond', attributes: { server: 'test-1' } });
+
+ Sentry.metrics.distribution('test.distribution', 200, { unit: 'second', attributes: { priority: 'high' } });
+
+ await Sentry.startSpan({ name: 'test-span', op: 'test' }, async () => {
+ Sentry.metrics.count('test.span.counter', 1, { attributes: { operation: 'test' } });
+ });
+
+ Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' });
+ Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } });
+
+ await Sentry.flush();
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts
new file mode 100644
index 000000000000..471fe114fa1e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts
@@ -0,0 +1,96 @@
+import { afterAll, describe, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
+
+describe('metrics', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ test('should capture all metric types', async () => {
+ const runner = createRunner(__dirname, 'scenario.ts')
+ .expect({
+ trace_metric: {
+ items: [
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ endpoint: { value: '/api/test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.gauge',
+ type: 'gauge',
+ unit: 'millisecond',
+ value: 42,
+ attributes: {
+ server: { value: 'test-1', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.distribution',
+ type: 'distribution',
+ unit: 'second',
+ value: 200,
+ attributes: {
+ priority: { value: 'high', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.span.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ operation: { value: 'test', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ {
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ name: 'test.user.counter',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ action: { value: 'click', type: 'string' },
+ 'user.id': { value: 'user-123', type: 'string' },
+ 'user.email': { value: 'test@example.com', type: 'string' },
+ 'user.name': { value: 'testuser', type: 'string' },
+ 'sentry.release': { value: '1.0.0', type: 'string' },
+ 'sentry.environment': { value: 'test', type: 'string' },
+ 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
+ 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
+ },
+ },
+ ],
+ },
+ })
+ .start();
+
+ await runner.completed();
+ });
+});
diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts
index 296bdc608bb4..8d9fb5f2251f 100644
--- a/dev-packages/node-integration-tests/utils/assertions.ts
+++ b/dev-packages/node-integration-tests/utils/assertions.ts
@@ -4,6 +4,7 @@ import type {
Event,
SerializedCheckIn,
SerializedLogContainer,
+ SerializedMetricContainer,
SerializedSession,
SessionAggregates,
TransactionEvent,
@@ -76,6 +77,15 @@ export function assertSentryLogContainer(
});
}
+export function assertSentryMetricContainer(
+ actual: SerializedMetricContainer,
+ expected: Partial,
+): void {
+ expect(actual).toMatchObject({
+ ...expected,
+ });
+}
+
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
expect(actual).toEqual({
event_id: expect.any(String),
diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts
index b0c6467fd75a..ac15a944ee0b 100644
--- a/dev-packages/node-integration-tests/utils/runner.ts
+++ b/dev-packages/node-integration-tests/utils/runner.ts
@@ -7,6 +7,7 @@ import type {
EventEnvelope,
SerializedCheckIn,
SerializedLogContainer,
+ SerializedMetricContainer,
SerializedSession,
SessionAggregates,
TransactionEvent,
@@ -25,6 +26,7 @@ import {
assertSentryClientReport,
assertSentryEvent,
assertSentryLogContainer,
+ assertSentryMetricContainer,
assertSentrySession,
assertSentrySessions,
assertSentryTransaction,
@@ -130,6 +132,7 @@ type ExpectedSessions = Partial | ((event: SessionAggregates)
type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void);
type ExpectedClientReport = Partial | ((event: ClientReport) => void);
type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void);
+type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void);
type Expected =
| {
@@ -152,6 +155,9 @@ type Expected =
}
| {
log: ExpectedLogContainer;
+ }
+ | {
+ trace_metric: ExpectedMetricContainer;
};
type ExpectedEnvelopeHeader =
@@ -380,6 +386,11 @@ export function createRunner(...paths: string[]) {
expectedEnvelopeHeaders.push(expected);
return this;
},
+ expectMetricEnvelope: function () {
+ // Unignore metric envelopes
+ ignored.delete('metric');
+ return this;
+ },
withEnv: function (env: Record) {
withEnv = env;
return this;
@@ -514,6 +525,9 @@ export function createRunner(...paths: string[]) {
} else if ('log' in expected) {
expectLog(item[1] as SerializedLogContainer, expected.log);
expectCallbackCalled();
+ } else if ('trace_metric' in expected) {
+ expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric);
+ expectCallbackCalled();
} else {
throw new Error(
`Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`,
@@ -769,6 +783,14 @@ function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer)
}
}
+function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricContainer): void {
+ if (typeof expected === 'function') {
+ expected(item);
+ } else {
+ assertSentryMetricContainer(item, expected);
+ }
+}
+
/**
* Converts ESM import statements to CommonJS require statements
* @param content The content of an ESM file
diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts
index b4e4f24d3b90..af7a1d6ee2ec 100644
--- a/packages/browser/src/client.ts
+++ b/packages/browser/src/client.ts
@@ -11,6 +11,7 @@ import type {
} from '@sentry/core';
import {
_INTERNAL_flushLogsBuffer,
+ _INTERNAL_flushMetricsBuffer,
addAutoIpAddressToSession,
applySdkMetadata,
Client,
@@ -85,6 +86,7 @@ export type BrowserClientOptions = ClientOptions & Brow
*/
export class BrowserClient extends Client {
private _logFlushIdleTimeout: ReturnType | undefined;
+ private _metricFlushIdleTimeout: ReturnType | undefined;
/**
* Creates a new Browser SDK instance.
*
@@ -106,9 +108,9 @@ export class BrowserClient extends Client {
super(opts);
- const { sendDefaultPii, sendClientReports, enableLogs } = this._options;
+ const { sendDefaultPii, sendClientReports, enableLogs, _experiments } = this._options;
- if (WINDOW.document && (sendClientReports || enableLogs)) {
+ if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableMetrics)) {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
if (sendClientReports) {
@@ -117,6 +119,9 @@ export class BrowserClient extends Client {
if (enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
+ if (_experiments?.enableMetrics) {
+ _INTERNAL_flushMetricsBuffer(this);
+ }
}
});
}
@@ -137,6 +142,22 @@ export class BrowserClient extends Client {
});
}
+ if (_experiments?.enableMetrics) {
+ this.on('flush', () => {
+ _INTERNAL_flushMetricsBuffer(this);
+ });
+
+ this.on('afterCaptureMetric', () => {
+ if (this._metricFlushIdleTimeout) {
+ clearTimeout(this._metricFlushIdleTimeout);
+ }
+
+ this._metricFlushIdleTimeout = setTimeout(() => {
+ _INTERNAL_flushMetricsBuffer(this);
+ }, DEFAULT_FLUSH_INTERVAL);
+ });
+ }
+
if (sendDefaultPii) {
this.on('beforeSendSession', addAutoIpAddressToSession);
}
diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts
index 2a45880de82b..50223e4b9fd9 100644
--- a/packages/browser/src/exports.ts
+++ b/packages/browser/src/exports.ts
@@ -65,6 +65,7 @@ export {
spanToTraceHeader,
spanToBaggageHeader,
updateSpanName,
+ metrics,
} from '@sentry/core';
export {
diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts
index 201e79cb4514..992c30681924 100644
--- a/packages/core/src/carrier.ts
+++ b/packages/core/src/carrier.ts
@@ -3,6 +3,7 @@ import type { AsyncContextStrategy } from './asyncContext/types';
import type { Client } from './client';
import type { Scope } from './scope';
import type { SerializedLog } from './types-hoist/log';
+import type { SerializedMetric } from './types-hoist/metric';
import { SDK_VERSION } from './utils/version';
import { GLOBAL_OBJ } from './utils/worldwide';
@@ -32,6 +33,12 @@ export interface SentryCarrier {
*/
clientToLogBufferMap?: WeakMap>;
+ /**
+ * A map of Sentry clients to their metric buffers.
+ * This is used to store metrics that are sent to Sentry.
+ */
+ clientToMetricBufferMap?: WeakMap>;
+
/** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */
encodePolyfill?: (input: string) => Uint8Array;
/** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 365b4f42d078..de6c5f9f1119 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -24,6 +24,7 @@ import type { EventProcessor } from './types-hoist/eventprocessor';
import type { FeedbackEvent } from './types-hoist/feedback';
import type { Integration } from './types-hoist/integration';
import type { Log } from './types-hoist/log';
+import type { Metric } from './types-hoist/metric';
import type { ClientOptions } from './types-hoist/options';
import type { ParameterizedString } from './types-hoist/parameterize';
import type { RequestEventData } from './types-hoist/request';
@@ -688,6 +689,20 @@ export abstract class Client {
*/
public on(hook: 'flushLogs', callback: () => void): () => void;
+ /**
+ * A hook that is called after capturing a metric. This hooks runs after `beforeSendMetric` is fired.
+ *
+ * @returns {() => void} A function that, when executed, removes the registered callback.
+ */
+ public on(hook: 'afterCaptureMetric', callback: (metric: Metric) => void): () => void;
+
+ /**
+ * A hook that is called when the client is flushing metrics
+ *
+ * @returns {() => void} A function that, when executed, removes the registered callback.
+ */
+ public on(hook: 'flushMetrics', callback: () => void): () => void;
+
/**
* A hook that is called when a http server request is started.
* This hook is called after request isolation, but before the request is processed.
@@ -887,6 +902,16 @@ export abstract class Client {
*/
public emit(hook: 'flushLogs'): void;
+ /**
+ * Emit a hook event for client after capturing a metric.
+ */
+ public emit(hook: 'afterCaptureMetric', metric: Metric): void;
+
+ /**
+ * Emit a hook event for client flush metrics
+ */
+ public emit(hook: 'flushMetrics'): void;
+
/**
* Emit a hook event for client when a http server request is started.
* This hook is called after request isolation, but before the request is processed.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index e0daefd54d76..06be19c86774 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -126,6 +126,13 @@ export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/internal';
export * as logger from './logs/public-api';
export { consoleLoggingIntegration } from './logs/console-integration';
+export {
+ _INTERNAL_captureMetric,
+ _INTERNAL_flushMetricsBuffer,
+ _INTERNAL_captureSerializedMetric,
+} from './metrics/internal';
+export * as metrics from './metrics/public-api';
+export type { MetricOptions } from './metrics/public-api';
export { createConsolaReporter } from './integrations/consola';
export { addVercelAiProcessors } from './utils/vercel-ai';
export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils';
@@ -355,6 +362,7 @@ export type {
SpanEnvelope,
SpanItem,
LogEnvelope,
+ MetricEnvelope,
} from './types-hoist/envelope';
export type { ExtendedError } from './types-hoist/error';
export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent } from './types-hoist/event';
@@ -416,6 +424,13 @@ export type {
} from './types-hoist/span';
export type { SpanStatus } from './types-hoist/spanStatus';
export type { Log, LogSeverityLevel } from './types-hoist/log';
+export type {
+ Metric,
+ MetricType,
+ SerializedMetric,
+ SerializedMetricContainer,
+ SerializedMetricAttributeValue,
+} from './types-hoist/metric';
export type { TimedEvent } from './types-hoist/timedEvent';
export type { StackFrame } from './types-hoist/stackframe';
export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './types-hoist/stacktrace';
diff --git a/packages/core/src/metrics/envelope.ts b/packages/core/src/metrics/envelope.ts
new file mode 100644
index 000000000000..71ef0832667b
--- /dev/null
+++ b/packages/core/src/metrics/envelope.ts
@@ -0,0 +1,58 @@
+import type { DsnComponents } from '../types-hoist/dsn';
+import type { MetricContainerItem, MetricEnvelope } from '../types-hoist/envelope';
+import type { SerializedMetric } from '../types-hoist/metric';
+import type { SdkMetadata } from '../types-hoist/sdkmetadata';
+import { dsnToString } from '../utils/dsn';
+import { createEnvelope } from '../utils/envelope';
+
+/**
+ * Creates a metric container envelope item for a list of metrics.
+ *
+ * @param items - The metrics to include in the envelope.
+ * @returns The created metric container envelope item.
+ */
+export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem {
+ return [
+ {
+ type: 'trace_metric',
+ item_count: items.length,
+ content_type: 'application/vnd.sentry.items.trace-metric+json',
+ } as MetricContainerItem[0],
+ {
+ items,
+ },
+ ];
+}
+
+/**
+ * Creates an envelope for a list of metrics.
+ *
+ * Metrics from multiple traces can be included in the same envelope.
+ *
+ * @param metrics - The metrics to include in the envelope.
+ * @param metadata - The metadata to include in the envelope.
+ * @param tunnel - The tunnel to include in the envelope.
+ * @param dsn - The DSN to include in the envelope.
+ * @returns The created envelope.
+ */
+export function createMetricEnvelope(
+ metrics: Array,
+ metadata?: SdkMetadata,
+ tunnel?: string,
+ dsn?: DsnComponents,
+): MetricEnvelope {
+ const headers: MetricEnvelope[0] = {};
+
+ if (metadata?.sdk) {
+ headers.sdk = {
+ name: metadata.sdk.name,
+ version: metadata.sdk.version,
+ };
+ }
+
+ if (!!tunnel && !!dsn) {
+ headers.dsn = dsnToString(dsn);
+ }
+
+ return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics)]);
+}
diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts
new file mode 100644
index 000000000000..0f16d98b790e
--- /dev/null
+++ b/packages/core/src/metrics/internal.ts
@@ -0,0 +1,280 @@
+import { getGlobalSingleton } from '../carrier';
+import type { Client } from '../client';
+import { _getTraceInfoFromScope } from '../client';
+import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes';
+import { DEBUG_BUILD } from '../debug-build';
+import type { Scope, ScopeData } from '../scope';
+import type { Integration } from '../types-hoist/integration';
+import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric';
+import { mergeScopeData } from '../utils/applyScopeDataToEvent';
+import { consoleSandbox, debug } from '../utils/debug-logger';
+import { _getSpanForScope } from '../utils/spanOnScope';
+import { timestampInSeconds } from '../utils/time';
+import { createMetricEnvelope } from './envelope';
+
+const MAX_METRIC_BUFFER_SIZE = 100;
+
+/**
+ * Converts a metric attribute to a serialized metric attribute.
+ *
+ * @param value - The value of the metric attribute.
+ * @returns The serialized metric attribute.
+ */
+export function metricAttributeToSerializedMetricAttribute(value: unknown): SerializedMetricAttributeValue {
+ switch (typeof value) {
+ case 'number':
+ if (Number.isInteger(value)) {
+ return {
+ value,
+ type: 'integer',
+ };
+ }
+ return {
+ value,
+ type: 'double',
+ };
+ case 'boolean':
+ return {
+ value,
+ type: 'boolean',
+ };
+ case 'string':
+ return {
+ value,
+ type: 'string',
+ };
+ default: {
+ let stringValue = '';
+ try {
+ stringValue = JSON.stringify(value) ?? '';
+ } catch {
+ // Do nothing
+ }
+ return {
+ value: stringValue,
+ type: 'string',
+ };
+ }
+ }
+}
+
+/**
+ * Sets a metric attribute if the value exists and the attribute key is not already present.
+ *
+ * @param metricAttributes - The metric attributes object to modify.
+ * @param key - The attribute key to set.
+ * @param value - The value to set (only sets if truthy and key not present).
+ * @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true.
+ */
+function setMetricAttribute(
+ metricAttributes: Record,
+ key: string,
+ value: unknown,
+ setEvenIfPresent = true,
+): void {
+ if (value && (setEvenIfPresent || !(key in metricAttributes))) {
+ metricAttributes[key] = value;
+ }
+}
+
+/**
+ * Captures a serialized metric event and adds it to the metric buffer for the given client.
+ *
+ * @param client - A client. Uses the current client if not provided.
+ * @param serializedMetric - The serialized metric event to capture.
+ *
+ * @experimental This method will experience breaking changes. This is not yet part of
+ * the stable Sentry SDK API and can be changed or removed without warning.
+ */
+export function _INTERNAL_captureSerializedMetric(client: Client, serializedMetric: SerializedMetric): void {
+ const bufferMap = _getBufferMap();
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ if (metricBuffer === undefined) {
+ bufferMap.set(client, [serializedMetric]);
+ } else {
+ bufferMap.set(client, [...metricBuffer, serializedMetric]);
+ if (metricBuffer.length >= MAX_METRIC_BUFFER_SIZE) {
+ _INTERNAL_flushMetricsBuffer(client, metricBuffer);
+ }
+ }
+}
+
+/**
+ * Options for capturing a metric internally.
+ */
+export interface InternalCaptureMetricOptions {
+ /**
+ * The scope to capture the metric with.
+ */
+ scope?: Scope;
+
+ /**
+ * A function to capture the serialized metric.
+ */
+ captureSerializedMetric?: (client: Client, metric: SerializedMetric) => void;
+}
+
+/**
+ * Captures a metric event and sends it to Sentry.
+ *
+ * @param metric - The metric event to capture.
+ * @param options - Options for capturing the metric.
+ *
+ * @experimental This method will experience breaking changes. This is not yet part of
+ * the stable Sentry SDK API and can be changed or removed without warning.
+ */
+export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: InternalCaptureMetricOptions): void {
+ const currentScope = options?.scope ?? getCurrentScope();
+ const captureSerializedMetric = options?.captureSerializedMetric ?? _INTERNAL_captureSerializedMetric;
+ const client = currentScope?.getClient() ?? getClient();
+ if (!client) {
+ DEBUG_BUILD && debug.warn('No client available to capture metric.');
+ return;
+ }
+
+ const { release, environment, _experiments } = client.getOptions();
+ if (!_experiments?.enableMetrics) {
+ DEBUG_BUILD && debug.warn('metrics option not enabled, metric will not be captured.');
+ return;
+ }
+
+ const [, traceContext] = _getTraceInfoFromScope(client, currentScope);
+
+ const processedMetricAttributes = {
+ ...beforeMetric.attributes,
+ };
+
+ const {
+ user: { id, email, username },
+ } = getMergedScopeData(currentScope);
+ setMetricAttribute(processedMetricAttributes, 'user.id', id, false);
+ setMetricAttribute(processedMetricAttributes, 'user.email', email, false);
+ setMetricAttribute(processedMetricAttributes, 'user.name', username, false);
+
+ setMetricAttribute(processedMetricAttributes, 'sentry.release', release);
+ setMetricAttribute(processedMetricAttributes, 'sentry.environment', environment);
+
+ const { name, version } = client.getSdkMetadata()?.sdk ?? {};
+ setMetricAttribute(processedMetricAttributes, 'sentry.sdk.name', name);
+ setMetricAttribute(processedMetricAttributes, 'sentry.sdk.version', version);
+
+ const replay = client.getIntegrationByName<
+ Integration & {
+ getReplayId: (onlyIfSampled?: boolean) => string;
+ getRecordingMode: () => 'session' | 'buffer' | undefined;
+ }
+ >('Replay');
+
+ const replayId = replay?.getReplayId(true);
+
+ setMetricAttribute(processedMetricAttributes, 'sentry.replay_id', replayId);
+
+ if (replayId && replay?.getRecordingMode() === 'buffer') {
+ // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry
+ setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', replayId);
+ }
+
+ const metric: Metric = {
+ ...beforeMetric,
+ attributes: processedMetricAttributes,
+ };
+
+ // Run beforeSendMetric callback
+ const processedMetric = _experiments?.beforeSendMetric ? _experiments.beforeSendMetric(metric) : metric;
+
+ if (!processedMetric) {
+ DEBUG_BUILD && debug.log('`beforeSendMetric` returned `null`, will not send metric.');
+ return;
+ }
+
+ const serializedAttributes: Record = {};
+ for (const key in processedMetric.attributes) {
+ if (processedMetric.attributes[key] !== undefined) {
+ serializedAttributes[key] = metricAttributeToSerializedMetricAttribute(processedMetric.attributes[key]);
+ }
+ }
+
+ const span = _getSpanForScope(currentScope);
+ const traceId = span ? span.spanContext().traceId : traceContext?.trace_id;
+ const spanId = span ? span.spanContext().spanId : undefined;
+
+ const serializedMetric: SerializedMetric = {
+ timestamp: timestampInSeconds(),
+ trace_id: traceId,
+ span_id: spanId,
+ name: processedMetric.name,
+ type: processedMetric.type,
+ unit: processedMetric.unit,
+ value: processedMetric.value,
+ attributes: serializedAttributes,
+ };
+
+ consoleSandbox(() => {
+ // eslint-disable-next-line no-console
+ DEBUG_BUILD && console.log('[Metric]', serializedMetric);
+ });
+
+ captureSerializedMetric(client, serializedMetric);
+
+ client.emit('afterCaptureMetric', metric);
+}
+
+/**
+ * Flushes the metrics buffer to Sentry.
+ *
+ * @param client - A client.
+ * @param maybeMetricBuffer - A metric buffer. Uses the metric buffer for the given client if not provided.
+ *
+ * @experimental This method will experience breaking changes. This is not yet part of
+ * the stable Sentry SDK API and can be changed or removed without warning.
+ */
+export function _INTERNAL_flushMetricsBuffer(client: Client, maybeMetricBuffer?: Array): void {
+ const metricBuffer = maybeMetricBuffer ?? _INTERNAL_getMetricBuffer(client) ?? [];
+ if (metricBuffer.length === 0) {
+ return;
+ }
+
+ const clientOptions = client.getOptions();
+ const envelope = createMetricEnvelope(metricBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());
+
+ // Clear the metric buffer after envelopes have been constructed.
+ _getBufferMap().set(client, []);
+
+ client.emit('flushMetrics');
+
+ // sendEnvelope should not throw
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ client.sendEnvelope(envelope);
+}
+
+/**
+ * Returns the metric buffer for a given client.
+ *
+ * Exported for testing purposes.
+ *
+ * @param client - The client to get the metric buffer for.
+ * @returns The metric buffer for the given client.
+ */
+export function _INTERNAL_getMetricBuffer(client: Client): Array | undefined {
+ return _getBufferMap().get(client);
+}
+
+/**
+ * Get the scope data for the current scope after merging with the
+ * global scope and isolation scope.
+ *
+ * @param currentScope - The current scope.
+ * @returns The scope data.
+ */
+function getMergedScopeData(currentScope: Scope): ScopeData {
+ const scopeData = getGlobalScope().getScopeData();
+ mergeScopeData(scopeData, getIsolationScope().getScopeData());
+ mergeScopeData(scopeData, currentScope.getScopeData());
+ return scopeData;
+}
+
+function _getBufferMap(): WeakMap> {
+ // The reference to the Client <> MetricBuffer map is stored on the carrier to ensure it's always the same
+ return getGlobalSingleton('clientToMetricBufferMap', () => new WeakMap>());
+}
diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts
new file mode 100644
index 000000000000..e508fcb9e6d0
--- /dev/null
+++ b/packages/core/src/metrics/public-api.ts
@@ -0,0 +1,140 @@
+import type { Scope } from '../scope';
+import type { Metric, MetricType } from '../types-hoist/metric';
+import { _INTERNAL_captureMetric } from './internal';
+
+/**
+ * Options for capturing a metric.
+ */
+export interface MetricOptions {
+ /**
+ * The unit of the metric value.
+ */
+ unit?: string;
+
+ /**
+ * Arbitrary structured data that stores information about the metric.
+ */
+ attributes?: Metric['attributes'];
+
+ /**
+ * The scope to capture the metric with.
+ */
+ scope?: Scope;
+}
+
+/**
+ * Capture a metric with the given type, name, and value.
+ *
+ * @param type - The type of the metric.
+ * @param name - The name of the metric.
+ * @param value - The value of the metric.
+ * @param options - Options for capturing the metric.
+ */
+function captureMetric(type: MetricType, name: string, value: number | string, options?: MetricOptions): void {
+ _INTERNAL_captureMetric(
+ { type, name, value, unit: options?.unit, attributes: options?.attributes },
+ { scope: options?.scope },
+ );
+}
+
+/**
+ * @summary Increment a counter metric. Requires the `_experiments.enableMetrics` option to be enabled.
+ *
+ * @param name - The name of the counter metric.
+ * @param value - The value to increment by (defaults to 1).
+ * @param options - Options for capturing the metric.
+ *
+ * @example
+ *
+ * ```
+ * Sentry.metrics.count('api.requests', 1, {
+ * attributes: {
+ * endpoint: '/api/users',
+ * method: 'GET',
+ * status: 200
+ * }
+ * });
+ * ```
+ *
+ * @example With custom value
+ *
+ * ```
+ * Sentry.metrics.count('items.processed', 5, {
+ * attributes: {
+ * processor: 'batch-processor',
+ * queue: 'high-priority'
+ * }
+ * });
+ * ```
+ */
+export function count(name: string, value: number = 1, options?: MetricOptions): void {
+ captureMetric('counter', name, value, options);
+}
+
+/**
+ * @summary Set a gauge metric to a specific value. Requires the `_experiments.enableMetrics` option to be enabled.
+ *
+ * @param name - The name of the gauge metric.
+ * @param value - The current value of the gauge.
+ * @param options - Options for capturing the metric.
+ *
+ * @example
+ *
+ * ```
+ * Sentry.metrics.gauge('memory.usage', 1024, {
+ * unit: 'megabyte',
+ * attributes: {
+ * process: 'web-server',
+ * region: 'us-east-1'
+ * }
+ * });
+ * ```
+ *
+ * @example Without unit
+ *
+ * ```
+ * Sentry.metrics.gauge('active.connections', 42, {
+ * attributes: {
+ * server: 'api-1',
+ * protocol: 'websocket'
+ * }
+ * });
+ * ```
+ */
+export function gauge(name: string, value: number, options?: MetricOptions): void {
+ captureMetric('gauge', name, value, options);
+}
+
+/**
+ * @summary Record a value in a distribution metric. Requires the `_experiments.enableMetrics` option to be enabled.
+ *
+ * @param name - The name of the distribution metric.
+ * @param value - The value to record in the distribution.
+ * @param options - Options for capturing the metric.
+ *
+ * @example
+ *
+ * ```
+ * Sentry.metrics.distribution('task.duration', 500, {
+ * unit: 'millisecond',
+ * attributes: {
+ * task: 'data-processing',
+ * priority: 'high'
+ * }
+ * });
+ * ```
+ *
+ * @example Without unit
+ *
+ * ```
+ * Sentry.metrics.distribution('batch.size', 100, {
+ * attributes: {
+ * processor: 'batch-1',
+ * type: 'async'
+ * }
+ * });
+ * ```
+ */
+export function distribution(name: string, value: number, options?: MetricOptions): void {
+ captureMetric('distribution', name, value, options);
+}
diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts
index 44e608925535..761d4aca7cd7 100644
--- a/packages/core/src/server-runtime-client.ts
+++ b/packages/core/src/server-runtime-client.ts
@@ -3,11 +3,13 @@ import { _getTraceInfoFromScope, Client } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import { _INTERNAL_flushLogsBuffer } from './logs/internal';
+import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { registerSpanErrorInstrumentation } from './tracing';
import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin';
import type { Event, EventHint } from './types-hoist/event';
import type { Log } from './types-hoist/log';
+import type { Metric } from './types-hoist/metric';
import type { Primitive } from './types-hoist/misc';
import type { ClientOptions } from './types-hoist/options';
import type { ParameterizedString } from './types-hoist/parameterize';
@@ -36,6 +38,8 @@ export class ServerRuntimeClient<
> extends Client {
private _logFlushIdleTimeout: ReturnType | undefined;
private _logWeight: number;
+ private _metricFlushIdleTimeout: ReturnType | undefined;
+ private _metricWeight: number;
/**
* Creates a new Edge SDK instance.
@@ -48,6 +52,7 @@ export class ServerRuntimeClient<
super(options);
this._logWeight = 0;
+ this._metricWeight = 0;
if (this._options.enableLogs) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -78,6 +83,36 @@ export class ServerRuntimeClient<
_INTERNAL_flushLogsBuffer(client);
});
}
+
+ if (this._options._experiments?.enableMetrics) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const client = this;
+
+ client.on('flushMetrics', () => {
+ client._metricWeight = 0;
+ clearTimeout(client._metricFlushIdleTimeout);
+ });
+
+ client.on('afterCaptureMetric', metric => {
+ client._metricWeight += estimateMetricSizeInBytes(metric);
+
+ // We flush the metrics buffer if it exceeds 0.8 MB
+ // The metric weight is a rough estimate, so we flush way before
+ // the payload gets too big.
+ if (client._metricWeight >= 800_000) {
+ _INTERNAL_flushMetricsBuffer(client);
+ } else {
+ // start an idle timeout to flush the metrics buffer if no metrics are captured for a while
+ client._metricFlushIdleTimeout = setTimeout(() => {
+ _INTERNAL_flushMetricsBuffer(client);
+ }, DEFAULT_LOG_FLUSH_INTERVAL);
+ }
+ });
+
+ client.on('flush', () => {
+ _INTERNAL_flushMetricsBuffer(client);
+ });
+ }
}
/**
@@ -233,6 +268,43 @@ function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
}
}
+/**
+ * Estimate the size of a metric in bytes.
+ *
+ * @param metric - The metric to estimate the size of.
+ * @returns The estimated size of the metric in bytes.
+ */
+function estimateMetricSizeInBytes(metric: Metric): number {
+ let weight = 0;
+
+ // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16.
+ if (metric.name) {
+ weight += metric.name.length * 2;
+ }
+
+ // Add weight for the value
+ if (typeof metric.value === 'string') {
+ weight += metric.value.length * 2;
+ } else {
+ weight += 8; // number
+ }
+
+ if (metric.attributes) {
+ Object.values(metric.attributes).forEach(value => {
+ if (Array.isArray(value)) {
+ weight += value.length * estimatePrimitiveSizeInBytes(value[0]);
+ } else if (isPrimitive(value)) {
+ weight += estimatePrimitiveSizeInBytes(value);
+ } else {
+ // For objects values, we estimate the size of the object as 100 bytes
+ weight += 100;
+ }
+ });
+ }
+
+ return weight;
+}
+
/**
* Estimate the size of a log in bytes.
*
diff --git a/packages/core/src/types-hoist/datacategory.ts b/packages/core/src/types-hoist/datacategory.ts
index 2e636b605fcf..ad1e61732816 100644
--- a/packages/core/src/types-hoist/datacategory.ts
+++ b/packages/core/src/types-hoist/datacategory.ts
@@ -32,5 +32,7 @@ export type DataCategory =
| 'log_item'
// Log bytes stored (unused for rate limiting)
| 'log_byte'
+ // Metric event
+ | 'metric'
// Unknown data category
| 'unknown';
diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts
index 58671c1eba70..272f8cde9f62 100644
--- a/packages/core/src/types-hoist/envelope.ts
+++ b/packages/core/src/types-hoist/envelope.ts
@@ -6,6 +6,7 @@ import type { DsnComponents } from './dsn';
import type { Event } from './event';
import type { FeedbackEvent, UserFeedback } from './feedback';
import type { SerializedLogContainer } from './log';
+import type { SerializedMetricContainer } from './metric';
import type { Profile, ProfileChunk } from './profiling';
import type { ReplayEvent, ReplayRecordingData } from './replay';
import type { SdkInfo } from './sdkinfo';
@@ -46,6 +47,8 @@ export type EnvelopeItemType =
| 'check_in'
| 'span'
| 'log'
+ | 'metric'
+ | 'trace_metric'
| 'raw_security';
export type BaseEnvelopeHeaders = {
@@ -99,6 +102,11 @@ type LogContainerItemHeaders = {
*/
content_type: 'application/vnd.sentry.items.log+json';
};
+type MetricContainerItemHeaders = {
+ type: 'trace_metric';
+ item_count: number;
+ content_type: 'application/vnd.sentry.items.trace-metric+json';
+};
type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string };
export type EventItem = BaseEnvelopeItem;
@@ -116,6 +124,7 @@ export type ProfileItem = BaseEnvelopeItem;
export type ProfileChunkItem = BaseEnvelopeItem;
export type SpanItem = BaseEnvelopeItem>;
export type LogContainerItem = BaseEnvelopeItem;
+export type MetricContainerItem = BaseEnvelopeItem;
export type RawSecurityItem = BaseEnvelopeItem;
export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: Partial };
@@ -125,6 +134,7 @@ type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders;
type ReplayEnvelopeHeaders = BaseEnvelopeHeaders;
type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext };
type LogEnvelopeHeaders = BaseEnvelopeHeaders;
+type MetricEnvelopeHeaders = BaseEnvelopeHeaders;
export type EventEnvelope = BaseEnvelope<
EventEnvelopeHeaders,
EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem
@@ -137,6 +147,7 @@ export type SpanEnvelope = BaseEnvelope;
export type ProfileChunkEnvelope = BaseEnvelope;
export type RawSecurityEnvelope = BaseEnvelope;
export type LogEnvelope = BaseEnvelope;
+export type MetricEnvelope = BaseEnvelope;
export type Envelope =
| EventEnvelope
@@ -147,5 +158,6 @@ export type Envelope =
| CheckInEnvelope
| SpanEnvelope
| RawSecurityEnvelope
- | LogEnvelope;
+ | LogEnvelope
+ | MetricEnvelope;
export type EnvelopeItem = Envelope[1][number];
diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts
new file mode 100644
index 000000000000..9201243c4a38
--- /dev/null
+++ b/packages/core/src/types-hoist/metric.ts
@@ -0,0 +1,80 @@
+export type MetricType = 'counter' | 'gauge' | 'distribution';
+
+export interface Metric {
+ /**
+ * The name of the metric.
+ */
+ name: string;
+
+ /**
+ * The value of the metric.
+ */
+ value: number | string;
+
+ /**
+ * The type of metric.
+ */
+ type: MetricType;
+
+ /**
+ * The unit of the metric value.
+ */
+ unit?: string;
+
+ /**
+ * Arbitrary structured data that stores information about the metric.
+ */
+ attributes?: Record;
+}
+
+export type SerializedMetricAttributeValue =
+ | { value: string; type: 'string' }
+ | { value: number; type: 'integer' }
+ | { value: number; type: 'double' }
+ | { value: boolean; type: 'boolean' };
+
+export interface SerializedMetric {
+ /**
+ * Timestamp in seconds (epoch time) indicating when the metric was recorded.
+ */
+ timestamp: number;
+
+ /**
+ * The trace ID for this metric.
+ */
+ trace_id?: string;
+
+ /**
+ * The span ID for this metric.
+ */
+ span_id?: string;
+
+ /**
+ * The name of the metric.
+ */
+ name: string;
+
+ /**
+ * The type of metric.
+ */
+ type: MetricType;
+
+ /**
+ * The unit of the metric value.
+ */
+ unit?: string;
+
+ /**
+ * The value of the metric.
+ */
+ value: number | string;
+
+ /**
+ * Arbitrary structured data that stores information about the metric.
+ */
+ attributes?: Record;
+}
+
+export type SerializedMetricContainer = {
+ items: Array;
+};
diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts
index 43946c3d08e0..1f172aaa1f4a 100644
--- a/packages/core/src/types-hoist/options.ts
+++ b/packages/core/src/types-hoist/options.ts
@@ -3,6 +3,7 @@ import type { Breadcrumb, BreadcrumbHint } from './breadcrumb';
import type { ErrorEvent, EventHint, TransactionEvent } from './event';
import type { Integration } from './integration';
import type { Log } from './log';
+import type { Metric } from './metric';
import type { TracesSamplerSamplingContext } from './samplingcontext';
import type { SdkMetadata } from './sdkmetadata';
import type { SpanJSON } from './span';
@@ -282,6 +283,29 @@ export interface ClientOptions Metric | null;
};
/**
diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts
index ffda9434d886..8f21a00dc590 100644
--- a/packages/core/src/utils/envelope.ts
+++ b/packages/core/src/utils/envelope.ts
@@ -221,6 +221,8 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = {
span: 'span',
raw_security: 'security',
log: 'log_item',
+ metric: 'metric',
+ trace_metric: 'metric',
};
/**
diff --git a/packages/core/test/lib/metrics/envelope.test.ts b/packages/core/test/lib/metrics/envelope.test.ts
new file mode 100644
index 000000000000..87132e4bcaa0
--- /dev/null
+++ b/packages/core/test/lib/metrics/envelope.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createMetricContainerEnvelopeItem, createMetricEnvelope } from '../../../src/metrics/envelope';
+import type { DsnComponents } from '../../../src/types-hoist/dsn';
+import type { SerializedMetric } from '../../../src/types-hoist/metric';
+import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata';
+import * as utilsDsn from '../../../src/utils/dsn';
+import * as utilsEnvelope from '../../../src/utils/envelope';
+
+vi.mock('../../../src/utils/dsn', () => ({
+ dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`),
+}));
+vi.mock('../../../src/utils/envelope', () => ({
+ createEnvelope: vi.fn((_headers, items) => [_headers, items]),
+}));
+
+describe('createMetricContainerEnvelopeItem', () => {
+ it('creates an envelope item with correct structure', () => {
+ const mockMetric: SerializedMetric = {
+ timestamp: 1713859200,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'test.metric',
+ type: 'counter',
+ value: 1,
+ unit: 'count',
+ attributes: {},
+ };
+
+ const result = createMetricContainerEnvelopeItem([mockMetric, mockMetric]);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual({
+ type: 'trace_metric',
+ item_count: 2,
+ content_type: 'application/vnd.sentry.items.trace-metric+json',
+ });
+ expect(result[1]).toEqual({ items: [mockMetric, mockMetric] });
+ });
+});
+
+describe('createMetricEnvelope', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2023-01-01T12:00:00Z'));
+
+ // Reset mocks
+ vi.mocked(utilsEnvelope.createEnvelope).mockClear();
+ vi.mocked(utilsDsn.dsnToString).mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('creates an envelope with basic headers', () => {
+ const mockMetrics: SerializedMetric[] = [
+ {
+ timestamp: 1713859200,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'test.metric',
+ type: 'counter',
+ value: 1,
+ unit: 'count',
+ attributes: {},
+ },
+ ];
+
+ const result = createMetricEnvelope(mockMetrics);
+
+ expect(result[0]).toEqual({});
+
+ expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array));
+ });
+
+ it('includes SDK info when metadata is provided', () => {
+ const mockMetrics: SerializedMetric[] = [
+ {
+ timestamp: 1713859200,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'test.metric',
+ type: 'counter',
+ value: 1,
+ unit: 'count',
+ attributes: {},
+ },
+ ];
+
+ const metadata: SdkMetadata = {
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '10.0.0',
+ },
+ };
+
+ const result = createMetricEnvelope(mockMetrics, metadata);
+
+ expect(result[0]).toEqual({
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '10.0.0',
+ },
+ });
+ });
+
+ it('includes DSN when tunnel and DSN are provided', () => {
+ const mockMetrics: SerializedMetric[] = [
+ {
+ timestamp: 1713859200,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'test.metric',
+ type: 'counter',
+ value: 1,
+ unit: 'count',
+ attributes: {},
+ },
+ ];
+
+ const dsn: DsnComponents = {
+ host: 'example.sentry.io',
+ path: '/',
+ projectId: '123',
+ port: '',
+ protocol: 'https',
+ publicKey: 'abc123',
+ };
+
+ const result = createMetricEnvelope(mockMetrics, undefined, 'https://tunnel.example.com', dsn);
+
+ expect(result[0]).toHaveProperty('dsn');
+ expect(utilsDsn.dsnToString).toHaveBeenCalledWith(dsn);
+ });
+
+ it('maps each metric to an envelope item', () => {
+ const mockMetrics: SerializedMetric[] = [
+ {
+ timestamp: 1713859200,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'first.metric',
+ type: 'counter',
+ value: 1,
+ unit: 'count',
+ attributes: {},
+ },
+ {
+ timestamp: 1713859201,
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ span_id: '8b5f5e5e5e5e5e5e',
+ name: 'second.metric',
+ type: 'gauge',
+ value: 42,
+ unit: 'bytes',
+ attributes: {},
+ },
+ ];
+
+ createMetricEnvelope(mockMetrics);
+
+ // Check that createEnvelope was called with a single container item containing all metrics
+ expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([
+ expect.arrayContaining([
+ { type: 'trace_metric', item_count: 2, content_type: 'application/vnd.sentry.items.trace-metric+json' },
+ { items: mockMetrics },
+ ]),
+ ]),
+ );
+ });
+});
diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts
new file mode 100644
index 000000000000..33f5bb0de3ae
--- /dev/null
+++ b/packages/core/test/lib/metrics/internal.test.ts
@@ -0,0 +1,1082 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Scope } from '../../../src';
+import {
+ _INTERNAL_captureMetric,
+ _INTERNAL_flushMetricsBuffer,
+ _INTERNAL_getMetricBuffer,
+ metricAttributeToSerializedMetricAttribute,
+} from '../../../src/metrics/internal';
+import type { Metric } from '../../../src/types-hoist/metric';
+import * as loggerModule from '../../../src/utils/debug-logger';
+import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
+
+const PUBLIC_DSN = 'https://username@domain/123';
+
+describe('metricAttributeToSerializedMetricAttribute', () => {
+ it('serializes integer values', () => {
+ const result = metricAttributeToSerializedMetricAttribute(42);
+ expect(result).toEqual({
+ value: 42,
+ type: 'integer',
+ });
+ });
+
+ it('serializes double values', () => {
+ const result = metricAttributeToSerializedMetricAttribute(42.34);
+ expect(result).toEqual({
+ value: 42.34,
+ type: 'double',
+ });
+ });
+
+ it('serializes boolean values', () => {
+ const result = metricAttributeToSerializedMetricAttribute(true);
+ expect(result).toEqual({
+ value: true,
+ type: 'boolean',
+ });
+ });
+
+ it('serializes string values', () => {
+ const result = metricAttributeToSerializedMetricAttribute('endpoint');
+ expect(result).toEqual({
+ value: 'endpoint',
+ type: 'string',
+ });
+ });
+
+ it('serializes object values as JSON strings', () => {
+ const obj = { name: 'John', age: 30 };
+ const result = metricAttributeToSerializedMetricAttribute(obj);
+ expect(result).toEqual({
+ value: JSON.stringify(obj),
+ type: 'string',
+ });
+ });
+
+ it('serializes array values as JSON strings', () => {
+ const array = [1, 2, 3, 'test'];
+ const result = metricAttributeToSerializedMetricAttribute(array);
+ expect(result).toEqual({
+ value: JSON.stringify(array),
+ type: 'string',
+ });
+ });
+
+ it('serializes undefined values as empty strings', () => {
+ const result = metricAttributeToSerializedMetricAttribute(undefined);
+ expect(result).toEqual({
+ value: '',
+ type: 'string',
+ });
+ });
+
+ it('serializes null values as JSON strings', () => {
+ const result = metricAttributeToSerializedMetricAttribute(null);
+ expect(result).toEqual({
+ value: 'null',
+ type: 'string',
+ });
+ });
+});
+
+describe('_INTERNAL_captureMetric', () => {
+ it('captures and sends metrics', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+ expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(1);
+ expect(_INTERNAL_getMetricBuffer(client)?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'test.metric',
+ type: 'counter',
+ value: 1,
+ timestamp: expect.any(Number),
+ trace_id: expect.any(String),
+ attributes: {},
+ }),
+ );
+ });
+
+ it('does not capture metrics when enableMetrics is not enabled', () => {
+ const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined);
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(logWarnSpy).toHaveBeenCalledWith('metrics option not enabled, metric will not be captured.');
+ expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
+
+ logWarnSpy.mockRestore();
+ });
+
+ it('includes trace context when available', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setPropagationContext({
+ traceId: '3d9355f71e9c444b81161599adac6e29',
+ sampleRand: 1,
+ });
+
+ _INTERNAL_captureMetric({ type: 'gauge', name: 'test.gauge', value: 42 }, { scope });
+
+ expect(_INTERNAL_getMetricBuffer(client)?.[0]).toEqual(
+ expect.objectContaining({
+ trace_id: '3d9355f71e9c444b81161599adac6e29',
+ }),
+ );
+ });
+
+ it('includes release and environment in metric attributes when available', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ });
+ });
+
+ it('includes SDK metadata in metric attributes when available', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ vi.spyOn(client, 'getSdkMetadata').mockReturnValue({
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '10.0.0',
+ },
+ });
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.sdk.name': {
+ value: 'sentry.javascript.node',
+ type: 'string',
+ },
+ 'sentry.sdk.version': {
+ value: '10.0.0',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not include SDK metadata in metric attributes when not available', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ // Mock getSdkMetadata to return no SDK info
+ vi.spyOn(client, 'getSdkMetadata').mockReturnValue({});
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ key: 'sentry.sdk.name' }),
+ expect.objectContaining({ key: 'sentry.sdk.version' }),
+ ]),
+ );
+ });
+
+ it('includes custom attributes in metric', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: { endpoint: '/api/users', method: 'GET' },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ endpoint: {
+ value: '/api/users',
+ type: 'string',
+ },
+ method: {
+ value: 'GET',
+ type: 'string',
+ },
+ });
+ });
+
+ it('flushes metrics buffer when it reaches max size', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Fill the buffer to max size (100 is the MAX_METRIC_BUFFER_SIZE constant)
+ for (let i = 0; i < 100; i++) {
+ _INTERNAL_captureMetric({ type: 'counter', name: `metric.${i}`, value: i }, { scope });
+ }
+
+ expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(100);
+
+ // Add one more to trigger flush
+ _INTERNAL_captureMetric({ type: 'counter', name: 'trigger.flush', value: 999 }, { scope });
+
+ expect(_INTERNAL_getMetricBuffer(client)).toEqual([]);
+ });
+
+ it('does not flush metrics buffer when it is empty', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+
+ const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {});
+ _INTERNAL_flushMetricsBuffer(client);
+ expect(mockSendEnvelope).not.toHaveBeenCalled();
+ });
+
+ it('processes metrics through beforeSendMetric when provided', () => {
+ const beforeSendMetric = vi.fn().mockImplementation(metric => ({
+ ...metric,
+ name: `modified.${metric.name}`,
+ attributes: { ...metric.attributes, processed: true },
+ }));
+
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true, beforeSendMetric },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'original.metric',
+ value: 1,
+ attributes: { original: true },
+ },
+ { scope },
+ );
+
+ expect(beforeSendMetric).toHaveBeenCalledWith({
+ type: 'counter',
+ name: 'original.metric',
+ value: 1,
+ attributes: { original: true },
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toBeDefined();
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'modified.original.metric',
+ attributes: {
+ processed: {
+ value: true,
+ type: 'boolean',
+ },
+ original: {
+ value: true,
+ type: 'boolean',
+ },
+ },
+ }),
+ );
+ });
+
+ it('drops metrics when beforeSendMetric returns null', () => {
+ const beforeSendMetric = vi.fn().mockReturnValue(null);
+ const loggerWarnSpy = vi.spyOn(loggerModule.debug, 'log').mockImplementation(() => undefined);
+
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true, beforeSendMetric },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ },
+ { scope },
+ );
+
+ expect(beforeSendMetric).toHaveBeenCalled();
+ expect(loggerWarnSpy).toHaveBeenCalledWith('`beforeSendMetric` returned `null`, will not send metric.');
+ expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
+
+ loggerWarnSpy.mockRestore();
+ });
+
+ it('emits afterCaptureMetric event', () => {
+ const afterCaptureMetricSpy = vi.spyOn(TestClient.prototype, 'emit');
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ const metric: Metric = {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: {},
+ };
+
+ _INTERNAL_captureMetric(metric, { scope });
+
+ expect(afterCaptureMetricSpy).toHaveBeenCalledWith('afterCaptureMetric', expect.objectContaining(metric));
+ afterCaptureMetricSpy.mockRestore();
+ });
+
+ describe('replay integration with onlyIfSampled', () => {
+ it('includes replay ID for sampled sessions', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with sampled session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((onlyIfSampled?: boolean) => {
+ return onlyIfSampled ? 'sampled-replay-id' : 'any-replay-id';
+ }),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'sampled-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with unsampled session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((onlyIfSampled?: boolean) => {
+ return onlyIfSampled ? undefined : 'unsampled-replay-id';
+ }),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ });
+
+ it('includes replay ID for buffer mode sessions', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode session
+ const mockReplayIntegration = {
+ getReplayId: vi.fn((_onlyIfSampled?: boolean) => {
+ return 'buffer-replay-id';
+ }),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('handles missing replay integration gracefully', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock no replay integration found
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ });
+
+ it('combines replay ID with other metric attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'test-replay-id'),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: { endpoint: '/api/users', method: 'GET' },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ endpoint: {
+ value: '/api/users',
+ type: 'string',
+ },
+ method: {
+ value: 'GET',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ 'sentry.replay_id': {
+ value: 'test-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not set replay ID attribute when getReplayId returns null or undefined', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ const testCases = [null, undefined];
+
+ testCases.forEach(returnValue => {
+ // Clear buffer for each test
+ _INTERNAL_getMetricBuffer(client)?.splice(0);
+
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => returnValue),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ expect(metricAttributes).not.toHaveProperty('sentry.replay_id');
+ });
+ });
+
+ it('sets replay_is_buffering attribute when replay is in buffer mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'buffer-replay-id'),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not set replay_is_buffering attribute when replay is in session mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with session mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'session-replay-id'),
+ getRecordingMode: vi.fn(() => 'session'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'session-replay-id',
+ type: 'string',
+ },
+ });
+ expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when replay is undefined mode', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with undefined mode (replay stopped/disabled)
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'stopped-replay-id'),
+ getRecordingMode: vi.fn(() => undefined),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ expect(mockReplayIntegration.getRecordingMode).toHaveBeenCalled();
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'sentry.replay_id': {
+ value: 'stopped-replay-id',
+ type: 'string',
+ },
+ });
+ expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when no replay ID is available', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration that returns no replay ID but has buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => undefined),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ expect(mockReplayIntegration.getReplayId).toHaveBeenCalledWith(true);
+ // getRecordingMode should not be called if there's no replay ID
+ expect(mockReplayIntegration.getRecordingMode).not.toHaveBeenCalled();
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ expect(metricAttributes).not.toHaveProperty('sentry.replay_id');
+ expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('does not set replay_is_buffering attribute when replay integration is missing', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock no replay integration found
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined);
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ expect(metricAttributes).not.toHaveProperty('sentry.replay_id');
+ expect(metricAttributes).not.toHaveProperty('sentry._internal.replay_is_buffering');
+ });
+
+ it('combines replay_is_buffering with other replay attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ // Mock replay integration with buffer mode
+ const mockReplayIntegration = {
+ getReplayId: vi.fn(() => 'buffer-replay-id'),
+ getRecordingMode: vi.fn(() => 'buffer'),
+ };
+
+ vi.spyOn(client, 'getIntegrationByName').mockReturnValue(mockReplayIntegration as any);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: { endpoint: '/api/users', method: 'GET' },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ endpoint: {
+ value: '/api/users',
+ type: 'string',
+ },
+ method: {
+ value: 'GET',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ 'sentry.replay_id': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ 'sentry._internal.replay_is_buffering': {
+ value: 'buffer-replay-id',
+ type: 'string',
+ },
+ });
+ });
+ });
+
+ describe('user functionality', () => {
+ it('includes user data in metric attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ });
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'testuser',
+ type: 'string',
+ },
+ });
+ });
+
+ it('includes partial user data when only some fields are available', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ // email and username are missing
+ });
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ });
+ });
+
+ it('includes user email and username without id', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ email: 'user@example.com',
+ username: 'testuser',
+ // id is missing
+ });
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'testuser',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not include user data when user object is empty', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({});
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({});
+ });
+
+ it('combines user data with other metric attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ });
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: { endpoint: '/api/users', method: 'GET' },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ endpoint: {
+ value: '/api/users',
+ type: 'string',
+ },
+ method: {
+ value: 'GET',
+ type: 'string',
+ },
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ });
+ });
+
+ it('handles user data with non-string values', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: 123,
+ email: 'user@example.com',
+ username: undefined,
+ });
+
+ _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope });
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.id': {
+ value: 123,
+ type: 'integer',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ });
+ });
+
+ it('preserves existing user attributes in metric and does not override them', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ });
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: {
+ 'user.id': 'existing-id',
+ 'user.custom': 'custom-value',
+ },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.custom': {
+ value: 'custom-value',
+ type: 'string',
+ },
+ 'user.id': {
+ value: 'existing-id',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ });
+ });
+
+ it('only adds scope user data for attributes that do not already exist', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+ scope.setUser({
+ id: 'scope-id',
+ email: 'scope@example.com',
+ username: 'scope-user',
+ });
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: {
+ 'user.email': 'existing@example.com',
+ 'other.attr': 'value',
+ },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'other.attr': {
+ value: 'value',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'existing@example.com',
+ type: 'string',
+ },
+ 'user.id': {
+ value: 'scope-id',
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'scope-user',
+ type: 'string',
+ },
+ });
+ });
+ });
+
+ it('overrides user-provided system attributes with SDK values', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableMetrics: true },
+ release: 'sdk-release-1.0.0',
+ environment: 'sdk-environment',
+ });
+ const client = new TestClient(options);
+ vi.spyOn(client, 'getSdkMetadata').mockReturnValue({
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '10.0.0',
+ },
+ });
+
+ const scope = new Scope();
+ scope.setClient(client);
+
+ _INTERNAL_captureMetric(
+ {
+ type: 'counter',
+ name: 'test.metric',
+ value: 1,
+ attributes: {
+ 'sentry.release': 'user-release-2.0.0',
+ 'sentry.environment': 'user-environment',
+ 'sentry.sdk.name': 'user-sdk-name',
+ 'sentry.sdk.version': 'user-sdk-version',
+ 'user.custom': 'preserved-value',
+ },
+ },
+ { scope },
+ );
+
+ const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes;
+ expect(metricAttributes).toEqual({
+ 'user.custom': {
+ value: 'preserved-value',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: 'sdk-release-1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'sdk-environment',
+ type: 'string',
+ },
+ 'sentry.sdk.name': {
+ value: 'sentry.javascript.node',
+ type: 'string',
+ },
+ 'sentry.sdk.version': {
+ value: '10.0.0',
+ type: 'string',
+ },
+ });
+ });
+});
diff --git a/packages/core/test/lib/metrics/public-api.test.ts b/packages/core/test/lib/metrics/public-api.test.ts
new file mode 100644
index 000000000000..42fe7c41ae4a
--- /dev/null
+++ b/packages/core/test/lib/metrics/public-api.test.ts
@@ -0,0 +1,337 @@
+import { describe, expect, it } from 'vitest';
+import { Scope } from '../../../src';
+import { _INTERNAL_getMetricBuffer } from '../../../src/metrics/internal';
+import { count, distribution, gauge } from '../../../src/metrics/public-api';
+import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
+
+const PUBLIC_DSN = 'https://username@domain/123';
+
+describe('Metrics Public API', () => {
+ describe('count', () => {
+ it('captures a counter metric with default value of 1', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('api.requests', undefined, { scope });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'api.requests',
+ type: 'counter',
+ value: 1,
+ }),
+ );
+ });
+
+ it('captures a counter metric with custom value', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('items.processed', 5, { scope });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'items.processed',
+ type: 'counter',
+ value: 5,
+ }),
+ );
+ });
+
+ it('captures a counter metric with attributes', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('api.requests', 1, {
+ scope,
+ attributes: {
+ endpoint: '/api/users',
+ method: 'GET',
+ status: 200,
+ },
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'api.requests',
+ type: 'counter',
+ value: 1,
+ attributes: {
+ endpoint: {
+ value: '/api/users',
+ type: 'string',
+ },
+ method: {
+ value: 'GET',
+ type: 'string',
+ },
+ status: {
+ value: 200,
+ type: 'integer',
+ },
+ },
+ }),
+ );
+ });
+
+ it('captures a counter metric with unit', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('data.uploaded', 1024, {
+ scope,
+ unit: 'byte',
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'data.uploaded',
+ type: 'counter',
+ value: 1024,
+ unit: 'byte',
+ }),
+ );
+ });
+
+ it('does not capture counter when enableMetrics is not enabled', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('api.requests', 1, { scope });
+
+ expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
+ });
+ });
+
+ describe('gauge', () => {
+ it('captures a gauge metric', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ gauge('memory.usage', 1024, { scope });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'memory.usage',
+ type: 'gauge',
+ value: 1024,
+ }),
+ );
+ });
+
+ it('captures a gauge metric with unit', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ gauge('memory.usage', 1024, {
+ scope,
+ unit: 'megabyte',
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'memory.usage',
+ type: 'gauge',
+ value: 1024,
+ unit: 'megabyte',
+ }),
+ );
+ });
+
+ it('captures a gauge metric with attributes', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ gauge('active.connections', 42, {
+ scope,
+ attributes: {
+ server: 'api-1',
+ protocol: 'websocket',
+ },
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'active.connections',
+ type: 'gauge',
+ value: 42,
+ attributes: {
+ server: {
+ value: 'api-1',
+ type: 'string',
+ },
+ protocol: {
+ value: 'websocket',
+ type: 'string',
+ },
+ },
+ }),
+ );
+ });
+
+ it('does not capture gauge when enableMetrics is not enabled', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ gauge('memory.usage', 1024, { scope });
+
+ expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
+ });
+ });
+
+ describe('distribution', () => {
+ it('captures a distribution metric', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ distribution('task.duration', 500, { scope });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'task.duration',
+ type: 'distribution',
+ value: 500,
+ }),
+ );
+ });
+
+ it('captures a distribution metric with unit', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ distribution('task.duration', 500, {
+ scope,
+ unit: 'millisecond',
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'task.duration',
+ type: 'distribution',
+ value: 500,
+ unit: 'millisecond',
+ }),
+ );
+ });
+
+ it('captures a distribution metric with attributes', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ distribution('batch.size', 100, {
+ scope,
+ attributes: {
+ processor: 'batch-1',
+ type: 'async',
+ },
+ });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(1);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'batch.size',
+ type: 'distribution',
+ value: 100,
+ attributes: {
+ processor: {
+ value: 'batch-1',
+ type: 'string',
+ },
+ type: {
+ value: 'async',
+ type: 'string',
+ },
+ },
+ }),
+ );
+ });
+
+ it('does not capture distribution when enableMetrics is not enabled', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ distribution('task.duration', 500, { scope });
+
+ expect(_INTERNAL_getMetricBuffer(client)).toBeUndefined();
+ });
+ });
+
+ describe('mixed metric types', () => {
+ it('captures multiple different metric types', () => {
+ const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableMetrics: true } });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setClient(client);
+
+ count('api.requests', 1, { scope });
+ gauge('memory.usage', 1024, { scope });
+ distribution('task.duration', 500, { scope });
+
+ const metricBuffer = _INTERNAL_getMetricBuffer(client);
+ expect(metricBuffer).toHaveLength(3);
+ expect(metricBuffer?.[0]).toEqual(
+ expect.objectContaining({
+ name: 'api.requests',
+ type: 'counter',
+ }),
+ );
+ expect(metricBuffer?.[1]).toEqual(
+ expect.objectContaining({
+ name: 'memory.usage',
+ type: 'gauge',
+ }),
+ );
+ expect(metricBuffer?.[2]).toEqual(
+ expect.objectContaining({
+ name: 'task.duration',
+ type: 'distribution',
+ }),
+ );
+ });
+ });
+});
diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts
index 0f976bd23436..7557d73c74a2 100644
--- a/packages/node-core/src/index.ts
+++ b/packages/node-core/src/index.ts
@@ -135,6 +135,7 @@ export {
consoleIntegration,
wrapMcpServerWithSentry,
featureFlagsIntegration,
+ metrics,
} from '@sentry/core';
export type {
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index db378e55f6ca..54a90dbfcd09 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -152,11 +152,13 @@ export type {
Thread,
User,
Span,
+ Metric,
FeatureFlagsIntegration,
} from '@sentry/core';
export {
logger,
+ metrics,
httpServerIntegration,
httpServerSpansIntegration,
nodeContextIntegration,