Skip to content

Commit 457b806

Browse files
authored
feat: Add attachments API (#5004)
- Adds `Attachment` interface and extends `AttachmentItemHeaders` with required fields - Adds `addAttachment`, `getAttachments` and `clearAttachments` to `Scope` - Adds `attachments: Attachment[]` to `EventHint` - In the private capture/process/prepare/send methods in the `BaseClient`, the `EventHint` argument has moved as it's no longer optional - We can't mutate the hint if it's undefined! - Copies attachments from the scope to the hint in `_prepareEvent` - Adds optional `textEncoder` to `InternalBaseTransportOptions` and overrides this in the node client to support node 8-10 - Manually pass `new TextEncoder()` in many of the tests so they pass on node.js 8-10 - Adds binary serialisation support for envelopes - `serializeEnvelope` returns `string | Uint8Array` which all transports supported without modification - Defaults to concatenating strings when no attachments are found. String concatenation is about 10x faster than the binary serialisation - Rewrites `parseEnvelope` in `testutils.ts` so that it can parse binary envelopes
1 parent 8d08a8b commit 457b806

37 files changed

+393
-106
lines changed

packages/browser/src/client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class BrowserClient extends BaseClient<BrowserClientOptions> {
104104
/**
105105
* @inheritDoc
106106
*/
107-
public sendEvent(event: Event): void {
107+
public sendEvent(event: Event, hint?: EventHint): void {
108108
// We only want to add the sentry event breadcrumb when the user has the breadcrumb integration installed and
109109
// activated its `sentry` option.
110110
// 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<BrowserClientOptions> {
133133
);
134134
}
135135

136-
super.sendEvent(event);
136+
super.sendEvent(event, hint);
137137
}
138138

139139
/**
140140
* @inheritDoc
141141
*/
142-
protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike<Event | null> {
142+
protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event | null> {
143143
event.platform = event.platform || 'javascript';
144-
return super._prepareEvent(event, scope, hint);
144+
return super._prepareEvent(event, hint, scope);
145145
}
146146

147147
/**

packages/browser/src/transports/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function getNativeFetchImplementation(): FetchImpl {
8686
* @param url report endpoint
8787
* @param body report payload
8888
*/
89-
export function sendReport(url: string, body: string): void {
89+
export function sendReport(url: string, body: string | Uint8Array): void {
9090
const isRealNavigator = Object.prototype.toString.call(global && global.navigator) === '[object Navigator]';
9191
const hasSendBeacon = isRealNavigator && typeof global.navigator.sendBeacon === 'function';
9292

packages/browser/test/unit/transports/fetch.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventEnvelope, EventItem } from '@sentry/types';
22
import { createEnvelope, serializeEnvelope } from '@sentry/utils';
3+
import { TextEncoder } from 'util';
34

45
import { makeFetchTransport } from '../../../src/transports/fetch';
56
import { BrowserTransportOptions } from '../../../src/transports/types';
@@ -8,6 +9,7 @@ import { FetchImpl } from '../../../src/transports/utils';
89
const DEFAULT_FETCH_TRANSPORT_OPTIONS: BrowserTransportOptions = {
910
url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7',
1011
recordDroppedEvent: () => undefined,
12+
textEncoder: new TextEncoder(),
1113
};
1214

1315
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
@@ -40,7 +42,7 @@ describe('NewFetchTransport', () => {
4042
expect(mockFetch).toHaveBeenCalledTimes(1);
4143

4244
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, {
43-
body: serializeEnvelope(ERROR_ENVELOPE),
45+
body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()),
4446
method: 'POST',
4547
referrerPolicy: 'origin',
4648
});
@@ -90,7 +92,7 @@ describe('NewFetchTransport', () => {
9092

9193
await transport.send(ERROR_ENVELOPE);
9294
expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_FETCH_TRANSPORT_OPTIONS.url, {
93-
body: serializeEnvelope(ERROR_ENVELOPE),
95+
body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()),
9496
method: 'POST',
9597
...REQUEST_OPTIONS,
9698
});

packages/browser/test/unit/transports/xhr.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { EventEnvelope, EventItem } from '@sentry/types';
22
import { createEnvelope, serializeEnvelope } from '@sentry/utils';
3+
import { TextEncoder } from 'util';
34

45
import { BrowserTransportOptions } from '../../../src/transports/types';
56
import { makeXHRTransport } from '../../../src/transports/xhr';
67

78
const DEFAULT_XHR_TRANSPORT_OPTIONS: BrowserTransportOptions = {
89
url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7',
910
recordDroppedEvent: () => undefined,
11+
textEncoder: new TextEncoder(),
1012
};
1113

1214
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
@@ -64,7 +66,7 @@ describe('NewXHRTransport', () => {
6466
expect(xhrMock.open).toHaveBeenCalledTimes(1);
6567
expect(xhrMock.open).toHaveBeenCalledWith('POST', DEFAULT_XHR_TRANSPORT_OPTIONS.url);
6668
expect(xhrMock.send).toHaveBeenCalledTimes(1);
67-
expect(xhrMock.send).toHaveBeenCalledWith(serializeEnvelope(ERROR_ENVELOPE));
69+
expect(xhrMock.send).toHaveBeenCalledWith(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()));
6870
});
6971

7072
it('sets rate limit response headers', async () => {

packages/core/src/baseclient.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
Transport,
2020
} from '@sentry/types';
2121
import {
22+
addItemToEnvelope,
2223
checkOrSetAlreadyCaught,
24+
createAttachmentEnvelopeItem,
2325
dateTimestampInSeconds,
2426
isPlainObject,
2527
isPrimitive,
@@ -283,9 +285,14 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
283285
/**
284286
* @inheritDoc
285287
*/
286-
public sendEvent(event: Event): void {
288+
public sendEvent(event: Event, hint: EventHint = {}): void {
287289
if (this._dsn) {
288290
const env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel);
291+
292+
for (const attachment of hint.attachments || []) {
293+
addItemToEnvelope(env, createAttachmentEnvelopeItem(attachment, this._options.transportOptions?.textEncoder));
294+
}
295+
289296
this._sendEnvelope(env);
290297
}
291298
}
@@ -401,11 +408,11 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
401408
* @param scope A scope containing event metadata.
402409
* @returns A new event with more information.
403410
*/
404-
protected _prepareEvent(event: Event, scope?: Scope, hint?: EventHint): PromiseLike<Event | null> {
411+
protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event | null> {
405412
const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = this.getOptions();
406413
const prepared: Event = {
407414
...event,
408-
event_id: event.event_id || (hint && hint.event_id ? hint.event_id : uuid4()),
415+
event_id: event.event_id || hint.event_id || uuid4(),
409416
timestamp: event.timestamp || dateTimestampInSeconds(),
410417
};
411418

@@ -415,7 +422,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
415422
// If we have scope given to us, use it as the base for further modifications.
416423
// This allows us to prevent unnecessary copying of data if `captureContext` is not provided.
417424
let finalScope = scope;
418-
if (hint && hint.captureContext) {
425+
if (hint.captureContext) {
419426
finalScope = Scope.clone(finalScope).update(hint.captureContext);
420427
}
421428

@@ -425,6 +432,13 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
425432
// This should be the last thing called, since we want that
426433
// {@link Hub.addEventProcessor} gets the finished prepared event.
427434
if (finalScope) {
435+
// Collect attachments from the hint and scope
436+
const attachments = [...(hint.attachments || []), ...finalScope.getAttachments()];
437+
438+
if (attachments.length) {
439+
hint.attachments = attachments;
440+
}
441+
428442
// In case we have a hub we reassign it.
429443
result = finalScope.applyToEvent(prepared, hint);
430444
}
@@ -552,7 +566,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
552566
* @param hint
553567
* @param scope
554568
*/
555-
protected _captureEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike<string | undefined> {
569+
protected _captureEvent(event: Event, hint: EventHint = {}, scope?: Scope): PromiseLike<string | undefined> {
556570
return this._processEvent(event, hint, scope).then(
557571
finalEvent => {
558572
return finalEvent.event_id;
@@ -577,7 +591,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
577591
* @param scope A scope containing event metadata.
578592
* @returns A SyncPromise that resolves with the event or rejects in case event was/will not be send.
579593
*/
580-
protected _processEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike<Event> {
594+
protected _processEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event> {
581595
const { beforeSend, sampleRate } = this.getOptions();
582596

583597
if (!this._isEnabled()) {
@@ -597,14 +611,14 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
597611
);
598612
}
599613

600-
return this._prepareEvent(event, scope, hint)
614+
return this._prepareEvent(event, hint, scope)
601615
.then(prepared => {
602616
if (prepared === null) {
603617
this.recordDroppedEvent('event_processor', event.type || 'error');
604618
throw new SentryError('An event processor returned null, will not send event.');
605619
}
606620

607-
const isInternalException = hint && hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true;
621+
const isInternalException = hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true;
608622
if (isInternalException || isTransaction || !beforeSend) {
609623
return prepared;
610624
}
@@ -623,7 +637,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
623637
this._updateSessionFromEvent(session, processedEvent);
624638
}
625639

626-
this.sendEvent(processedEvent);
640+
this.sendEvent(processedEvent, hint);
627641
return processedEvent;
628642
})
629643
.then(null, reason => {

packages/core/src/transports/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function createTransport(
6969
};
7070

7171
const requestTask = (): PromiseLike<void> =>
72-
makeRequest({ body: serializeEnvelope(filteredEnvelope) }).then(
72+
makeRequest({ body: serializeEnvelope(filteredEnvelope, options.textEncoder) }).then(
7373
response => {
7474
// We don't want to throw on NOK responses, but we want to at least log them
7575
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode >= 300)) {

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventEnvelope, EventItem, TransportMakeRequestResponse } from '@sentry/types';
22
import { createEnvelope, PromiseBuffer, resolvedSyncPromise, serializeEnvelope } from '@sentry/utils';
3+
import { TextEncoder } from 'util';
34

45
import { createTransport } from '../../../src/transports/base';
56

@@ -14,6 +15,7 @@ const TRANSACTION_ENVELOPE = createEnvelope<EventEnvelope>(
1415

1516
const transportOptions = {
1617
recordDroppedEvent: () => undefined, // noop
18+
textEncoder: new TextEncoder(),
1719
};
1820

1921
describe('createTransport', () => {
@@ -36,7 +38,7 @@ describe('createTransport', () => {
3638
it('constructs a request to send to Sentry', async () => {
3739
expect.assertions(1);
3840
const transport = createTransport(transportOptions, req => {
39-
expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE));
41+
expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()));
4042
return resolvedSyncPromise({});
4143
});
4244
await transport.send(ERROR_ENVELOPE);
@@ -46,7 +48,7 @@ describe('createTransport', () => {
4648
expect.assertions(2);
4749

4850
const transport = createTransport(transportOptions, req => {
49-
expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE));
51+
expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()));
5052
throw new Error();
5153
});
5254

@@ -82,7 +84,10 @@ describe('createTransport', () => {
8284

8385
const mockRecordDroppedEventCallback = jest.fn();
8486

85-
const transport = createTransport({ recordDroppedEvent: mockRecordDroppedEventCallback }, mockRequestExecutor);
87+
const transport = createTransport(
88+
{ recordDroppedEvent: mockRecordDroppedEventCallback, textEncoder: new TextEncoder() },
89+
mockRequestExecutor,
90+
);
8691

8792
return [transport, setTransportResponse, mockRequestExecutor, mockRecordDroppedEventCallback] as const;
8893
}

packages/core/test/mocks/client.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { ClientOptions, Event, Integration, Outcome, Session, Severity, SeverityLevel } from '@sentry/types';
1+
import { ClientOptions, Event, EventHint, Integration, Outcome, Session, Severity, SeverityLevel } from '@sentry/types';
22
import { resolvedSyncPromise } from '@sentry/utils';
3+
import { TextEncoder } from 'util';
34

45
import { BaseClient } from '../../src/baseclient';
56
import { initAndBind } from '../../src/sdk';
@@ -9,9 +10,13 @@ export function getDefaultTestClientOptions(options: Partial<TestClientOptions>
910
return {
1011
integrations: [],
1112
sendClientReports: true,
13+
transportOptions: { textEncoder: new TextEncoder() },
1214
transport: () =>
1315
createTransport(
14-
{ recordDroppedEvent: () => undefined }, // noop
16+
{
17+
recordDroppedEvent: () => undefined,
18+
textEncoder: new TextEncoder(),
19+
}, // noop
1520
_ => resolvedSyncPromise({}),
1621
),
1722
stackParser: () => [],
@@ -62,10 +67,10 @@ export class TestClient extends BaseClient<TestClientOptions> {
6267
return resolvedSyncPromise({ message, level });
6368
}
6469

65-
public sendEvent(event: Event): void {
70+
public sendEvent(event: Event, hint?: EventHint): void {
6671
this.event = event;
6772
if (this._options.enableSend) {
68-
super.sendEvent(event);
73+
super.sendEvent(event, hint);
6974
return;
7075
}
7176
// eslint-disable-next-line @typescript-eslint/no-unused-expressions

packages/core/test/mocks/transport.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SyncPromise } from '@sentry/utils';
2+
import { TextEncoder } from 'util';
23

34
import { createTransport } from '../../src/transports/base';
45

@@ -10,7 +11,7 @@ export function makeFakeTransport(delay: number = 2000) {
1011
let sendCalled = 0;
1112
let sentCount = 0;
1213
const makeTransport = () =>
13-
createTransport({ recordDroppedEvent: () => undefined }, () => {
14+
createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, () => {
1415
sendCalled += 1;
1516
return new SyncPromise(async res => {
1617
await sleep(delay);

packages/gatsby/test/integration.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { render } from '@testing-library/react';
33
import { useEffect } from 'react';
44
// eslint-disable-next-line @typescript-eslint/no-unused-vars
55
import * as React from 'react';
6+
import { TextDecoder,TextEncoder } from 'util';
67

78
import { onClientEntry } from '../gatsby-browser';
89
import * as Sentry from '../src';
910

1011
beforeAll(() => {
1112
(global as any).__SENTRY_RELEASE__ = '683f3a6ab819d47d23abfca9a914c81f0524d35b';
1213
(global as any).__SENTRY_DSN__ = 'https://[email protected]/0';
14+
(global as any).TextEncoder = TextEncoder;
15+
(global as any).TextDecoder = TextDecoder;
1316
});
1417

1518
describe('useEffect', () => {

packages/hub/src/exports.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CaptureContext,
44
CustomSamplingContext,
55
Event,
6+
EventHint,
67
Extra,
78
Extras,
89
Primitive,
@@ -59,8 +60,8 @@ export function captureMessage(
5960
* @param event The event to send to Sentry.
6061
* @returns The generated eventId.
6162
*/
62-
export function captureEvent(event: Event): ReturnType<Hub['captureEvent']> {
63-
return getCurrentHub().captureEvent(event);
63+
export function captureEvent(event: Event, hint?: EventHint): ReturnType<Hub['captureEvent']> {
64+
return getCurrentHub().captureEvent(event, hint);
6465
}
6566

6667
/**

0 commit comments

Comments
 (0)