Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): Client Report Support #3955

Merged
merged 3 commits into from Sep 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/src/backend.ts
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
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
@@ -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 } = {};
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved

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>>>>`
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
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.');
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved
return;
}

const outcomes = this._outcomes;
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -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);
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved
}
throw reason;
});
}
}
58 changes: 35 additions & 23 deletions packages/browser/src/transports/xhr.ts
@@ -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) {
kamilogorek marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -1,10 +1,99 @@
import { Outcome } from '@sentry/types';

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

const testDsn = 'https://123@sentry.io/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