Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
97bafa8
feat(metrics): initialise MULTIPLEXED_METRIC_ROUTING_KEY for routing
harshit078 Jan 29, 2026
ab12a06
Merge branch 'develop' into routing-to-different-dsn
harshit078 Jan 29, 2026
b77918f
Merge branch 'develop' into routing-to-different-dsn
harshit078 Jan 30, 2026
9b635b1
fix(metrics): resolve linting errors
harshit078 Jan 30, 2026
a7632a9
Merge branch 'develop' into routing-to-different-dsn
harshit078 Jan 30, 2026
37d54c2
Merge branch 'develop' into routing-to-different-dsn
harshit078 Jan 31, 2026
00f559f
fix(metrics): updated build files and added striping logic
harshit078 Jan 31, 2026
c2faff6
fix(metrics): linting errors
harshit078 Jan 31, 2026
8a82bfd
fix(metrics): add tests for multiplex
harshit078 Jan 31, 2026
d989887
fix(metrics): fix failing core eslint tests
harshit078 Feb 2, 2026
d9854cb
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 2, 2026
f845c7b
fix(metrics): fix failing tests
harshit078 Feb 2, 2026
1c836c5
fix(metrics): updated limit for failing size test
harshit078 Feb 2, 2026
350f662
fix(metrics): address comments left by cursor and sentry
harshit078 Feb 3, 2026
d38b5f5
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 3, 2026
cb71961
fix(metrics): address comments
harshit078 Feb 3, 2026
222973e
fix(metrics): address comments by cursor for race conditions
harshit078 Feb 3, 2026
6baf564
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 3, 2026
33c0d25
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 4, 2026
23800bf
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 4, 2026
b861ed2
fix(metrics): address comments
harshit078 Feb 5, 2026
e95310b
Merge branch 'develop' into routing-to-different-dsn
harshit078 Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
1 change: 0 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export {
getSpanStatusFromHttpCode,
setHttpStatus,
makeMultiplexedTransport,
MULTIPLEXED_TRANSPORT_EXTRA_KEY,
moduleMetadataIntegration,
supabaseIntegration,
instrumentSupabaseClient,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed public export MULTIPLEXED_TRANSPORT_EXTRA_KEY is breaking change

Medium Severity

The constant MULTIPLEXED_TRANSPORT_EXTRA_KEY was previously exported from @sentry/core and @sentry/browser but has been removed from the public exports. Existing consumers who import this constant will experience a breaking change. Per the review rules, removal of publicly exported constants requires proper deprecation notices.

Additional Locations (1)

Fix in Cursor Fix in Web

export { getIntegrationsToSetup, addIntegration, defineIntegration, installedIntegrations } from './integration';
export {
_INTERNAL_skipAiProviderWrapping,
Expand Down
17 changes: 12 additions & 5 deletions packages/core/src/metrics/public-api.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<MetricRoutingInfo>;
}

/**
Expand All @@ -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 });
}

/**
Expand Down
120 changes: 118 additions & 2 deletions packages/core/src/transports/multiplexed.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 };
Expand All @@ -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.
*
Expand All @@ -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];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this mean we always just pull the first metric?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it would mean we pull the first metric. What I'm thinking now after your comment is that I can add a logic which will check if all metrics and route to same or multiple destinations. Whats your opinion ?

}
}
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;
}
}
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable strippedCount incremented but never used

Low Severity

In stripRoutingAttributesFromMetrics, the variable strippedCount is declared and conditionally incremented via DEBUG_BUILD && strippedCount++ but is never used afterward. A reviewer mentioned adding a debug log here, suggesting this was intended for logging but was never completed. The variable serves no purpose without an accompanying log statement.

Fix in Cursor Fix in Web


/**
* Creates a transport that overrides the release on all events and metrics.
*/
function makeOverrideReleaseTransport<TO extends BaseTransportOptions>(
createTransport: (options: TO) => Transport,
Expand All @@ -64,6 +142,9 @@ function makeOverrideReleaseTransport<TO extends BaseTransportOptions>(
if (event) {
event.release = release;
}

applyReleaseToMetrics(envelope, release);

return transport.send(envelope);
},
};
Expand Down Expand Up @@ -109,6 +190,35 @@ export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
) {
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 [];
});

Expand Down Expand Up @@ -142,7 +252,11 @@ export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
return eventFromEnvelope(envelope, eventTypes);
}

const transports = actualMatcher({ envelope, getEvent })
function getMetric(): SerializedMetric | undefined {
return metricFromEnvelope(envelope);
}

const transports = actualMatcher({ envelope, getEvent, getMetric })
.map(result => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A race condition occurs when sending metrics to multiple transports with different releases. The shared envelope is mutated concurrently, leading to unpredictable release values on metrics.
Severity: HIGH

Suggested Fix

To prevent the race condition, ensure that each transport operates on a unique copy of the envelope's items. Before applyReleaseToMetrics is called, deep-clone the envelope's items array (envelope[1]) so that mutations within one transport's send method do not affect others.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/core/src/transports/multiplexed.ts#L146

Potential issue: A race condition exists when sending metrics to multiple transports
with different `release` versions. The `overrideDsn` function creates a new envelope
header for each transport but reuses the same reference to the envelope's items array.
When `makeOverrideReleaseTransport` calls `applyReleaseToMetrics`, it mutates this
shared array by replacing it with new metric objects containing the `release`. Because
multiple transports do this concurrently via `Promise.all`, the final `release` value on
the metrics is unpredictable, depending on which mutation finishes last. This leads to
data corruption where metrics can be assigned the wrong release version.

if (typeof result === 'string') {
return getTransport(result, undefined);
Expand All @@ -152,6 +266,8 @@ export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
})
.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]];
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/types-hoist/metric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,23 @@ 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;
}

export type SerializedMetricContainer = {
items: Array<SerializedMetric>;
};

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;
}
85 changes: 85 additions & 0 deletions packages/core/test/lib/metrics/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading