Skip to content

Commit 15c0c69

Browse files
committed
feat: Send transactions in envelopes
The new Envelope endpoint and format is what we want to use for transactions going forward.
1 parent 74036ce commit 15c0c69

File tree

14 files changed

+166
-49
lines changed

14 files changed

+166
-49
lines changed

packages/browser/src/integrations/breadcrumbs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export class Breadcrumbs implements Integration {
170170
const client = getCurrentHub().getClient<BrowserClient>();
171171
const dsn = client && client.getDsn();
172172
if (this._options.sentry && dsn) {
173-
const filterUrl = new API(dsn).getStoreEndpoint();
173+
const filterUrl = new API(dsn).getBaseApiEndpoint();
174174
// if Sentry key appears in URL, don't capture it as a request
175175
// but rather as our own 'sentry' type breadcrumb
176176
if (

packages/browser/src/transports/base.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@ import { PromiseBuffer, SentryError } from '@sentry/utils';
55
/** Base Transport class implementation */
66
export abstract class BaseTransport implements Transport {
77
/**
8-
* @inheritDoc
8+
* @deprecated
99
*/
1010
public url: string;
1111

12+
/** Helper to get Sentry API endpoints. */
13+
protected readonly _api: API;
14+
1215
/** A simple buffer holding all requests. */
1316
protected readonly _buffer: PromiseBuffer<Response> = new PromiseBuffer(30);
1417

1518
public constructor(public options: TransportOptions) {
16-
this.url = new API(this.options.dsn).getStoreEndpointWithUrlEncodedAuth();
19+
this._api = new API(this.options.dsn);
20+
// tslint:disable-next-line:deprecation
21+
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();
1722
}
1823

1924
/**

packages/browser/src/transports/fetch.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { eventToSentryRequest } from '@sentry/core';
12
import { Event, Response, Status } from '@sentry/types';
23
import { getGlobalObject, logger, parseRetryAfterHeader, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
34

@@ -22,8 +23,10 @@ export class FetchTransport extends BaseTransport {
2223
});
2324
}
2425

25-
const defaultOptions: RequestInit = {
26-
body: JSON.stringify(event),
26+
const sentryReq = eventToSentryRequest(event, this._api);
27+
28+
const options: RequestInit = {
29+
body: sentryReq.body,
2730
method: 'POST',
2831
// Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default
2932
// https://caniuse.com/#feat=referrer-policy
@@ -33,13 +36,13 @@ export class FetchTransport extends BaseTransport {
3336
};
3437

3538
if (this.options.headers !== undefined) {
36-
defaultOptions.headers = this.options.headers;
39+
options.headers = this.options.headers;
3740
}
3841

3942
return this._buffer.add(
4043
new SyncPromise<Response>((resolve, reject) => {
4144
global
42-
.fetch(this.url, defaultOptions)
45+
.fetch(sentryReq.url, options)
4346
.then(response => {
4447
const status = Status.fromHttpCode(response.status);
4548

packages/browser/src/transports/xhr.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { eventToSentryRequest } from '@sentry/core';
12
import { Event, Response, Status } from '@sentry/types';
23
import { logger, parseRetryAfterHeader, SyncPromise } from '@sentry/utils';
34

@@ -20,6 +21,8 @@ export class XHRTransport extends BaseTransport {
2021
});
2122
}
2223

24+
const sentryReq = eventToSentryRequest(event, this._api);
25+
2326
return this._buffer.add(
2427
new SyncPromise<Response>((resolve, reject) => {
2528
const request = new XMLHttpRequest();
@@ -45,13 +48,13 @@ export class XHRTransport extends BaseTransport {
4548
reject(request);
4649
};
4750

48-
request.open('POST', this.url);
51+
request.open('POST', sentryReq.url);
4952
for (const header in this.options.headers) {
5053
if (this.options.headers.hasOwnProperty(header)) {
5154
request.setRequestHeader(header, this.options.headers[header]);
5255
}
5356
}
54-
request.send(JSON.stringify(event));
57+
request.send(sentryReq.body);
5558
}),
5659
);
5760
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe('BaseTransport', () => {
1919

2020
it('has correct endpoint url', () => {
2121
const transport = new SimpleTransport({ dsn: testDsn });
22+
// tslint:disable-next-line:deprecation
2223
expect(transport.url).equal('https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7');
2324
});
2425
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('FetchTransport', () => {
2828
});
2929

3030
it('inherits composeEndpointUrl() implementation', () => {
31+
// tslint:disable-next-line:deprecation
3132
expect(transport.url).equal(transportUrl);
3233
});
3334

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('XHRTransport', () => {
2828
});
2929

3030
it('inherits composeEndpointUrl() implementation', () => {
31+
// tslint:disable-next-line:deprecation
3132
expect(transport.url).equal(transportUrl);
3233
});
3334

packages/core/src/api.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,58 @@ export class API {
1717
return this._dsnObject;
1818
}
1919

20-
/** Returns a string with auth headers in the url to the store endpoint. */
20+
/** Returns the prefix to construct Sentry ingestion API endpoints. */
21+
public getBaseApiEndpoint(): string {
22+
const dsn = this._dsnObject;
23+
const protocol = dsn.protocol ? `${dsn.protocol}:` : '';
24+
const port = dsn.port ? `:${dsn.port}` : '';
25+
return `${protocol}//${dsn.host}${port}${dsn.path ? `/${dsn.path}` : ''}/api/`;
26+
}
27+
28+
/** Returns the store endpoint URL. */
2129
public getStoreEndpoint(): string {
22-
return `${this._getBaseUrl()}${this.getStoreEndpointPath()}`;
30+
return this._getIngestEndpoint('store');
31+
}
32+
33+
/** Returns the envelope endpoint URL. */
34+
public getEnvelopeEndpoint(): string {
35+
return this._getIngestEndpoint('envelope');
36+
}
37+
38+
/** Returns the ingest API endpoint for target. */
39+
private _getIngestEndpoint(target: string): string {
40+
const base = this.getBaseApiEndpoint();
41+
const dsn = this._dsnObject;
42+
return `${base}${dsn.projectId}/${target}/`;
2343
}
2444

25-
/** Returns the store endpoint with auth added in url encoded. */
45+
/**
46+
* Returns the store endpoint URL with auth in the query string.
47+
*
48+
* Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests.
49+
*/
2650
public getStoreEndpointWithUrlEncodedAuth(): string {
51+
return `${this.getStoreEndpoint()}?${this._encodedAuth()}`;
52+
}
53+
54+
/**
55+
* Returns the envelope endpoint URL with auth in the query string.
56+
*
57+
* Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests.
58+
*/
59+
public getEnvelopeEndpointWithUrlEncodedAuth(): string {
60+
return `${this.getEnvelopeEndpoint()}?${this._encodedAuth()}`;
61+
}
62+
63+
/** Returns a URL-encoded string with auth config suitable for a query string. */
64+
private _encodedAuth(): string {
2765
const dsn = this._dsnObject;
2866
const auth = {
29-
sentry_key: dsn.user, // sentry_key is currently used in tracing integration to identify internal sentry requests
67+
sentry_key: dsn.user,
3068
sentry_version: SENTRY_API_VERSION,
69+
// REVIEW: don't we send sentry_client (with SDK info) when using this?! Compare with `getRequestHeaders`.
3170
};
32-
// Auth is intentionally sent as part of query string (NOT as custom HTTP header)
33-
// to avoid preflight CORS requests
34-
return `${this.getStoreEndpoint()}?${urlEncode(auth)}`;
35-
}
36-
37-
/** Returns the base path of the url including the port. */
38-
private _getBaseUrl(): string {
39-
const dsn = this._dsnObject;
40-
const protocol = dsn.protocol ? `${dsn.protocol}:` : '';
41-
const port = dsn.port ? `:${dsn.port}` : '';
42-
return `${protocol}//${dsn.host}${port}`;
71+
return urlEncode(auth);
4372
}
4473

4574
/** Returns only the path component for the store endpoint. */
@@ -48,7 +77,11 @@ export class API {
4877
return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`;
4978
}
5079

51-
/** Returns an object that can be used in request headers. */
80+
/**
81+
* Returns an object that can be used in request headers.
82+
*
83+
* @deprecated in favor of `getStoreEndpointWithUrlEncodedAuth` and `getEnvelopeEndpointWithUrlEncodedAuth`.
84+
*/
5285
public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } {
5386
const dsn = this._dsnObject;
5487
const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`];
@@ -71,7 +104,7 @@ export class API {
71104
} = {},
72105
): string {
73106
const dsn = this._dsnObject;
74-
const endpoint = `${this._getBaseUrl()}${dsn.path ? `/${dsn.path}` : ''}/api/embed/error-page/`;
107+
const endpoint = `${this.getBaseApiEndpoint()}embed/error-page/`;
75108

76109
const encodedOptions = [];
77110
encodedOptions.push(`dsn=${dsn.toString()}`);

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export { addGlobalEventProcessor, getCurrentHub, getHubFromCarrier, Hub, Scope }
1616
export { API } from './api';
1717
export { BaseClient } from './baseclient';
1818
export { BackendClass, BaseBackend } from './basebackend';
19+
export { eventToSentryRequest } from './request';
1920
export { initAndBind, ClientClass } from './sdk';
2021
export { NoopTransport } from './transports/noop';
2122

packages/core/src/request.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Event } from '@sentry/types';
2+
3+
import { API } from './api';
4+
5+
/** A generic client request. */
6+
interface SentryRequest {
7+
body: string;
8+
url: string;
9+
// headers would contain auth & content-type headers for @sentry/node, but
10+
// since @sentry/browser avoids custom headers to prevent CORS preflight
11+
// requests, we can use the same approach for @sentry/browser and @sentry/node
12+
// for simplicity -- no headers involved.
13+
// headers: { [key: string]: string };
14+
}
15+
16+
/** Creates a SentryRequest from an event. */
17+
export function eventToSentryRequest(event: Event, api: API): SentryRequest {
18+
const useEnvelope = event.type === 'transaction';
19+
20+
const req: SentryRequest = {
21+
body: JSON.stringify(event),
22+
url: useEnvelope ? api.getEnvelopeEndpointWithUrlEncodedAuth() : api.getStoreEndpointWithUrlEncodedAuth(),
23+
};
24+
25+
// https://docs.sentry.io/development/sdk-dev/envelopes/
26+
27+
// Since we don't need to manipulate envelopes nor store them, there is no
28+
// exported concept of an Envelope with operations including serialization and
29+
// deserialization. Instead, we only implement a minimal subset of the spec to
30+
// serialize events inline here.
31+
if (useEnvelope) {
32+
const envelopeHeaders = JSON.stringify({
33+
event_id: event.event_id,
34+
sent_at: new Date().toISOString(),
35+
});
36+
const itemHeaders = JSON.stringify({
37+
type: event.type,
38+
// The content-type is assumed to be 'application/json' and not part of
39+
// the current spec for transaction items, so we don't bloat the request
40+
// body with it.
41+
//
42+
// content_type: 'application/json',
43+
//
44+
// The length is optional. It must be the number of bytes in req.Body
45+
// encoded as UTF-8. Since the server can figure this out and would
46+
// otherwise refuse events that report the length incorrectly, we decided
47+
// not to send the length to avoid problems related to reporting the wrong
48+
// size and to reduce request body size.
49+
//
50+
// length: new TextEncoder().encode(req.body).length,
51+
});
52+
// The trailing newline is optional. We intentionally don't send it to avoid
53+
// sending unnecessary bytes.
54+
//
55+
// const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`;
56+
const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`;
57+
req.body = envelope;
58+
}
59+
60+
return req;
61+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ describe('API', () => {
1616
});
1717

1818
test('getRequestHeaders', () => {
19+
// tslint:disable-next-line:deprecation
1920
expect(new API(dsnPublic).getRequestHeaders('a', '1.0')).toMatchObject({
2021
'Content-Type': 'application/json',
2122
'X-Sentry-Auth': expect.stringMatching(/^Sentry sentry_version=\d, sentry_client=a\/1\.0, sentry_key=abc$/),
2223
});
2324

25+
// tslint:disable-next-line:deprecation
2426
expect(new API(legacyDsn).getRequestHeaders('a', '1.0')).toMatchObject({
2527
'Content-Type': 'application/json',
2628
'X-Sentry-Auth': expect.stringMatching(

packages/node/src/transports/base.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { API } from '@sentry/core';
1+
import { API, eventToSentryRequest } from '@sentry/core';
22
import { Event, Response, Status, Transport, TransportOptions } from '@sentry/types';
3-
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError, isString } from '@sentry/utils';
3+
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
44
import * as fs from 'fs';
55
import * as http from 'http';
66
import * as https from 'https';
77
import * as url from 'url';
88

9-
import { SDK_NAME, SDK_VERSION } from '../version';
9+
// import { SDK_NAME, SDK_VERSION } from '../version';
1010

1111
/**
1212
* Internal used interface for typescript.
@@ -23,7 +23,9 @@ export interface HTTPRequest {
2323
callback?: (res: http.IncomingMessage) => void,
2424
): http.ClientRequest;
2525

26-
// This is the new type for versions that handle URL argument correctly, but it's most likely not needed here just yet
26+
// This is the type for nodejs versions that handle the URL argument
27+
// (v10.9.0+), but we do not use it just yet because we support older node
28+
// versions:
2729

2830
// request(
2931
// url: string | url.URL,
@@ -55,23 +57,22 @@ export abstract class BaseTransport implements Transport {
5557
}
5658

5759
/** Returns a build request option object used by request */
58-
protected _getRequestOptions(address: string | url.URL): http.RequestOptions | https.RequestOptions {
59-
if (!isString(address) || !(address instanceof url.URL)) {
60-
throw new SentryError(`Incorrect transport url: ${address}`);
61-
}
62-
60+
protected _getRequestOptions(uri: url.URL): http.RequestOptions | https.RequestOptions {
6361
const headers = {
64-
...this._api.getRequestHeaders(SDK_NAME, SDK_VERSION),
62+
// The auth headers are not included because auth is done via query string to match @sentry/browser.
63+
//
64+
// ...this._api.getRequestHeaders(SDK_NAME, SDK_VERSION)
6565
...this.options.headers,
6666
};
67-
const addr = address instanceof url.URL ? address : new url.URL(address);
68-
const { hostname, pathname: path, port, protocol } = addr;
67+
const { hostname, port, protocol } = uri;
68+
// See https://github.com/nodejs/node/blob/38146e717fed2fabe3aacb6540d839475e0ce1c6/lib/internal/url.js#L1268-L1290
69+
const path = `${uri.pathname || ''}${uri.search || ''}`;
6970

7071
return {
7172
agent: this.client,
72-
method: 'POST',
7373
headers,
7474
hostname,
75+
method: 'POST',
7576
path,
7677
port,
7778
protocol,
@@ -92,7 +93,10 @@ export abstract class BaseTransport implements Transport {
9293
}
9394
return this._buffer.add(
9495
new Promise<Response>((resolve, reject) => {
95-
const req = httpModule.request(this._getRequestOptions('http://foo.com/123'), (res: http.IncomingMessage) => {
96+
const sentryReq = eventToSentryRequest(event, this._api);
97+
const options = this._getRequestOptions(new url.URL(sentryReq.url));
98+
99+
const req = httpModule.request(options, (res: http.IncomingMessage) => {
96100
const statusCode = res.statusCode || 500;
97101
const status = Status.fromHttpCode(statusCode);
98102

@@ -126,7 +130,7 @@ export abstract class BaseTransport implements Transport {
126130
});
127131
});
128132
req.on('error', reject);
129-
req.end(JSON.stringify(event));
133+
req.end(sentryReq.body);
130134
}),
131135
);
132136
}

0 commit comments

Comments
 (0)