Skip to content

Commit

Permalink
feat(sdk): Add ClientReports (#2496)
Browse files Browse the repository at this point in the history
  • Loading branch information
krystofwoldrich authored Sep 27, 2022
1 parent 649fee5 commit 7122f76
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 4 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Features

- Add ClientReports ([#2496](https://github.com/getsentry/sentry-react-native/pull/2496))

### Sentry Self-hosted Compatibility

- Starting with version `4.6.0` of the `@sentry/react-native` package, [Sentry's self hosted version >= v21.9.0](https://github.com/getsentry/self-hosted/releases) is required or you have to manually disable sending client reports via the `sendClientReports` option. This only applies to self-hosted Sentry. If you are using [sentry.io](https://sentry.io), no action is needed.

## 4.5.0

### Features
Expand Down
3 changes: 3 additions & 0 deletions android/src/main/java/io/sentry/react/RNSentryModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
// SentryAndroid needs an empty string fallback for the dsn.
options.setDsn("");
}
if (rnOptions.hasKey("sendClientReports")) {
options.setSendClientReports(rnOptions.getBoolean("sendClientReports"));
}
if (rnOptions.hasKey("maxBreadcrumbs")) {
options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs"));
}
Expand Down
65 changes: 62 additions & 3 deletions src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@ import { BrowserTransportOptions } from '@sentry/browser/types/transports/types'
import { FetchImpl } from '@sentry/browser/types/transports/utils';
import { BaseClient } from '@sentry/core';
import {
ClientReportEnvelope,
ClientReportItem,
Envelope,
Event,
EventHint,
Outcome,
SeverityLevel,
Transport,
UserFeedback,
} from '@sentry/types';
import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
// @ts-ignore LogBox introduced in RN 0.63
import { Alert, LogBox, YellowBox } from 'react-native';

import { defaultSdkInfo } from './integrations/sdkinfo';
import { ReactNativeClientOptions } from './options';
import { NativeTransport } from './transports/native';
import { createUserFeedbackEnvelope } from './utils/envelope';
import { createUserFeedbackEnvelope, items } from './utils/envelope';
import { mergeOutcomes } from './utils/outcome';
import { NATIVE } from './wrapper';

/**
Expand All @@ -26,6 +32,8 @@ import { NATIVE } from './wrapper';
*/
export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {

private _outcomesBuffer: Outcome[];

private readonly _browserClient: BrowserClient;

/**
Expand All @@ -45,6 +53,8 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
options._metadata.sdk = options._metadata.sdk || defaultSdkInfo;
super(options);

this._outcomesBuffer = [];

// This is a workaround for now using fetch on RN, this is a known issue in react-native and only generates a warning
// YellowBox deprecated and replaced with with LogBox in RN 0.63
if (LogBox) {
Expand Down Expand Up @@ -116,8 +126,40 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
}

/**
* Starts native client with dsn and options
*/
* @inheritdoc
*/
protected _sendEnvelope(envelope: Envelope): void {
const outcomes = this._clearOutcomes();
this._outcomesBuffer = mergeOutcomes(this._outcomesBuffer, outcomes);

if (this._options.sendClientReports) {
this._attachClientReportTo(this._outcomesBuffer, envelope as ClientReportEnvelope);
}

let shouldClearOutcomesBuffer = true;
if (this._transport && this._dsn) {
this._transport.send(envelope)
.then(null, reason => {
if (reason instanceof SentryError) { // SentryError is thrown by SyncPromise
shouldClearOutcomesBuffer = false;
// If this is called asynchronously we want the _outcomesBuffer to be cleared
logger.error('SentryError while sending event, keeping outcomes buffer:', reason);
} else {
logger.error('Error while sending event:', reason);
}
});
} else {
logger.error('Transport disabled');
}

if (shouldClearOutcomesBuffer) {
this._outcomesBuffer = []; // if send fails synchronously the _outcomesBuffer will stay intact
}
}

/**
* Starts native client with dsn and options
*/
private async _initNativeSdk(): Promise<void> {
let didCallNativeInit = false;

Expand All @@ -144,4 +186,21 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
);
}
}

/**
* Attaches a client report from outcomes to the envelope.
*/
private _attachClientReportTo(outcomes: Outcome[], envelope: ClientReportEnvelope): void {
if (outcomes.length > 0) {
const clientReportItem: ClientReportItem = [
{ type: 'client_report' },
{
timestamp: dateTimestampInSeconds(),
discarded_events: outcomes,
},
];

envelope[items].push(clientReportItem);
}
}
}
1 change: 1 addition & 0 deletions src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = {
transportOptions: {
textEncoder: makeUtf8TextEncoder(),
},
sendClientReports: true,
};

/**
Expand Down
3 changes: 3 additions & 0 deletions src/js/utils/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
} from '@sentry/types';
import { createEnvelope, dsnToString } from '@sentry/utils';

export const header = 0;
export const items = 1;

/**
* Creates an envelope from a user feedback.
*/
Expand Down
22 changes: 22 additions & 0 deletions src/js/utils/outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Outcome } from '@sentry/types';

/**
* Merges buffer with new outcomes.
*/
export function mergeOutcomes(...merge: Outcome[][]): Outcome[] {
const map = new Map<string, Outcome>();

const process = (outcome: Outcome): void => {
const key = `${outcome.reason}:${outcome.category}`;
const existing = map.get(key);
if (existing) {
existing.quantity += outcome.quantity;
} else {
map.set(key, outcome);
}
};

merge.forEach((outcomes) => outcomes.forEach(process));

return [...map.values()];
}
148 changes: 147 additions & 1 deletion test/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Envelope, Transport } from '@sentry/types';
import { Envelope, Outcome, Transport } from '@sentry/types';
import { rejectedSyncPromise, SentryError } from '@sentry/utils';
import * as RN from 'react-native';

import { ReactNativeClient } from '../src/js/client';
Expand All @@ -14,6 +15,7 @@ import {
firstArg,
getMockSession,
getMockUserFeedback,
getSyncPromiseRejectOnFirstCall,
} from './testutils';

const EXAMPLE_DSN =
Expand Down Expand Up @@ -295,4 +297,148 @@ describe('Tests ReactNativeClient', () => {
expect(getSdkInfoFrom(mockTransportSend)).toStrictEqual(expectedSdkInfo);
});
});

describe('clientReports', () => {
test('does not send client reports if disabled', () => {
const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve());
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
dsn: EXAMPLE_DSN,
transport: () => ({
send: mockTransportSend,
flush: jest.fn(),
}),
sendClientReports: false,
} as ReactNativeClientOptions);

mockDroppedEvent(client);

client.captureMessage('message_test_value');

expectOnlyMessageEventInEnvelope(mockTransportSend);
});

test('send client reports on event envelope', () => {
const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve());
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
dsn: EXAMPLE_DSN,
transport: () => ({
send: mockTransportSend,
flush: jest.fn(),
}),
sendClientReports: true,
} as ReactNativeClientOptions);

mockDroppedEvent(client);

client.captureMessage('message_test_value');

expect(mockTransportSend).toBeCalledTimes(1);
expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][1][envelopeItemHeader]).toEqual(
{ type: 'client_report' }
);
expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][1][envelopeItemPayload]).toEqual(
expect.objectContaining({
discarded_events: [
{
reason: 'before_send',
category: 'error',
quantity: 1,
}
],
}),
);
expect((client as unknown as { _outcomesBuffer: Outcome[] })._outcomesBuffer).toEqual(<Outcome[]>[]);
});

test('does not send empty client report', () => {
const mockTransportSend = jest.fn((_envelope: Envelope) => Promise.resolve());
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
dsn: EXAMPLE_DSN,
transport: () => ({
send: mockTransportSend,
flush: jest.fn(),
}),
sendClientReports: true,
} as ReactNativeClientOptions);

client.captureMessage('message_test_value');

expectOnlyMessageEventInEnvelope(mockTransportSend);
});

test('keeps outcomes in case envelope fails to send', () => {
const mockTransportSend = jest.fn((_envelope: Envelope) =>
rejectedSyncPromise(new SentryError('Test')));
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
dsn: EXAMPLE_DSN,
transport: () => ({
send: mockTransportSend,
flush: jest.fn(),
}),
sendClientReports: true,
} as ReactNativeClientOptions);

mockDroppedEvent(client);

client.captureMessage('message_test_value');

expect((client as unknown as { _outcomesBuffer: Outcome[] })._outcomesBuffer).toEqual(<Outcome[]>[
{ reason: 'before_send', category: 'error', quantity: 1 },
]);
});

test('sends buffered client reports on second try', () => {
const mockTransportSend = getSyncPromiseRejectOnFirstCall<[Envelope]>(new SentryError('Test'));
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
dsn: EXAMPLE_DSN,
transport: () => ({
send: mockTransportSend,
flush: jest.fn(),
}),
sendClientReports: true,
} as ReactNativeClientOptions);

mockDroppedEvent(client);
client.captureMessage('message_test_value_1');
mockDroppedEvent(client);
client.captureMessage('message_test_value_2');

expect(mockTransportSend).toBeCalledTimes(2);
expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems].length).toEqual(2);
expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][1][envelopeItemHeader]).toEqual(
{ type: 'client_report' }
);
expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][1][envelopeItemPayload]).toEqual(
expect.objectContaining({
discarded_events: [
{
reason: 'before_send',
category: 'error',
quantity: 2,
},
],
}),
);
expect((client as unknown as { _outcomesBuffer: Outcome[] })._outcomesBuffer).toEqual(<Outcome[]>[]);
});

function expectOnlyMessageEventInEnvelope(transportSend: jest.Mock) {
expect(transportSend).toBeCalledTimes(1);
expect(transportSend.mock.calls[0][firstArg][envelopeItems]).toHaveLength(1);
expect(transportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemHeader]).toEqual(
expect.objectContaining({ type: 'event' }),
);
}

function mockDroppedEvent(
client: ReactNativeClient,
) {
client.recordDroppedEvent('before_send', 'error');
}
});
});
13 changes: 13 additions & 0 deletions test/testutils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Transaction } from '@sentry/tracing';
import { Session, UserFeedback } from '@sentry/types';
import { rejectedSyncPromise } from '@sentry/utils';

import { getBlankTransactionContext } from '../src/js/tracing/utils';

Expand Down Expand Up @@ -51,3 +52,15 @@ export const getMockUserFeedback = (): UserFeedback => ({
name: 'name_test_value',
event_id: 'event_id_test_value',
});

export const getSyncPromiseRejectOnFirstCall = <Y extends any[]>(reason: unknown): jest.Mock => {
let shouldSyncReject = true;
return jest.fn((..._args: Y) => {
if (shouldSyncReject) {
shouldSyncReject = false;
return rejectedSyncPromise(reason);
} else {
return Promise.resolve();
}
});
};

0 comments on commit 7122f76

Please sign in to comment.