Skip to content
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {
setHttpStatus,
makeMultiplexedTransport,
MULTIPLEXED_TRANSPORT_EXTRA_KEY,
MULTIPLEXED_METRIC_ROUTING_KEY,
moduleMetadataIntegration,
supabaseIntegration,
instrumentSupabaseClient,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/metrics/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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';
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;

Expand Down Expand Up @@ -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<MetricRoutingInfo>;
}

/**
* 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<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!attributes) return attributes;

const { [MULTIPLEXED_METRIC_ROUTING_KEY]: _routing, ...rest } = attributes;
return rest;
}

/**
Expand Down Expand Up @@ -145,7 +164,7 @@ function _buildSerializedMetric(
value: metric.value,
attributes: {
...serializeAttributes(scopeAttributes),
...serializeAttributes(metric.attributes, 'skip-undefined'),
...serializeAttributes(_stripRoutingAttributes(metric.attributes), 'skip-undefined'),
},
};
}
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,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.
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
49 changes: 47 additions & 2 deletions packages/core/src/transports/multiplexed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 };
Expand All @@ -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.
*
Expand All @@ -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<TO extends BaseTransportOptions>(
createTransport: (options: TO) => Transport,
Expand All @@ -64,6 +89,15 @@ function makeOverrideReleaseTransport<TO extends BaseTransportOptions>(
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);
},
};
Expand Down Expand Up @@ -109,6 +143,13 @@ export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
) {
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 [];
});

Expand Down Expand Up @@ -142,7 +183,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 => {
if (typeof result === 'string') {
return getTransport(result, undefined);
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;
}
82 changes: 82 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,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');
});
});
Loading
Loading