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..520d4835fe7d --- /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('metric') + .expect({ + 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..4e973954806d 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; + } + | { + 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 ('metric' in expected) { + expectMetric(item[1] as SerializedMetricContainer, expected.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..78c9fa23b4bd --- /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({ + 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..2f6390e35dc3 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; + } + | { + 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 ('metric' in expected) { + expectMetric(item[1] as SerializedMetricContainer, expected.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..03918f353064 --- /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: '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..8db43bb69618 --- /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('trace 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,