Skip to content

Commit c1f86c4

Browse files
committed
feat(tracing): Add initial tracestate header handling (#3909)
This is the result of rebasing the feature branch for the initial implementation of `tracestate` header handling (which had grown very stale) on top of current `master`. That branch is going to get deleted, so for posterity, it included the following PRs (oldest -> newest): feat(tracing): Add dynamic sampling correlation context data to envelope header (#3062) chore(utils): Split browser/node compatibility utils into separate module (#3123) ref(tracing): Prework for initial `tracestate` implementation (#3242) feat(tracing): Add `tracestate` header to outgoing requests (#3092) ref(tracing): Rework tracestate internals to allow for third-party data (#3266) feat(tracing): Handle incoming tracestate data, allow for third-party data (#3275) chore(tracing): Various small fixes to first `tracestate` implementation (#3291) fix(tracing): Use `Request.headers.append` correctly (#3311) feat(tracing): Add user data to tracestate header (#3343) chore(various): Small fixes (#3368) fix(build): Prevent Node's `Buffer` module from being included in browser bundles (#3372) fix(tracing): Remove undefined tracestate data rather than setting it to `null` (#3373) More detail in the PR description.
1 parent 1d1b41c commit c1f86c4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1781
-497
lines changed

.jest/dom-environment.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const JSDOMEnvironment = require('jest-environment-jsdom');
2+
3+
// TODO Node >= 8.3 includes the same TextEncoder and TextDecoder as exist in the browser, but they haven't yet been
4+
// added to jsdom. Until they are, we can do it ourselves. Once they do, this file can go away.
5+
6+
// see https://github.com/jsdom/jsdom/issues/2524 and https://nodejs.org/api/util.html#util_class_util_textencoder
7+
8+
module.exports = class DOMEnvironment extends JSDOMEnvironment {
9+
async setup() {
10+
await super.setup();
11+
if (typeof this.global.TextEncoder === 'undefined') {
12+
const { TextEncoder, TextDecoder } = require('util');
13+
this.global.TextEncoder = TextEncoder;
14+
this.global.TextDecoder = TextDecoder;
15+
}
16+
}
17+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { base64ToUnicode, unicodeToBase64 } from '@sentry/utils';
3+
import { expect } from 'chai';
4+
5+
// See https://tools.ietf.org/html/rfc4648#section-4 for base64 spec
6+
// eslint-disable-next-line no-useless-escape
7+
const BASE64_REGEX = /([a-zA-Z0-9+/]{4})*(|([a-zA-Z0-9+/]{3}=)|([a-zA-Z0-9+/]{2}==))/;
8+
9+
// NOTE: These tests are copied (and adapted for chai syntax) from `string.test.ts` in `@sentry/utils`. The
10+
// base64-conversion functions have a different implementation in browser and node, so they're copied here to prove they
11+
// work in a real live browser. If you make changes here, make sure to also port them over to that copy.
12+
describe('base64ToUnicode/unicodeToBase64', () => {
13+
const unicodeString = 'Dogs are great!';
14+
const base64String = 'RG9ncyBhcmUgZ3JlYXQh';
15+
16+
it('converts to valid base64', () => {
17+
expect(BASE64_REGEX.test(unicodeToBase64(unicodeString))).to.be.true;
18+
});
19+
20+
it('works as expected (and conversion functions are inverses)', () => {
21+
expect(unicodeToBase64(unicodeString)).to.equal(base64String);
22+
expect(base64ToUnicode(base64String)).to.equal(unicodeString);
23+
});
24+
25+
it('can handle and preserve multi-byte characters in original string', () => {
26+
['🐶', 'Καλό κορίτσι, Μάιζεϊ!', 'Of margir hundar! Ég geri ráð fyrir að ég þurfi stærra rúm.'].forEach(orig => {
27+
expect(() => {
28+
unicodeToBase64(orig);
29+
}).not.to.throw;
30+
expect(base64ToUnicode(unicodeToBase64(orig))).to.equal(orig);
31+
});
32+
});
33+
34+
it('throws an error when given invalid input', () => {
35+
expect(() => {
36+
unicodeToBase64(null as any);
37+
}).to.throw('Unable to convert to base64');
38+
expect(() => {
39+
unicodeToBase64(undefined as any);
40+
}).to.throw('Unable to convert to base64');
41+
expect(() => {
42+
unicodeToBase64({} as any);
43+
}).to.throw('Unable to convert to base64');
44+
45+
expect(() => {
46+
base64ToUnicode(null as any);
47+
}).to.throw('Unable to convert from base64');
48+
expect(() => {
49+
base64ToUnicode(undefined as any);
50+
}).to.throw('Unable to convert from base64');
51+
expect(() => {
52+
base64ToUnicode({} as any);
53+
}).to.throw('Unable to convert from base64');
54+
expect(() => {
55+
// the exclamation point makes this invalid base64
56+
base64ToUnicode('Dogs are great!');
57+
}).to.throw('Unable to convert from base64');
58+
});
59+
});

packages/core/src/baseclient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,8 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
417417
const options = this.getOptions();
418418
const { environment, release, dist, maxValueLength = 250 } = options;
419419

420-
if (!('environment' in event)) {
421-
event.environment = 'environment' in options ? environment : 'production';
420+
if (event.environment === undefined && environment !== undefined) {
421+
event.environment = environment;
422422
}
423423

424424
if (event.release === undefined && release !== undefined) {

packages/core/src/request.ts

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Event, SdkInfo, SentryRequest, SentryRequestType, Session, SessionAggregates } from '@sentry/types';
2+
import { base64ToUnicode, logger } from '@sentry/utils';
23

34
import { API } from './api';
45

@@ -12,19 +13,20 @@ function getSdkMetadataForEnvelopeHeader(api: API): SdkInfo | undefined {
1213
}
1314

1415
/**
15-
* Apply SdkInfo (name, version, packages, integrations) to the corresponding event key.
16-
* Merge with existing data if any.
16+
* Add SDK metadata (name, version, packages, integrations) to the event.
17+
*
18+
* Mutates the object in place. If prior metadata exists, it will be merged with the given metadata.
1719
**/
18-
function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event {
20+
function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): void {
1921
if (!sdkInfo) {
20-
return event;
22+
return;
2123
}
2224
event.sdk = event.sdk || {};
2325
event.sdk.name = event.sdk.name || sdkInfo.name;
2426
event.sdk.version = event.sdk.version || sdkInfo.version;
2527
event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])];
2628
event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])];
27-
return event;
29+
return;
2830
}
2931

3032
/** Creates a SentryRequest from a Session. */
@@ -54,61 +56,81 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest {
5456
const eventType = event.type || 'event';
5557
const useEnvelope = eventType === 'transaction' || api.forceEnvelope();
5658

57-
const { transactionSampling, ...metadata } = event.debug_meta || {};
58-
const { method: samplingMethod, rate: sampleRate } = transactionSampling || {};
59-
if (Object.keys(metadata).length === 0) {
60-
delete event.debug_meta;
61-
} else {
62-
event.debug_meta = metadata;
63-
}
59+
enhanceEventWithSdkInfo(event, api.metadata.sdk);
6460

65-
const req: SentryRequest = {
66-
body: JSON.stringify(sdkInfo ? enhanceEventWithSdkInfo(event, api.metadata.sdk) : event),
67-
type: eventType,
68-
url: useEnvelope ? api.getEnvelopeEndpointWithUrlEncodedAuth() : api.getStoreEndpointWithUrlEncodedAuth(),
69-
};
61+
// Since we don't need to manipulate envelopes nor store them, there is no exported concept of an Envelope with
62+
// operations including serialization and deserialization. Instead, we only implement a minimal subset of the spec to
63+
// serialize events inline here. See https://develop.sentry.dev/sdk/envelopes/.
64+
if (useEnvelope) {
65+
// Extract header information from event
66+
const { transactionSampling, tracestate, ...metadata } = event.debug_meta || {};
67+
if (Object.keys(metadata).length === 0) {
68+
delete event.debug_meta;
69+
} else {
70+
event.debug_meta = metadata;
71+
}
7072

71-
// https://develop.sentry.dev/sdk/envelopes/
73+
// the tracestate is stored in bas64-encoded JSON, but envelope header values are expected to be full JS values,
74+
// so we have to decode and reinflate it
75+
let reinflatedTracestate;
76+
try {
77+
// Because transaction metadata passes through a number of locations (transactionContext, transaction, event during
78+
// processing, event as sent), each with different requirements, all of the parts are typed as optional. That said,
79+
// if we get to this point and either `tracestate` or `tracestate.sentry` are undefined, something's gone very wrong.
80+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
81+
const encodedSentryValue = tracestate!.sentry!.replace('sentry=', '');
82+
reinflatedTracestate = JSON.parse(base64ToUnicode(encodedSentryValue));
83+
} catch (err) {
84+
logger.warn(err);
85+
}
7286

73-
// Since we don't need to manipulate envelopes nor store them, there is no
74-
// exported concept of an Envelope with operations including serialization and
75-
// deserialization. Instead, we only implement a minimal subset of the spec to
76-
// serialize events inline here.
77-
if (useEnvelope) {
7887
const envelopeHeaders = JSON.stringify({
7988
event_id: event.event_id,
8089
sent_at: new Date().toISOString(),
8190
...(sdkInfo && { sdk: sdkInfo }),
8291
...(api.forceEnvelope() && { dsn: api.getDsn().toString() }),
92+
...(reinflatedTracestate && { trace: reinflatedTracestate }), // trace context for dynamic sampling on relay
8393
});
84-
const itemHeaders = JSON.stringify({
85-
type: eventType,
8694

87-
// TODO: Right now, sampleRate may or may not be defined (it won't be in the cases of inheritance and
88-
// explicitly-set sampling decisions). Are we good with that?
89-
sample_rates: [{ id: samplingMethod, rate: sampleRate }],
95+
const itemHeaderEntries: { [key: string]: unknown } = {
96+
type: eventType,
9097

91-
// The content-type is assumed to be 'application/json' and not part of
92-
// the current spec for transaction items, so we don't bloat the request
93-
// body with it.
98+
// Note: as mentioned above, `content_type` and `length` were left out on purpose.
9499
//
95-
// content_type: 'application/json',
100+
// `content_type`:
101+
// Assumed to be 'application/json' and not part of the current spec for transaction items. No point in bloating the
102+
// request body with it. (Would be `content_type: 'application/json'`.)
96103
//
97-
// The length is optional. It must be the number of bytes in req.Body
98-
// encoded as UTF-8. Since the server can figure this out and would
99-
// otherwise refuse events that report the length incorrectly, we decided
100-
// not to send the length to avoid problems related to reporting the wrong
101-
// size and to reduce request body size.
102-
//
103-
// length: new TextEncoder().encode(req.body).length,
104-
});
105-
// The trailing newline is optional. We intentionally don't send it to avoid
106-
// sending unnecessary bytes.
107-
//
108-
// const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`;
109-
const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`;
110-
req.body = envelope;
104+
// `length`:
105+
// Optional and equal to the number of bytes in `req.Body` encoded as UTF-8. Since the server can figure this out
106+
// and will refuse events that report the length incorrectly, we decided not to send the length to reduce request
107+
// body size and to avoid problems related to reporting the wrong size.(Would be
108+
// `length: new TextEncoder().encode(req.body).length`.)
109+
};
110+
111+
if (eventType === 'transaction') {
112+
// TODO: Right now, `sampleRate` will be undefined in the cases of inheritance and explicitly-set sampling decisions.
113+
itemHeaderEntries.sample_rates = [{ id: transactionSampling?.method, rate: transactionSampling?.rate }];
114+
}
115+
116+
const itemHeaders = JSON.stringify(itemHeaderEntries);
117+
118+
const eventJSON = JSON.stringify(event);
119+
120+
// The trailing newline is optional; leave it off to avoid sending unnecessary bytes. (Would be
121+
// `const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`;`.)
122+
const envelope = `${envelopeHeaders}\n${itemHeaders}\n${eventJSON}`;
123+
124+
return {
125+
body: envelope,
126+
type: eventType,
127+
url: api.getEnvelopeEndpointWithUrlEncodedAuth(),
128+
};
111129
}
112130

113-
return req;
131+
return {
132+
body: JSON.stringify(event),
133+
type: eventType,
134+
url: api.getStoreEndpointWithUrlEncodedAuth(),
135+
};
114136
}

packages/core/src/sdk.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function initAndBind<F extends Client, O extends Options>(clientClass: Cl
1616
if (options.debug === true) {
1717
logger.enable();
1818
}
19+
options.environment = options.environment || 'production';
1920
const hub = getCurrentHub();
2021
hub.getScope()?.update(options.initialScope);
2122
const client = new clientClass(options);

packages/core/test/lib/base.test.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ describe('BaseClient', () => {
177177
const client = new TestClient({ dsn: PUBLIC_DSN });
178178
client.captureException(new Error('test exception'));
179179
expect(TestBackend.instance!.event).toEqual({
180-
environment: 'production',
181180
event_id: '42',
182181
exception: {
183182
values: [
@@ -246,7 +245,6 @@ describe('BaseClient', () => {
246245
const client = new TestClient({ dsn: PUBLIC_DSN });
247246
client.captureMessage('test message');
248247
expect(TestBackend.instance!.event).toEqual({
249-
environment: 'production',
250248
event_id: '42',
251249
level: 'info',
252250
message: 'test message',
@@ -322,7 +320,6 @@ describe('BaseClient', () => {
322320
client.captureEvent({ message: 'message' }, undefined, scope);
323321
expect(TestBackend.instance!.event!.message).toBe('message');
324322
expect(TestBackend.instance!.event).toEqual({
325-
environment: 'production',
326323
event_id: '42',
327324
message: 'message',
328325
timestamp: 2020,
@@ -336,7 +333,6 @@ describe('BaseClient', () => {
336333
client.captureEvent({ message: 'message', timestamp: 1234 }, undefined, scope);
337334
expect(TestBackend.instance!.event!.message).toBe('message');
338335
expect(TestBackend.instance!.event).toEqual({
339-
environment: 'production',
340336
event_id: '42',
341337
message: 'message',
342338
timestamp: 1234,
@@ -349,28 +345,12 @@ describe('BaseClient', () => {
349345
const scope = new Scope();
350346
client.captureEvent({ message: 'message' }, { event_id: 'wat' }, scope);
351347
expect(TestBackend.instance!.event!).toEqual({
352-
environment: 'production',
353348
event_id: 'wat',
354349
message: 'message',
355350
timestamp: 2020,
356351
});
357352
});
358353

359-
test('sets default environment to `production` it none provided', () => {
360-
expect.assertions(1);
361-
const client = new TestClient({
362-
dsn: PUBLIC_DSN,
363-
});
364-
const scope = new Scope();
365-
client.captureEvent({ message: 'message' }, undefined, scope);
366-
expect(TestBackend.instance!.event!).toEqual({
367-
environment: 'production',
368-
event_id: '42',
369-
message: 'message',
370-
timestamp: 2020,
371-
});
372-
});
373-
374354
test('adds the configured environment', () => {
375355
expect.assertions(1);
376356
const client = new TestClient({
@@ -412,7 +392,6 @@ describe('BaseClient', () => {
412392
const scope = new Scope();
413393
client.captureEvent({ message: 'message' }, undefined, scope);
414394
expect(TestBackend.instance!.event!).toEqual({
415-
environment: 'production',
416395
event_id: '42',
417396
message: 'message',
418397
release: 'v1.0.0',
@@ -453,7 +432,6 @@ describe('BaseClient', () => {
453432
scope.setUser({ id: 'user' });
454433
client.captureEvent({ message: 'message' }, undefined, scope);
455434
expect(TestBackend.instance!.event!).toEqual({
456-
environment: 'production',
457435
event_id: '42',
458436
extra: { b: 'b' },
459437
message: 'message',
@@ -470,7 +448,6 @@ describe('BaseClient', () => {
470448
scope.setFingerprint(['abcd']);
471449
client.captureEvent({ message: 'message' }, undefined, scope);
472450
expect(TestBackend.instance!.event!).toEqual({
473-
environment: 'production',
474451
event_id: '42',
475452
fingerprint: ['abcd'],
476453
message: 'message',
@@ -525,7 +502,6 @@ describe('BaseClient', () => {
525502
expect(TestBackend.instance!.event!).toEqual({
526503
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
527504
contexts: normalizedObject,
528-
environment: 'production',
529505
event_id: '42',
530506
extra: normalizedObject,
531507
timestamp: 2020,
@@ -571,7 +547,6 @@ describe('BaseClient', () => {
571547
expect(TestBackend.instance!.event!).toEqual({
572548
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
573549
contexts: normalizedObject,
574-
environment: 'production',
575550
event_id: '42',
576551
extra: normalizedObject,
577552
timestamp: 2020,
@@ -622,7 +597,6 @@ describe('BaseClient', () => {
622597
expect(TestBackend.instance!.event!).toEqual({
623598
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
624599
contexts: normalizedObject,
625-
environment: 'production',
626600
event_id: '42',
627601
extra: normalizedObject,
628602
timestamp: 2020,

0 commit comments

Comments
 (0)