Skip to content

Commit 9871829

Browse files
lforstAbhiPrasad
authored andcommitted
ref: Record dropped events in BaseClient (#5017)
1 parent 6831dbd commit 9871829

File tree

8 files changed

+196
-105
lines changed

8 files changed

+196
-105
lines changed

packages/core/src/baseclient.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { Scope, Session } from '@sentry/hub';
33
import {
44
Client,
55
ClientOptions,
6+
DataCategory,
67
DsnComponents,
78
Envelope,
89
Event,
10+
EventDropReason,
911
EventHint,
1012
Integration,
1113
IntegrationClass,
14+
Outcome,
1215
SessionAggregates,
1316
Severity,
1417
SeverityLevel,
@@ -87,6 +90,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
8790
/** Number of calls being processed */
8891
protected _numProcessing: number = 0;
8992

93+
/** Holds flushable */
94+
private _outcomes: { [key: string]: number } = {};
95+
9096
/**
9197
* Initializes this client instance.
9298
*
@@ -98,7 +104,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
98104
this._dsn = makeDsn(options.dsn);
99105
const url = getEnvelopeEndpointWithUrlEncodedAuth(this._dsn, options.tunnel);
100106
this._transport = options.transport({
101-
recordDroppedEvent: () => undefined, // TODO(v7): Provide a proper function instead of noop
107+
recordDroppedEvent: this.recordDroppedEvent.bind(this),
102108
...options.transportOptions,
103109
url,
104110
});
@@ -270,7 +276,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
270276
public sendEvent(event: Event): void {
271277
if (this._dsn) {
272278
const env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel);
273-
this.sendEnvelope(env);
279+
this._sendEnvelope(env);
274280
}
275281
}
276282

@@ -280,20 +286,26 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
280286
public sendSession(session: Session | SessionAggregates): void {
281287
if (this._dsn) {
282288
const env = createSessionEnvelope(session, this._dsn, this._options._metadata, this._options.tunnel);
283-
this.sendEnvelope(env);
289+
this._sendEnvelope(env);
284290
}
285291
}
286292

287293
/**
288-
* @inheritdoc
294+
* @inheritDoc
289295
*/
290-
public sendEnvelope(env: Envelope): void {
291-
if (this._transport && this._dsn) {
292-
this._transport.send(env).then(null, reason => {
293-
IS_DEBUG_BUILD && logger.error('Error while sending event:', reason);
294-
});
295-
} else {
296-
IS_DEBUG_BUILD && logger.error('Transport disabled');
296+
public recordDroppedEvent(reason: EventDropReason, category: DataCategory): void {
297+
if (this._options.sendClientReports) {
298+
// We want to track each category (error, transaction, session) separately
299+
// but still keep the distinction between different type of outcomes.
300+
// We could use nested maps, but it's much easier to read and type this way.
301+
// A correct type for map-based implementation if we want to go that route
302+
// would be `Partial<Record<SentryRequestType, Partial<Record<Outcome, number>>>>`
303+
// With typescript 4.1 we could even use template literal types
304+
const key = `${reason}:${category}`;
305+
IS_DEBUG_BUILD && logger.log(`Adding outcome: "${key}"`);
306+
307+
// The following works because undefined + 1 === NaN and NaN is falsy
308+
this._outcomes[key] = this._outcomes[key] + 1 || 1;
297309
}
298310
}
299311

@@ -565,20 +577,8 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
565577
* @returns A SyncPromise that resolves with the event or rejects in case event was/will not be send.
566578
*/
567579
protected _processEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike<Event> {
568-
// eslint-disable-next-line @typescript-eslint/unbound-method
569580
const { beforeSend, sampleRate } = this.getOptions();
570581

571-
// type RecordLostEvent = NonNullable<Transport['recordLostEvent']>;
572-
type RecordLostEventParams = unknown[]; // Parameters<RecordLostEvent>;
573-
574-
// no-op as new transports don't have client outcomes
575-
// TODO(v7): Re-add this functionality
576-
function recordLostEvent(_outcome: RecordLostEventParams[0], _category: RecordLostEventParams[1]): void {
577-
// if (transport.recordLostEvent) {
578-
// transport.recordLostEvent(outcome, category);
579-
// }
580-
}
581-
582582
if (!this._isEnabled()) {
583583
return rejectedSyncPromise(new SentryError('SDK not enabled, will not capture event.'));
584584
}
@@ -588,7 +588,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
588588
// 0.0 === 0% events are sent
589589
// Sampling for transaction happens somewhere else
590590
if (!isTransaction && typeof sampleRate === 'number' && Math.random() > sampleRate) {
591-
recordLostEvent('sample_rate', 'event');
591+
this.recordDroppedEvent('sample_rate', 'error');
592592
return rejectedSyncPromise(
593593
new SentryError(
594594
`Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`,
@@ -599,7 +599,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
599599
return this._prepareEvent(event, scope, hint)
600600
.then(prepared => {
601601
if (prepared === null) {
602-
recordLostEvent('event_processor', event.type || 'event');
602+
this.recordDroppedEvent('event_processor', event.type || 'error');
603603
throw new SentryError('An event processor returned null, will not send event.');
604604
}
605605

@@ -613,7 +613,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
613613
})
614614
.then(processedEvent => {
615615
if (processedEvent === null) {
616-
recordLostEvent('before_send', event.type || 'event');
616+
this.recordDroppedEvent('before_send', event.type || 'error');
617617
throw new SentryError('`beforeSend` returned `null`, will not send event.');
618618
}
619619

@@ -659,6 +659,35 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
659659
);
660660
}
661661

662+
/**
663+
* @inheritdoc
664+
*/
665+
protected _sendEnvelope(envelope: Envelope): void {
666+
if (this._transport && this._dsn) {
667+
this._transport.send(envelope).then(null, reason => {
668+
IS_DEBUG_BUILD && logger.error('Error while sending event:', reason);
669+
});
670+
} else {
671+
IS_DEBUG_BUILD && logger.error('Transport disabled');
672+
}
673+
}
674+
675+
/**
676+
* Clears outcomes on this client and returns them.
677+
*/
678+
protected _clearOutcomes(): Outcome[] {
679+
const outcomes = this._outcomes;
680+
this._outcomes = {};
681+
return Object.keys(outcomes).map(key => {
682+
const [reason, category] = key.split(':') as [EventDropReason, DataCategory];
683+
return {
684+
reason,
685+
category,
686+
quantity: outcomes[key],
687+
};
688+
});
689+
}
690+
662691
/**
663692
* @inheritDoc
664693
*/

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

Lines changed: 101 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,30 +1061,24 @@ describe('BaseClient', () => {
10611061
expect((TestClient.instance!.event! as any).data).toBe('someRandomThing');
10621062
});
10631063

1064-
// TODO(v7): Add back test with client reports
1065-
// test('beforeSend records dropped events', () => {
1066-
// expect.assertions(1);
1067-
1068-
// const client = new TestClient(
1069-
// getDefaultTestClientOptions({
1070-
// dsn: PUBLIC_DSN,
1071-
// beforeSend() {
1072-
// return null;
1073-
// },
1074-
// }),
1075-
// );
1076-
// const recordLostEventSpy = jest.fn();
1077-
// jest.spyOn(client, 'getTransport').mockImplementationOnce(
1078-
// () =>
1079-
// ({
1080-
// recordLostEvent: recordLostEventSpy,
1081-
// } as any as Transport),
1082-
// );
1083-
1084-
// client.captureEvent({ message: 'hello' }, {});
1085-
1086-
// expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'event');
1087-
// });
1064+
test('beforeSend records dropped events', () => {
1065+
expect.assertions(1);
1066+
1067+
const client = new TestClient(
1068+
getDefaultTestClientOptions({
1069+
dsn: PUBLIC_DSN,
1070+
beforeSend() {
1071+
return null;
1072+
},
1073+
}),
1074+
);
1075+
1076+
const recordLostEventSpy = jest.spyOn(client, 'recordDroppedEvent');
1077+
1078+
client.captureEvent({ message: 'hello' }, {});
1079+
1080+
expect(recordLostEventSpy).toHaveBeenCalledWith('before_send', 'error');
1081+
});
10881082

10891083
test('eventProcessor can drop the even when it returns null', () => {
10901084
expect.assertions(3);
@@ -1102,28 +1096,21 @@ describe('BaseClient', () => {
11021096
expect(loggerWarnSpy).toBeCalledWith(new SentryError('An event processor returned null, will not send event.'));
11031097
});
11041098

1105-
// TODO(v7): Add back tests with client reports
1106-
// test('eventProcessor records dropped events', () => {
1107-
// expect.assertions(1);
1099+
test('eventProcessor records dropped events', () => {
1100+
expect.assertions(1);
11081101

1109-
// const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
1110-
// const client = new TestClient(options);
1102+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
1103+
const client = new TestClient(options);
11111104

1112-
// const recordLostEventSpy = jest.fn();
1113-
// jest.spyOn(client, 'getTransport').mockImplementationOnce(
1114-
// () =>
1115-
// ({
1116-
// recordLostEvent: recordLostEventSpy,
1117-
// } as any as Transport),
1118-
// );
1105+
const recordLostEventSpy = jest.spyOn(client, 'recordDroppedEvent');
11191106

1120-
// const scope = new Scope();
1121-
// scope.addEventProcessor(() => null);
1107+
const scope = new Scope();
1108+
scope.addEventProcessor(() => null);
11221109

1123-
// client.captureEvent({ message: 'hello' }, {}, scope);
1110+
client.captureEvent({ message: 'hello' }, {}, scope);
11241111

1125-
// expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'event');
1126-
// });
1112+
expect(recordLostEventSpy).toHaveBeenCalledWith('event_processor', 'error');
1113+
});
11271114

11281115
test('eventProcessor sends an event and logs when it crashes', () => {
11291116
expect.assertions(3);
@@ -1154,24 +1141,17 @@ describe('BaseClient', () => {
11541141
);
11551142
});
11561143

1157-
// TODO(v7): Add back test with client reports
1158-
// test('records events dropped due to sampleRate', () => {
1159-
// expect.assertions(1);
1144+
test('records events dropped due to sampleRate', () => {
1145+
expect.assertions(1);
11601146

1161-
// const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 });
1162-
// const client = new TestClient(options);
1147+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 });
1148+
const client = new TestClient(options);
11631149

1164-
// const recordLostEventSpy = jest.fn();
1165-
// jest.spyOn(client, 'getTransport').mockImplementationOnce(
1166-
// () =>
1167-
// ({
1168-
// recordLostEvent: recordLostEventSpy,
1169-
// } as any as Transport),
1170-
// );
1150+
const recordLostEventSpy = jest.spyOn(client, 'recordDroppedEvent');
11711151

1172-
// client.captureEvent({ message: 'hello' }, {});
1173-
// expect(recordLostEventSpy).toHaveBeenCalledWith('sample_rate', 'event');
1174-
// });
1152+
client.captureEvent({ message: 'hello' }, {});
1153+
expect(recordLostEventSpy).toHaveBeenCalledWith('sample_rate', 'error');
1154+
});
11751155
});
11761156

11771157
describe('integrations', () => {
@@ -1366,4 +1346,69 @@ describe('BaseClient', () => {
13661346
expect(TestClient.instance!.session).toBeUndefined();
13671347
});
13681348
});
1349+
1350+
describe('recordDroppedEvent()/_clearOutcomes()', () => {
1351+
test('record and return outcomes', () => {
1352+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
1353+
const client = new TestClient(options);
1354+
1355+
client.recordDroppedEvent('ratelimit_backoff', 'error');
1356+
client.recordDroppedEvent('ratelimit_backoff', 'error');
1357+
client.recordDroppedEvent('network_error', 'transaction');
1358+
client.recordDroppedEvent('network_error', 'transaction');
1359+
client.recordDroppedEvent('before_send', 'session');
1360+
client.recordDroppedEvent('event_processor', 'attachment');
1361+
client.recordDroppedEvent('network_error', 'transaction');
1362+
1363+
const clearedOutcomes = client._clearOutcomes();
1364+
1365+
expect(clearedOutcomes).toEqual(
1366+
expect.arrayContaining([
1367+
{
1368+
reason: 'ratelimit_backoff',
1369+
category: 'error',
1370+
quantity: 2,
1371+
},
1372+
{
1373+
reason: 'network_error',
1374+
category: 'transaction',
1375+
quantity: 3,
1376+
},
1377+
{
1378+
reason: 'before_send',
1379+
category: 'session',
1380+
quantity: 1,
1381+
},
1382+
{
1383+
reason: 'event_processor',
1384+
category: 'attachment',
1385+
quantity: 1,
1386+
},
1387+
]),
1388+
);
1389+
});
1390+
1391+
test('to clear outcomes', () => {
1392+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN });
1393+
const client = new TestClient(options);
1394+
1395+
client.recordDroppedEvent('ratelimit_backoff', 'error');
1396+
client.recordDroppedEvent('ratelimit_backoff', 'error');
1397+
client.recordDroppedEvent('event_processor', 'attachment');
1398+
1399+
const clearedOutcomes1 = client._clearOutcomes();
1400+
expect(clearedOutcomes1.length).toEqual(2);
1401+
1402+
const clearedOutcomes2 = client._clearOutcomes();
1403+
expect(clearedOutcomes2.length).toEqual(0);
1404+
1405+
client.recordDroppedEvent('network_error', 'attachment');
1406+
1407+
const clearedOutcomes3 = client._clearOutcomes();
1408+
expect(clearedOutcomes3.length).toEqual(1);
1409+
1410+
const clearedOutcomes4 = client._clearOutcomes();
1411+
expect(clearedOutcomes4.length).toEqual(0);
1412+
});
1413+
});
13691414
});

packages/core/test/mocks/client.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import { Session } from '@sentry/hub';
2-
import { ClientOptions, Event, Integration, Severity, SeverityLevel } from '@sentry/types';
2+
import { ClientOptions, Event, Integration, Outcome, Severity, SeverityLevel } from '@sentry/types';
33
import { resolvedSyncPromise } from '@sentry/utils';
44

55
import { BaseClient } from '../../src/baseclient';
66
import { initAndBind } from '../../src/sdk';
77
import { createTransport } from '../../src/transports/base';
88

9-
// TODO(v7): Add client reports tests to this file
10-
// See old tests in packages/browser/test/unit/transports/base.test.ts
11-
// from https://github.com/getsentry/sentry-javascript/pull/4967
12-
139
export function getDefaultTestClientOptions(options: Partial<TestClientOptions> = {}): TestClientOptions {
1410
return {
1511
integrations: [],
12+
sendClientReports: true,
1613
transport: () =>
1714
createTransport(
1815
{ recordDroppedEvent: () => undefined }, // noop
@@ -79,6 +76,11 @@ export class TestClient extends BaseClient<TestClientOptions> {
7976
public sendSession(session: Session): void {
8077
this.session = session;
8178
}
79+
80+
// Public proxy for protected method
81+
public _clearOutcomes(): Outcome[] {
82+
return super._clearOutcomes();
83+
}
8284
}
8385

8486
export function init(options: TestClientOptions): void {

0 commit comments

Comments
 (0)