From 296a971ac5ae3d81bda2a16b3c5099ccd91c96c0 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 7 Oct 2025 14:33:21 +0200 Subject: [PATCH 1/3] chore: add info latest release for the cursor release command --- .cursor/rules/publishing_release.mdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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` From f4c6c541e2d30928f4fb87b4277f02aecbde3ca0 Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Thu, 9 Oct 2025 05:39:48 -0400 Subject: [PATCH 2/3] feat(tracemetrics): Add trace metrics behind an experiments flag (#17883) ### Summary This allows the js sdk to send in new trace metric protocol items, although this code is experimental since the schema may still change. Most of this has been copied from logs so some parts may need to be modified / removed later (eg. api, buffer) but this should allow us to start on UI work by sending in larger amounts of data from sentry js app to test grouping / aggregations etc. Closes LOGS-366 --------- Co-authored-by: Charly Gomez --- .size-limit.js | 8 +- .../suites/public-api/metrics/init.js | 15 + .../public-api/metrics/simple/subject.js | 12 + .../suites/public-api/metrics/simple/test.ts | 104 ++ .../scripts/consistentExports.ts | 8 + .../suites/public-api/metrics/scenario.ts | 35 + .../suites/public-api/metrics/test.ts | 97 ++ .../utils/assertions.ts | 10 + .../utils/runner.ts | 17 + .../suites/public-api/metrics/scenario.ts | 32 + .../suites/public-api/metrics/test.ts | 96 ++ .../utils/assertions.ts | 10 + .../node-integration-tests/utils/runner.ts | 22 + packages/browser/src/client.ts | 25 +- packages/browser/src/exports.ts | 1 + packages/core/src/carrier.ts | 7 + packages/core/src/client.ts | 25 + packages/core/src/index.ts | 15 + packages/core/src/metrics/envelope.ts | 58 + packages/core/src/metrics/internal.ts | 280 +++++ packages/core/src/metrics/public-api.ts | 140 +++ packages/core/src/server-runtime-client.ts | 72 ++ packages/core/src/types-hoist/datacategory.ts | 2 + packages/core/src/types-hoist/envelope.ts | 14 +- packages/core/src/types-hoist/metric.ts | 80 ++ packages/core/src/types-hoist/options.ts | 24 + packages/core/src/utils/envelope.ts | 2 + .../core/test/lib/metrics/envelope.test.ts | 173 +++ .../core/test/lib/metrics/internal.test.ts | 1082 +++++++++++++++++ .../core/test/lib/metrics/public-api.test.ts | 337 +++++ packages/node-core/src/index.ts | 1 + packages/node/src/index.ts | 2 + 32 files changed, 2799 insertions(+), 7 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/init.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts create mode 100644 dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts create mode 100644 dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/test.ts create mode 100644 packages/core/src/metrics/envelope.ts create mode 100644 packages/core/src/metrics/internal.ts create mode 100644 packages/core/src/metrics/public-api.ts create mode 100644 packages/core/src/types-hoist/metric.ts create mode 100644 packages/core/test/lib/metrics/envelope.test.ts create mode 100644 packages/core/test/lib/metrics/internal.test.ts create mode 100644 packages/core/test/lib/metrics/public-api.test.ts 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/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, From fc5b57651c9a9de26ea4b35eaa84fc405251b4a4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 9 Oct 2025 12:02:27 +0200 Subject: [PATCH 3/3] meta(changelog): Update changelog for 10.19.0 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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