Skip to content

ref(transports): Add back client reports #4998

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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/browser/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ export type {
SdkInfo,
Event,
EventHint,
EventStatus,
Exception,
Response,
// eslint-disable-next-line deprecation/deprecation
Severity,
SeverityLevel,
Expand Down
7 changes: 3 additions & 4 deletions packages/browser/src/transports/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,18 @@ export interface XHRTransportOptions extends BaseTransportOptions {
*/
export function makeXHRTransport(options: XHRTransportOptions): Transport {
function makeRequest(request: TransportRequest): PromiseLike<TransportMakeRequestResponse> {
return new SyncPromise<TransportMakeRequestResponse>((resolve, _reject) => {
return new SyncPromise((resolve, reject) => {
const xhr = new XMLHttpRequest();

xhr.onerror = reject;

xhr.onreadystatechange = (): void => {
if (xhr.readyState === XHR_READYSTATE_DONE) {
const response = {
body: xhr.response,
headers: {
'x-sentry-rate-limits': xhr.getResponseHeader('X-Sentry-Rate-Limits'),
'retry-after': xhr.getResponseHeader('Retry-After'),
},
reason: xhr.statusText,
statusCode: xhr.status,
};
resolve(response);
}
Expand Down
104 changes: 74 additions & 30 deletions packages/core/src/baseclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { Scope, Session } from '@sentry/hub';
import {
Client,
ClientOptions,
DataCategory,
DsnComponents,
Envelope,
Event,
EventDropReason,
EventHint,
Integration,
IntegrationClass,
Expand Down Expand Up @@ -87,6 +89,8 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
/** Number of calls being processed */
protected _numProcessing: number = 0;

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

/**
* Initializes this client instance.
*
Expand All @@ -97,7 +101,11 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
if (options.dsn) {
this._dsn = makeDsn(options.dsn);
const url = getEnvelopeEndpointWithUrlEncodedAuth(this._dsn, options.tunnel);
this._transport = options.transport({ ...options.transportOptions, url });
this._transport = options.transport({
...options.transportOptions,
url,
recordDroppedEvent: this._recordDroppedEvent.bind(this),
});
} else {
IS_DEBUG_BUILD && logger.warn('No DSN provided, client will not do anything.');
}
Expand Down Expand Up @@ -266,7 +274,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
public sendEvent(event: Event): void {
if (this._dsn) {
const env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel);
this.sendEnvelope(env);
this._sendEnvelope(env);
}
}

Expand All @@ -276,20 +284,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
public sendSession(session: Session | SessionAggregates): void {
if (this._dsn) {
const env = createSessionEnvelope(session, this._dsn, this._options._metadata, this._options.tunnel);
this.sendEnvelope(env);
}
}

/**
* @inheritdoc
*/
public sendEnvelope(env: Envelope): void {
if (this._transport && this._dsn) {
this._transport.send(env).then(null, reason => {
IS_DEBUG_BUILD && logger.error('Error while sending event:', reason);
});
} else {
IS_DEBUG_BUILD && logger.error('Transport disabled');
this._sendEnvelope(env);
}
}

Expand Down Expand Up @@ -564,17 +559,6 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { beforeSend, sampleRate } = this.getOptions();

// type RecordLostEvent = NonNullable<Transport['recordLostEvent']>;
type RecordLostEventParams = unknown[]; // Parameters<RecordLostEvent>;

// no-op as new transports don't have client outcomes
// TODO(v7): Re-add this functionality
function recordLostEvent(_outcome: RecordLostEventParams[0], _category: RecordLostEventParams[1]): void {
// if (transport.recordLostEvent) {
// transport.recordLostEvent(outcome, category);
// }
}

if (!this._isEnabled()) {
return rejectedSyncPromise(new SentryError('SDK not enabled, will not capture event.'));
}
Expand All @@ -584,7 +568,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
// 0.0 === 0% events are sent
// Sampling for transaction happens somewhere else
if (!isTransaction && typeof sampleRate === 'number' && Math.random() > sampleRate) {
recordLostEvent('sample_rate', 'event');
this._recordDroppedEvent('sample_rate', 'error');
return rejectedSyncPromise(
new SentryError(
`Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`,
Expand All @@ -595,7 +579,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
return this._prepareEvent(event, scope, hint)
.then(prepared => {
if (prepared === null) {
recordLostEvent('event_processor', event.type || 'event');
this._recordDroppedEvent('event_processor', event.type || 'error');
throw new SentryError('An event processor returned null, will not send event.');
}

Expand All @@ -609,7 +593,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
})
.then(processedEvent => {
if (processedEvent === null) {
recordLostEvent('before_send', event.type || 'event');
this._recordDroppedEvent('before_send', event.type || 'error');
throw new SentryError('`beforeSend` returned `null`, will not send event.');
}

Expand Down Expand Up @@ -655,6 +639,66 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
);
}

/**
* Send outcomes as an envelope
*/
// protected _flushOutcomes(): void {
// if (!this._options.sendClientReports) {
// return;
// }
// const outcomes = this._outcomes;
// this._outcomes = {};
// // Nothing to send
// if (!Object.keys(outcomes).length) {
// IS_DEBUG_BUILD && logger.log('No outcomes to flush');
// return;
// }
// IS_DEBUG_BUILD && logger.log(`Flushing outcomes:\n${JSON.stringify(outcomes, null, 2)}`);
// const url = getEnvelopeEndpointWithUrlEncodedAuth(this._api.dsn, this._api.tunnel);
// const discardedEvents = Object.keys(outcomes).map(key => {
// const [category, reason] = key.split(':');
// return {
// reason,
// category,
// quantity: outcomes[key],
// };
// // TODO: Improve types on discarded_events to get rid of cast
// }) as ClientReport['discarded_events'];
// const envelope = createClientReportEnvelope(discardedEvents, this._api.tunnel && dsnToString(this._api.dsn));
// try {
// sendReport(url, serializeEnvelope(envelope));
// } catch (e) {
// IS_DEBUG_BUILD && logger.error(e);
// }
// }

/**
* TODO: Doc
*/
private _recordDroppedEvent(reason: EventDropReason, category: DataCategory): void {
// We want to track each category (error, 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}:${reason}`;
IS_DEBUG_BUILD && logger.log(`Adding outcome: ${key}`);
this._outcomes[key] = this._outcomes[key] + 1 || 1; // This works because undefined + 1 === NaN
}

/**
* @inheritdoc
*/
private _sendEnvelope(envelope: Envelope): void {
if (this._transport && this._dsn) {
this._transport.send(envelope).then(null, reason => {
IS_DEBUG_BUILD && logger.error('Error while sending event:', reason);
});
} else {
IS_DEBUG_BUILD && logger.error('Transport disabled');
}
}

/**
* @inheritDoc
*/
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
EventItem,
SdkInfo,
SdkMetadata,
SentryRequestType,
Session,
SessionAggregates,
SessionEnvelope,
Expand Down Expand Up @@ -52,14 +51,10 @@ export function createSessionEnvelope(
...(!!tunnel && { dsn: dsnToString(dsn) }),
};

// I know this is hacky but we don't want to add `sessions` to request type since it's never rate limited
const type = 'aggregates' in session ? ('sessions' as SentryRequestType) : 'session';
const envelopeItem: SessionItem =
'aggregates' in session ? [{ type: 'sessions' }, session] : [{ type: 'session' }, session];

// TODO (v7) Have to cast type because envelope items do not accept a `SentryRequestType`
const envelopeItem = [{ type } as { type: 'session' | 'sessions' }, session] as SessionItem;
const envelope = createEnvelope<SessionEnvelope>(envelopeHeaders, [envelopeItem]);

return envelope;
return createEnvelope<SessionEnvelope>(envelopeHeaders, [envelopeItem]);
}

/**
Expand Down
103 changes: 61 additions & 42 deletions packages/core/src/transports/base.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import {
Envelope,
EventDropReason,
InternalBaseTransportOptions,
Transport,
TransportCategory,
TransportRequest,
TransportRequestExecutor,
TransportResponse,
} from '@sentry/types';
import {
disabledUntil,
eventStatusFromHttpCode,
getEnvelopeType,
envelopeItemTypeToDataCategory,
isRateLimited,
logger,
makePromiseBuffer,
PromiseBuffer,
RateLimits,
rejectedSyncPromise,
resolvedSyncPromise,
SentryError,
serializeEnvelope,
updateRateLimits,
} from '@sentry/utils';

import { IS_DEBUG_BUILD } from '../flags';

export const DEFAULT_TRANSPORT_BUFFER_SIZE = 30;

/**
Expand All @@ -32,57 +32,76 @@ export const DEFAULT_TRANSPORT_BUFFER_SIZE = 30;
export function createTransport(
options: InternalBaseTransportOptions,
makeRequest: TransportRequestExecutor,
buffer: PromiseBuffer<TransportResponse> = makePromiseBuffer(options.bufferSize || DEFAULT_TRANSPORT_BUFFER_SIZE),
buffer: PromiseBuffer<void> = makePromiseBuffer(options.bufferSize || DEFAULT_TRANSPORT_BUFFER_SIZE),
): Transport {
let rateLimits: RateLimits = {};

const flush = (timeout?: number): PromiseLike<boolean> => buffer.drain(timeout);

function send(envelope: Envelope): PromiseLike<TransportResponse> {
const envCategory = getEnvelopeType(envelope);
const category = envCategory === 'event' ? 'error' : (envCategory as TransportCategory);
const request: TransportRequest = {
category,
body: serializeEnvelope(envelope),
};
function send(envelope: Envelope): PromiseLike<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filteredEnvelopeItems = (envelope[1] as any[]).filter((envelopeItem: Envelope[1][number]) => {
const envelopeItemType = envelopeItem[0].type;

// Don't add to buffer if transport is already rate-limited
if (isRateLimited(rateLimits, category)) {
return rejectedSyncPromise({
status: 'rate_limit',
reason: getRateLimitReason(rateLimits, category),
});
const itemIsRateLimited = isRateLimited(rateLimits, envelopeItemType);

if (itemIsRateLimited && options.recordDroppedEvent) {
options.recordDroppedEvent('ratelimit_backoff', envelopeItemTypeToDataCategory(envelopeItemType));
}

return !itemIsRateLimited;
});

if (filteredEnvelopeItems.length === 0) {
return resolvedSyncPromise(undefined);
}

const requestTask = (): PromiseLike<TransportResponse> =>
makeRequest(request).then(({ body, headers, reason, statusCode }): PromiseLike<TransportResponse> => {
const status = eventStatusFromHttpCode(statusCode);
if (headers) {
rateLimits = updateRateLimits(rateLimits, headers);
}
if (status === 'success') {
return resolvedSyncPromise({ status, reason });
const filteredEvelope: Envelope = [envelope[0], filteredEnvelopeItems];

const recordEnvelopeLoss = (reason: EventDropReason): void => {
envelope[1].forEach((envelopeItem: Envelope[1][number]) => {
const envelopeItemType = envelopeItem[0].type;
if (options.recordDroppedEvent) {
options.recordDroppedEvent(reason, envelopeItemTypeToDataCategory(envelopeItemType));
}
return rejectedSyncPromise({
status,
reason:
reason ||
body ||
(status === 'rate_limit' ? getRateLimitReason(rateLimits, category) : 'Unknown transport error'),
});
});
};

const request: TransportRequest = {
body: serializeEnvelope(filteredEvelope),
};

const requestTask = (): PromiseLike<void> =>
makeRequest(request).then(
({ headers }): PromiseLike<void> => {
if (headers) {
rateLimits = updateRateLimits(rateLimits, headers);
}

return buffer.add(requestTask);
return resolvedSyncPromise(undefined);
},
error => {
IS_DEBUG_BUILD && logger.error('Failed while making request:', error);
recordEnvelopeLoss('network_error');
return resolvedSyncPromise(undefined);
},
);

return buffer.add(requestTask).then(
result => result,
error => {
if (error instanceof SentryError) {
recordEnvelopeLoss('queue_overflow');
return resolvedSyncPromise(undefined);
} else {
throw error;
}
},
);
}

return {
send,
flush,
};
}

function getRateLimitReason(rateLimits: RateLimits, category: TransportCategory): string {
return `Too many ${category} requests, backing off until: ${new Date(
disabledUntil(rateLimits, category),
).toISOString()}`;
}
2 changes: 0 additions & 2 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ export type {
SdkInfo,
Event,
EventHint,
EventStatus,
Exception,
Response,
// eslint-disable-next-line deprecation/deprecation
Severity,
SeverityLevel,
Expand Down
Loading