Skip to content

Commit

Permalink
feat(browser): Client Report Support (#3955)
Browse files Browse the repository at this point in the history
* feat(browser): Client Report Support
  • Loading branch information
kamilogorek authored Sep 20, 2021
1 parent eea6d54 commit 44033d1
Show file tree
Hide file tree
Showing 19 changed files with 481 additions and 59 deletions.
1 change: 1 addition & 0 deletions packages/browser/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export class BrowserBackend extends BaseBackend<BrowserOptions> {
...this._options.transportOptions,
dsn: this._options.dsn,
tunnel: this._options.tunnel,
sendClientReports: this._options.sendClientReports,
_metadata: this._options._metadata,
};

Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export function init(options: BrowserOptions = {}): void {
if (options.autoSessionTracking === undefined) {
options.autoSessionTracking = true;
}
if (options.sendClientReports === undefined) {
options.sendClientReports = true;
}

initAndBind(BrowserClient, options);

Expand Down
76 changes: 75 additions & 1 deletion packages/browser/src/transports/base.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { API } from '@sentry/core';
import {
Event,
Outcome,
Response as SentryResponse,
SentryRequestType,
Status,
Transport,
TransportOptions,
} from '@sentry/types';
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
import { dateTimestampInSeconds, logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';

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

protected _outcomes: { [key: string]: number } = {};

public constructor(public options: TransportOptions) {
this._api = new API(options.dsn, options._metadata, options.tunnel);
// eslint-disable-next-line deprecation/deprecation
this.url = this._api.getStoreEndpointWithUrlEncodedAuth();

if (this.options.sendClientReports) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this._flushOutcomes();
}
});
}
}

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

/**
* @inheritDoc
*/
public recordLostEvent(reason: Outcome, category: SentryRequestType): void {
if (!this.options.sendClientReports) {
return;
}
// We want to track each category (event, transaction, session) separately
// but still keep the distinction between different type of outcomes.
// We could use nested maps, but it's much easier to read and type this way.
// A correct type for map-based implementation if we want to go that route
// would be `Partial<Record<SentryRequestType, Partial<Record<Outcome, number>>>>`
const key = `${CATEGORY_MAPPING[category]}:${reason}`;
logger.log(`Adding outcome: ${key}`);
this._outcomes[key] = (this._outcomes[key] ?? 0) + 1;
}

/**
* Send outcomes as an envelope
*/
protected _flushOutcomes(): void {
if (!this.options.sendClientReports) {
return;
}

if (!navigator || typeof navigator.sendBeacon !== 'function') {
logger.warn('Beacon API not available, skipping sending outcomes.');
return;
}

const outcomes = this._outcomes;
this._outcomes = {};

// Nothing to send
if (!Object.keys(outcomes).length) {
logger.log('No outcomes to flush');
return;
}

logger.log(`Flushing outcomes:\n${JSON.stringify(outcomes, null, 2)}`);

const url = this._api.getEnvelopeEndpointWithUrlEncodedAuth();
// Envelope header is required to be at least an empty object
const envelopeHeader = JSON.stringify({});
const itemHeaders = JSON.stringify({
type: 'client_report',
});
const item = JSON.stringify({
timestamp: dateTimestampInSeconds(),
discarded_events: Object.keys(outcomes).map(key => {
const [category, reason] = key.split(':');
return {
reason,
category,
quantity: outcomes[key],
};
}),
});
const envelope = `${envelopeHeader}\n${itemHeaders}\n${item}`;

navigator.sendBeacon(url, envelope);
}

/**
* Handle Sentry repsonse for promise-based transports.
*/
Expand Down
63 changes: 41 additions & 22 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
import { Event, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
import { getGlobalObject, isNativeFetch, logger, supportsReferrerPolicy, SyncPromise } from '@sentry/utils';
import { Event, Outcome, Response, SentryRequest, Session, TransportOptions } from '@sentry/types';
import {
getGlobalObject,
isNativeFetch,
logger,
SentryError,
supportsReferrerPolicy,
SyncPromise,
} from '@sentry/utils';

import { BaseTransport } from './base';

Expand Down Expand Up @@ -106,6 +113,8 @@ export class FetchTransport extends BaseTransport {
*/
private _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike<Response> {
if (this._isRateLimited(sentryRequest.type)) {
this.recordLostEvent(Outcome.RateLimitBackoff, sentryRequest.type);

return Promise.reject({
event: originalPayload,
type: sentryRequest.type,
Expand All @@ -132,25 +141,35 @@ export class FetchTransport extends BaseTransport {
options.headers = this.options.headers;
}

return this._buffer.add(
() =>
new SyncPromise<Response>((resolve, reject) => {
void this._fetch(sentryRequest.url, options)
.then(response => {
const headers = {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
};
this._handleResponse({
requestType: sentryRequest.type,
response,
headers,
resolve,
reject,
});
})
.catch(reject);
}),
);
return this._buffer
.add(
() =>
new SyncPromise<Response>((resolve, reject) => {
void this._fetch(sentryRequest.url, options)
.then(response => {
const headers = {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
};
this._handleResponse({
requestType: sentryRequest.type,
response,
headers,
resolve,
reject,
});
})
.catch(reject);
}),
)
.then(undefined, reason => {
// It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError.
if (reason instanceof SentryError) {
this.recordLostEvent(Outcome.QueueOverflow, sentryRequest.type);
} else {
this.recordLostEvent(Outcome.NetworkError, sentryRequest.type);
}
throw reason;
});
}
}
58 changes: 35 additions & 23 deletions packages/browser/src/transports/xhr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
import { Event, Response, SentryRequest, Session } from '@sentry/types';
import { SyncPromise } from '@sentry/utils';
import { Event, Outcome, Response, SentryRequest, Session } from '@sentry/types';
import { SentryError, SyncPromise } from '@sentry/utils';

import { BaseTransport } from './base';

Expand All @@ -26,6 +26,8 @@ export class XHRTransport extends BaseTransport {
*/
private _sendRequest(sentryRequest: SentryRequest, originalPayload: Event | Session): PromiseLike<Response> {
if (this._isRateLimited(sentryRequest.type)) {
this.recordLostEvent(Outcome.RateLimitBackoff, sentryRequest.type);

return Promise.reject({
event: originalPayload,
type: sentryRequest.type,
Expand All @@ -36,29 +38,39 @@ export class XHRTransport extends BaseTransport {
});
}

return this._buffer.add(
() =>
new SyncPromise<Response>((resolve, reject) => {
const request = new XMLHttpRequest();
return this._buffer
.add(
() =>
new SyncPromise<Response>((resolve, reject) => {
const request = new XMLHttpRequest();

request.onreadystatechange = (): void => {
if (request.readyState === 4) {
const headers = {
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
'retry-after': request.getResponseHeader('Retry-After'),
};
this._handleResponse({ requestType: sentryRequest.type, response: request, headers, resolve, reject });
}
};
request.onreadystatechange = (): void => {
if (request.readyState === 4) {
const headers = {
'x-sentry-rate-limits': request.getResponseHeader('X-Sentry-Rate-Limits'),
'retry-after': request.getResponseHeader('Retry-After'),
};
this._handleResponse({ requestType: sentryRequest.type, response: request, headers, resolve, reject });
}
};

request.open('POST', sentryRequest.url);
for (const header in this.options.headers) {
if (this.options.headers.hasOwnProperty(header)) {
request.setRequestHeader(header, this.options.headers[header]);
request.open('POST', sentryRequest.url);
for (const header in this.options.headers) {
if (this.options.headers.hasOwnProperty(header)) {
request.setRequestHeader(header, this.options.headers[header]);
}
}
}
request.send(sentryRequest.body);
}),
);
request.send(sentryRequest.body);
}),
)
.then(undefined, reason => {
// It's either buffer rejection or any other xhr/fetch error, which are treated as NetworkError.
if (reason instanceof SentryError) {
this.recordLostEvent(Outcome.QueueOverflow, sentryRequest.type);
} else {
this.recordLostEvent(Outcome.NetworkError, sentryRequest.type);
}
throw reason;
});
}
}
89 changes: 89 additions & 0 deletions packages/browser/test/unit/transports/base.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,99 @@
import { Outcome } from '@sentry/types';

import { BaseTransport } from '../../../src/transports/base';

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

class SimpleTransport extends BaseTransport {}

describe('BaseTransport', () => {
describe('Client Reports', () => {
const sendBeaconSpy = jest.fn();
let visibilityState: string;

beforeAll(() => {
navigator.sendBeacon = sendBeaconSpy;
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: function() {
return visibilityState;
},
});
jest.spyOn(Date, 'now').mockImplementation(() => 12345);
});

beforeEach(() => {
sendBeaconSpy.mockClear();
});

it('attaches visibilitychange handler if sendClientReport is set to true', () => {
const eventListenerSpy = jest.spyOn(document, 'addEventListener');
new SimpleTransport({ dsn: testDsn, sendClientReports: true });
expect(eventListenerSpy.mock.calls[0][0]).toBe('visibilitychange');
eventListenerSpy.mockRestore();
});

it('doesnt attach visibilitychange handler if sendClientReport is set to false', () => {
const eventListenerSpy = jest.spyOn(document, 'addEventListener');
new SimpleTransport({ dsn: testDsn, sendClientReports: false });
expect(eventListenerSpy).not.toHaveBeenCalled();
eventListenerSpy.mockRestore();
});

it('sends beacon request when there are outcomes captured and visibility changed to `hidden`', () => {
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });

transport.recordLostEvent(Outcome.BeforeSend, 'event');

visibilityState = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));

const outcomes = [{ reason: Outcome.BeforeSend, category: 'error', quantity: 1 }];

expect(sendBeaconSpy).toHaveBeenCalledWith(
envelopeEndpoint,
`{}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify(outcomes)}}`,
);
});

it('doesnt send beacon request when there are outcomes captured, but visibility state did not change to `hidden`', () => {
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });
transport.recordLostEvent(Outcome.BeforeSend, 'event');

visibilityState = 'visible';
document.dispatchEvent(new Event('visibilitychange'));

expect(sendBeaconSpy).not.toHaveBeenCalled();
});

it('correctly serializes request with different categories/reasons pairs', () => {
const transport = new SimpleTransport({ dsn: testDsn, sendClientReports: true });

transport.recordLostEvent(Outcome.BeforeSend, 'event');
transport.recordLostEvent(Outcome.BeforeSend, 'event');
transport.recordLostEvent(Outcome.SampleRate, 'transaction');
transport.recordLostEvent(Outcome.NetworkError, 'session');
transport.recordLostEvent(Outcome.NetworkError, 'session');
transport.recordLostEvent(Outcome.RateLimitBackoff, 'event');

visibilityState = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));

const outcomes = [
{ reason: Outcome.BeforeSend, category: 'error', quantity: 2 },
{ reason: Outcome.SampleRate, category: 'transaction', quantity: 1 },
{ reason: Outcome.NetworkError, category: 'session', quantity: 2 },
{ reason: Outcome.RateLimitBackoff, category: 'error', quantity: 1 },
];

expect(sendBeaconSpy).toHaveBeenCalledWith(
envelopeEndpoint,
`{}\n{"type":"client_report"}\n{"timestamp":12.345,"discarded_events":${JSON.stringify(outcomes)}}`,
);
});
});

it('doesnt provide sendEvent() implementation', () => {
const transport = new SimpleTransport({ dsn: testDsn });

Expand Down
Loading

0 comments on commit 44033d1

Please sign in to comment.