diff --git a/.size-limit.js b/.size-limit.js index 7f8e1809522f..90962d4b9df8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -178,7 +178,7 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '28 KB', + limit: '28.1 KB', }, { name: 'CDN Bundle (incl. Tracing)', diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6e7c54198edc..149068892f54 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -57,7 +57,6 @@ export { getSpanStatusFromHttpCode, setHttpStatus, makeMultiplexedTransport, - MULTIPLEXED_TRANSPORT_EXTRA_KEY, moduleMetadataIntegration, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30ace1803b1a..6eb9812e2a00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,7 +55,7 @@ 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 } from './transports/multiplexed'; export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration'; export { _INTERNAL_skipAiProviderWrapping, diff --git a/packages/core/src/metrics/public-api.ts b/packages/core/src/metrics/public-api.ts index 7dcfe74dfdb0..95722e637e98 100644 --- a/packages/core/src/metrics/public-api.ts +++ b/packages/core/src/metrics/public-api.ts @@ -1,5 +1,6 @@ import type { Scope } from '../scope'; -import type { Metric, MetricType } from '../types-hoist/metric'; +import { MULTIPLEXED_METRIC_ROUTING_KEY } from '../transports/multiplexed'; +import type { Metric, MetricRoutingInfo, MetricType } from '../types-hoist/metric'; import { _INTERNAL_captureMetric } from './internal'; /** @@ -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..943370105dd4 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -1,7 +1,10 @@ import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { DEBUG_BUILD } from '../debug-build'; import type { Envelope, EnvelopeItemType, EventItem } from '../types-hoist/envelope'; import type { Event } from '../types-hoist/event'; +import type { SerializedMetric, SerializedMetricContainer } from '../types-hoist/metric'; import type { BaseTransportOptions, Transport, TransportMakeRequestResponse } from '../types-hoist/transport'; +import { debug } from '../utils/debug-logger'; import { dsnFromString } from '../utils/dsn'; import { createEnvelope, forEachEnvelopeItem } from '../utils/envelope'; @@ -16,6 +19,7 @@ interface MatchParam { * @param types Defaults to ['event'] */ getEvent(types?: EnvelopeItemType[]): Event | undefined; + getMetric(): SerializedMetric | undefined; } type RouteTo = { dsn: string; release: string }; @@ -27,6 +31,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 +53,79 @@ export function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Eve } /** - * Creates a transport that overrides the release on all events. + * It iterates over metric containers in an envelope. + */ +function forEachMetricContainer( + envelope: Envelope, + callback: (container: SerializedMetricContainer, metrics: SerializedMetric[]) => void | boolean, +): void { + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'trace_metric') { + const container = Array.isArray(item) ? (item[1] as SerializedMetricContainer) : undefined; + if (container?.items) { + return callback(container, container.items); + } + } + }); +} + +/** + * Gets a metric from an envelope. + * + * This is only exported for use in tests and advanced use cases. + */ +export function metricFromEnvelope(envelope: Envelope): SerializedMetric | undefined { + let metric: SerializedMetric | undefined; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'trace_metric') { + const container = Array.isArray(item) ? (item[1] as SerializedMetricContainer) : undefined; + const containerItems = container?.items; + if (containerItems) { + metric = containerItems[0]; + } + } + return !!metric; + }); + + return metric; +} + +/** + * Applies the release to all metrics in an envelope. + */ +function applyReleaseToMetrics(envelope: Envelope, release: string): void { + forEachMetricContainer(envelope, (container, metrics) => { + container.items = metrics.map(metric => ({ + ...metric, + attributes: { + ...metric.attributes, + 'sentry.release': { type: 'string', value: release }, + }, + })); + }); +} + +/** + * It strips routing attributes from all metrics in an envelope. + * This prevents the routing information from being sent to Sentry. + */ +function stripRoutingAttributesFromMetrics(envelope: Envelope): void { + let strippedCount = 0; + forEachMetricContainer(envelope, (_container, metrics) => { + for (const metric of metrics) { + if (metric.attributes && MULTIPLEXED_METRIC_ROUTING_KEY in metric.attributes) { + DEBUG_BUILD && strippedCount++; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [MULTIPLEXED_METRIC_ROUTING_KEY]: _routing, ...restAttributes } = metric.attributes; + metric.attributes = restAttributes; + } + } + }); +} + +/** + * Creates a transport that overrides the release on all events and metrics. */ function makeOverrideReleaseTransport( createTransport: (options: TO) => Transport, @@ -64,6 +142,9 @@ function makeOverrideReleaseTransport( if (event) { event.release = release; } + + applyReleaseToMetrics(envelope, release); + return transport.send(envelope); }, }; @@ -109,6 +190,35 @@ export function makeMultiplexedTransport( ) { return event.extra[MULTIPLEXED_TRANSPORT_EXTRA_KEY]; } + const metric = args.getMetric(); + if (metric?.attributes?.[MULTIPLEXED_METRIC_ROUTING_KEY]) { + const routingAttr = metric.attributes[MULTIPLEXED_METRIC_ROUTING_KEY]; + DEBUG_BUILD && debug.log('[Multiplexed Transport] Found metric routing attribute:', routingAttr); + + let routingValue: unknown; + if (typeof routingAttr === 'object' && routingAttr !== null && 'value' in routingAttr) { + routingValue = routingAttr.value; + if (typeof routingValue === 'string') { + try { + routingValue = JSON.parse(routingValue); + DEBUG_BUILD && debug.log('[Multiplexed Transport] Parsed routing value:', routingValue); + } catch (e) { + DEBUG_BUILD && debug.warn('[Multiplexed Transport] Failed to parse routing JSON:', e); + return []; + } + } + } else { + routingValue = routingAttr; + } + + if (Array.isArray(routingValue)) { + const validRoutes = routingValue.filter( + (route): route is RouteTo => route !== null && route !== undefined && typeof route === 'object', + ); + DEBUG_BUILD && debug.log('[Multiplexed Transport] Valid routes:', validRoutes); + return validRoutes; + } + } return []; }); @@ -142,7 +252,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); @@ -152,6 +266,8 @@ export function makeMultiplexedTransport( }) .filter((t): t is [string, Transport] => !!t); + stripRoutingAttributesFromMetrics(envelope); + // If we have no transports to send to, use the fallback transport // Don't override the DSN in the header for the fallback transport. '' is falsy const transportsWithFallback: [string, Transport][] = transports.length ? transports : [['', fallbackTransport]]; 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..90b5d7dc4019 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -1050,3 +1050,88 @@ describe('_INTERNAL_captureMetric', () => { }); }); }); + +describe('routing attribute preservation', () => { + it('preserves routing attributes during 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', + }, + 'sentry.routing': { + type: 'string', + value: JSON.stringify([{ dsn: 'https://test.dsn', release: 'v1.0.0' }]), + }, + }); + }); + + it('handles missing attributes during 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, + }, + { scope }, + ); + + const buffer = _INTERNAL_getMetricBuffer(client); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]?.attributes).toEqual({}); + }); + + it('preserves all attributes including routing', () => { + 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).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..de78bb342747 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -4,14 +4,22 @@ import { createEnvelope, createTransport, dsnFromString, + forEachEnvelopeItem, getEnvelopeEndpointWithUrlEncodedAuth, makeMultiplexedTransport, parseEnvelope, } from '../../../src'; -import { eventFromEnvelope, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from '../../../src/transports/multiplexed'; +import { createMetricEnvelope } from '../../../src/metrics/envelope'; +import { + eventFromEnvelope, + metricFromEnvelope, + MULTIPLEXED_METRIC_ROUTING_KEY, + MULTIPLEXED_TRANSPORT_EXTRA_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 { SerializedMetric } from '../../../src/types-hoist/metric'; import type { BaseTransportOptions, Transport } from '../../../src/types-hoist/transport'; const DSN1 = 'https://1234@5678.ingest.sentry.io/4321'; @@ -321,3 +329,260 @@ 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(4); + + const makeTransport = makeMultiplexedTransport( + createTestTransport((url, _, envelope) => { + expect(url).toBe(DSN2_URL); + const metric = metricFromEnvelope(envelope); + expect(metric?.attributes?.['sentry.release']).toEqual({ type: 'string', value: 'cart@1.0.0' }); + expect(metric?.attributes?.[MULTIPLEXED_METRIC_ROUTING_KEY]).toBeUndefined(); + 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); + }); + + it('applies release to all metrics in batch', async () => { + expect.assertions(4); + + const makeTransport = makeMultiplexedTransport( + createTestTransport((url, _, envelope) => { + expect(url).toBe(DSN2_URL); + + const metrics: SerializedMetric[] = []; + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'trace_metric') { + const container = Array.isArray(item) ? (item[1] as any) : undefined; + if (container?.items) { + metrics.push(...container.items); + } + } + }); + + expect(metrics).toHaveLength(3); + expect(metrics[0]?.attributes?.['sentry.release']).toEqual({ type: 'string', value: 'batch@1.0.0' }); + expect(metrics[2]?.attributes?.['sentry.release']).toEqual({ type: 'string', value: 'batch@1.0.0' }); + }), + ); + + const metric1: SerializedMetric = { + ...METRIC, + name: 'metric1', + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'batch@1.0.0' }] as any, + }, + }; + + const metric2: SerializedMetric = { + ...METRIC, + name: 'metric2', + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'batch@1.0.0' }] as any, + }, + }; + + const metric3: SerializedMetric = { + ...METRIC, + name: 'metric3', + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'batch@1.0.0' }] as any, + }, + }; + + const envelope = createMetricEnvelope([metric1, metric2, metric3], undefined, undefined, undefined); + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(envelope); + }); + + it('strips routing attributes from all metrics before sending', async () => { + expect.assertions(5); + + const makeTransport = makeMultiplexedTransport( + createTestTransport((url, _, envelope) => { + expect(url).toBe(DSN2_URL); + + // Extract all metrics from the envelope + const metrics: SerializedMetric[] = []; + forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'trace_metric') { + const container = Array.isArray(item) ? (item[1] as any) : undefined; + if (container?.items) { + metrics.push(...container.items); + } + } + }); + + // Verify all metrics have routing attributes stripped + expect(metrics).toHaveLength(2); + expect(metrics[0]?.attributes?.[MULTIPLEXED_METRIC_ROUTING_KEY]).toBeUndefined(); + expect(metrics[1]?.attributes?.[MULTIPLEXED_METRIC_ROUTING_KEY]).toBeUndefined(); + // But other attributes should be preserved + expect(metrics[0]?.attributes?.['custom']).toBeDefined(); + }), + ); + + const metric1: SerializedMetric = { + ...METRIC, + name: 'metric1', + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'test@1.0.0' }] as any, + custom: { type: 'string', value: 'value1' }, + }, + }; + + const metric2: SerializedMetric = { + ...METRIC, + name: 'metric2', + attributes: { + [MULTIPLEXED_METRIC_ROUTING_KEY]: [{ dsn: DSN2, release: 'test@1.0.0' }] as any, + custom: { type: 'string', value: 'value2' }, + }, + }; + + const envelope = createMetricEnvelope([metric1, metric2], 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(); + }); +});