Skip to content

Commit 76c8de0

Browse files
committed
[WIP] feat: Send transactions in envelopes
The new Envelope endpoint and format is what we want to use for transactions going forward. TODO: include rationale.
1 parent 74036ce commit 76c8de0

File tree

10 files changed

+148
-46
lines changed

10 files changed

+148
-46
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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ 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+
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();
1721
}
1822

1923
/**

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/core/src/api.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,54 @@ 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+
private _getIngestEndpoint(target: string): string {
39+
const base = this.getBaseApiEndpoint();
40+
const dsn = this._dsnObject;
41+
return `${base}${dsn.projectId}/${target}/`;
2342
}
2443

25-
/** Returns the store endpoint with auth added in url encoded. */
44+
/** Returns the store endpoint URL with auth in the query string.
45+
*
46+
* Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests.
47+
*/
2648
public getStoreEndpointWithUrlEncodedAuth(): string {
49+
return `${this.getStoreEndpoint()}?${this._encodedAuth()}`;
50+
}
51+
52+
/** Returns the envelope endpoint URL with auth in the query string.
53+
*
54+
* Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests.
55+
*/
56+
public getEnvelopeEndpointWithUrlEncodedAuth(): string {
57+
return `${this.getEnvelopeEndpoint()}?${this._encodedAuth()}`;
58+
}
59+
60+
private _encodedAuth(): string {
2761
const dsn = this._dsnObject;
2862
const auth = {
29-
sentry_key: dsn.user, // sentry_key is currently used in tracing integration to identify internal sentry requests
63+
sentry_key: dsn.user,
3064
sentry_version: SENTRY_API_VERSION,
65+
// REVIEW: don't we send sentry_client (with SDK info) when using this?! Compare with `getRequestHeaders`.
3166
};
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}`;
67+
return urlEncode(auth);
4368
}
4469

4570
/** Returns only the path component for the store endpoint. */
@@ -48,7 +73,10 @@ export class API {
4873
return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`;
4974
}
5075

51-
/** Returns an object that can be used in request headers. */
76+
/** Returns an object that can be used in request headers.
77+
*
78+
* @deprecated in favor of `getStoreEndpointWithUrlEncodedAuth` and `getEnvelopeEndpointWithUrlEncodedAuth`.
79+
*/
5280
public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } {
5381
const dsn = this._dsnObject;
5482
const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`];
@@ -71,7 +99,7 @@ export class API {
7199
} = {},
72100
): string {
73101
const dsn = this._dsnObject;
74-
const endpoint = `${this._getBaseUrl()}${dsn.path ? `/${dsn.path}` : ''}/api/embed/error-page/`;
102+
const endpoint = `${this.getBaseApiEndpoint()}embed/error-page/`;
75103

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

packages/node/src/transports/base.ts

Lines changed: 17 additions & 14 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,17 +57,15 @@ 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(url: 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+
//...this._api.getRequestHeaders(SDK_NAME, SDK_VERSION)
6564
...this.options.headers,
6665
};
67-
const addr = address instanceof url.URL ? address : new url.URL(address);
68-
const { hostname, pathname: path, port, protocol } = addr;
66+
const { hostname, port, protocol } = url;
67+
// See https://github.com/nodejs/node/blob/38146e717fed2fabe3aacb6540d839475e0ce1c6/lib/internal/url.js#L1268-L1290
68+
const path = `${url.pathname || ''}${url.search || ''}`;
6969

7070
return {
7171
agent: this.client,
@@ -92,7 +92,10 @@ export abstract class BaseTransport implements Transport {
9292
}
9393
return this._buffer.add(
9494
new Promise<Response>((resolve, reject) => {
95-
const req = httpModule.request(this._getRequestOptions('http://foo.com/123'), (res: http.IncomingMessage) => {
95+
const sentryReq = eventToSentryRequest(event, this._api);
96+
const options = this._getRequestOptions(new url.URL(sentryReq.url));
97+
98+
const req = httpModule.request(options, (res: http.IncomingMessage) => {
9699
const statusCode = res.statusCode || 500;
97100
const status = Status.fromHttpCode(statusCode);
98101

@@ -126,7 +129,7 @@ export abstract class BaseTransport implements Transport {
126129
});
127130
});
128131
req.on('error', reject);
129-
req.end(JSON.stringify(event));
132+
req.end(sentryReq.body);
130133
}),
131134
);
132135
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ function createTransport(options: TransportOptions): HTTPTransport {
2727
}
2828

2929
function assertBasicOptions(options: any): void {
30-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
31-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
32-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
30+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
31+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
32+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
3333
expect(options.port).toEqual('8989');
3434
expect(options.path).toEqual('/mysubpath/api/50622/store/');
3535
expect(options.hostname).toEqual('sentry.io');

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ function createTransport(options: TransportOptions): HTTPSTransport {
3333
}
3434

3535
function assertBasicOptions(options: any): void {
36-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
37-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
38-
expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
36+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
37+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
38+
// expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
3939
expect(options.port).toEqual('8989');
4040
expect(options.path).toEqual('/mysubpath/api/50622/store/');
4141
expect(options.hostname).toEqual('sentry.io');

0 commit comments

Comments
 (0)