Skip to content
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export { BaseClient } from './baseclient';
export { initAndBind } from './sdk';
export { createTransport } from './transports/base';
export { makeOfflineTransport } from './transports/offline';
export { makeMultiplexedTransport } from './transports/multiplexed';
export { SDK_VERSION } from './version';
export { getIntegrationsToSetup } from './integration';
export { FunctionToString, InboundFilters } from './integrations';
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/transports/multiplexed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type {
BaseTransportOptions,
Envelope,
EnvelopeItemType,
Event,
EventItem,
Transport,
TransportMakeRequestResponse,
} from '@sentry/types';
import { dsnFromString, forEachEnvelopeItem } from '@sentry/utils';

import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';

interface MatchParam {
/** The envelope to be sent */
envelope: Envelope;
/**
* A function that returns an event from the envelope if one exists. You can optionally pass an array of envelope item
* types to filter by - only envelopes matching the given types will be multiplexed.
*
* @param types Defaults to ['event', 'transaction', 'profile', 'replay_event']
*/
getEvent(types?: EnvelopeItemType[]): Event | undefined;
}

type Matcher = (param: MatchParam) => string[];

function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Event | undefined {
let event: Event | undefined;

forEachEnvelopeItem(env, (item, type) => {
if (types.includes(type)) {
event = Array.isArray(item) ? (item as EventItem)[1] : undefined;
}
// bail out if we found an event
return !!event;
});

return event;
}

/**
* Creates a transport that can send events to different DSNs depending on the envelope contents.
*/
export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
createTransport: (options: TO) => Transport,
matcher: Matcher,
): (options: TO) => Transport {
return options => {
const fallbackTransport = createTransport(options);
const otherTransports: Record<string, Transport> = {};

function getTransport(dsn: string): Transport {
if (!otherTransports[dsn]) {
const url = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(dsn));
Comment thread
timfish marked this conversation as resolved.
otherTransports[dsn] = createTransport({ ...options, url });
}

return otherTransports[dsn];
}

async function send(envelope: Envelope): Promise<void | TransportMakeRequestResponse> {
function getEvent(types?: EnvelopeItemType[]): Event | undefined {
const eventTypes: EnvelopeItemType[] =
types && types.length ? types : ['event', 'transaction', 'profile', 'replay_event'];
return eventFromEnvelope(envelope, eventTypes);
}

const transports = matcher({ envelope, getEvent }).map(dsn => getTransport(dsn));

// If we have no transports to send to, use the fallback transport
if (transports.length === 0) {
transports.push(fallbackTransport);
}

const results = await Promise.all(transports.map(transport => transport.send(envelope)));

return results[0];
Comment thread
timfish marked this conversation as resolved.
}

async function flush(timeout: number | undefined): Promise<boolean> {
const allTransports = [...Object.keys(otherTransports).map(dsn => otherTransports[dsn]), fallbackTransport];
Comment thread
timfish marked this conversation as resolved.
const results = await Promise.all(allTransports.map(transport => transport.flush(timeout)));
return results.every(r => r);
}

return {
send,
flush,
};
};
}
162 changes: 162 additions & 0 deletions packages/core/test/lib/transports/multiplexed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { BaseTransportOptions, ClientReport, EventEnvelope, EventItem, Transport } from '@sentry/types';
import { createClientReportEnvelope, createEnvelope, dsnFromString } from '@sentry/utils';
import { TextEncoder } from 'util';

import { createTransport, getEnvelopeEndpointWithUrlEncodedAuth, makeMultiplexedTransport } from '../../../src';

const DSN1 = 'https://1234@5678.ingest.sentry.io/4321';
const DSN1_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN1));

const DSN2 = 'https://5678@1234.ingest.sentry.io/8765';
const DSN2_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN2));

const ERROR_EVENT = { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' };
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
[{ type: 'event' }, ERROR_EVENT] as EventItem,
]);

const TRANSACTION_ENVELOPE = createEnvelope<EventEnvelope>(
{ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' },
[[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem],
);

const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [
{
reason: 'before_send',
category: 'error',
quantity: 30,
},
{
reason: 'network_error',
category: 'transaction',
quantity: 23,
},
];

const CLIENT_REPORT_ENVELOPE = createClientReportEnvelope(
DEFAULT_DISCARDED_EVENTS,
'https://public@dsn.ingest.sentry.io/1337',
123456,
);

type Assertion = (url: string, body: string | Uint8Array) => void;

const createTestTransport = (...assertions: Assertion[]): ((options: BaseTransportOptions) => Transport) => {
return (options: BaseTransportOptions) =>
createTransport(options, request => {
return new Promise(resolve => {
const assertion = assertions.shift();
if (!assertion) {
throw new Error('No assertion left');
}
assertion(options.url, request.body);
resolve({ statusCode: 200 });
});
});
};

const transportOptions = {
recordDroppedEvent: () => undefined, // noop
textEncoder: new TextEncoder(),
};

describe('makeMultiplexedTransport', () => {
it('Falls back to options DSN when no match', async () => {
expect.assertions(1);

const makeTransport = makeMultiplexedTransport(
createTestTransport(url => {
expect(url).toBe(DSN1_URL);
}),
() => [],
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(ERROR_ENVELOPE);
});

it('DSN can be overridden via match callback', async () => {
expect.assertions(1);

const makeTransport = makeMultiplexedTransport(
createTestTransport(url => {
expect(url).toBe(DSN2_URL);
}),
() => [DSN2],
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(ERROR_ENVELOPE);
});

it('match callback can return multiple DSNs', async () => {
expect.assertions(2);

const makeTransport = makeMultiplexedTransport(
createTestTransport(
url => {
expect(url).toBe(DSN1_URL);
},
url => {
expect(url).toBe(DSN2_URL);
},
),
() => [DSN1, DSN2],
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(ERROR_ENVELOPE);
});

it('callback getEvent returns event', async () => {
expect.assertions(3);

const makeTransport = makeMultiplexedTransport(
createTestTransport(url => {
expect(url).toBe(DSN2_URL);
}),
({ envelope, getEvent }) => {
expect(envelope).toBe(ERROR_ENVELOPE);
expect(getEvent()).toBe(ERROR_EVENT);
return [DSN2];
},
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(ERROR_ENVELOPE);
});

it('callback getEvent returns undefined if not event', async () => {
expect.assertions(2);

const makeTransport = makeMultiplexedTransport(
createTestTransport(url => {
expect(url).toBe(DSN2_URL);
}),
({ getEvent }) => {
expect(getEvent()).toBeUndefined();
return [DSN2];
},
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(CLIENT_REPORT_ENVELOPE);
});

it('callback getEvent can ignore transactions', async () => {
expect.assertions(2);

const makeTransport = makeMultiplexedTransport(
createTestTransport(url => {
expect(url).toBe(DSN2_URL);
}),
({ getEvent }) => {
expect(getEvent(['event'])).toBeUndefined();
return [DSN2];
},
);

const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
await transport.send(TRANSACTION_ENVELOPE);
});
});