Skip to content

Commit 44033d1

Browse files
authored
feat(browser): Client Report Support (#3955)
* feat(browser): Client Report Support
1 parent eea6d54 commit 44033d1

File tree

19 files changed

+481
-59
lines changed

19 files changed

+481
-59
lines changed

packages/browser/src/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class BrowserBackend extends BaseBackend<BrowserOptions> {
6262
...this._options.transportOptions,
6363
dsn: this._options.dsn,
6464
tunnel: this._options.tunnel,
65+
sendClientReports: this._options.sendClientReports,
6566
_metadata: this._options._metadata,
6667
};
6768

packages/browser/src/sdk.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export function init(options: BrowserOptions = {}): void {
8888
if (options.autoSessionTracking === undefined) {
8989
options.autoSessionTracking = true;
9090
}
91+
if (options.sendClientReports === undefined) {
92+
options.sendClientReports = true;
93+
}
9194

9295
initAndBind(BrowserClient, options);
9396

packages/browser/src/transports/base.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { API } from '@sentry/core';
22
import {
33
Event,
4+
Outcome,
45
Response as SentryResponse,
56
SentryRequestType,
67
Status,
78
Transport,
89
TransportOptions,
910
} from '@sentry/types';
10-
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
11+
import { dateTimestampInSeconds, logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
1112

1213
const CATEGORY_MAPPING: {
1314
[key in SentryRequestType]: string;
@@ -34,10 +35,20 @@ export abstract class BaseTransport implements Transport {
3435
/** Locks transport after receiving rate limits in a response */
3536
protected readonly _rateLimits: Record<string, Date> = {};
3637

38+
protected _outcomes: { [key: string]: number } = {};
39+
3740
public constructor(public options: TransportOptions) {
3841
this._api = new API(options.dsn, options._metadata, options.tunnel);
3942
// eslint-disable-next-line deprecation/deprecation
4043
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();
44+
45+
if (this.options.sendClientReports) {
46+
document.addEventListener('visibilitychange', () => {
47+
if (document.visibilityState === 'hidden') {
48+
this._flushOutcomes();
49+
}
50+
});
51+
}
4152
}
4253

4354
/**
@@ -54,6 +65,69 @@ export abstract class BaseTransport implements Transport {
5465
return this._buffer.drain(timeout);
5566
}
5667

68+
/**
69+
* @inheritDoc
70+
*/
71+
public recordLostEvent(reason: Outcome, category: SentryRequestType): void {
72+
if (!this.options.sendClientReports) {
73+
return;
74+
}
75+
// We want to track each category (event, transaction, session) separately
76+
// but still keep the distinction between different type of outcomes.
77+
// We could use nested maps, but it's much easier to read and type this way.
78+
// A correct type for map-based implementation if we want to go that route
79+
// would be `Partial<Record<SentryRequestType, Partial<Record<Outcome, number>>>>`
80+
const key = `${CATEGORY_MAPPING[category]}:${reason}`;
81+
logger.log(`Adding outcome: ${key}`);
82+
this._outcomes[key] = (this._outcomes[key] ?? 0) + 1;
83+
}
84+
85+
/**
86+
* Send outcomes as an envelope
87+
*/
88+
protected _flushOutcomes(): void {
89+
if (!this.options.sendClientReports) {
90+
return;
91+
}
92+
93+
if (!navigator || typeof navigator.sendBeacon !== 'function') {
94+
logger.warn('Beacon API not available, skipping sending outcomes.');
95+
return;
96+
}
97+
98+
const outcomes = this._outcomes;
99+
this._outcomes = {};
100+
101+
// Nothing to send
102+
if (!Object.keys(outcomes).length) {
103+
logger.log('No outcomes to flush');
104+
return;
105+
}
106+
107+
logger.log(`Flushing outcomes:\n${JSON.stringify(outcomes, null, 2)}`);
108+
109+
const url = this._api.getEnvelopeEndpointWithUrlEncodedAuth();
110+
// Envelope header is required to be at least an empty object
111+
const envelopeHeader = JSON.stringify({});
112+
const itemHeaders = JSON.stringify({
113+
type: 'client_report',
114+
});
115+
const item = JSON.stringify({
116+
timestamp: dateTimestampInSeconds(),
117+
discarded_events: Object.keys(outcomes).map(key => {
118+
const [category, reason] = key.split(':');
119+
return {
120+
reason,
121+
category,
122+
quantity: outcomes[key],
123+
};
124+
}),
125+
});
126+
const envelope = `${envelopeHeader}\n${itemHeaders}\n${item}`;
127+
128+
navigator.sendBeacon(url, envelope);
129+
}
130+
57131
/**
58132
* Handle Sentry repsonse for promise-based transports.
59133
*/

packages/browser/src/transports/fetch.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2-
import { Event, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
3-
import { getGlobalObject, isNativeFetch, logger, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
2+
import { Event, Outcome, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
3+
import {
4+
getGlobalObject,
5+
isNativeFetch,
6+
logger,
7+
SentryError,
8+
supportsReferrerPolicy,
9+
SyncPromise,
10+
} from '@sentry/utils';
411

512
import { BaseTransport } from './base';
613

@@ -106,6 +113,8 @@ export class FetchTransport extends BaseTransport {
106113
*/
107114
private _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike<Response> {
108115
if (this._isRateLimited(sentryRequest.type)) {
116+
this.recordLostEvent(Outcome.RateLimitBackoff, sentryRequest.type);
117+
109118
return Promise.reject({
110119
event: originalPayload,
111120
type: sentryRequest.type,
@@ -132,25 +141,35 @@ export class FetchTransport extends BaseTransport {
132141
options.headers = this.options.headers;
133142
}
134143

135-
return this._buffer.add(
136-
() =>
137-
new SyncPromise<Response>((resolve, reject) => {
138-
void this._fetch(sentryRequest.url, options)
139-
.then(response => {
140-
const headers = {
141-
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
142-
'retry-after': response.headers.get('Retry-After'),
143-
};
144-
this._handleResponse({
145-
requestType: sentryRequest.type,
146-
response,
147-
headers,
148-
resolve,
149-
reject,
150-
});
151-
})
152-
.catch(reject);
153-
}),
154-
);
144+
return this._buffer
145+
.add(
146+
() =>
147+
new SyncPromise<Response>((resolve, reject) => {
148+
void this._fetch(sentryRequest.url, options)
149+
.then(response => {
150+
const headers = {
151+
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
152+
'retry-after': response.headers.get('Retry-After'),
153+
};
154+
this._handleResponse({
155+
requestType: sentryRequest.type,
156+
response,
157+
headers,
158+
resolve,
159+
reject,
160+
});
161+
})
162+
.catch(reject);
163+
}),
164+
)
165+
.then(undefined, reason => {
166+
// It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError.
167+
if (reason instanceof SentryError) {
168+
this.recordLostEvent(Outcome.QueueOverflow, sentryRequest.type);
169+
} else {
170+
this.recordLostEvent(Outcome.NetworkError, sentryRequest.type);
171+
}
172+
throw reason;
173+
});
155174
}
156175
}
Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2-
import { Event, Response, SentryRequest, Session } from '@sentry/types';
3-
import { SyncPromise } from '@sentry/utils';
2+
import { Event, Outcome, Response, SentryRequest, Session } from '@sentry/types';
3+
import { SentryError, SyncPromise } from '@sentry/utils';
44

55
import { BaseTransport } from './base';
66

@@ -26,6 +26,8 @@ export class XHRTransport extends BaseTransport {
2626
*/
2727
private _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike<Response> {
2828
if (this._isRateLimited(sentryRequest.type)) {
29+
this.recordLostEvent(Outcome.RateLimitBackoff, sentryRequest.type);
30+
2931
return Promise.reject({
3032
event: originalPayload,
3133
type: sentryRequest.type,
@@ -36,29 +38,39 @@ export class XHRTransport extends BaseTransport {
3638
});
3739
}
3840

39-
return this._buffer.add(
40-
() =>
41-
new SyncPromise<Response>((resolve, reject) => {
42-
const request = new XMLHttpRequest();
41+
return this._buffer
42+
.add(
43+
() =>
44+
new SyncPromise<Response>((resolve, reject) => {
45+
const request = new XMLHttpRequest();
4346

44-
request.onreadystatechange = (): void => {
45-
if (request.readyState === 4) {
46-
const headers = {
47-
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
48-
'retry-after': request.getResponseHeader('Retry-After'),
49-
};
50-
this._handleResponse({ requestType: sentryRequest.type, response: request, headers, resolve, reject });
51-
}
52-
};
47+
request.onreadystatechange = (): void => {
48+
if (request.readyState === 4) {
49+
const headers = {
50+
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
51+
'retry-after': request.getResponseHeader('Retry-After'),
52+
};
53+
this._handleResponse({ requestType: sentryRequest.type, response: request, headers, resolve, reject });
54+
}
55+
};
5356

54-
request.open('POST', sentryRequest.url);
55-
for (const header in this.options.headers) {
56-
if (this.options.headers.hasOwnProperty(header)) {
57-
request.setRequestHeader(header, this.options.headers[header]);
57+
request.open('POST', sentryRequest.url);
58+
for (const header in this.options.headers) {
59+
if (this.options.headers.hasOwnProperty(header)) {
60+
request.setRequestHeader(header, this.options.headers[header]);
61+
}
5862
}
59-
}
60-
request.send(sentryRequest.body);
61-
}),
62-
);
63+
request.send(sentryRequest.body);
64+
}),
65+
)
66+
.then(undefined, reason => {
67+
// It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError.
68+
if (reason instanceof SentryError) {
69+
this.recordLostEvent(Outcome.QueueOverflow, sentryRequest.type);
70+
} else {
71+
this.recordLostEvent(Outcome.NetworkError, sentryRequest.type);
72+
}
73+
throw reason;
74+
});
6375
}
6476
}

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(() => 12345);
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: Outcome.BeforeSend, category: 'error', quantity: 1 }];
53+
54+
expect(sendBeaconSpy).toHaveBeenCalledWith(
55+
envelopeEndpoint,
56+
`{}\n{"type":"client_report"}\n{"timestamp":12.345,"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.RateLimitBackoff, 'event');
79+
80+
visibilityState = 'hidden';
81+
document.dispatchEvent(new Event('visibilitychange'));
82+
83+
const outcomes = [
84+
{ reason: Outcome.BeforeSend, category: 'error', quantity: 2 },
85+
{ reason: Outcome.SampleRate, category: 'transaction', quantity: 1 },
86+
{ reason: Outcome.NetworkError, category: 'session', quantity: 2 },
87+
{ reason: Outcome.RateLimitBackoff, category: 'error', quantity: 1 },
88+
];
89+
90+
expect(sendBeaconSpy).toHaveBeenCalledWith(
91+
envelopeEndpoint,
92+
`{}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify(outcomes)}}`,
93+
);
94+
});
95+
});
96+
897
it('doesnt provide sendEvent() implementation', () => {
998
const transport = new SimpleTransport({ dsn: testDsn });
1099

0 commit comments

Comments
 (0)