Skip to content

Commit 8958f61

Browse files
feat: Send transactions in envelopes (getsentry#2553)
The new Envelope endpoint and format is what we want to use for transactions going forward. Also unify how `@sentry/browser` and `@sentry/node` send requests to Sentry. Co-authored-by: Kamil Ogórek <kamil.ogorek@gmail.com>
1 parent cf2bc7e commit 8958f61

File tree

15 files changed

+178
-54
lines changed

15 files changed

+178
-54
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
- [core] feat: Send transactions in envelopes (#2553)
8+
79
## 5.15.5
810

911
- [browser/node] Add missing `BreadcrumbHint` and `EventHint` types exports (#2545)

packages/browser/src/integrations/breadcrumbs.ts

+1-1
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

+7-2
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

+7-4
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

+5-2
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

+1
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

+1
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

+1
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

+51-17
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,59 @@ 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+
private _getEnvelopeEndpoint(): string {
35+
return this._getIngestEndpoint('envelope');
36+
}
37+
38+
/** Returns the ingest API endpoint for target. */
39+
private _getIngestEndpoint(target: 'store' | 'envelope'): 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+
// We send only the minimum set of required information. See
68+
// https://github.com/getsentry/sentry-javascript/issues/2572.
69+
sentry_key: dsn.user,
3070
sentry_version: SENTRY_API_VERSION,
3171
};
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}`;
72+
return urlEncode(auth);
4373
}
4474

4575
/** Returns only the path component for the store endpoint. */
@@ -48,7 +78,11 @@ export class API {
4878
return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`;
4979
}
5080

51-
/** Returns an object that can be used in request headers. */
81+
/**
82+
* Returns an object that can be used in request headers.
83+
*
84+
* @deprecated in favor of `getStoreEndpointWithUrlEncodedAuth` and `getEnvelopeEndpointWithUrlEncodedAuth`.
85+
*/
5286
public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } {
5387
const dsn = this._dsnObject;
5488
const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`];
@@ -71,7 +105,7 @@ export class API {
71105
} = {},
72106
): string {
73107
const dsn = this._dsnObject;
74-
const endpoint = `${this._getBaseUrl()}${dsn.path ? `/${dsn.path}` : ''}/api/embed/error-page/`;
108+
const endpoint = `${this.getBaseApiEndpoint()}embed/error-page/`;
75109

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

packages/core/src/index.ts

+1
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

+61
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://develop.sentry.dev/sdk/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

+2
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(

0 commit comments

Comments
 (0)