Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
Comment thread
krystofwoldrich marked this conversation as resolved.

### 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);
Comment thread
krystofwoldrich marked this conversation as resolved.
}
}
}
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,
Comment thread
krystofwoldrich marked this conversation as resolved.
Comment thread
krystofwoldrich marked this conversation as resolved.
};

/**
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();
}
});
};