Skip to content

Commit 8b97f80

Browse files
authored
feat(core): Add multiplexed transport (#7926)
1 parent c0e5504 commit 8b97f80

File tree

3 files changed

+255
-0
lines changed

3 files changed

+255
-0
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export { BaseClient } from './baseclient';
3737
export { initAndBind } from './sdk';
3838
export { createTransport } from './transports/base';
3939
export { makeOfflineTransport } from './transports/offline';
40+
export { makeMultiplexedTransport } from './transports/multiplexed';
4041
export { SDK_VERSION } from './version';
4142
export { getIntegrationsToSetup } from './integration';
4243
export { FunctionToString, InboundFilters } from './integrations';
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type {
2+
BaseTransportOptions,
3+
Envelope,
4+
EnvelopeItemType,
5+
Event,
6+
EventItem,
7+
Transport,
8+
TransportMakeRequestResponse,
9+
} from '@sentry/types';
10+
import { dsnFromString, forEachEnvelopeItem } from '@sentry/utils';
11+
12+
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';
13+
14+
interface MatchParam {
15+
/** The envelope to be sent */
16+
envelope: Envelope;
17+
/**
18+
* A function that returns an event from the envelope if one exists. You can optionally pass an array of envelope item
19+
* types to filter by - only envelopes matching the given types will be multiplexed.
20+
*
21+
* @param types Defaults to ['event', 'transaction', 'profile', 'replay_event']
22+
*/
23+
getEvent(types?: EnvelopeItemType[]): Event | undefined;
24+
}
25+
26+
type Matcher = (param: MatchParam) => string[];
27+
28+
function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Event | undefined {
29+
let event: Event | undefined;
30+
31+
forEachEnvelopeItem(env, (item, type) => {
32+
if (types.includes(type)) {
33+
event = Array.isArray(item) ? (item as EventItem)[1] : undefined;
34+
}
35+
// bail out if we found an event
36+
return !!event;
37+
});
38+
39+
return event;
40+
}
41+
42+
/**
43+
* Creates a transport that can send events to different DSNs depending on the envelope contents.
44+
*/
45+
export function makeMultiplexedTransport<TO extends BaseTransportOptions>(
46+
createTransport: (options: TO) => Transport,
47+
matcher: Matcher,
48+
): (options: TO) => Transport {
49+
return options => {
50+
const fallbackTransport = createTransport(options);
51+
const otherTransports: Record<string, Transport> = {};
52+
53+
function getTransport(dsn: string): Transport {
54+
if (!otherTransports[dsn]) {
55+
const url = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(dsn));
56+
otherTransports[dsn] = createTransport({ ...options, url });
57+
}
58+
59+
return otherTransports[dsn];
60+
}
61+
62+
async function send(envelope: Envelope): Promise<void | TransportMakeRequestResponse> {
63+
function getEvent(types?: EnvelopeItemType[]): Event | undefined {
64+
const eventTypes: EnvelopeItemType[] =
65+
types && types.length ? types : ['event', 'transaction', 'profile', 'replay_event'];
66+
return eventFromEnvelope(envelope, eventTypes);
67+
}
68+
69+
const transports = matcher({ envelope, getEvent }).map(dsn => getTransport(dsn));
70+
71+
// If we have no transports to send to, use the fallback transport
72+
if (transports.length === 0) {
73+
transports.push(fallbackTransport);
74+
}
75+
76+
const results = await Promise.all(transports.map(transport => transport.send(envelope)));
77+
78+
return results[0];
79+
}
80+
81+
async function flush(timeout: number | undefined): Promise<boolean> {
82+
const allTransports = [...Object.keys(otherTransports).map(dsn => otherTransports[dsn]), fallbackTransport];
83+
const results = await Promise.all(allTransports.map(transport => transport.flush(timeout)));
84+
return results.every(r => r);
85+
}
86+
87+
return {
88+
send,
89+
flush,
90+
};
91+
};
92+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { BaseTransportOptions, ClientReport, EventEnvelope, EventItem, Transport } from '@sentry/types';
2+
import { createClientReportEnvelope, createEnvelope, dsnFromString } from '@sentry/utils';
3+
import { TextEncoder } from 'util';
4+
5+
import { createTransport, getEnvelopeEndpointWithUrlEncodedAuth, makeMultiplexedTransport } from '../../../src';
6+
7+
const DSN1 = 'https://[email protected]/4321';
8+
const DSN1_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN1));
9+
10+
const DSN2 = 'https://[email protected]/8765';
11+
const DSN2_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN2));
12+
13+
const ERROR_EVENT = { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' };
14+
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
15+
[{ type: 'event' }, ERROR_EVENT] as EventItem,
16+
]);
17+
18+
const TRANSACTION_ENVELOPE = createEnvelope<EventEnvelope>(
19+
{ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' },
20+
[[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem],
21+
);
22+
23+
const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [
24+
{
25+
reason: 'before_send',
26+
category: 'error',
27+
quantity: 30,
28+
},
29+
{
30+
reason: 'network_error',
31+
category: 'transaction',
32+
quantity: 23,
33+
},
34+
];
35+
36+
const CLIENT_REPORT_ENVELOPE = createClientReportEnvelope(
37+
DEFAULT_DISCARDED_EVENTS,
38+
'https://[email protected]/1337',
39+
123456,
40+
);
41+
42+
type Assertion = (url: string, body: string | Uint8Array) => void;
43+
44+
const createTestTransport = (...assertions: Assertion[]): ((options: BaseTransportOptions) => Transport) => {
45+
return (options: BaseTransportOptions) =>
46+
createTransport(options, request => {
47+
return new Promise(resolve => {
48+
const assertion = assertions.shift();
49+
if (!assertion) {
50+
throw new Error('No assertion left');
51+
}
52+
assertion(options.url, request.body);
53+
resolve({ statusCode: 200 });
54+
});
55+
});
56+
};
57+
58+
const transportOptions = {
59+
recordDroppedEvent: () => undefined, // noop
60+
textEncoder: new TextEncoder(),
61+
};
62+
63+
describe('makeMultiplexedTransport', () => {
64+
it('Falls back to options DSN when no match', async () => {
65+
expect.assertions(1);
66+
67+
const makeTransport = makeMultiplexedTransport(
68+
createTestTransport(url => {
69+
expect(url).toBe(DSN1_URL);
70+
}),
71+
() => [],
72+
);
73+
74+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
75+
await transport.send(ERROR_ENVELOPE);
76+
});
77+
78+
it('DSN can be overridden via match callback', async () => {
79+
expect.assertions(1);
80+
81+
const makeTransport = makeMultiplexedTransport(
82+
createTestTransport(url => {
83+
expect(url).toBe(DSN2_URL);
84+
}),
85+
() => [DSN2],
86+
);
87+
88+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
89+
await transport.send(ERROR_ENVELOPE);
90+
});
91+
92+
it('match callback can return multiple DSNs', async () => {
93+
expect.assertions(2);
94+
95+
const makeTransport = makeMultiplexedTransport(
96+
createTestTransport(
97+
url => {
98+
expect(url).toBe(DSN1_URL);
99+
},
100+
url => {
101+
expect(url).toBe(DSN2_URL);
102+
},
103+
),
104+
() => [DSN1, DSN2],
105+
);
106+
107+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
108+
await transport.send(ERROR_ENVELOPE);
109+
});
110+
111+
it('callback getEvent returns event', async () => {
112+
expect.assertions(3);
113+
114+
const makeTransport = makeMultiplexedTransport(
115+
createTestTransport(url => {
116+
expect(url).toBe(DSN2_URL);
117+
}),
118+
({ envelope, getEvent }) => {
119+
expect(envelope).toBe(ERROR_ENVELOPE);
120+
expect(getEvent()).toBe(ERROR_EVENT);
121+
return [DSN2];
122+
},
123+
);
124+
125+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
126+
await transport.send(ERROR_ENVELOPE);
127+
});
128+
129+
it('callback getEvent returns undefined if not event', async () => {
130+
expect.assertions(2);
131+
132+
const makeTransport = makeMultiplexedTransport(
133+
createTestTransport(url => {
134+
expect(url).toBe(DSN2_URL);
135+
}),
136+
({ getEvent }) => {
137+
expect(getEvent()).toBeUndefined();
138+
return [DSN2];
139+
},
140+
);
141+
142+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
143+
await transport.send(CLIENT_REPORT_ENVELOPE);
144+
});
145+
146+
it('callback getEvent can ignore transactions', async () => {
147+
expect.assertions(2);
148+
149+
const makeTransport = makeMultiplexedTransport(
150+
createTestTransport(url => {
151+
expect(url).toBe(DSN2_URL);
152+
}),
153+
({ getEvent }) => {
154+
expect(getEvent(['event'])).toBeUndefined();
155+
return [DSN2];
156+
},
157+
);
158+
159+
const transport = makeTransport({ url: DSN1_URL, ...transportOptions });
160+
await transport.send(TRANSACTION_ENVELOPE);
161+
});
162+
});

0 commit comments

Comments
 (0)