Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
lobsterkatie committed Sep 14, 2021
commit 3944174300f027a43b781066acaf86724a36a63b
17 changes: 17 additions & 0 deletions .jest/dom-environment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const JSDOMEnvironment = require('jest-environment-jsdom');

// TODO Node >= 8.3 includes the same TextEncoder and TextDecoder as exist in the browser, but they haven't yet been
// added to jsdom. Until they are, we can do it ourselves. Once they do, this file can go away.

// see https://github.com/jsdom/jsdom/issues/2524 and https://nodejs.org/api/util.html#util_class_util_textencoder

module.exports = class DOMEnvironment extends JSDOMEnvironment {
async setup() {
await super.setup();
if (typeof this.global.TextEncoder === 'undefined') {
const { TextEncoder, TextDecoder } = require('util');
this.global.TextEncoder = TextEncoder;
this.global.TextDecoder = TextDecoder;
}
}
};
59 changes: 59 additions & 0 deletions packages/browser/test/unit/string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { base64ToUnicode, unicodeToBase64 } from '@sentry/utils';
import { expect } from 'chai';

// See https://tools.ietf.org/html/rfc4648#section-4 for base64 spec
// eslint-disable-next-line no-useless-escape
const BASE64_REGEX = /([a-zA-Z0-9+/]{4})*(|([a-zA-Z0-9+/]{3}=)|([a-zA-Z0-9+/]{2}==))/;

// NOTE: These tests are copied (and adapted for chai syntax) from `string.test.ts` in `@sentry/utils`. The
// base64-conversion functions have a different implementation in browser and node, so they're copied here to prove they
// work in a real live browser. If you make changes here, make sure to also port them over to that copy.
describe('base64ToUnicode/unicodeToBase64', () => {
const unicodeString = 'Dogs are great!';
const base64String = 'RG9ncyBhcmUgZ3JlYXQh';

it('converts to valid base64', () => {
expect(BASE64_REGEX.test(unicodeToBase64(unicodeString))).to.be.true;
});

it('works as expected (and conversion functions are inverses)', () => {
expect(unicodeToBase64(unicodeString)).to.equal(base64String);
expect(base64ToUnicode(base64String)).to.equal(unicodeString);
});

it('can handle and preserve multi-byte characters in original string', () => {
['🐶', 'Καλό κορίτσι, Μάιζεϊ!', 'Of margir hundar! Ég geri ráð fyrir að ég þurfi stærra rúm.'].forEach(orig => {
expect(() => {
unicodeToBase64(orig);
}).not.to.throw;
expect(base64ToUnicode(unicodeToBase64(orig))).to.equal(orig);
});
});

it('throws an error when given invalid input', () => {
expect(() => {
unicodeToBase64(null as any);
}).to.throw('Unable to convert to base64');
expect(() => {
unicodeToBase64(undefined as any);
}).to.throw('Unable to convert to base64');
expect(() => {
unicodeToBase64({} as any);
}).to.throw('Unable to convert to base64');

expect(() => {
base64ToUnicode(null as any);
}).to.throw('Unable to convert from base64');
expect(() => {
base64ToUnicode(undefined as any);
}).to.throw('Unable to convert from base64');
expect(() => {
base64ToUnicode({} as any);
}).to.throw('Unable to convert from base64');
expect(() => {
// the exclamation point makes this invalid base64
base64ToUnicode('Dogs are great!');
}).to.throw('Unable to convert from base64');
});
});
4 changes: 2 additions & 2 deletions packages/core/src/baseclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,8 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
const options = this.getOptions();
const { environment, release, dist, maxValueLength = 250 } = options;

if (!('environment' in event)) {
event.environment = 'environment' in options ? environment : 'production';
if (event.environment === undefined && environment !== undefined) {
event.environment = environment;
}

if (event.release === undefined && release !== undefined) {
Expand Down
116 changes: 69 additions & 47 deletions packages/core/src/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Event, SdkInfo, SentryRequest, SentryRequestType, Session, SessionAggregates } from '@sentry/types';
import { base64ToUnicode, logger } from '@sentry/utils';

import { API } from './api';

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

/**
* Apply SdkInfo (name, version, packages, integrations) to the corresponding event key.
* Merge with existing data if any.
* Add SDK metadata (name, version, packages, integrations) to the event.
*
* Mutates the object in place. If prior metadata exists, it will be merged with the given metadata.
**/
function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event {
function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): void {
if (!sdkInfo) {
return event;
return;
}
event.sdk = event.sdk || {};
event.sdk.name = event.sdk.name || sdkInfo.name;
event.sdk.version = event.sdk.version || sdkInfo.version;
event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])];
event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])];
return event;
return;
}

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

const { transactionSampling, ...metadata } = event.debug_meta || {};
const { method: samplingMethod, rate: sampleRate } = transactionSampling || {};
if (Object.keys(metadata).length === 0) {
delete event.debug_meta;
} else {
event.debug_meta = metadata;
}
enhanceEventWithSdkInfo(event, api.metadata.sdk);

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

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

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

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

// The content-type is assumed to be 'application/json' and not part of
// the current spec for transaction items, so we don't bloat the request
// body with it.
// Note: as mentioned above, `content_type` and `length` were left out on purpose.
//
// content_type: 'application/json',
// `content_type`:
// Assumed to be 'application/json' and not part of the current spec for transaction items. No point in bloating the
// request body with it. (Would be `content_type: 'application/json'`.)
//
// The length is optional. It must be the number of bytes in req.Body
// encoded as UTF-8. Since the server can figure this out and would
// otherwise refuse events that report the length incorrectly, we decided
// not to send the length to avoid problems related to reporting the wrong
// size and to reduce request body size.
//
// length: new TextEncoder().encode(req.body).length,
});
// The trailing newline is optional. We intentionally don't send it to avoid
// sending unnecessary bytes.
//
// const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`;
const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`;
req.body = envelope;
// `length`:
// Optional and equal to the number of bytes in `req.Body` encoded as UTF-8. Since the server can figure this out
// and will refuse events that report the length incorrectly, we decided not to send the length to reduce request
// body size and to avoid problems related to reporting the wrong size.(Would be
// `length: new TextEncoder().encode(req.body).length`.)
};

if (eventType === 'transaction') {
// TODO: Right now, `sampleRate` will be undefined in the cases of inheritance and explicitly-set sampling decisions.
itemHeaderEntries.sample_rates = [{ id: transactionSampling?.method, rate: transactionSampling?.rate }];
}

const itemHeaders = JSON.stringify(itemHeaderEntries);

const eventJSON = JSON.stringify(event);

// The trailing newline is optional; leave it off to avoid sending unnecessary bytes. (Would be
// `const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`;`.)
const envelope = `${envelopeHeaders}\n${itemHeaders}\n${eventJSON}`;

return {
body: envelope,
type: eventType,
url: api.getEnvelopeEndpointWithUrlEncodedAuth(),
};
}

return req;
return {
body: JSON.stringify(event),
type: eventType,
url: api.getStoreEndpointWithUrlEncodedAuth(),
};
}
1 change: 1 addition & 0 deletions packages/core/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function initAndBind<F extends Client, O extends Options>(clientClass: Cl
if (options.debug === true) {
logger.enable();
}
options.environment = options.environment || 'production';
const hub = getCurrentHub();
hub.getScope()?.update(options.initialScope);
const client = new clientClass(options);
Expand Down
26 changes: 0 additions & 26 deletions packages/core/test/lib/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ describe('BaseClient', () => {
const client = new TestClient({ dsn: PUBLIC_DSN });
client.captureException(new Error('test exception'));
expect(TestBackend.instance!.event).toEqual({
environment: 'production',
event_id: '42',
exception: {
values: [
Expand Down Expand Up @@ -246,7 +245,6 @@ describe('BaseClient', () => {
const client = new TestClient({ dsn: PUBLIC_DSN });
client.captureMessage('test message');
expect(TestBackend.instance!.event).toEqual({
environment: 'production',
event_id: '42',
level: 'info',
message: 'test message',
Expand Down Expand Up @@ -322,7 +320,6 @@ describe('BaseClient', () => {
client.captureEvent({ message: 'message' }, undefined, scope);
expect(TestBackend.instance!.event!.message).toBe('message');
expect(TestBackend.instance!.event).toEqual({
environment: 'production',
event_id: '42',
message: 'message',
timestamp: 2020,
Expand All @@ -336,7 +333,6 @@ describe('BaseClient', () => {
client.captureEvent({ message: 'message', timestamp: 1234 }, undefined, scope);
expect(TestBackend.instance!.event!.message).toBe('message');
expect(TestBackend.instance!.event).toEqual({
environment: 'production',
event_id: '42',
message: 'message',
timestamp: 1234,
Expand All @@ -349,28 +345,12 @@ describe('BaseClient', () => {
const scope = new Scope();
client.captureEvent({ message: 'message' }, { event_id: 'wat' }, scope);
expect(TestBackend.instance!.event!).toEqual({
environment: 'production',
event_id: 'wat',
message: 'message',
timestamp: 2020,
});
});

test('sets default environment to `production` it none provided', () => {
expect.assertions(1);
const client = new TestClient({
dsn: PUBLIC_DSN,
});
const scope = new Scope();
client.captureEvent({ message: 'message' }, undefined, scope);
expect(TestBackend.instance!.event!).toEqual({
environment: 'production',
event_id: '42',
message: 'message',
timestamp: 2020,
});
});

test('adds the configured environment', () => {
expect.assertions(1);
const client = new TestClient({
Expand Down Expand Up @@ -412,7 +392,6 @@ describe('BaseClient', () => {
const scope = new Scope();
client.captureEvent({ message: 'message' }, undefined, scope);
expect(TestBackend.instance!.event!).toEqual({
environment: 'production',
event_id: '42',
message: 'message',
release: 'v1.0.0',
Expand Down Expand Up @@ -453,7 +432,6 @@ describe('BaseClient', () => {
scope.setUser({ id: 'user' });
client.captureEvent({ message: 'message' }, undefined, scope);
expect(TestBackend.instance!.event!).toEqual({
environment: 'production',
event_id: '42',
extra: { b: 'b' },
message: 'message',
Expand All @@ -470,7 +448,6 @@ describe('BaseClient', () => {
scope.setFingerprint(['abcd']);
client.captureEvent({ message: 'message' }, undefined, scope);
expect(TestBackend.instance!.event!).toEqual({
environment: 'production',
event_id: '42',
fingerprint: ['abcd'],
message: 'message',
Expand Down Expand Up @@ -525,7 +502,6 @@ describe('BaseClient', () => {
expect(TestBackend.instance!.event!).toEqual({
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
contexts: normalizedObject,
environment: 'production',
event_id: '42',
extra: normalizedObject,
timestamp: 2020,
Expand Down Expand Up @@ -571,7 +547,6 @@ describe('BaseClient', () => {
expect(TestBackend.instance!.event!).toEqual({
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
contexts: normalizedObject,
environment: 'production',
event_id: '42',
extra: normalizedObject,
timestamp: 2020,
Expand Down Expand Up @@ -622,7 +597,6 @@ describe('BaseClient', () => {
expect(TestBackend.instance!.event!).toEqual({
breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb],
contexts: normalizedObject,
environment: 'production',
event_id: '42',
extra: normalizedObject,
timestamp: 2020,
Expand Down
Loading