Skip to content

feat(core): Add multiplexed transport #7926

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 25, 2023
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));
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];
}

async function flush(timeout: number | undefined): Promise<boolean> {
const allTransports = [...Object.keys(otherTransports).map(dsn => otherTransports[dsn]), fallbackTransport];
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://[email protected]/4321';
const DSN1_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN1));

const DSN2 = 'https://[email protected]/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://[email protected]/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);
});
});