Skip to content

Commit 3f2d081

Browse files
committed
feat(browser): SDK Outcomes
1 parent e71454e commit 3f2d081

File tree

7 files changed

+145
-49
lines changed

7 files changed

+145
-49
lines changed

packages/browser/src/transports/base.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { API } from '@sentry/core';
22
import {
33
Event,
4+
Outcome,
45
Response as SentryResponse,
56
SentryRequestType,
67
Status,
@@ -34,10 +35,18 @@ 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 in Outcome]?: 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+
document.addEventListener('visibilitychange', () => {
46+
if (document.visibilityState === 'hidden') {
47+
this._flushOutcomes();
48+
}
49+
});
4150
}
4251

4352
/**
@@ -54,6 +63,48 @@ export abstract class BaseTransport implements Transport {
5463
return this._buffer.drain(timeout);
5564
}
5665

66+
/**
67+
* @inheritDoc
68+
*/
69+
public recordLostEvent(type: Outcome): void {
70+
logger.log(`Adding ${type} outcome`);
71+
this._outcomes[type] = (this._outcomes[type] ?? 0) + 1;
72+
}
73+
74+
/**
75+
* Send outcomes as an envelope
76+
*/
77+
protected _flushOutcomes(): void {
78+
if (!navigator || typeof navigator.sendBeacon !== 'function') {
79+
logger.warn('Beacon API not available, skipping sending outcomes.');
80+
return;
81+
}
82+
83+
const outcomes = this._outcomes;
84+
85+
// Nothing to send
86+
if (!Object.keys(outcomes).length) {
87+
logger.log('No outcomes to flush');
88+
return;
89+
}
90+
91+
logger.log(`Flushing outcomes:\n${JSON.stringify(outcomes, null, 2)}`);
92+
93+
const url = this._api.getEnvelopeEndpointWithUrlEncodedAuth();
94+
const itemHeaders = JSON.stringify({
95+
type: 'client_report',
96+
});
97+
const item = JSON.stringify({
98+
timestamp: Date.now(),
99+
discarded_events: this._outcomes,
100+
});
101+
const envelope = `${itemHeaders}\n${item}`;
102+
103+
navigator.sendBeacon(url, envelope);
104+
105+
this._outcomes = {};
106+
}
107+
57108
/**
58109
* Handle Sentry repsonse for promise-based transports.
59110
*/

packages/browser/src/transports/fetch.ts

Lines changed: 40 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.RateLimit);
117+
109118
return Promise.reject({
110119
event: originalPayload,
111120
type: sentryRequest.type,
@@ -132,25 +141,34 @@ 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+
if (reason instanceof SentryError) {
167+
this.recordLostEvent(Outcome.QueueSize);
168+
} else {
169+
this.recordLostEvent(Outcome.NetworkError);
170+
}
171+
throw reason;
172+
});
155173
}
156174
}
Lines changed: 34 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.RateLimit);
30+
2931
return Promise.reject({
3032
event: originalPayload,
3133
type: sentryRequest.type,
@@ -36,29 +38,38 @@ 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+
if (reason instanceof SentryError) {
68+
this.recordLostEvent(Outcome.QueueSize);
69+
} else {
70+
this.recordLostEvent(Outcome.NetworkError);
71+
}
72+
throw reason;
73+
});
6374
}
6475
}

packages/core/src/baseclient.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Integration,
88
IntegrationClass,
99
Options,
10+
Outcome,
1011
SessionStatus,
1112
Severity,
1213
} from '@sentry/types';
@@ -498,6 +499,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
498499
protected _processEvent(event: Event, hint?: EventHint, scope?: Scope): PromiseLike<Event> {
499500
// eslint-disable-next-line @typescript-eslint/unbound-method
500501
const { beforeSend, sampleRate } = this.getOptions();
502+
const transport = this._getBackend().getTransport();
501503

502504
if (!this._isEnabled()) {
503505
return SyncPromise.reject(new SentryError('SDK not enabled, will not capture event.'));
@@ -508,6 +510,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
508510
// 0.0 === 0% events are sent
509511
// Sampling for transaction happens somewhere else
510512
if (!isTransaction && typeof sampleRate === 'number' && Math.random() > sampleRate) {
513+
transport.recordLostEvent?.(Outcome.SampleRate);
511514
return SyncPromise.reject(
512515
new SentryError(
513516
`Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`,
@@ -518,6 +521,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
518521
return this._prepareEvent(event, scope, hint)
519522
.then(prepared => {
520523
if (prepared === null) {
524+
transport.recordLostEvent?.(Outcome.EventProcessor);
521525
throw new SentryError('An event processor returned null, will not send event.');
522526
}
523527

@@ -531,6 +535,7 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
531535
})
532536
.then(processedEvent => {
533537
if (processedEvent === null) {
538+
transport.recordLostEvent?.(Outcome.BeforeSend);
534539
throw new SentryError('`beforeSend` returned `null`, will not send event.');
535540
}
536541

packages/node/src/transports/base/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,6 @@ export abstract class BaseTransport implements Transport {
199199
});
200200
}
201201

202-
if (!this._buffer.isReady()) {
203-
return Promise.reject(new SentryError('Not adding Promise due to buffer limit reached.'));
204-
}
205202
return this._buffer.add(
206203
() =>
207204
new Promise<Response>((resolve, reject) => {

packages/types/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,6 @@ export {
4747
TransactionSamplingMethod,
4848
} from './transaction';
4949
export { Thread } from './thread';
50-
export { Transport, TransportOptions, TransportClass } from './transport';
50+
export { Outcome, Transport, TransportOptions, TransportClass } from './transport';
5151
export { User } from './user';
5252
export { WrappedFunction } from './wrappedfunction';

packages/types/src/transport.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ import { Response } from './response';
44
import { SdkMetadata } from './sdkmetadata';
55
import { Session, SessionAggregates } from './session';
66

7+
export enum Outcome {
8+
BeforeSend = 'before_send',
9+
EventProcessor = 'event_processor',
10+
NetworkError = 'network_error',
11+
QueueSize = 'queue_size',
12+
RateLimit = 'backoff', // NOTE(kamil): I'd prefer to call it `rate_limit` instead of `backoff`
13+
SampleRate = 'sample_rate',
14+
}
15+
716
/** Transport used sending data to Sentry */
817
export interface Transport {
918
/**
@@ -29,6 +38,11 @@ export interface Transport {
2938
* still events in the queue when the timeout is reached.
3039
*/
3140
close(timeout?: number): PromiseLike<boolean>;
41+
42+
/**
43+
* Increment the counter for the specific client outcome
44+
*/
45+
recordLostEvent?(type: Outcome): void;
3246
}
3347

3448
/** JSDoc */

0 commit comments

Comments
 (0)