diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts new file mode 100644 index 000000000000..635fcfc8721e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + release: '1.0.0', + environment: 'test', + serverName: 'mi-servidor.com', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + await Sentry.flush(); + return new Response('OK'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..5ee5b0954e59 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,50 @@ +import type { SerializedMetricContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('should add server.address attribute to metrics when serverName is set', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const metric = envelope[1]?.[0]?.[1] as SerializedMetricContainer; + + expect(metric.items[0]).toEqual( + expect.objectContaining({ + name: 'test.counter', + type: 'counter', + value: 1, + span_id: expect.any(String), + timestamp: expect.any(Number), + trace_id: expect.any(String), + attributes: { + endpoint: { + type: 'string', + value: '/api/test', + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: expect.any(String), + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.cloudflare', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'server.address': { + type: 'string', + value: 'mi-servidor.com', + }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts new file mode 100644 index 000000000000..1f6d60f91cac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts @@ -0,0 +1,19 @@ +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', + serverName: 'mi-servidor.com', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + 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/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts new file mode 100644 index 000000000000..825d94f41624 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: 'mi-servidor.com', 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/suites/public-api/metrics/server-address/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts new file mode 100644 index 000000000000..a985f2a0fce3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts @@ -0,0 +1,18 @@ +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', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + 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/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..1ee4eda2de3e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: expect.any(String), 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/packages/core/src/client.ts b/packages/core/src/client.ts index f363e61becd7..1c925d930036 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -783,6 +783,13 @@ export abstract class Client { */ public on(hook: 'flushMetrics', callback: () => void): () => void; + /** + * A hook that is called when a metric is processed before it is captured and before the `beforeSendMetric` callback is fired. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'processMetric', callback: (metric: Metric) => 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. @@ -992,6 +999,13 @@ export abstract class Client { */ public emit(hook: 'flushMetrics'): void; + /** + * + * Emit a hook event for client to process a metric before it is captured. + * This hook is called before the `beforeSendMetric` callback is fired. + */ + public emit(hook: 'processMetric', metric: Metric): 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/metrics/internal.ts b/packages/core/src/metrics/internal.ts index b38d61b5195c..7ac1372d1285 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -227,6 +227,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal // Enrich metric with contextual attributes const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + client.emit('processMetric', enrichedMetric); + // todo(v11): Remove the experimental `beforeSendMetric` // eslint-disable-next-line deprecation/deprecation const beforeSendCallback = beforeSendMetric || _experiments?.beforeSendMetric; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 988e642d0a27..d1ae8e9063e6 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,6 +40,8 @@ export class ServerRuntimeClient< addUserAgentToTransportHeaders(options); super(options); + + this._setUpMetricsProcessing(); } /** @@ -176,6 +178,20 @@ export class ServerRuntimeClient< return super._prepareEvent(event, hint, currentScope, isolationScope); } + + /** + * Process a server-side metric before it is captured. + */ + private _setUpMetricsProcessing(): void { + this.on('processMetric', metric => { + if (this._options.serverName) { + metric.attributes = { + 'server.address': this._options.serverName, + ...metric.attributes, + }; + } + }); + } } function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 3c5fe874af9f..24fb60d187ef 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest'; import { applySdkMetadata, createTransport, Scope } from '../../src'; +import { _INTERNAL_captureMetric, _INTERNAL_getMetricBuffer } from '../../src/metrics/internal'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -236,4 +237,68 @@ describe('ServerRuntimeClient', () => { }); }); }); + + describe('metrics processing', () => { + it('adds server.address attribute to metrics when serverName is set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(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( + expect.objectContaining({ + 'server.address': { + value: 'my-server.example.com', + type: 'string', + }, + }), + ); + }); + + it('does not add server.address attribute when serverName is not set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(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).not.toEqual( + expect.objectContaining({ + 'server.address': expect.anything(), + }), + ); + }); + + it('does not overwrite existing server.address attribute', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { 'server.address': 'existing-server.example.com' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual( + expect.objectContaining({ + 'server.address': { + value: 'existing-server.example.com', + type: 'string', + }, + }), + ); + }); + }); });