diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 2eeefc18c7ed..9916ebd9c984 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -104,7 +104,7 @@ export class BrowserClient extends BaseClient { /** * @inheritDoc */ - public sendEvent(event: Event): void { + public sendEvent(event: Event, hint?: EventHint): void { // We only want to add the sentry event breadcrumb when the user has the breadcrumb integration installed and // activated its `sentry` option. // We also do not want to use the `Breadcrumbs` class here directly, because we do not want it to be included in @@ -133,15 +133,15 @@ export class BrowserClient extends BaseClient { ); } - super.sendEvent(event); + super.sendEvent(event, hint); } /** * @inheritDoc */ - protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike { + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { event.platform = event.platform || 'javascript'; - return super._prepareEvent(event, scope, hint); + return super._prepareEvent(event, hint, scope); } /** diff --git a/packages/browser/src/transports/utils.ts b/packages/browser/src/transports/utils.ts index b386557499c5..bd17ec097c28 100644 --- a/packages/browser/src/transports/utils.ts +++ b/packages/browser/src/transports/utils.ts @@ -86,7 +86,7 @@ export function getNativeFetchImplementation(): FetchImpl { * @param url report endpoint * @param body report payload */ -export function sendReport(url: string, body: string): void { +export function sendReport(url: string, body: string | Uint8Array): void { const isRealNavigator = Object.prototype.toString.call(global && global.navigator) === '[object Navigator]'; const hasSendBeacon = isRealNavigator && typeof global.navigator.sendBeacon === 'function'; diff --git a/packages/browser/test/unit/transports/fetch.test.ts b/packages/browser/test/unit/transports/fetch.test.ts index 4684b87fdf8b..87ad77266106 100644 --- a/packages/browser/test/unit/transports/fetch.test.ts +++ b/packages/browser/test/unit/transports/fetch.test.ts @@ -1,5 +1,6 @@ import { EventEnvelope, EventItem } from '@sentry/types'; import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { makeFetchTransport } from '../../../src/transports/fetch'; import { BrowserTransportOptions } from '../../../src/transports/types'; @@ -8,6 +9,7 @@ import { FetchImpl } from '../../../src/transports/utils'; const DEFAULT_FETCH_TRANSPORT_OPTIONS: BrowserTransportOptions = { url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', recordDroppedEvent: () => undefined, + textEncoder: new TextEncoder(), }; const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ @@ -40,7 +42,7 @@ describe('NewFetchTransport', () => { expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, { - body: serializeEnvelope(ERROR_ENVELOPE), + body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()), method: 'POST', referrerPolicy: 'origin', }); @@ -90,7 +92,7 @@ describe('NewFetchTransport', () => { await transport.send(ERROR_ENVELOPE); expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, { - body: serializeEnvelope(ERROR_ENVELOPE), + body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()), method: 'POST', ...REQUEST_OPTIONS, }); diff --git a/packages/browser/test/unit/transports/xhr.test.ts b/packages/browser/test/unit/transports/xhr.test.ts index 15a5c7ce3d62..117edce8d2ea 100644 --- a/packages/browser/test/unit/transports/xhr.test.ts +++ b/packages/browser/test/unit/transports/xhr.test.ts @@ -1,5 +1,6 @@ import { EventEnvelope, EventItem } from '@sentry/types'; import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { BrowserTransportOptions } from '../../../src/transports/types'; import { makeXHRTransport } from '../../../src/transports/xhr'; @@ -7,6 +8,7 @@ import { makeXHRTransport } from '../../../src/transports/xhr'; const DEFAULT_XHR_TRANSPORT_OPTIONS: BrowserTransportOptions = { url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', recordDroppedEvent: () => undefined, + textEncoder: new TextEncoder(), }; const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ @@ -64,7 +66,7 @@ describe('NewXHRTransport', () => { expect(xhrMock.open).toHaveBeenCalledTimes(1); expect(xhrMock.open).toHaveBeenCalledWith('POST', DEFAULT_XHR_TRANSPORT_OPTIONS.url); expect(xhrMock.send).toHaveBeenCalledTimes(1); - expect(xhrMock.send).toHaveBeenCalledWith(serializeEnvelope(ERROR_ENVELOPE)); + expect(xhrMock.send).toHaveBeenCalledWith(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder())); }); it('sets rate limit response headers', async () => { diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index f64f2b11d114..8ddc1877f62f 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -19,7 +19,9 @@ import { Transport, } from '@sentry/types'; import { + addItemToEnvelope, checkOrSetAlreadyCaught, + createAttachmentEnvelopeItem, dateTimestampInSeconds, isPlainObject, isPrimitive, @@ -283,9 +285,14 @@ export abstract class BaseClient implements Client { /** * @inheritDoc */ - public sendEvent(event: Event): void { + public sendEvent(event: Event, hint: EventHint = {}): void { if (this._dsn) { const env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); + + for (const attachment of hint.attachments || []) { + addItemToEnvelope(env, createAttachmentEnvelopeItem(attachment, this._options.transportOptions?.textEncoder)); + } + this._sendEnvelope(env); } } @@ -401,11 +408,11 @@ export abstract class BaseClient implements Client { * @param scope A scope containing event metadata. * @returns A new event with more information. */ - protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike { + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = this.getOptions(); const prepared: Event = { ...event, - event_id: event.event_id || (hint && hint.event_id ? hint.event_id : uuid4()), + event_id: event.event_id || hint.event_id || uuid4(), timestamp: event.timestamp || dateTimestampInSeconds(), }; @@ -415,7 +422,7 @@ export abstract class BaseClient implements Client { // If we have scope given to us, use it as the base for further modifications. // This allows us to prevent unnecessary copying of data if `captureContext` is not provided. let finalScope = scope; - if (hint && hint.captureContext) { + if (hint.captureContext) { finalScope = Scope.clone(finalScope).update(hint.captureContext); } @@ -425,6 +432,13 @@ export abstract class BaseClient implements Client { // This should be the last thing called, since we want that // {@link Hub.addEventProcessor} gets the finished prepared event. if (finalScope) { + // Collect attachments from the hint and scope + const attachments = [...(hint.attachments || []), ...finalScope.getAttachments()]; + + if (attachments.length) { + hint.attachments = attachments; + } + // In case we have a hub we reassign it. result = finalScope.applyToEvent(prepared, hint); } @@ -552,7 +566,7 @@ export abstract class BaseClient implements Client { * @param hint * @param scope */ - protected _captureEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike { + protected _captureEvent(event: Event, hint: EventHint = {}, scope?: Scope): PromiseLike { return this._processEvent(event, hint, scope).then( finalEvent => { return finalEvent.event_id; @@ -577,7 +591,7 @@ export abstract class BaseClient implements Client { * @param scope A scope containing event metadata. * @returns A SyncPromise that resolves with the event or rejects in case event was/will not be send. */ - protected _processEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike { + protected _processEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { const { beforeSend, sampleRate } = this.getOptions(); if (!this._isEnabled()) { @@ -597,14 +611,14 @@ export abstract class BaseClient implements Client { ); } - return this._prepareEvent(event, scope, hint) + return this._prepareEvent(event, hint, scope) .then(prepared => { if (prepared === null) { this.recordDroppedEvent('event_processor', event.type || 'error'); throw new SentryError('An event processor returned null, will not send event.'); } - const isInternalException = hint && hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true; + const isInternalException = hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true; if (isInternalException || isTransaction || !beforeSend) { return prepared; } @@ -623,7 +637,7 @@ export abstract class BaseClient implements Client { this._updateSessionFromEvent(session, processedEvent); } - this.sendEvent(processedEvent); + this.sendEvent(processedEvent, hint); return processedEvent; }) .then(null, reason => { diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index 8b435ed77541..fc83fb6b2855 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -69,7 +69,7 @@ export function createTransport( }; const requestTask = (): PromiseLike => - makeRequest({ body: serializeEnvelope(filteredEnvelope) }).then( + makeRequest({ body: serializeEnvelope(filteredEnvelope, options.textEncoder) }).then( response => { // We don't want to throw on NOK responses, but we want to at least log them if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode >= 300)) { diff --git a/packages/core/test/lib/transports/base.test.ts b/packages/core/test/lib/transports/base.test.ts index b57cc36f6e6f..94d83751a21d 100644 --- a/packages/core/test/lib/transports/base.test.ts +++ b/packages/core/test/lib/transports/base.test.ts @@ -1,5 +1,6 @@ import { EventEnvelope, EventItem, TransportMakeRequestResponse } from '@sentry/types'; import { createEnvelope, PromiseBuffer, resolvedSyncPromise, serializeEnvelope } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { createTransport } from '../../../src/transports/base'; @@ -14,6 +15,7 @@ const TRANSACTION_ENVELOPE = createEnvelope( const transportOptions = { recordDroppedEvent: () => undefined, // noop + textEncoder: new TextEncoder(), }; describe('createTransport', () => { @@ -36,7 +38,7 @@ describe('createTransport', () => { it('constructs a request to send to Sentry', async () => { expect.assertions(1); const transport = createTransport(transportOptions, req => { - expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE)); + expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder())); return resolvedSyncPromise({}); }); await transport.send(ERROR_ENVELOPE); @@ -46,7 +48,7 @@ describe('createTransport', () => { expect.assertions(2); const transport = createTransport(transportOptions, req => { - expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE)); + expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder())); throw new Error(); }); @@ -82,7 +84,10 @@ describe('createTransport', () => { const mockRecordDroppedEventCallback = jest.fn(); - const transport = createTransport({ recordDroppedEvent: mockRecordDroppedEventCallback }, mockRequestExecutor); + const transport = createTransport( + { recordDroppedEvent: mockRecordDroppedEventCallback, textEncoder: new TextEncoder() }, + mockRequestExecutor, + ); return [transport, setTransportResponse, mockRequestExecutor, mockRecordDroppedEventCallback] as const; } diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 9caef7b0e0cf..62dfa10219da 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -1,5 +1,6 @@ -import { ClientOptions, Event, Integration, Outcome, Session, Severity, SeverityLevel } from '@sentry/types'; +import { ClientOptions, Event, EventHint, Integration, Outcome, Session, Severity, SeverityLevel } from '@sentry/types'; import { resolvedSyncPromise } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { BaseClient } from '../../src/baseclient'; import { initAndBind } from '../../src/sdk'; @@ -9,9 +10,13 @@ export function getDefaultTestClientOptions(options: Partial return { integrations: [], sendClientReports: true, + transportOptions: { textEncoder: new TextEncoder() }, transport: () => createTransport( - { recordDroppedEvent: () => undefined }, // noop + { + recordDroppedEvent: () => undefined, + textEncoder: new TextEncoder(), + }, // noop _ => resolvedSyncPromise({}), ), stackParser: () => [], @@ -62,10 +67,10 @@ export class TestClient extends BaseClient { return resolvedSyncPromise({ message, level }); } - public sendEvent(event: Event): void { + public sendEvent(event: Event, hint?: EventHint): void { this.event = event; if (this._options.enableSend) { - super.sendEvent(event); + super.sendEvent(event, hint); return; } // eslint-disable-next-line @typescript-eslint/no-unused-expressions diff --git a/packages/core/test/mocks/transport.ts b/packages/core/test/mocks/transport.ts index f59e72a516a1..90d35e2a0247 100644 --- a/packages/core/test/mocks/transport.ts +++ b/packages/core/test/mocks/transport.ts @@ -1,4 +1,5 @@ import { SyncPromise } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { createTransport } from '../../src/transports/base'; @@ -10,7 +11,7 @@ export function makeFakeTransport(delay: number = 2000) { let sendCalled = 0; let sentCount = 0; const makeTransport = () => - createTransport({ recordDroppedEvent: () => undefined }, () => { + createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, () => { sendCalled += 1; return new SyncPromise(async res => { await sleep(delay); diff --git a/packages/gatsby/test/integration.test.tsx b/packages/gatsby/test/integration.test.tsx index 18c738ba2ac2..3fd06dc21990 100644 --- a/packages/gatsby/test/integration.test.tsx +++ b/packages/gatsby/test/integration.test.tsx @@ -3,6 +3,7 @@ import { render } from '@testing-library/react'; import { useEffect } from 'react'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as React from 'react'; +import { TextDecoder,TextEncoder } from 'util'; import { onClientEntry } from '../gatsby-browser'; import * as Sentry from '../src'; @@ -10,6 +11,8 @@ import * as Sentry from '../src'; beforeAll(() => { (global as any).__SENTRY_RELEASE__ = '683f3a6ab819d47d23abfca9a914c81f0524d35b'; (global as any).__SENTRY_DSN__ = 'https://examplePublicKey@o0.ingest.sentry.io/0'; + (global as any).TextEncoder = TextEncoder; + (global as any).TextDecoder = TextDecoder; }); describe('useEffect', () => { diff --git a/packages/hub/src/exports.ts b/packages/hub/src/exports.ts index 2ae3000df1c1..528da6289d3e 100644 --- a/packages/hub/src/exports.ts +++ b/packages/hub/src/exports.ts @@ -3,6 +3,7 @@ import { CaptureContext, CustomSamplingContext, Event, + EventHint, Extra, Extras, Primitive, @@ -59,8 +60,8 @@ export function captureMessage( * @param event The event to send to Sentry. * @returns The generated eventId. */ -export function captureEvent(event: Event): ReturnType { - return getCurrentHub().captureEvent(event); +export function captureEvent(event: Event, hint?: EventHint): ReturnType { + return getCurrentHub().captureEvent(event, hint); } /** diff --git a/packages/hub/src/scope.ts b/packages/hub/src/scope.ts index b51c620d86b0..f1ffd51cc9e5 100644 --- a/packages/hub/src/scope.ts +++ b/packages/hub/src/scope.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { + Attachment, Breadcrumb, CaptureContext, Context, @@ -86,6 +87,9 @@ export class Scope implements ScopeInterface { /** Request Mode Session Status */ protected _requestSession?: RequestSession; + /** Attachments */ + protected _attachments: Attachment[] = []; + /** * A place to stash data which is needed at some point in the SDK's event processing pipeline but which shouldn't get * sent to Sentry @@ -111,6 +115,7 @@ export class Scope implements ScopeInterface { newScope._fingerprint = scope._fingerprint; newScope._eventProcessors = [...scope._eventProcessors]; newScope._requestSession = scope._requestSession; + newScope._attachments = [...scope._attachments]; } return newScope; } @@ -366,6 +371,7 @@ export class Scope implements ScopeInterface { this._span = undefined; this._session = undefined; this._notifyScopeListeners(); + this._attachments = []; return this; } @@ -399,6 +405,29 @@ export class Scope implements ScopeInterface { return this; } + /** + * @inheritDoc + */ + public addAttachment(attachment: Attachment): this { + this._attachments.push(attachment); + return this; + } + + /** + * @inheritDoc + */ + public getAttachments(): Attachment[] { + return this._attachments; + } + + /** + * @inheritDoc + */ + public clearAttachments(): this { + this._attachments = []; + return this; + } + /** * Applies the current context and fingerprint to the event. * Note that breadcrumbs will be added by the client. diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index e6a0fc7a43a0..9ebad827d3e4 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -2,6 +2,7 @@ import { BaseClient, Scope, SDK_VERSION } from '@sentry/core'; import { SessionFlusher } from '@sentry/hub'; import { Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; import { logger, resolvedSyncPromise } from '@sentry/utils'; +import { TextEncoder } from 'util'; import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; import { IS_DEBUG_BUILD } from './flags'; @@ -33,6 +34,12 @@ export class NodeClient extends BaseClient { version: SDK_VERSION, }; + // Until node supports global TextEncoder in all versions we support, we are forced to pass it from util + options.transportOptions = { + textEncoder: new TextEncoder(), + ...options.transportOptions, + }; + super(options); } @@ -131,12 +138,12 @@ export class NodeClient extends BaseClient { /** * @inheritDoc */ - protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike { + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { event.platform = event.platform || 'node'; if (this.getOptions().serverName) { event.server_name = this.getOptions().serverName; } - return super._prepareEvent(event, scope, hint); + return super._prepareEvent(event, hint, scope); } /** diff --git a/packages/node/src/transports/http-module.ts b/packages/node/src/transports/http-module.ts index 0189d4971e4b..3d21faf2fc34 100644 --- a/packages/node/src/transports/http-module.ts +++ b/packages/node/src/transports/http-module.ts @@ -20,7 +20,7 @@ export interface HTTPModuleRequestIncomingMessage { * Some transports work in a special Javascript environment where http.IncomingMessage is not available. */ export interface HTTPModuleClientRequest { - end(chunk: string): void; + end(chunk: string | Uint8Array): void; on(event: 'error', listener: () => void): void; } diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 8731922267af..b12bd23fdd17 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -1,6 +1,6 @@ import { initAndBind, SDK_VERSION } from '@sentry/core'; import { getMainCarrier } from '@sentry/hub'; -import { Integration } from '@sentry/types'; +import { EventHint, Integration } from '@sentry/types'; import * as domain from 'domain'; import { @@ -76,7 +76,7 @@ describe('SentryNode', () => { }); describe('breadcrumbs', () => { - let s: jest.SpyInstance; + let s: jest.SpyInstance; beforeEach(() => { s = jest.spyOn(NodeClient.prototype, 'sendEvent').mockImplementation(async () => Promise.resolve({ code: 200 })); @@ -107,7 +107,7 @@ describe('SentryNode', () => { }); describe('capture', () => { - let s: jest.SpyInstance; + let s: jest.SpyInstance; beforeEach(() => { s = jest.spyOn(NodeClient.prototype, 'sendEvent').mockImplementation(async () => Promise.resolve({ code: 200 })); diff --git a/packages/node/test/manual/express-scope-separation/start.js b/packages/node/test/manual/express-scope-separation/start.js index b85d68c7b1f8..81ca392a4407 100644 --- a/packages/node/test/manual/express-scope-separation/start.js +++ b/packages/node/test/manual/express-scope-separation/start.js @@ -3,6 +3,7 @@ const express = require('express'); const app = express(); const Sentry = require('../../../build/cjs'); const { colorize } = require('../colorize'); +const { TextEncoder } = require('util'); // don't log the test errors we're going to throw, so at a quick glance it doesn't look like the test itself has failed global.console.error = () => null; @@ -17,7 +18,7 @@ function assertTags(actual, expected) { let remaining = 3; function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { --remaining; if (!remaining) { diff --git a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js index ae6a660607b1..300870d21fac 100644 --- a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js +++ b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js @@ -3,6 +3,7 @@ const express = require('express'); const app = express(); const Sentry = require('../../../../build/cjs'); const { assertSessions } = require('../test-utils'); +const { TextEncoder } = require('util'); function cleanUpAndExitSuccessfully() { server.close(); @@ -28,8 +29,11 @@ function assertSessionAggregates(session, expected) { } function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { - const sessionEnv = req.body.split('\n').map(e => JSON.parse(e)); + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { + const sessionEnv = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); assertSessionAggregates(sessionEnv[2], { attrs: { release: '1.1' }, aggregates: [{ crashed: 2, errored: 1, exited: 1 }], diff --git a/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js b/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js index b7a9538a3fa2..0cecc8ee75e4 100644 --- a/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js +++ b/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js @@ -1,5 +1,6 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject, validateSessionCountFunction } = require('../test-utils'); +const { TextEncoder } = require('util'); const sessionCounts = { sessionCounter: 0, @@ -9,8 +10,11 @@ const sessionCounts = { validateSessionCountFunction(sessionCounts); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { - const payload = req.body.split('\n').map(e => JSON.parse(e)); + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { + const payload = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); const isSessionPayload = payload[1].type === 'session'; if (isSessionPayload) { diff --git a/packages/node/test/manual/release-health/single-session/errors-in-session-capped-to-one.js b/packages/node/test/manual/release-health/single-session/errors-in-session-capped-to-one.js index dae307182ed1..18ec6fefdc78 100644 --- a/packages/node/test/manual/release-health/single-session/errors-in-session-capped-to-one.js +++ b/packages/node/test/manual/release-health/single-session/errors-in-session-capped-to-one.js @@ -1,5 +1,6 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject, validateSessionCountFunction } = require('../test-utils'); +const { TextEncoder } = require('util'); const sessionCounts = { sessionCounter: 0, @@ -9,8 +10,11 @@ const sessionCounts = { validateSessionCountFunction(sessionCounts); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { - const payload = req.body.split('\n').map(e => JSON.parse(e)); + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { + const payload = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); const isSessionPayload = payload[1].type === 'session'; if (isSessionPayload) { diff --git a/packages/node/test/manual/release-health/single-session/healthy-session.js b/packages/node/test/manual/release-health/single-session/healthy-session.js index 0533b8a28728..11c3092dfcad 100644 --- a/packages/node/test/manual/release-health/single-session/healthy-session.js +++ b/packages/node/test/manual/release-health/single-session/healthy-session.js @@ -1,5 +1,6 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject, validateSessionCountFunction } = require('../test-utils'); +const { TextEncoder } = require('util'); const sessionCounts = { sessionCounter: 0, @@ -9,9 +10,13 @@ const sessionCounts = { validateSessionCountFunction(sessionCounts); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { sessionCounts.sessionCounter++; - const sessionEnv = req.body.split('\n').map(e => JSON.parse(e)); + + const sessionEnv = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); assertSessions(constructStrippedSessionObject(sessionEnv[2]), { init: true, diff --git a/packages/node/test/manual/release-health/single-session/terminal-state-sessions-sent-once.js b/packages/node/test/manual/release-health/single-session/terminal-state-sessions-sent-once.js index aa0796782c2f..571af19d0f94 100644 --- a/packages/node/test/manual/release-health/single-session/terminal-state-sessions-sent-once.js +++ b/packages/node/test/manual/release-health/single-session/terminal-state-sessions-sent-once.js @@ -1,5 +1,6 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject, validateSessionCountFunction } = require('../test-utils'); +const { TextEncoder } = require('util'); const sessionCounts = { sessionCounter: 0, @@ -9,8 +10,11 @@ const sessionCounts = { validateSessionCountFunction(sessionCounts); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { - const payload = req.body.split('\n').map(e => JSON.parse(e)); + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { + const payload = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); const isSessionPayload = payload[1].type === 'session'; if (isSessionPayload) { diff --git a/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js b/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js index 6fa3ac0a6821..1759c1cc2d0f 100644 --- a/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js +++ b/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js @@ -1,11 +1,15 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject } = require('../test-utils'); +const { TextEncoder } = require('util'); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { if (req.category === 'session') { sessionCounts.sessionCounter++; - const sessionEnv = req.body.split('\n').map(e => JSON.parse(e)); + const sessionEnv = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); assertSessions(constructStrippedSessionObject(sessionEnv[2]), { init: true, diff --git a/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js b/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js index b550260b62b7..cc8cf921c5d0 100644 --- a/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js +++ b/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js @@ -1,5 +1,6 @@ const Sentry = require('../../../../build/cjs'); const { assertSessions, constructStrippedSessionObject, validateSessionCountFunction } = require('../test-utils'); +const { TextEncoder } = require('util'); const sessionCounts = { sessionCounter: 0, @@ -9,8 +10,11 @@ const sessionCounts = { validateSessionCountFunction(sessionCounts); function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { - const payload = req.body.split('\n').map(e => JSON.parse(e)); + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { + const payload = req.body + .split('\n') + .filter(l => !!l) + .map(e => JSON.parse(e)); const isSessionPayload = payload[1].type === 'session'; if (isSessionPayload) { diff --git a/packages/node/test/manual/webpack-domain/index.js b/packages/node/test/manual/webpack-domain/index.js index 5d3968106bab..001ba1fa8c78 100644 --- a/packages/node/test/manual/webpack-domain/index.js +++ b/packages/node/test/manual/webpack-domain/index.js @@ -1,10 +1,11 @@ const Sentry = require('../../../build/cjs'); const { colorize } = require('../colorize'); +const { TextEncoder } = require('util'); let remaining = 2; function makeDummyTransport() { - return Sentry.createTransport({ recordDroppedEvent: () => undefined }, req => { + return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => { --remaining; if (!remaining) { diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index d33efc42ff34..789baaf871ab 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -2,6 +2,7 @@ import { createTransport } from '@sentry/core'; import { EventEnvelope, EventItem } from '@sentry/types'; import { createEnvelope, serializeEnvelope } from '@sentry/utils'; import * as http from 'http'; +import { TextEncoder } from 'util'; import { makeNodeTransport } from '../../src/transports'; @@ -66,11 +67,12 @@ const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, ]); -const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()); const defaultOptions = { url: TEST_SERVER_URL, recordDroppedEvent: () => undefined, + textEncoder: new TextEncoder(), }; describe('makeNewHttpTransport()', () => { @@ -244,7 +246,7 @@ describe('makeNewHttpTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -264,7 +266,7 @@ describe('makeNewHttpTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -292,7 +294,7 @@ describe('makeNewHttpTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -320,7 +322,7 @@ describe('makeNewHttpTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); diff --git a/packages/node/test/transports/https.test.ts b/packages/node/test/transports/https.test.ts index 4313f326e933..cf7051b54fe4 100644 --- a/packages/node/test/transports/https.test.ts +++ b/packages/node/test/transports/https.test.ts @@ -3,6 +3,7 @@ import { EventEnvelope, EventItem } from '@sentry/types'; import { createEnvelope, serializeEnvelope } from '@sentry/utils'; import * as http from 'http'; import * as https from 'https'; +import { TextEncoder } from 'util'; import { makeNodeTransport } from '../../src/transports'; import { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../src/transports/http-module'; @@ -69,7 +70,7 @@ const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, ]); -const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()); const unsafeHttpsModule: HTTPModule = { request: jest @@ -83,6 +84,7 @@ const defaultOptions = { httpModule: unsafeHttpsModule, url: TEST_SERVER_URL, recordDroppedEvent: () => undefined, // noop + textEncoder: new TextEncoder(), }; describe('makeNewHttpsTransport()', () => { @@ -297,7 +299,7 @@ describe('makeNewHttpsTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -317,7 +319,7 @@ describe('makeNewHttpsTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -345,7 +347,7 @@ describe('makeNewHttpsTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); @@ -373,7 +375,7 @@ describe('makeNewHttpsTransport()', () => { const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; const executorResult = registeredRequestExecutor({ - body: serializeEnvelope(EVENT_ENVELOPE), + body: serializeEnvelope(EVENT_ENVELOPE, new TextEncoder()), category: 'error', }); diff --git a/packages/types/src/attachment.ts b/packages/types/src/attachment.ts new file mode 100644 index 000000000000..55cc795732ea --- /dev/null +++ b/packages/types/src/attachment.ts @@ -0,0 +1,6 @@ +export interface Attachment { + data: string | Uint8Array; + filename: string; + contentType?: string; + attachmentType?: string; +} diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 3425006e645d..b09557ccba75 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -116,7 +116,7 @@ export interface Client { ): PromiseLike; /** Submits the event to Sentry */ - sendEvent(event: Event): void; + sendEvent(event: Event, hint?: EventHint): void; /** Submits the session to Sentry */ sendSession(session: Session | SessionAggregates): void; diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index 33846f077a79..2f47689fe27a 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -39,17 +39,20 @@ type EventItemHeaders = { type: 'event' | 'transaction'; sample_rates?: [{ id?: TransactionSamplingMethod; rate?: number }]; }; -type AttachmentItemHeaders = { type: 'attachment'; filename: string }; +type AttachmentItemHeaders = { + type: 'attachment'; + length: number; + filename: string; + content_type?: string; + attachment_type?: string; +}; type UserFeedbackItemHeaders = { type: 'user_report' }; type SessionItemHeaders = { type: 'session' }; type SessionAggregatesItemHeaders = { type: 'sessions' }; type ClientReportItemHeaders = { type: 'client_report' }; -// TODO(v7): Remove the string union from `Event | string` -// We have to allow this hack for now as we pre-serialize events because we support -// both store and envelope endpoints. -export type EventItem = BaseEnvelopeItem; -export type AttachmentItem = BaseEnvelopeItem; +export type EventItem = BaseEnvelopeItem; +export type AttachmentItem = BaseEnvelopeItem; export type UserFeedbackItem = BaseEnvelopeItem; export type SessionItem = | BaseEnvelopeItem diff --git a/packages/types/src/event.ts b/packages/types/src/event.ts index 7cb3047ed4d5..c769feee65ff 100644 --- a/packages/types/src/event.ts +++ b/packages/types/src/event.ts @@ -1,3 +1,4 @@ +import { Attachment } from './attachment'; import { Breadcrumb } from './breadcrumb'; import { Contexts } from './context'; import { DebugMeta } from './debugMeta'; @@ -56,5 +57,6 @@ export interface EventHint { captureContext?: CaptureContext; syntheticException?: Error | null; originalException?: Error | string | null; + attachments?: Attachment[]; data?: any; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8fe14cd3ac16..d46132bf4cb3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,4 @@ +export type { Attachment } from './attachment'; export type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; export type { Client } from './client'; export type { ClientReport, Outcome, EventDropReason } from './clientreport'; diff --git a/packages/types/src/scope.ts b/packages/types/src/scope.ts index c3ee56a2a763..a2a14ffd4664 100644 --- a/packages/types/src/scope.ts +++ b/packages/types/src/scope.ts @@ -1,3 +1,4 @@ +import { Attachment } from './attachment'; import { Breadcrumb } from './breadcrumb'; import { Context, Contexts } from './context'; import { EventProcessor } from './eventprocessor'; @@ -158,4 +159,20 @@ export interface Scope { * Clears all currently set Breadcrumbs. */ clearBreadcrumbs(): this; + + /** + * Adds an attachment to the scope + * @param attachment Attachment options + */ + addAttachment(attachment: Attachment): this; + + /** + * Returns an array of attachments on the scope + */ + getAttachments(): Attachment[]; + + /** + * Clears attachments from the scope + */ + clearAttachments(): this; } diff --git a/packages/types/src/transport.ts b/packages/types/src/transport.ts index 3c900819a785..6d358dc7ddc9 100644 --- a/packages/types/src/transport.ts +++ b/packages/types/src/transport.ts @@ -3,7 +3,7 @@ import { DataCategory } from './datacategory'; import { Envelope } from './envelope'; export type TransportRequest = { - body: string; + body: string | Uint8Array; }; export type TransportMakeRequestResponse = { @@ -15,9 +15,15 @@ export type TransportMakeRequestResponse = { }; }; +// Combination of global TextEncoder and Node require('util').TextEncoder +interface TextEncoderInternal extends TextEncoderCommon { + encode(input?: string): Uint8Array; +} + export interface InternalBaseTransportOptions { bufferSize?: number; recordDroppedEvent: (reason: EventDropReason, dataCategory: DataCategory) => void; + textEncoder?: TextEncoderInternal; } export interface BaseTransportOptions extends InternalBaseTransportOptions { diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index a58c48029066..1bc69915345d 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -1,6 +1,6 @@ -import { DataCategory, Envelope, EnvelopeItem, EnvelopeItemType } from '@sentry/types'; +import { Attachment, AttachmentItem, DataCategory, Envelope, EnvelopeItem, EnvelopeItemType } from '@sentry/types'; -import { isPrimitive } from './is'; +import { dropUndefinedKeys } from './object'; /** * Creates an envelope. @@ -36,24 +36,74 @@ export function forEachEnvelopeItem( }); } +// Combination of global TextEncoder and Node require('util').TextEncoder +interface TextEncoderInternal extends TextEncoderCommon { + encode(input?: string): Uint8Array; +} + +function encodeUTF8(input: string, textEncoder?: TextEncoderInternal): Uint8Array { + const utf8 = textEncoder || new TextEncoder(); + return utf8.encode(input); +} + /** - * Serializes an envelope into a string. + * Serializes an envelope. */ -export function serializeEnvelope(envelope: Envelope): string { - const [headers, items] = envelope; - const serializedHeaders = JSON.stringify(headers); - - // Have to cast items to any here since Envelope is a union type - // Fixed in Typescript 4.2 - // TODO: Remove any[] cast when we upgrade to TS 4.2 - // https://github.com/microsoft/TypeScript/issues/36390 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (items as any[]).reduce((acc, item: typeof items[number]) => { - const [itemHeaders, payload] = item; - // We do not serialize payloads that are primitives - const serializedPayload = isPrimitive(payload) ? String(payload) : JSON.stringify(payload); - return `${acc}\n${JSON.stringify(itemHeaders)}\n${serializedPayload}`; - }, serializedHeaders); +export function serializeEnvelope(envelope: Envelope, textEncoder?: TextEncoderInternal): string | Uint8Array { + const [envHeaders, items] = envelope; + + // Initially we construct our envelope as a string and only convert to binary chunks if we encounter binary data + let parts: string | Uint8Array[] = JSON.stringify(envHeaders); + + function append(next: string | Uint8Array): void { + if (typeof parts === 'string') { + parts = typeof next === 'string' ? parts + next : [encodeUTF8(parts, textEncoder), next]; + } else { + parts.push(typeof next === 'string' ? encodeUTF8(next, textEncoder) : next); + } + } + + for (const item of items) { + const [itemHeaders, payload] = item as typeof items[number]; + append(`\n${JSON.stringify(itemHeaders)}\n`); + append(typeof payload === 'string' || payload instanceof Uint8Array ? payload : JSON.stringify(payload)); + } + + return typeof parts === 'string' ? parts : concatBuffers(parts); +} + +function concatBuffers(buffers: Uint8Array[]): Uint8Array { + const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0); + + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const buffer of buffers) { + merged.set(buffer, offset); + offset += buffer.length; + } + + return merged; +} + +/** + * Creates attachment envelope items + */ +export function createAttachmentEnvelopeItem( + attachment: Attachment, + textEncoder?: TextEncoderInternal, +): AttachmentItem { + const buffer = typeof attachment.data === 'string' ? encodeUTF8(attachment.data, textEncoder) : attachment.data; + + return [ + dropUndefinedKeys({ + type: 'attachment', + length: buffer.length, + filename: attachment.filename, + content_type: attachment.contentType, + attachment_type: attachment.attachmentType, + }), + buffer, + ]; } const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { diff --git a/packages/utils/test/clientreport.test.ts b/packages/utils/test/clientreport.test.ts index f2abca98e798..442df49c6edf 100644 --- a/packages/utils/test/clientreport.test.ts +++ b/packages/utils/test/clientreport.test.ts @@ -1,7 +1,9 @@ import { ClientReport } from '@sentry/types'; +import { TextEncoder } from 'util'; import { createClientReportEnvelope } from '../src/clientreport'; import { serializeEnvelope } from '../src/envelope'; +import { parseEnvelope } from './testutils'; const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [ { @@ -41,11 +43,21 @@ describe('createClientReportEnvelope', () => { it('serializes an envelope', () => { const env = createClientReportEnvelope(DEFAULT_DISCARDED_EVENTS, MOCK_DSN, 123456); - const serializedEnv = serializeEnvelope(env); - expect(serializedEnv).toMatchInlineSnapshot(` - "{\\"dsn\\":\\"https://public@example.com/1\\"} - {\\"type\\":\\"client_report\\"} - {\\"timestamp\\":123456,\\"discarded_events\\":[{\\"reason\\":\\"before_send\\",\\"category\\":\\"error\\",\\"quantity\\":30},{\\"reason\\":\\"network_error\\",\\"category\\":\\"transaction\\",\\"quantity\\":23}]}" - `); + + const [headers, items] = parseEnvelope(serializeEnvelope(env, new TextEncoder())); + + expect(headers).toEqual({ dsn: 'https://public@example.com/1' }); + expect(items).toEqual([ + [ + { type: 'client_report' }, + { + timestamp: 123456, + discarded_events: [ + { reason: 'before_send', category: 'error', quantity: 30 }, + { reason: 'network_error', category: 'transaction', quantity: 23 }, + ], + }, + ], + ]); }); }); diff --git a/packages/utils/test/envelope.test.ts b/packages/utils/test/envelope.test.ts index df07629a083e..af63b5bfb9bf 100644 --- a/packages/utils/test/envelope.test.ts +++ b/packages/utils/test/envelope.test.ts @@ -1,4 +1,5 @@ import { EventEnvelope } from '@sentry/types'; +import { TextEncoder } from 'util'; import { addItemToEnvelope, createEnvelope, forEachEnvelopeItem, serializeEnvelope } from '../src/envelope'; import { parseEnvelope } from './testutils'; @@ -20,28 +21,61 @@ describe('envelope', () => { describe('serializeEnvelope()', () => { it('serializes an envelope', () => { const env = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, []); - expect(serializeEnvelope(env)).toMatchInlineSnapshot( - '"{\\"event_id\\":\\"aa3ff046696b4bc6b609ce6d28fde9e2\\",\\"sent_at\\":\\"123\\"}"', + const serializedEnvelope = serializeEnvelope(env, new TextEncoder()); + expect(typeof serializedEnvelope).toBe('string'); + + const [headers] = parseEnvelope(serializedEnvelope); + expect(headers).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); + }); + + it('serializes an envelope with attachments', () => { + const items: EventEnvelope[1] = [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + [{ type: 'attachment', filename: 'bar.txt', length: 6 }, Uint8Array.from([1, 2, 3, 4, 5, 6])], + [{ type: 'attachment', filename: 'foo.txt', length: 6 }, Uint8Array.from([7, 8, 9, 10, 11, 12])], + ]; + + const env = createEnvelope( + { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, + items, ); + + expect.assertions(6); + + const serializedEnvelope = serializeEnvelope(env, new TextEncoder()); + expect(serializedEnvelope).toBeInstanceOf(Uint8Array); + + const [parsedHeaders, parsedItems] = parseEnvelope(serializedEnvelope); + expect(parsedHeaders).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); + expect(parsedItems).toHaveLength(3); + expect(items[0]).toEqual([{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }]); + expect(items[1]).toEqual([ + { type: 'attachment', filename: 'bar.txt', length: 6 }, + Uint8Array.from([1, 2, 3, 4, 5, 6]), + ]); + expect(items[2]).toEqual([ + { type: 'attachment', filename: 'foo.txt', length: 6 }, + Uint8Array.from([7, 8, 9, 10, 11, 12]), + ]); }); }); describe('addItemToEnvelope()', () => { it('adds an item to an envelope', () => { const env = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, []); - const parsedEnvelope = parseEnvelope(serializeEnvelope(env)); - expect(parsedEnvelope).toHaveLength(1); - expect(parsedEnvelope[0]).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); + let [envHeaders, items] = parseEnvelope(serializeEnvelope(env, new TextEncoder())); + expect(items).toHaveLength(0); + expect(envHeaders).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); const newEnv = addItemToEnvelope(env, [ { type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }, ]); - const parsedNewEnvelope = parseEnvelope(serializeEnvelope(newEnv)); - expect(parsedNewEnvelope).toHaveLength(3); - expect(parsedNewEnvelope[0]).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); - expect(parsedNewEnvelope[1]).toEqual({ type: 'event' }); - expect(parsedNewEnvelope[2]).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }); + + [envHeaders, items] = parseEnvelope(serializeEnvelope(newEnv, new TextEncoder())); + expect(envHeaders).toEqual({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }); + expect(items).toHaveLength(1); + expect(items[0]).toEqual([{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }]); }); }); @@ -49,8 +83,8 @@ describe('envelope', () => { it('loops through an envelope', () => { const items: EventEnvelope[1] = [ [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], - [{ type: 'attachment', filename: 'bar.txt' }, '123456'], - [{ type: 'attachment', filename: 'foo.txt' }, '123456'], + [{ type: 'attachment', filename: 'bar.txt', length: 6 }, Uint8Array.from([1, 2, 3, 4, 5, 6])], + [{ type: 'attachment', filename: 'foo.txt', length: 6 }, Uint8Array.from([7, 8, 9, 10, 11, 12])], ]; const env = createEnvelope( diff --git a/packages/utils/test/testutils.ts b/packages/utils/test/testutils.ts index aa3c5485eec1..b708d77064eb 100644 --- a/packages/utils/test/testutils.ts +++ b/packages/utils/test/testutils.ts @@ -1,3 +1,6 @@ +import { BaseEnvelopeHeaders, BaseEnvelopeItemHeaders, Envelope } from '@sentry/types'; +import { TextDecoder, TextEncoder } from 'util'; + export const testOnlyIfNodeVersionAtLeast = (minVersion: number): jest.It => { const currentNodeVersion = process.env.NODE_VERSION; @@ -12,6 +15,55 @@ export const testOnlyIfNodeVersionAtLeast = (minVersion: number): jest.It => { return it; }; -export function parseEnvelope(env: string): Array> { - return env.split('\n').map(e => JSON.parse(e)); +/** + * A naive binary envelope parser + */ +export function parseEnvelope(env: string | Uint8Array): Envelope { + let buf = typeof env === 'string' ? new TextEncoder().encode(env) : env; + + let envelopeHeaders: BaseEnvelopeHeaders | undefined; + let lastItemHeader: BaseEnvelopeItemHeaders | undefined; + const items: [any, any][] = []; + + let binaryLength = 0; + while (buf.length) { + // Next length is either the binary length from the previous header + // or the next newline character + let i = binaryLength || buf.indexOf(0xa); + + // If no newline was found, assume this is the last block + if (i < 0) { + i = buf.length; + } + + // If we read out a length in the previous header, assume binary + if (binaryLength > 0) { + const bin = buf.slice(0, binaryLength); + binaryLength = 0; + items.push([lastItemHeader, bin]); + } else { + const json = JSON.parse(new TextDecoder().decode(buf.slice(0, i + 1))); + + if (typeof json.length === 'number') { + binaryLength = json.length; + } + + // First json is always the envelope headers + if (!envelopeHeaders) { + envelopeHeaders = json; + } else { + // If there is a type property, assume this is an item header + if ('type' in json) { + lastItemHeader = json; + } else { + items.push([lastItemHeader, json]); + } + } + } + + // Replace the buffer with the previous block and newline removed + buf = buf.slice(i + 1); + } + + return [envelopeHeaders as BaseEnvelopeHeaders, items]; }