Skip to content

Commit 2de0e3f

Browse files
committed
Unit tests for recorder and recording
1 parent 0b45ed5 commit 2de0e3f

File tree

8 files changed

+269
-9
lines changed

8 files changed

+269
-9
lines changed

packages/browser/src/transports/base.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export abstract class BaseTransport implements Transport {
4242
// eslint-disable-next-line deprecation/deprecation
4343
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();
4444

45-
if (this.options.sendClientReport) {
45+
if (this.options.sendClientReports) {
4646
document.addEventListener('visibilitychange', () => {
4747
if (document.visibilityState === 'hidden') {
4848
this._flushOutcomes();
@@ -69,15 +69,15 @@ export abstract class BaseTransport implements Transport {
6969
* @inheritDoc
7070
*/
7171
public recordLostEvent(reason: Outcome, category: SentryRequestType): void {
72-
if (!this.options.sendClientReport) {
72+
if (!this.options.sendClientReports) {
7373
return;
7474
}
7575
// We want to track each category (event, transaction, session) separately
7676
// but still keep the distinction between different type of outcomes.
7777
// We could use nested maps, but it's much easier to read and type this way.
7878
// A correct type for map-based implementation if we want to go that route
7979
// would be `Partial<Record<SentryRequestType, Partial<Record<Outcome, number>>>>`
80-
const key = `${CATEGORY_MAPPING[category]}:${reason}}`;
80+
const key = `${CATEGORY_MAPPING[category]}:${reason}`;
8181
logger.log(`Adding outcome: ${key}`);
8282
this._outcomes[key] = (this._outcomes[key] ?? 0) + 1;
8383
}
@@ -86,7 +86,7 @@ export abstract class BaseTransport implements Transport {
8686
* Send outcomes as an envelope
8787
*/
8888
protected _flushOutcomes(): void {
89-
if (!this.options.sendClientReport) {
89+
if (!this.options.sendClientReports) {
9090
return;
9191
}
9292

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,99 @@
1+
import { Outcome } from '@sentry/types';
2+
13
import { BaseTransport } from '../../../src/transports/base';
24

35
const testDsn = 'https://[email protected]/42';
6+
const envelopeEndpoint = 'https://sentry.io/api/42/envelope/?sentry_key=123&sentry_version=7';
47

58
class SimpleTransport extends BaseTransport {}
69

710
describe('BaseTransport', () => {
11+
describe('Client Reports', () => {
12+
const sendBeaconSpy = jest.fn();
13+
let visibilityState: string;
14+
15+
beforeAll(() => {
16+
navigator.sendBeacon = sendBeaconSpy;
17+
Object.defineProperty(document, 'visibilityState', {
18+
configurable: true,
19+
get: function() {
20+
return visibilityState;
21+
},
22+
});
23+
jest.spyOn(Date, 'now').mockImplementation(() => 1337);
24+
});
25+
26+
beforeEach(() => {
27+
sendBeaconSpy.mockClear();
28+
});
29+
30+
it('attaches visibilitychange handler if sendClientReport is set to true', () => {
31+
const eventListenerSpy = jest.spyOn(document, 'addEventListener');
32+
new SimpleTransport({ dsn: testDsn, sendClientReports: true });
33+
expect(eventListenerSpy.mock.calls[0][0]).toBe('visibilitychange');
34+
eventListenerSpy.mockRestore();
35+
});
36+
37+
it('doesnt attach visibilitychange handler if sendClientReport is set to false', () => {
38+
const eventListenerSpy = jest.spyOn(document, 'addEventListener');
39+
new SimpleTransport({ dsn: testDsn, sendClientReports: false });
40+
expect(eventListenerSpy).not.toHaveBeenCalled();
41+
eventListenerSpy.mockRestore();
42+
});
43+
44+
it('sends beacon request when there are outcomes captured and visibility changed to `hidden`', () => {
45+
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });
46+
47+
transport.recordLostEvent(Outcome.BeforeSend, 'event');
48+
49+
visibilityState = 'hidden';
50+
document.dispatchEvent(new Event('visibilitychange'));
51+
52+
const outcomes = [{ reason: 'before_send', category: 'error', quantity: 1 }];
53+
54+
expect(sendBeaconSpy).toHaveBeenCalledWith(
55+
envelopeEndpoint,
56+
`{"type":"client_report"}\n{"timestamp":1337,"discarded_events":${JSON.stringify(outcomes)}}`,
57+
);
58+
});
59+
60+
it('doesnt send beacon request when there are outcomes captured, but visibility state did not change to `hidden`', () => {
61+
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });
62+
transport.recordLostEvent(Outcome.BeforeSend, 'event');
63+
64+
visibilityState = 'visible';
65+
document.dispatchEvent(new Event('visibilitychange'));
66+
67+
expect(sendBeaconSpy).not.toHaveBeenCalled();
68+
});
69+
70+
it('correctly serializes request with different categories/reasons pairs', () => {
71+
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });
72+
73+
transport.recordLostEvent(Outcome.BeforeSend, 'event');
74+
transport.recordLostEvent(Outcome.BeforeSend, 'event');
75+
transport.recordLostEvent(Outcome.SampleRate, 'transaction');
76+
transport.recordLostEvent(Outcome.NetworkError, 'session');
77+
transport.recordLostEvent(Outcome.NetworkError, 'session');
78+
transport.recordLostEvent(Outcome.RateLimit, 'event');
79+
80+
visibilityState = 'hidden';
81+
document.dispatchEvent(new Event('visibilitychange'));
82+
83+
const outcomes = [
84+
{ reason: 'before_send', category: 'error', quantity: 2 },
85+
{ reason: 'sample_rate', category: 'transaction', quantity: 1 },
86+
{ reason: 'network_error', category: 'session', quantity: 2 },
87+
{ reason: 'rate_limit', category: 'error', quantity: 1 },
88+
];
89+
90+
expect(sendBeaconSpy).toHaveBeenCalledWith(
91+
envelopeEndpoint,
92+
`{"type":"client_report"}\n{"timestamp":1337,"discarded_events":${JSON.stringify(outcomes)}}`,
93+
);
94+
});
95+
});
96+
897
it('doesnt provide sendEvent() implementation', () => {
998
const transport = new SimpleTransport({ dsn: testDsn });
1099

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { Outcome } from '@sentry/types';
2+
import { SentryError } from '@sentry/utils';
3+
14
import { Event, Response, Status, Transports } from '../../../src';
25

36
const testDsn = 'https://[email protected]/42';
@@ -104,6 +107,32 @@ describe('FetchTransport', () => {
104107
}
105108
});
106109

110+
it('should record dropped event when fetch fails', async () => {
111+
const response = { status: 403, headers: new Headers() };
112+
113+
window.fetch.mockImplementation(() => Promise.reject(response));
114+
115+
const spy = jest.spyOn(transport, 'recordLostEvent');
116+
117+
try {
118+
await transport.sendEvent(eventPayload);
119+
} catch (_) {
120+
expect(spy).toHaveBeenCalledWith(Outcome.NetworkError, 'event');
121+
}
122+
});
123+
124+
it('should record dropped event when queue buffer overflows', async () => {
125+
// @ts-ignore private method
126+
jest.spyOn(transport._buffer, 'add').mockRejectedValue(new SentryError('Buffer Full'));
127+
const spy = jest.spyOn(transport, 'recordLostEvent');
128+
129+
try {
130+
await transport.sendEvent(transactionPayload);
131+
} catch (_) {
132+
expect(spy).toHaveBeenCalledWith(Outcome.QueueSize, 'transaction');
133+
}
134+
});
135+
107136
it('passes in headers', async () => {
108137
transport = new Transports.FetchTransport(
109138
{
@@ -451,6 +480,25 @@ describe('FetchTransport', () => {
451480
expect(eventRes.status).toBe(Status.Success);
452481
expect(fetch).toHaveBeenCalledTimes(2);
453482
});
483+
484+
it('should record dropped event', async () => {
485+
// @ts-ignore private method
486+
jest.spyOn(transport, '_isRateLimited').mockReturnValue(true);
487+
488+
const spy = jest.spyOn(transport, 'recordLostEvent');
489+
490+
try {
491+
await transport.sendEvent(eventPayload);
492+
} catch (_) {
493+
expect(spy).toHaveBeenCalledWith(Outcome.RateLimit, 'event');
494+
}
495+
496+
try {
497+
await transport.sendEvent(transactionPayload);
498+
} catch (_) {
499+
expect(spy).toHaveBeenCalledWith(Outcome.RateLimit, 'transaction');
500+
}
501+
});
454502
});
455503
});
456504
});

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Outcome } from '@sentry/types';
2+
import { SentryError } from '@sentry/utils';
13
import { fakeServer, SinonFakeServer } from 'sinon';
24

35
import { Event, Response, Status, Transports } from '../../../src';
@@ -69,6 +71,30 @@ describe('XHRTransport', () => {
6971
}
7072
});
7173

74+
it('should record dropped event when request fails', async () => {
75+
server.respondWith('POST', storeUrl, [403, {}, '']);
76+
77+
const spy = jest.spyOn(transport, 'recordLostEvent');
78+
79+
try {
80+
await transport.sendEvent(eventPayload);
81+
} catch (_) {
82+
expect(spy).toHaveBeenCalledWith(Outcome.NetworkError, 'event');
83+
}
84+
});
85+
86+
it('should record dropped event when queue buffer overflows', async () => {
87+
// @ts-ignore private method
88+
jest.spyOn(transport._buffer, 'add').mockRejectedValue(new SentryError('Buffer Full'));
89+
const spy = jest.spyOn(transport, 'recordLostEvent');
90+
91+
try {
92+
await transport.sendEvent(transactionPayload);
93+
} catch (_) {
94+
expect(spy).toHaveBeenCalledWith(Outcome.QueueSize, 'transaction');
95+
}
96+
});
97+
7298
it('passes in headers', async () => {
7399
transport = new Transports.XHRTransport({
74100
dsn: testDsn,
@@ -380,6 +406,25 @@ describe('XHRTransport', () => {
380406
expect(eventRes.status).toBe(Status.Success);
381407
expect(server.requests.length).toBe(2);
382408
});
409+
410+
it('should record dropped event', async () => {
411+
// @ts-ignore private method
412+
jest.spyOn(transport, '_isRateLimited').mockReturnValue(true);
413+
414+
const spy = jest.spyOn(transport, 'recordLostEvent');
415+
416+
try {
417+
await transport.sendEvent(eventPayload);
418+
} catch (_) {
419+
expect(spy).toHaveBeenCalledWith(Outcome.RateLimit, 'event');
420+
}
421+
422+
try {
423+
await transport.sendEvent(transactionPayload);
424+
} catch (_) {
425+
expect(spy).toHaveBeenCalledWith(Outcome.RateLimit, 'transaction');
426+
}
427+
});
383428
});
384429
});
385430
});

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Hub, Scope, Session } from '@sentry/hub';
2-
import { Event, Severity, Span } from '@sentry/types';
2+
import { Event, Outcome, Severity, Span, Transport } from '@sentry/types';
33
import { logger, SentryError, SyncPromise } from '@sentry/utils';
44

55
import * as integrationModule from '../../src/integration';
@@ -807,6 +807,28 @@ describe('BaseClient', () => {
807807
expect((TestBackend.instance!.event! as any).data).toBe('someRandomThing');
808808
});
809809

810+
test('beforeSend records dropped events', () => {
811+
expect.assertions(1);
812+
const client = new TestClient({
813+
dsn: PUBLIC_DSN,
814+
beforeSend() {
815+
return null;
816+
},
817+
});
818+
819+
const recordLostEventSpy = jest.fn();
820+
jest.spyOn(client, 'getTransport').mockImplementationOnce(
821+
() =>
822+
(({
823+
recordLostEvent: recordLostEventSpy,
824+
} as any) as Transport),
825+
);
826+
827+
client.captureEvent({ message: 'hello' }, {});
828+
829+
expect(recordLostEventSpy).toHaveBeenCalledWith(Outcome.BeforeSend, 'event');
830+
});
831+
810832
test('eventProcessor can drop the even when it returns null', () => {
811833
expect.assertions(3);
812834
const client = new TestClient({ dsn: PUBLIC_DSN });
@@ -820,6 +842,25 @@ describe('BaseClient', () => {
820842
expect(loggerErrorSpy).toBeCalledWith(new SentryError('An event processor returned null, will not send event.'));
821843
});
822844

845+
test('eventProcessor records dropped events', () => {
846+
expect.assertions(1);
847+
const client = new TestClient({ dsn: PUBLIC_DSN });
848+
849+
const recordLostEventSpy = jest.fn();
850+
jest.spyOn(client, 'getTransport').mockImplementationOnce(
851+
() =>
852+
(({
853+
recordLostEvent: recordLostEventSpy,
854+
} as any) as Transport),
855+
);
856+
857+
const scope = new Scope();
858+
scope.addEventProcessor(() => null);
859+
client.captureEvent({ message: 'hello' }, {}, scope);
860+
861+
expect(recordLostEventSpy).toHaveBeenCalledWith(Outcome.EventProcessor, 'event');
862+
});
863+
823864
test('eventProcessor sends an event and logs when it crashes', () => {
824865
expect.assertions(3);
825866
const client = new TestClient({ dsn: PUBLIC_DSN });
@@ -844,6 +885,25 @@ describe('BaseClient', () => {
844885
),
845886
);
846887
});
888+
889+
test('records events dropped due to sampleRate', () => {
890+
expect.assertions(1);
891+
const client = new TestClient({
892+
dsn: PUBLIC_DSN,
893+
sampleRate: 0,
894+
});
895+
896+
const recordLostEventSpy = jest.fn();
897+
jest.spyOn(client, 'getTransport').mockImplementationOnce(
898+
() =>
899+
(({
900+
recordLostEvent: recordLostEventSpy,
901+
} as any) as Transport),
902+
);
903+
904+
client.captureEvent({ message: 'hello' }, {});
905+
expect(recordLostEventSpy).toHaveBeenCalledWith(Outcome.SampleRate, 'event');
906+
});
847907
});
848908

849909
describe('integrations', () => {

packages/tracing/test/idletransaction.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { BrowserClient } from '@sentry/browser';
1+
import { BrowserClient, Transports } from '@sentry/browser';
22
import { Hub } from '@sentry/hub';
3+
import { Outcome } from '@sentry/types';
34

45
import { DEFAULT_IDLE_TIMEOUT, IdleTransaction, IdleTransactionSpanRecorder } from '../src/idletransaction';
56
import { Span } from '../src/span';
67
import { SpanStatus } from '../src/spanstatus';
78

9+
export class SimpleTransport extends Transports.BaseTransport {}
10+
11+
const dsn = 'https://[email protected]/42';
812
let hub: Hub;
913
beforeEach(() => {
10-
hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }));
14+
hub = new Hub(new BrowserClient({ dsn, tracesSampleRate: 1, transport: SimpleTransport }));
1115
});
1216

1317
describe('IdleTransaction', () => {
@@ -156,6 +160,19 @@ describe('IdleTransaction', () => {
156160
}
157161
});
158162

163+
it('should record dropped transactions', async () => {
164+
const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234, sampled: false }, hub, 1000);
165+
166+
const transport = hub.getClient()?.getTransport();
167+
168+
const spy = jest.spyOn(transport!, 'recordLostEvent');
169+
170+
transaction.initSpanRecorder(10);
171+
transaction.finish(transaction.startTimestamp + 10);
172+
173+
expect(spy).toHaveBeenCalledWith(Outcome.SampleRate, 'transaction');
174+
});
175+
159176
describe('_initTimeout', () => {
160177
it('finishes if no activities are added to the transaction', () => {
161178
const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, hub, 1000);

packages/types/src/request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Possible SentryRequest types that can be used to make a distinction between Sentry features */
2+
// NOTE(kamil): It would be nice if we make it a valid enum instead
23
export type SentryRequestType = 'event' | 'transaction' | 'session' | 'attachment';
34

45
/** A generic client request. */

0 commit comments

Comments
 (0)