diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6e7c54198edc..e1941278e0f1 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -58,6 +58,7 @@ export { setHttpStatus, makeMultiplexedTransport, MULTIPLEXED_TRANSPORT_EXTRA_KEY, + MULTIPLEXED_METRIC_ROUTING_KEY, moduleMetadataIntegration, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30ace1803b1a..3ef91fbe25b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,7 +55,12 @@ export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind, setCurrentClient } from './sdk'; export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; -export { makeMultiplexedTransport, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from './transports/multiplexed'; +export { + makeMultiplexedTransport, + MULTIPLEXED_TRANSPORT_EXTRA_KEY, + MULTIPLEXED_METRIC_ROUTING_KEY, + metricFromEnvelope, +} from './transports/multiplexed'; export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration'; export { _INTERNAL_skipAiProviderWrapping, diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index bdd13d884967..a3217c2966e4 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -5,7 +5,7 @@ import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes' 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 { Metric, MetricRoutingInfo, SerializedMetric } from '../types-hoist/metric'; import type { User } from '../types-hoist/user'; import { debug } from '../utils/debug-logger'; import { getCombinedScopeData } from '../utils/scopeData'; @@ -13,6 +13,7 @@ import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; import { createMetricEnvelope } from './envelope'; +import { MULTIPLEXED_METRIC_ROUTING_KEY } from '../transports/multiplexed'; const MAX_METRIC_BUFFER_SIZE = 1000; @@ -73,6 +74,24 @@ export interface InternalCaptureMetricOptions { * A function to capture the serialized metric. */ captureSerializedMetric?: (client: Client, metric: SerializedMetric) => void; + + /** + * The routing information for the metric. + */ + routing?: Array; +} + +/** + * A helper function which strips the routing information from the attributes. + * It is used to prevent the routing information from being sent to Sentry. + * @param attributes - The attributes to strip the routing information from. + * @returns The attributes without the routing information. + */ +function _stripRoutingAttributes(attributes: Record | undefined): Record | undefined { + if (!attributes) return attributes; + + const { [MULTIPLEXED_METRIC_ROUTING_KEY]: _routing, ...rest } = attributes; + return rest; } /** @@ -145,7 +164,7 @@ function _buildSerializedMetric( value: metric.value, attributes: { ...serializeAttributes(scopeAttributes), - ...serializeAttributes(metric.attributes, 'skip-undefined'), + ...serializeAttributes(_stripRoutingAttributes(metric.attributes), 'skip-undefined'), }, }; } diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts index 7dcfe74dfdb0..637ecb684427 100644 --- a/packages/core/src/metrics/public-api.ts +++ b/packages/core/src/metrics/public-api.ts @@ -1,6 +1,7 @@ import type { Scope } from '../scope'; -import type { Metric, MetricType } from '../types-hoist/metric'; +import type { Metric, MetricRoutingInfo, MetricType } from '../types-hoist/metric'; import { _INTERNAL_captureMetric } from './internal'; +import { MULTIPLEXED_METRIC_ROUTING_KEY } from '../transports/multiplexed'; /** * Options for capturing a metric. @@ -20,6 +21,12 @@ export interface MetricOptions { * The scope to capture the metric with. */ scope?: Scope; + + /** + * The routing information for multiplexed transport. + * Each metric can be sent to multiple DSNs. + */ + routing?: Array; } /** @@ -31,10 +38,10 @@ export interface MetricOptions { * @param options - Options for capturing the metric. */ function captureMetric(type: MetricType, name: string, value: number, options?: MetricOptions): void { - _INTERNAL_captureMetric( - { type, name, value, unit: options?.unit, attributes: options?.attributes }, - { scope: options?.scope }, - ); + const attributes = options?.routing + ? { ...options.attributes, [MULTIPLEXED_METRIC_ROUTING_KEY]: options.routing } + : options?.attributes; + _INTERNAL_captureMetric({ type, name, value, unit: options?.unit, attributes }, { scope: options?.scope }); } /** diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index 41426b4a5d5a..325dc8bb2f95 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -4,6 +4,7 @@ import type { Event } from '../types-hoist/event'; import type { BaseTransportOptions, Transport, TransportMakeRequestResponse } from '../types-hoist/transport'; import { dsnFromString } from '../utils/dsn'; import { createEnvelope, forEachEnvelopeItem } from '../utils/envelope'; +import type { SerializedMetric, SerializedMetricContainer } from '../types-hoist/metric'; interface MatchParam { /** The envelope to be sent */ @@ -16,6 +17,7 @@ interface MatchParam { * @param types Defaults to ['event'] */ getEvent(types?: EnvelopeItemType[]): Event | undefined; + getMetric(): SerializedMetric | undefined; } type RouteTo = { dsn: string; release: string }; @@ -27,6 +29,8 @@ type Matcher = (param: MatchParam) => (string | RouteTo)[]; */ export const MULTIPLEXED_TRANSPORT_EXTRA_KEY = 'MULTIPLEXED_TRANSPORT_EXTRA_KEY'; +export const MULTIPLEXED_METRIC_ROUTING_KEY = 'sentry.routing'; + /** * Gets an event from an envelope. * @@ -47,7 +51,28 @@ export function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Eve } /** - * Creates a transport that overrides the release on all events. + * Gets a metric from an envelope. + * + * This is only exported for use in tests and advanced use cases. + */ +export function metricFromEnvelope(env: Envelope): SerializedMetric | undefined { + let metric: SerializedMetric | undefined; + + forEachEnvelopeItem(env, (item, type) => { + if (type === 'trace_metric') { + const container = Array.isArray(item) ? (item[1] as SerializedMetricContainer) : undefined; + if (container && container.items && Array.isArray(container.items) && container.items.length > 0) { + metric = container.items[0]; + } + } + return !!metric; + }); + + return metric; +} + +/** + * Creates a transport that overrides the release on all events and metrics. */ function makeOverrideReleaseTransport( createTransport: (options: TO) => Transport, @@ -64,6 +89,15 @@ function makeOverrideReleaseTransport( if (event) { event.release = release; } + const metric = metricFromEnvelope(envelope); + if (metric) { + // This is mainly for tracking/debugging purposes + if (!metric.attributes) { + metric.attributes = {}; + } + metric.attributes['sentry.release'] = { type: 'string', value: release }; + } + return transport.send(envelope); }, }; @@ -109,6 +143,13 @@ export function makeMultiplexedTransport( ) { return event.extra[MULTIPLEXED_TRANSPORT_EXTRA_KEY]; } + const metric = args.getMetric(); + if ( + metric?.attributes?.[MULTIPLEXED_METRIC_ROUTING_KEY] && + Array.isArray(metric.attributes[MULTIPLEXED_METRIC_ROUTING_KEY]) + ) { + return metric.attributes[MULTIPLEXED_METRIC_ROUTING_KEY] as RouteTo[]; + } return []; }); @@ -142,7 +183,11 @@ export function makeMultiplexedTransport( return eventFromEnvelope(envelope, eventTypes); } - const transports = actualMatcher({ envelope, getEvent }) + function getMetric(): SerializedMetric | undefined { + return metricFromEnvelope(envelope); + } + + const transports = actualMatcher({ envelope, getEvent, getMetric }) .map(result => { if (typeof result === 'string') { return getTransport(result, undefined); diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 976fc9fe863f..58a3d26ad0fb 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -72,6 +72,7 @@ export interface SerializedMetric { /** * Arbitrary structured data that stores information about the metric. + * This can contain routing information via the `MULTIPLEXED_METRIC_ROUTING_KEY` key. */ attributes?: Attributes; } @@ -79,3 +80,15 @@ export interface SerializedMetric { export type SerializedMetricContainer = { items: Array; }; + +export interface MetricRoutingInfo { + /** + * The DSN of the Sentry project to send the metric to. + */ + dsn: string; + + /** + * The release of the Sentry project to send the metric to. + */ + release?: string; +} diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 434f4b6c8289..71a27ab7f7d7 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -1050,3 +1050,85 @@ describe('_INTERNAL_captureMetric', () => { }); }); }); + +describe('routing attribute stripping', () => { + it('strips routing attributes before serialization', () => { + 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, + attributes: { + 'sentry.routing': [{ dsn: 'https://test.dsn', release: 'v1.0.0' }], + normalAttribute: 'value', + }, + }, + { scope }, + ); + + const buffer = _INTERNAL_getMetricBuffer(client); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]?.attributes).toEqual({ + normalAttribute: { + type: 'string', + value: 'value', + }, + }); + expect(buffer?.[0]?.attributes).not.toHaveProperty('sentry.routing'); + }); + + it('handles missing attributes when stripping routing', () => { + 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 }, + ); + + const buffer = _INTERNAL_getMetricBuffer(client); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]?.attributes).toEqual({}); + }); + + it('preserves other attributes when routing is stripped', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, release: 'v1.0.0', environment: 'production' }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { + 'sentry.routing': [{ dsn: 'https://test.dsn' }], + feature: 'cart', + userId: '12345', + }, + }, + { scope }, + ); + + const buffer = _INTERNAL_getMetricBuffer(client); + const attrs = buffer?.[0]?.attributes; + + expect(attrs).toHaveProperty('feature'); + expect(attrs).toHaveProperty('userId'); + expect(attrs).toHaveProperty('sentry.release'); + expect(attrs).toHaveProperty('sentry.environment'); + expect(attrs).not.toHaveProperty('sentry.routing'); + }); +}); diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index 105d54b17eea..6d51bc6317a2 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -8,11 +8,18 @@ import { makeMultiplexedTransport, parseEnvelope, } from '../../../src'; -import { eventFromEnvelope, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from '../../../src/transports/multiplexed'; +import { + eventFromEnvelope, + MULTIPLEXED_TRANSPORT_EXTRA_KEY, + metricFromEnvelope, + MULTIPLEXED_METRIC_ROUTING_KEY, +} from '../../../src/transports/multiplexed'; import type { ClientReport } from '../../../src/types-hoist/clientreport'; import type { Envelope, EventEnvelope, EventItem } from '../../../src/types-hoist/envelope'; import type { TransactionEvent } from '../../../src/types-hoist/event'; import type { BaseTransportOptions, Transport } from '../../../src/types-hoist/transport'; +import type { SerializedMetric } from '../../../src/types-hoist/metric'; +import { createMetricEnvelope } from '../../../src/metrics/envelope'; const DSN1 = 'https://1234@5678.ingest.sentry.io/4321'; const DSN1_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN1)!); @@ -321,3 +328,157 @@ describe('makeMultiplexedTransport() with default matcher', () => { await transport.send(envelope); }); }); + +describe('makeMultiplexedTransport with metrics', () => { + const METRIC: SerializedMetric = { + timestamp: 1234567890, + trace_id: 'trace123', + name: 'test.metric', + type: 'counter', + value: 1, + attributes: {}, + }; + + it('routes metrics to DSN specified in attributes', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport((url, _, env) => { + expect(url).toBe(DSN2_URL); + expect(env[0].dsn).toBe(DSN2); + }), + ); + + const metricWithRouting: SerializedMetric = { + ...METRIC, + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'cart@1.0.0' }] as any, + }, + }; + + const envelope = createMetricEnvelope([metricWithRouting], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); + + it('custom matcher can route metrics based on attributes', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + ({ getMetric }) => { + const metric = getMetric(); + const featureAttr = metric?.attributes?.feature as any; + expect(featureAttr?.value || featureAttr).toBe('cart'); + if ((featureAttr?.value || featureAttr) === 'cart') { + return [DSN2]; + } + return []; + }, + ); + + const metricWithFeature: SerializedMetric = { + ...METRIC, + attributes: { feature: { type: 'string', value: 'cart' } }, + }; + + const envelope = createMetricEnvelope([metricWithFeature], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); + + it('falls back to default DSN when no metric routing info', async () => { + expect.assertions(1); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN1_URL); + }), + ); + + const envelope = createMetricEnvelope([METRIC], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); + + it('routes metrics to multiple DSNs', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport( + url => { + expect(url).toBe(DSN1_URL); + }, + url => { + expect(url).toBe(DSN2_URL); + }, + ), + ); + + const metricWithRouting: SerializedMetric = { + ...METRIC, + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [DSN1, DSN2] as any, + }, + }; + + const envelope = createMetricEnvelope([metricWithRouting], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); + + it('uses MULTIPLEXED_METRIC_ROUTING_KEY for routing', async () => { + expect.assertions(3); + + const makeTransport = makeMultiplexedTransport( + createTestTransport((url, _, envelope) => { + expect(url).toBe(DSN2_URL); + const metric = metricFromEnvelope(envelope); + expect(metric?.attributes?.['sentry.release']).toBe('cart@1.0.0'); + expect(envelope[0].dsn).toBe(DSN2); + }), + ); + + const metricWithRouting: SerializedMetric = { + ...METRIC, + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'cart@1.0.0' }] as any, + }, + }; + + const envelope = createMetricEnvelope([metricWithRouting], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); +}); + +describe('metricFromEnvelope', () => { + it('extracts metric from trace_metric envelope', () => { + const metric: SerializedMetric = { + timestamp: 1234567890, + trace_id: 'trace123', + name: 'test.metric', + type: 'counter' as const, + value: 1, + attributes: { foo: { type: 'string', value: 'bar' } }, + }; + + const envelope = createMetricEnvelope([metric], undefined, undefined, undefined); + const extracted = metricFromEnvelope(envelope); + + expect(extracted).toEqual(metric); + }); + + it('returns undefined for non-metric envelopes', () => { + const extracted = metricFromEnvelope(ERROR_ENVELOPE); + expect(extracted).toBeUndefined(); + }); + + it('returns undefined for empty metric container', () => { + const envelope = createMetricEnvelope([], undefined, undefined, undefined); + const extracted = metricFromEnvelope(envelope); + expect(extracted).toBeUndefined(); + }); +});