diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e61ce31050..fdea17ff2e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,24 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! +- **feat(core): Apply scope attributes to metrics ([#18738](https://github.com/getsentry/sentry-javascript/pull/18738))** + + You can now set attributes on the SDK's scopes which will be applied to all metrics as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. + + ```ts + Sentry.getGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); + + Sentry.withScope(scope => { + scope.setAttribute('step', 'authentication'); + + // scope attributes `is_admin`, `auth_provider` and `step` are added + Sentry.metrics.count('clicks', 1, { attributes: { activeSince: 100 } }); + Sentry.metrics.gauge('timeSinceRefresh', 4, { unit: 'hour' }); + }); + + // scope attributes `is_admin` and `auth_provider` are added + Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); + ``` - ref(nextjs): Drop `resolve` dependency from the Next.js SDK ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) @@ -22,6 +39,8 @@ Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, +Work in this release was contributed by @xgedev, @Mohataseem89, @sebws, @G-Rath, @maximepvrt, and @gianpaj. Thank you for your contributions! + ## 10.32.0 ### Important Changes 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 index 0b8fced8d6e3..6e1a35009e5f 100644 --- 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 @@ -9,4 +9,10 @@ Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); +Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.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 index 3a8ac97f8408..41eb00a90bc8 100644 --- 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 @@ -17,7 +17,7 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) expect(envelopeItems[0]).toEqual([ { type: 'trace_metric', - item_count: 5, + item_count: 6, content_type: 'application/vnd.sentry.items.trace-metric+json', }, { @@ -98,6 +98,60 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, ]); 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 index 77adfae79802..86905bae1066 100644 --- 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 @@ -25,6 +25,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } 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 index c89c8fb59e55..83715375f2cc 100644 --- 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 @@ -87,6 +87,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node-core', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) 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 index 8d02a1fcd17c..170d4b96e279 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts @@ -22,6 +22,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } 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 index 471fe114fa1e..7e4b71c9ad28 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -86,6 +86,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node', + }, + 'sentry.sdk.version': { + type: 'string', + value: '10.32.1', + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 846ecf89d6e5..bdd13d884967 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,4 +1,4 @@ -import { serializeAttributes } from '../attributes'; +import { type RawAttributes, serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; @@ -6,6 +6,7 @@ import { DEBUG_BUILD } from '../debug-build'; import type { Scope } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Metric, SerializedMetric } from '../types-hoist/metric'; +import type { User } from '../types-hoist/user'; import { debug } from '../utils/debug-logger'; import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; @@ -77,7 +78,7 @@ export interface InternalCaptureMetricOptions { /** * Enriches metric with all contextual attributes (user, SDK metadata, replay, etc.) */ -function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentScope: Scope): Metric { +function _enrichMetricAttributes(beforeMetric: Metric, client: Client, user: User): Metric { const { release, environment } = client.getOptions(); const processedMetricAttributes = { @@ -85,12 +86,9 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc }; // Add user attributes - const { - user: { id, email, username }, - } = getCombinedScopeData(getIsolationScope(), currentScope); - setMetricAttribute(processedMetricAttributes, 'user.id', id, false); - setMetricAttribute(processedMetricAttributes, 'user.email', email, false); - setMetricAttribute(processedMetricAttributes, 'user.name', username, false); + setMetricAttribute(processedMetricAttributes, 'user.id', user.id, false); + setMetricAttribute(processedMetricAttributes, 'user.email', user.email, false); + setMetricAttribute(processedMetricAttributes, 'user.name', user.username, false); // Add Sentry metadata setMetricAttribute(processedMetricAttributes, 'sentry.release', release); @@ -125,7 +123,12 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc /** * Creates a serialized metric ready to be sent to Sentry. */ -function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Scope): SerializedMetric { +function _buildSerializedMetric( + metric: Metric, + client: Client, + currentScope: Scope, + scopeAttributes: RawAttributes> | undefined, +): SerializedMetric { // Get trace context const [, traceContext] = _getTraceInfoFromScope(client, currentScope); const span = _getSpanForScope(currentScope); @@ -140,7 +143,10 @@ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Sc type: metric.type, unit: metric.unit, value: metric.value, - attributes: serializeAttributes(metric.attributes, 'skip-undefined'), + attributes: { + ...serializeAttributes(scopeAttributes), + ...serializeAttributes(metric.attributes, 'skip-undefined'), + }, }; } @@ -174,7 +180,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal } // Enrich metric with contextual attributes - const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + const { user, attributes: scopeAttributes } = getCombinedScopeData(getIsolationScope(), currentScope); + const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, user); client.emit('processMetric', enrichedMetric); @@ -188,7 +195,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal return; } - const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope); + const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope, scopeAttributes); DEBUG_BUILD && debug.log('[Metric]', serializedMetric); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..e6de9b1f27ef 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -306,8 +306,8 @@ export class Scope { /** * Sets attributes onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to @@ -338,8 +338,8 @@ export class Scope { /** * Sets an attribute onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 55753082d7ff..434f4b6c8289 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -171,6 +171,52 @@ describe('_INTERNAL_captureMetric', () => { }); }); + it('includes scope attributes in metric attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + scope_attribute_1: { + value: 1, + type: 'integer', + }, + scope_attribute_2: { + value: 'test', + type: 'string', + }, + scope_attribute_3: { + value: 38, + unit: 'gigabyte', + type: 'integer', + }, + }); + }); + + it('prefers metric attributes over scope attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('my-attribute', 42); + + _INTERNAL_captureMetric( + { type: 'counter', name: 'test.metric', value: 1, attributes: { 'my-attribute': 43 } }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'my-attribute': { value: 43, type: 'integer' }, + }); + }); + it('flushes metrics buffer when it reaches max size', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options);