Skip to content

Commit 0acf10c

Browse files
feat(browser): Envelope tunnel support for browser (#3521)
* feat(browser): Envelope tunnel support for browser * Make envelopeTunnel generic and add tests * Add fetch/xhr and http/https transports tests for tunnel * ref: Rename envelopeTunnel to tunnel Co-authored-by: Kamil Ogórek <[email protected]>
1 parent 6261953 commit 0acf10c

File tree

14 files changed

+139
-20
lines changed

14 files changed

+139
-20
lines changed

packages/browser/src/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class BrowserBackend extends BaseBackend<BrowserOptions> {
6161
const transportOptions = {
6262
...this._options.transportOptions,
6363
dsn: this._options.dsn,
64+
tunnel: this._options.tunnel,
6465
_metadata: this._options._metadata,
6566
};
6667

packages/browser/src/transports/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export abstract class BaseTransport implements Transport {
3535
protected readonly _rateLimits: Record<string, Date> = {};
3636

3737
public constructor(public options: TransportOptions) {
38-
this._api = new API(options.dsn, options._metadata);
38+
this._api = new API(options.dsn, options._metadata, options.tunnel);
3939
// eslint-disable-next-line deprecation/deprecation
4040
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();
4141
}

packages/browser/test/unit/transports/fetch.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Event, Status, Transports } from '../../../src';
55

66
const testDsn = 'https://[email protected]/42';
77
const storeUrl = 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7';
8+
const tunnel = 'https://hello.com/world';
89
const eventPayload: Event = {
910
event_id: '1337',
1011
};
@@ -50,6 +51,15 @@ describe('FetchTransport', () => {
5051
).equal(true);
5152
});
5253

54+
it('sends a request to tunnel if configured', async () => {
55+
transport = new Transports.FetchTransport({ dsn: testDsn, tunnel }, window.fetch);
56+
fetch.returns(Promise.resolve({ status: 200, headers: new Headers() }));
57+
58+
await transport.sendEvent(eventPayload);
59+
60+
expect(fetch.calledWith(tunnel)).equal(true);
61+
});
62+
5363
it('rejects with non-200 status code', async () => {
5464
const response = { status: 403, headers: new Headers() };
5565

packages/browser/test/unit/transports/xhr.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Event, Status, Transports } from '../../../src';
66
const testDsn = 'https://[email protected]/42';
77
const storeUrl = 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7';
88
const envelopeUrl = 'https://sentry.io/api/42/envelope/?sentry_key=123&sentry_version=7';
9+
const tunnel = 'https://hello.com/world';
910
const eventPayload: Event = {
1011
event_id: '1337',
1112
};
@@ -46,6 +47,15 @@ describe('XHRTransport', () => {
4647
expect(JSON.parse(request.requestBody)).deep.equal(eventPayload);
4748
});
4849

50+
it('sends a request to tunnel if configured', async () => {
51+
transport = new Transports.XHRTransport({ dsn: testDsn, tunnel });
52+
server.respondWith('POST', tunnel, [200, {}, '']);
53+
54+
await transport.sendEvent(eventPayload);
55+
56+
expect(server.requests[0].url).equal(tunnel);
57+
});
58+
4959
it('rejects with non-200 status code', async () => {
5060
server.respondWith('POST', storeUrl, [403, {}, '']);
5161

packages/core/src/api.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,30 @@ export class API {
1818
/** The internally used Dsn object. */
1919
private readonly _dsnObject: Dsn;
2020

21+
/** The envelope tunnel to use. */
22+
private readonly _tunnel?: string;
23+
2124
/** Create a new instance of API */
22-
public constructor(dsn: DsnLike, metadata: SdkMetadata = {}) {
25+
public constructor(dsn: DsnLike, metadata: SdkMetadata = {}, tunnel?: string) {
2326
this.dsn = dsn;
2427
this._dsnObject = new Dsn(dsn);
2528
this.metadata = metadata;
29+
this._tunnel = tunnel;
2630
}
2731

2832
/** Returns the Dsn object. */
2933
public getDsn(): Dsn {
3034
return this._dsnObject;
3135
}
3236

37+
/** Does this transport force envelopes? */
38+
public forceEnvelope(): boolean {
39+
return !!this._tunnel;
40+
}
41+
3342
/** Returns the prefix to construct Sentry ingestion API endpoints. */
3443
public getBaseApiEndpoint(): string {
35-
const dsn = this._dsnObject;
44+
const dsn = this.getDsn();
3645
const protocol = dsn.protocol ? `${dsn.protocol}:` : '';
3746
const port = dsn.port ? `:${dsn.port}` : '';
3847
return `${protocol}//${dsn.host}${port}${dsn.path ? `/${dsn.path}` : ''}/api/`;
@@ -58,12 +67,16 @@ export class API {
5867
* Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests.
5968
*/
6069
public getEnvelopeEndpointWithUrlEncodedAuth(): string {
70+
if (this.forceEnvelope()) {
71+
return this._tunnel as string;
72+
}
73+
6174
return `${this._getEnvelopeEndpoint()}?${this._encodedAuth()}`;
6275
}
6376

6477
/** Returns only the path component for the store endpoint. */
6578
public getStoreEndpointPath(): string {
66-
const dsn = this._dsnObject;
79+
const dsn = this.getDsn();
6780
return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`;
6881
}
6982

@@ -73,7 +86,7 @@ export class API {
7386
*/
7487
public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } {
7588
// CHANGE THIS to use metadata but keep clientName and clientVersion compatible
76-
const dsn = this._dsnObject;
89+
const dsn = this.getDsn();
7790
const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`];
7891
header.push(`sentry_client=${clientName}/${clientVersion}`);
7992
header.push(`sentry_key=${dsn.publicKey}`);
@@ -94,7 +107,7 @@ export class API {
94107
user?: { name?: string; email?: string };
95108
} = {},
96109
): string {
97-
const dsn = this._dsnObject;
110+
const dsn = this.getDsn();
98111
const endpoint = `${this.getBaseApiEndpoint()}embed/error-page/`;
99112

100113
const encodedOptions = [];
@@ -132,14 +145,17 @@ export class API {
132145

133146
/** Returns the ingest API endpoint for target. */
134147
private _getIngestEndpoint(target: 'store' | 'envelope'): string {
148+
if (this._tunnel) {
149+
return this._tunnel;
150+
}
135151
const base = this.getBaseApiEndpoint();
136-
const dsn = this._dsnObject;
152+
const dsn = this.getDsn();
137153
return `${base}${dsn.projectId}/${target}/`;
138154
}
139155

140156
/** Returns a URL-encoded string with auth config suitable for a query string. */
141157
private _encodedAuth(): string {
142-
const dsn = this._dsnObject;
158+
const dsn = this.getDsn();
143159
const auth = {
144160
// We send only the minimum set of required information. See
145161
// https://github.com/getsentry/sentry-javascript/issues/2572.

packages/core/src/request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function sessionToSentryRequest(session: Session | SessionAggregates, api
5151
export function eventToSentryRequest(event: Event, api: API): SentryRequest {
5252
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);
5353
const eventType = event.type || 'event';
54-
const useEnvelope = eventType === 'transaction';
54+
const useEnvelope = eventType === 'transaction' || api.forceEnvelope();
5555

5656
const { transactionSampling, ...metadata } = event.debug_meta || {};
5757
const { method: samplingMethod, rate: sampleRate } = transactionSampling || {};
@@ -78,6 +78,7 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest {
7878
event_id: event.event_id,
7979
sent_at: new Date().toISOString(),
8080
...(sdkInfo && { sdk: sdkInfo }),
81+
...(api.forceEnvelope() && { dsn: api.getDsn().toString() }),
8182
});
8283
const itemHeaders = JSON.stringify({
8384
type: event.type,

packages/core/test/lib/api.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { API } from '../../src/api';
55
const ingestDsn = 'https://[email protected]:1234/subpath/123';
66
const dsnPublic = 'https://[email protected]:1234/subpath/123';
77
const legacyDsn = 'https://abc:[email protected]:1234/subpath/123';
8+
const tunnel = 'https://hello.com/world';
89

910
describe('API', () => {
1011
test('getStoreEndpoint', () => {
@@ -15,6 +16,13 @@ describe('API', () => {
1516
expect(new API(ingestDsn).getStoreEndpoint()).toEqual('https://xxxx.ingest.sentry.io:1234/subpath/api/123/store/');
1617
});
1718

19+
test('getEnvelopeEndpoint', () => {
20+
expect(new API(dsnPublic).getEnvelopeEndpointWithUrlEncodedAuth()).toEqual(
21+
'https://sentry.io:1234/subpath/api/123/envelope/?sentry_key=abc&sentry_version=7',
22+
);
23+
expect(new API(dsnPublic, {}, tunnel).getEnvelopeEndpointWithUrlEncodedAuth()).toEqual(tunnel);
24+
});
25+
1826
test('getRequestHeaders', () => {
1927
expect(new API(dsnPublic).getRequestHeaders('a', '1.0')).toMatchObject({
2028
'Content-Type': 'application/json',

packages/core/test/lib/request.test.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,33 @@ const api = new API('https://[email protected]
1212
},
1313
});
1414

15+
const ingestDsn = 'https://[email protected]/12312012';
16+
const tunnel = 'https://hello.com/world';
17+
18+
function parseEnvelopeRequest(request: SentryRequest): any {
19+
const [envelopeHeaderString, itemHeaderString, eventString] = request.body.split('\n');
20+
21+
return {
22+
envelopeHeader: JSON.parse(envelopeHeaderString),
23+
itemHeader: JSON.parse(itemHeaderString),
24+
event: JSON.parse(eventString),
25+
};
26+
}
27+
1528
describe('eventToSentryRequest', () => {
29+
let api: API;
1630
let event: Event;
17-
function parseEnvelopeRequest(request: SentryRequest): any {
18-
const [envelopeHeaderString, itemHeaderString, eventString] = request.body.split('\n');
19-
20-
return {
21-
envelopeHeader: JSON.parse(envelopeHeaderString),
22-
itemHeader: JSON.parse(itemHeaderString),
23-
event: JSON.parse(eventString),
24-
};
25-
}
2631

2732
beforeEach(() => {
33+
api = new API(ingestDsn, {
34+
sdk: {
35+
integrations: ['AWSLambda'],
36+
name: 'sentry.javascript.browser',
37+
version: `12.31.12`,
38+
packages: [{ name: 'npm:@sentry/browser', version: `12.31.12` }],
39+
},
40+
});
41+
2842
event = {
2943
contexts: { trace: { trace_id: '1231201211212012', span_id: '12261980', op: 'pageload' } },
3044
environment: 'dogpark',
@@ -37,7 +51,7 @@ describe('eventToSentryRequest', () => {
3751
};
3852
});
3953

40-
it(`adds transaction sampling information to item header`, () => {
54+
it('adds transaction sampling information to item header', () => {
4155
event.debug_meta = { transactionSampling: { method: TransactionSamplingMethod.Rate, rate: 0.1121 } };
4256

4357
const result = eventToSentryRequest(event, api);
@@ -124,6 +138,27 @@ describe('eventToSentryRequest', () => {
124138
}),
125139
);
126140
});
141+
142+
it('uses tunnel as the url if it is configured', () => {
143+
api = new API(ingestDsn, {}, tunnel);
144+
145+
const result = eventToSentryRequest(event, api);
146+
147+
expect(result.url).toEqual(tunnel);
148+
});
149+
150+
it('adds dsn to envelope header if tunnel is configured', () => {
151+
api = new API(ingestDsn, {}, tunnel);
152+
153+
const result = eventToSentryRequest(event, api);
154+
const envelope = parseEnvelopeRequest(result);
155+
156+
expect(envelope.envelopeHeader).toEqual(
157+
expect.objectContaining({
158+
dsn: ingestDsn,
159+
}),
160+
);
161+
});
127162
});
128163

129164
describe('sessionToSentryRequest', () => {

packages/node/src/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export class NodeBackend extends BaseBackend<NodeOptions> {
116116
...(this._options.httpsProxy && { httpsProxy: this._options.httpsProxy }),
117117
...(this._options.caCerts && { caCerts: this._options.caCerts }),
118118
dsn: this._options.dsn,
119+
tunnel: this._options.tunnel,
119120
_metadata: this._options._metadata,
120121
};
121122

packages/node/src/transports/base/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export abstract class BaseTransport implements Transport {
5151

5252
/** Create instance and set this.dsn */
5353
public constructor(public options: TransportOptions) {
54-
this._api = new API(options.dsn, options._metadata);
54+
this._api = new API(options.dsn, options._metadata, options.tunnel);
5555
}
5656

5757
/** Default function used to parse URLs */

packages/node/test/transports/http.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const mockSetEncoding = jest.fn();
1010
const dsn = 'http://[email protected]:8989/mysubpath/50622';
1111
const storePath = '/mysubpath/api/50622/store/';
1212
const envelopePath = '/mysubpath/api/50622/envelope/';
13+
const tunnel = 'https://hello.com/world';
1314
const eventPayload: Event = {
1415
event_id: '1337',
1516
};
@@ -159,6 +160,19 @@ describe('HTTPTransport', () => {
159160
}
160161
});
161162

163+
test('sends a request to tunnel if configured', async () => {
164+
const transport = createTransport({ dsn, tunnel });
165+
166+
await transport.sendEvent({
167+
message: 'test',
168+
});
169+
170+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
171+
expect(requestOptions.protocol).toEqual('https:');
172+
expect(requestOptions.hostname).toEqual('hello.com');
173+
expect(requestOptions.path).toEqual('/world');
174+
});
175+
162176
test('back-off using retry-after header', async () => {
163177
const retryAfterSeconds = 10;
164178
mockReturnCode = 429;

packages/node/test/transports/https.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const mockSetEncoding = jest.fn();
1010
const dsn = 'https://[email protected]:8989/mysubpath/50622';
1111
const storePath = '/mysubpath/api/50622/store/';
1212
const envelopePath = '/mysubpath/api/50622/envelope/';
13+
const tunnel = 'https://hello.com/world';
1314
const sessionsPayload: SessionAggregates = {
1415
attrs: { environment: 'test', release: '1.0' },
1516
aggregates: [{ started: '2021-03-17T16:00:00.000Z', exited: 1 }],
@@ -144,6 +145,19 @@ describe('HTTPSTransport', () => {
144145
}
145146
});
146147

148+
test('sends a request to tunnel if configured', async () => {
149+
const transport = createTransport({ dsn, tunnel });
150+
151+
await transport.sendEvent({
152+
message: 'test',
153+
});
154+
155+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
156+
expect(requestOptions.protocol).toEqual('https:');
157+
expect(requestOptions.hostname).toEqual('hello.com');
158+
expect(requestOptions.path).toEqual('/world');
159+
});
160+
147161
test('back-off using retry-after header', async () => {
148162
const retryAfterSeconds = 10;
149163
mockReturnCode = 429;

packages/types/src/options.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ export interface Options {
5757
*/
5858
transportOptions?: TransportOptions;
5959

60+
/**
61+
* A URL to an envelope tunnel endpoint. An envelope tunnel is an HTTP endpoint
62+
* that accepts Sentry envelopes for forwarding. This can be used to force data
63+
* through a custom server independent of the type of data.
64+
*/
65+
tunnel?: string;
66+
6067
/**
6168
* The release identifier used when uploading respective source maps. Specify
6269
* this value to allow Sentry to resolve the correct source maps when

packages/types/src/transport.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface TransportOptions {
4545
caCerts?: string;
4646
/** Fetch API init parameters */
4747
fetchParameters?: { [key: string]: string };
48+
/** The envelope tunnel to use. */
49+
tunnel?: string;
4850
/**
4951
* Set of metadata about the SDK that can be internally used to enhance envelopes and events,
5052
* and provide additional data about every request.

0 commit comments

Comments
 (0)