diff --git a/.jest/dom-environment.js b/.jest/dom-environment.js new file mode 100644 index 000000000000..b8b45d1e59f2 --- /dev/null +++ b/.jest/dom-environment.js @@ -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; + } + } +}; diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 6f9573dc4bd1..dd841b8cb5db 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -402,8 +402,8 @@ export abstract class BaseClient 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) { diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts index 0264623c2cae..c3c0b08788df 100644 --- a/packages/core/src/request.ts +++ b/packages/core/src/request.ts @@ -31,29 +31,40 @@ function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { return event; } -/** Creates a SentryRequest from an event. */ -export function sessionToSentryRequest(session: Session, api: API): SentryRequest { +/** + * Create a SentryRequest from an error, message, or transaction event. + * + * @param event The event to send + * @param api Helper to provide the correct url for the request + * @returns SentryRequest representing the event + */ +export function eventToSentryRequest(event: Event, api: API): SentryRequest { const sdkInfo = getSdkMetadataForEnvelopeHeader(api); - const envelopeHeaders = JSON.stringify({ - sent_at: new Date().toISOString(), - ...(sdkInfo && { sdk: sdkInfo }), - }); - const itemHeaders = JSON.stringify({ - type: 'session', - }); + const eventWithSdkInfo = sdkInfo ? enhanceEventWithSdkInfo(event, api.metadata.sdk) : event; + if (event.type === 'transaction') { + return transactionToSentryRequest(eventWithSdkInfo, api); + } return { - body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(session)}`, - type: 'session', - url: api.getEnvelopeEndpointWithUrlEncodedAuth(), + body: JSON.stringify(eventWithSdkInfo), + type: event.type || 'event', + url: api.getStoreEndpointWithUrlEncodedAuth(), }; } -/** Creates a SentryRequest from an event. */ -export function eventToSentryRequest(event: Event, api: API): SentryRequest { +/** + * Create a SentryRequest from a transaction event. + * + * 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/. + * + * @param event The transaction event to send + * @param api Helper to provide the correct url for the request + * @returns SentryRequest in envelope form + */ +export function transactionToSentryRequest(event: Event, api: API): SentryRequest { const sdkInfo = getSdkMetadataForEnvelopeHeader(api); - const eventType = event.type || 'event'; - const useEnvelope = eventType === 'transaction'; const { transactionSampling, ...metadata } = event.debug_meta || {}; const { method: samplingMethod, rate: sampleRate } = transactionSampling || {}; @@ -63,62 +74,76 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest { event.debug_meta = metadata; } - const req: SentryRequest = { - body: JSON.stringify(sdkInfo ? enhanceEventWithSdkInfo(event, api.metadata.sdk) : event), - type: eventType, - url: useEnvelope ? api.getEnvelopeEndpointWithUrlEncodedAuth() : api.getStoreEndpointWithUrlEncodedAuth(), - }; + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), - // https://develop.sentry.dev/sdk/envelopes/ - - // 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 }), - - // trace context for dynamic sampling on relay - trace: { - trace_id: event.contexts?.trace?.trace_id, - public_key: api.getDsn().publicKey, - environment: event.environment || null, - release: event.release || null, - }, - }); - - const itemHeaders = JSON.stringify({ - type: event.type, - - // 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 }], - - // 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. - // - // 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. + // trace context for dynamic sampling on relay + trace: { + trace_id: event.contexts?.trace?.trace_id, + public_key: api.getDsn().publicKey, + environment: event.environment || null, + release: event.release || null, + }, + }); + + const itemHeaders = JSON.stringify({ + type: event.type, + + // TODO: Right now, sampleRate will be undefined in the cases of inheritance and explicitly-set sampling decisions. + sample_rates: [{ id: samplingMethod, rate: sampleRate }], + + // Note: `content_type` and `length` were left out on purpose. Here's a quick explanation of why, along with the + // value to use if we ever decide to put them back in. // - // const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`; - const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`; - req.body = envelope; - } + // `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', + // + // `length`: + // Optional and equal to 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. + // + // would be: + // length: new TextEncoder().encode(req.body).length, + }); + + const req: SentryRequest = { + // The trailing newline is optional; leave it off to avoid sending unnecessary bytes. + // body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(event)\n}`, + body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(event)}`, + type: 'transaction', + url: api.getEnvelopeEndpointWithUrlEncodedAuth(), + }; return req; } + +/** + * Create a SentryRequest from a session event. + * + * @param event The session event to send + * @param api Helper to provide the correct url for the request + * @returns SentryRequest in envelope form + */ +export function sessionToSentryRequest(session: Session, api: API): SentryRequest { + const sdkInfo = getSdkMetadataForEnvelopeHeader(api); + const envelopeHeaders = JSON.stringify({ + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + }); + const itemHeaders = JSON.stringify({ + type: 'session', + }); + + return { + body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(session)}`, + type: 'session', + url: api.getEnvelopeEndpointWithUrlEncodedAuth(), + }; +} diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index 17497c57f2be..b2f68a69f35b 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -16,6 +16,7 @@ export function initAndBind(clientClass: Cl if (options.debug === true) { logger.enable(); } + options.environment = options.environment || 'production'; const hub = getCurrentHub(); const client = new clientClass(options); hub.bindClient(client); diff --git a/packages/core/test/lib/base.test.ts b/packages/core/test/lib/base.test.ts index 8274f65baba8..9f6691b9514f 100644 --- a/packages/core/test/lib/base.test.ts +++ b/packages/core/test/lib/base.test.ts @@ -176,7 +176,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: [ @@ -245,7 +244,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', @@ -321,7 +319,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, @@ -335,7 +332,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, @@ -348,28 +344,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({ @@ -411,7 +391,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', @@ -452,7 +431,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', @@ -469,7 +447,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', @@ -515,7 +492,6 @@ describe('BaseClient', () => { expect(TestBackend.instance!.event!).toEqual({ breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb], contexts: normalizedObject, - environment: 'production', event_id: '42', extra: normalizedObject, timestamp: 2020, @@ -561,7 +537,6 @@ describe('BaseClient', () => { expect(TestBackend.instance!.event!).toEqual({ breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb], contexts: normalizedObject, - environment: 'production', event_id: '42', extra: normalizedObject, timestamp: 2020, @@ -612,7 +587,6 @@ describe('BaseClient', () => { expect(TestBackend.instance!.event!).toEqual({ breadcrumbs: [normalizedBreadcrumb, normalizedBreadcrumb, normalizedBreadcrumb], contexts: normalizedObject, - environment: 'production', event_id: '42', extra: normalizedObject, timestamp: 2020, diff --git a/packages/core/test/lib/sdk.test.ts b/packages/core/test/lib/sdk.test.ts index 241994d7584f..e9b17a09e348 100644 --- a/packages/core/test/lib/sdk.test.ts +++ b/packages/core/test/lib/sdk.test.ts @@ -14,15 +14,19 @@ jest.mock('@sentry/hub', () => ({ bindClient(client: Client): boolean; getClient(): boolean; } { - return { + const mockHub = { + _stack: [], getClient(): boolean { return false; }, bindClient(client: Client): boolean { + (this._stack as any[]).push({ client }); client.setupIntegrations(); return true; }, }; + global.__SENTRY__.hub = mockHub; + return mockHub; }, })); @@ -41,6 +45,15 @@ describe('SDK', () => { }); describe('initAndBind', () => { + test("sets environment to 'production' if none is provided", () => { + initAndBind(TestClient, { dsn: PUBLIC_DSN }); + expect(global.__SENTRY__.hub._stack[0].client.getOptions().environment).toEqual('production'); + }); + test("doesn't overwrite given environment", () => { + initAndBind(TestClient, { dsn: PUBLIC_DSN, environment: 'dogpark' }); + expect(global.__SENTRY__.hub._stack[0].client.getOptions().environment).toEqual('dogpark'); + }); + test('installs default integrations', () => { const DEFAULT_INTEGRATIONS: Integration[] = [ new MockIntegration('MockIntegration 1'), diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 5d6bbc213562..704f681f2710 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -76,7 +76,7 @@ "ts", "tsx" ], - "testEnvironment": "jsdom", + "testEnvironment": "../../.jest/dom-environment", "testMatch": [ "**/*.test.ts", "**/*.test.tsx" diff --git a/packages/react/package.json b/packages/react/package.json index dfae8e62b4de..8cee36225b8b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -90,7 +90,7 @@ "ts", "tsx" ], - "testEnvironment": "jsdom", + "testEnvironment": "../../.jest/dom-environment", "testMatch": [ "**/*.test.ts", "**/*.test.tsx" diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 4722ad4f172a..a568244029d3 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,3 +1,5 @@ +import { getGlobalObject } from './compat'; +import { SentryError } from './error'; import { isRegExp, isString } from './is'; /** @@ -101,3 +103,95 @@ export function isMatchingPattern(value: string, pattern: RegExp | string): bool } return false; } + +/** + * Convert a Unicode string to a base64 string. + * + * @param plaintext The string to base64-encode + * @throws SentryError (because using the logger creates a circular dependency) + * @returns A base64-encoded version of the string + */ +export function unicodeToBase64(plaintext: string): string { + const global = getGlobalObject(); + + // Cast to a string just in case we're given something else + const stringifiedInput = String(plaintext); + const errMsg = `Unable to convert to base64: ${ + stringifiedInput.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput + }`; + + // To account for the fact that different platforms use different character encodings natively, our `tracestate` + // spec calls for all jsonified data to be encoded in UTF-8 bytes before being passed to the base64 encoder. + try { + // browser + if ('btoa' in global) { + // encode using UTF-8 + const bytes = new TextEncoder().encode(plaintext); + + // decode using UTF-16 (JS's native encoding) since `btoa` requires string input + const bytesAsString = String.fromCharCode(...bytes); + + return btoa(bytesAsString); + } + + // Node + if ('Buffer' in global) { + // encode using UTF-8 + const bytes = Buffer.from(plaintext, 'utf-8'); + + // unlike the browser, Node can go straight from bytes to base64 + return bytes.toString('base64'); + } + } catch (err) { + throw new SentryError(`${errMsg} Got error: ${err}`); + } + + // we shouldn't ever get here, because one of `btoa` and `Buffer` should exist, but just in case... + throw new SentryError(errMsg); +} + +/** + * Convert a base64 string to a Unicode string. + * + * @param base64String The string to decode + * @throws SentryError (because using the logger creates a circular dependency) + * @returns A Unicode string + */ +export function base64ToUnicode(base64String: string): string { + const globalObject = getGlobalObject(); + + // we cast to a string just in case we're given something else + const stringifiedInput = String(base64String); + const errMsg = `Unable to convert from base64: ${ + stringifiedInput.length > 256 ? `${stringifiedInput.slice(0, 256)}...` : stringifiedInput + }`; + + // To account for the fact that different platforms use different character encodings natively, our `tracestate` spec + // calls for all jsonified data to be encoded in UTF-8 bytes before being passed to the base64 encoder. So to reverse + // the process, decode from base64 to bytes, then feed those bytes to a UTF-8 decoder. + try { + // browser + if ('atob' in globalObject) { + // `atob` returns a string rather than bytes, so we first need to encode using the native encoding (UTF-16) + const bytesAsString = atob(base64String); + const bytes = [...bytesAsString].map(char => char.charCodeAt(0)); + + // decode using UTF-8 (cast the `bytes` arry to a Uint8Array just because that's the format `decode()` expects) + return new TextDecoder().decode(Uint8Array.from(bytes)); + } + + // Node + if ('Buffer' in globalObject) { + // unlike the browser, Node can go straight from base64 to bytes + const bytes = Buffer.from(base64String, 'base64'); + + // decode using UTF-8 + return bytes.toString('utf-8'); + } + } catch (err) { + throw new SentryError(`${errMsg} Got error: ${err}`); + } + + // we shouldn't ever get here, because one of `atob` and `Buffer` should exist, but just in case... + throw new SentryError(errMsg); +} diff --git a/packages/utils/test/string.test.ts b/packages/utils/test/string.test.ts index 316328d9ee13..1ed7a2d23264 100644 --- a/packages/utils/test/string.test.ts +++ b/packages/utils/test/string.test.ts @@ -1,4 +1,8 @@ -import { isMatchingPattern, truncate } from '../src/string'; +import { base64ToUnicode, isMatchingPattern, truncate, unicodeToBase64 } from '../src/string'; + +// 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}==))/; describe('truncate()', () => { test('it works as expected', () => { @@ -45,3 +49,57 @@ describe('isMatchingPattern()', () => { expect(isMatchingPattern([] as any, 'foo')).toEqual(false); }); }); + +describe('base64ToUnicode/unicodeToBase64', () => { + const unicodeString = 'Dogs are great!'; + const base64String = 'RG9ncyBhcmUgZ3JlYXQh'; + + test('converts to valid base64', () => { + expect(BASE64_REGEX.test(unicodeToBase64(unicodeString))).toBe(true); + }); + + test('works as expected', () => { + expect(unicodeToBase64(unicodeString)).toEqual(base64String); + expect(base64ToUnicode(base64String)).toEqual(unicodeString); + }); + + test('conversion functions are inverses', () => { + expect(base64ToUnicode(unicodeToBase64(unicodeString))).toEqual(unicodeString); + expect(unicodeToBase64(base64ToUnicode(base64String))).toEqual(base64String); + }); + + test('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.toThrowError(); + expect(base64ToUnicode(unicodeToBase64(orig))).toEqual(orig); + }); + }); + + test('throws an error when given invalid input', () => { + expect(() => { + unicodeToBase64(null as any); + }).toThrowError('Unable to convert to base64'); + expect(() => { + unicodeToBase64(undefined as any); + }).toThrowError('Unable to convert to base64'); + expect(() => { + unicodeToBase64({} as any); + }).toThrowError('Unable to convert to base64'); + + expect(() => { + base64ToUnicode(null as any); + }).toThrowError('Unable to convert from base64'); + expect(() => { + base64ToUnicode(undefined as any); + }).toThrowError('Unable to convert from base64'); + expect(() => { + base64ToUnicode({} as any); + }).toThrowError('Unable to convert from base64'); + + // Note that by design, in node base64 encoding and decoding will accept any string, whether or not it's valid + // base64, by ignoring all invalid characters, including whitespace. Therefore, no wacky strings have been included + // here because they don't actually error. + }); +});