From 89e06ea2186ca288cfdd33a1037281da1024d956 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 20 Apr 2023 23:24:45 +0200 Subject: [PATCH 1/7] feat(core): Add multiplexed transport --- packages/core/src/transports/multiplexed.ts | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/core/src/transports/multiplexed.ts diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts new file mode 100644 index 000000000000..d379c9071f60 --- /dev/null +++ b/packages/core/src/transports/multiplexed.ts @@ -0,0 +1,84 @@ +import type { + BaseTransportOptions, + Envelope, + 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 + getEvent(): Event | undefined; +} + +type Matcher = (param: MatchParam) => string[]; + +function eventFromEnvelope(env: Envelope): Event | undefined { + let event: Event | undefined; + + forEachEnvelopeItem(env, (item, type) => { + if (type === 'event' || type === 'transaction') { + 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( + createTransport: (options: TO) => Transport, + matcher: Matcher, +): (options: TO) => Transport { + return options => { + const fallbackTransport = createTransport(options); + const otherTransports: Record = {}; + + 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 { + function getEvent(): Event | undefined { + return eventFromEnvelope(envelope); + } + + 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 { + 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, + }; + }; +} From 005a15daf18f6d96b91debb4b50b415dd75abf99 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 24 Apr 2023 16:36:26 +0200 Subject: [PATCH 2/7] add tests and export --- packages/core/src/index.ts | 1 + packages/core/src/transports/multiplexed.ts | 4 +- .../test/lib/transports/multiplexed.test.ts | 125 ++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/lib/transports/multiplexed.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04fc78e12f12..1acf0264f69b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index d379c9071f60..080ead563832 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -23,7 +23,7 @@ function eventFromEnvelope(env: Envelope): Event | undefined { let event: Event | undefined; forEachEnvelopeItem(env, (item, type) => { - if (type === 'event' || type === 'transaction') { + if (type === 'event') { event = Array.isArray(item) ? (item as EventItem)[1] : undefined; } // bail out if we found an event @@ -58,7 +58,7 @@ export function makeMultiplexedTransport( return eventFromEnvelope(envelope); } - const transports = matcher({ envelope, getEvent }).map(([dsn]) => getTransport(dsn)); + 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) { diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts new file mode 100644 index 000000000000..a0d1f6d1f77a --- /dev/null +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -0,0 +1,125 @@ +import type { BaseTransportOptions, EventEnvelope, EventItem, Transport } from '@sentry/types'; +import { createEnvelope, dsnFromString } from '@sentry/utils'; + +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({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, ERROR_EVENT] as EventItem, +]); + +const TRANSACTION_ENVELOPE = createEnvelope( + { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, + [[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem], +); + +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(TRANSACTION_ENVELOPE); + }); +}); From 1da4baac6f5bfea65196965bffb889a1aaa1d9c4 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 24 Apr 2023 16:57:42 +0200 Subject: [PATCH 3/7] import TextEncoder --- packages/core/test/lib/transports/multiplexed.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index a0d1f6d1f77a..657c98e38577 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -1,5 +1,6 @@ import type { BaseTransportOptions, EventEnvelope, EventItem, Transport } from '@sentry/types'; import { createEnvelope, dsnFromString } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { createTransport, getEnvelopeEndpointWithUrlEncodedAuth, makeMultiplexedTransport } from '../../../src'; From f5150e4c11dd013bda9916e1e2f15e817f14da7b Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 25 Apr 2023 11:50:40 +0200 Subject: [PATCH 4/7] Allow getting specific types of event and default to `event|transaction|profile|replay_event` --- packages/core/src/transports/multiplexed.ts | 14 ++++++---- .../test/lib/transports/multiplexed.test.ts | 26 ++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index 080ead563832..11e1c763c1b2 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -1,6 +1,7 @@ import type { BaseTransportOptions, Envelope, + EnvelopeItemType, Event, EventItem, Transport, @@ -14,16 +15,16 @@ interface MatchParam { // The envelope to be sent envelope: Envelope; // A function that returns an event from the envelope if one exists - getEvent(): Event | undefined; + getEvent(...types: EnvelopeItemType[]): Event | undefined; } type Matcher = (param: MatchParam) => string[]; -function eventFromEnvelope(env: Envelope): Event | undefined { +function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Event | undefined { let event: Event | undefined; forEachEnvelopeItem(env, (item, type) => { - if (type === 'event') { + if (types.includes(type)) { event = Array.isArray(item) ? (item as EventItem)[1] : undefined; } // bail out if we found an event @@ -54,8 +55,11 @@ export function makeMultiplexedTransport( } async function send(envelope: Envelope): Promise { - function getEvent(): Event | undefined { - return eventFromEnvelope(envelope); + function getEvent(...types: EnvelopeItemType[]): Event | undefined { + const eventTypes: EnvelopeItemType[] = types.length + ? types + : ['event', 'transaction', 'profile', 'replay_event']; + return eventFromEnvelope(envelope, eventTypes); } const transports = matcher({ envelope, getEvent }).map(dsn => getTransport(dsn)); diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index 657c98e38577..b4294a504ff2 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -1,5 +1,5 @@ -import type { BaseTransportOptions, EventEnvelope, EventItem, Transport } from '@sentry/types'; -import { createEnvelope, dsnFromString } from '@sentry/utils'; +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'; @@ -15,9 +15,23 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, ERROR_EVENT] as EventItem, ]); -const TRANSACTION_ENVELOPE = createEnvelope( - { 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; @@ -121,6 +135,6 @@ describe('makeMultiplexedTransport', () => { ); const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); - await transport.send(TRANSACTION_ENVELOPE); + await transport.send(CLIENT_REPORT_ENVELOPE); }); }); From 0951162b2f352497ab58e8b797d3b89ed18d0459 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 25 Apr 2023 11:52:19 +0200 Subject: [PATCH 5/7] Use jsdoc --- packages/core/src/transports/multiplexed.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index 11e1c763c1b2..5ac82f51b2e9 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -12,9 +12,12 @@ import { dsnFromString, forEachEnvelopeItem } from '@sentry/utils'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; interface MatchParam { - // The envelope to be sent + /** The envelope to be sent */ envelope: Envelope; - // A function that returns an event from the envelope if one exists + /** + * A function that returns an event from the envelope if one exists + * @param types Defaults to ['event', 'transaction', 'profile', 'replay_event'] + */ getEvent(...types: EnvelopeItemType[]): Event | undefined; } From 89dbdd6dc0e74a68756bc86a2c0ccfc99586e079 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 25 Apr 2023 13:46:51 +0200 Subject: [PATCH 6/7] Add test case that can filter by event type --- .../test/lib/transports/multiplexed.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index b4294a504ff2..ace226becea2 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -15,6 +15,11 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, ERROR_EVENT] as EventItem, ]); +const TRANSACTION_ENVELOPE = createEnvelope( + { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, + [[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem], +); + const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [ { reason: 'before_send', @@ -137,4 +142,21 @@ describe('makeMultiplexedTransport', () => { 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); + }); }); From 7271604cb02bd3ecb4f6bf3f5330164af6245e93 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 25 Apr 2023 13:51:06 +0200 Subject: [PATCH 7/7] Changes from PR review --- packages/core/src/transports/multiplexed.ts | 13 +++++++------ .../core/test/lib/transports/multiplexed.test.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index 5ac82f51b2e9..859101bf56bc 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -15,10 +15,12 @@ interface MatchParam { /** The envelope to be sent */ envelope: Envelope; /** - * A function that returns an event from the envelope if one exists + * 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; + getEvent(types?: EnvelopeItemType[]): Event | undefined; } type Matcher = (param: MatchParam) => string[]; @@ -58,10 +60,9 @@ export function makeMultiplexedTransport( } async function send(envelope: Envelope): Promise { - function getEvent(...types: EnvelopeItemType[]): Event | undefined { - const eventTypes: EnvelopeItemType[] = types.length - ? types - : ['event', 'transaction', 'profile', 'replay_event']; + function getEvent(types?: EnvelopeItemType[]): Event | undefined { + const eventTypes: EnvelopeItemType[] = + types && types.length ? types : ['event', 'transaction', 'profile', 'replay_event']; return eventFromEnvelope(envelope, eventTypes); } diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index ace226becea2..0849af8a81f7 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -151,7 +151,7 @@ describe('makeMultiplexedTransport', () => { expect(url).toBe(DSN2_URL); }), ({ getEvent }) => { - expect(getEvent('event')).toBeUndefined(); + expect(getEvent(['event'])).toBeUndefined(); return [DSN2]; }, );