diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e9b74c44..9d0d573808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/src/main/java/io/sentry/react/RNSentryModule.java b/android/src/main/java/io/sentry/react/RNSentryModule.java index 58a1127412..1eb779a26a 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModule.java +++ b/android/src/main/java/io/sentry/react/RNSentryModule.java @@ -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")); } diff --git a/src/js/client.ts b/src/js/client.ts index 26e45fddd3..2d34d213e5 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -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'; /** @@ -26,6 +32,8 @@ import { NATIVE } from './wrapper'; */ export class ReactNativeClient extends BaseClient { + private _outcomesBuffer: Outcome[]; + private readonly _browserClient: BrowserClient; /** @@ -45,6 +53,8 @@ export class ReactNativeClient extends BaseClient { 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) { @@ -116,8 +126,40 @@ export class ReactNativeClient extends BaseClient { } /** - * 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 { let didCallNativeInit = false; @@ -144,4 +186,21 @@ export class ReactNativeClient extends BaseClient { ); } } + + /** + * 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); + } + } } diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 7e95b90d71..754acfc772 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -41,6 +41,7 @@ const DEFAULT_OPTIONS: ReactNativeOptions = { transportOptions: { textEncoder: makeUtf8TextEncoder(), }, + sendClientReports: true, }; /** diff --git a/src/js/utils/envelope.ts b/src/js/utils/envelope.ts index d0efa9e988..b8be42f40d 100644 --- a/src/js/utils/envelope.ts +++ b/src/js/utils/envelope.ts @@ -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. */ diff --git a/src/js/utils/outcome.ts b/src/js/utils/outcome.ts new file mode 100644 index 0000000000..4f07debae1 --- /dev/null +++ b/src/js/utils/outcome.ts @@ -0,0 +1,22 @@ +import { Outcome } from '@sentry/types'; + +/** + * Merges buffer with new outcomes. + */ +export function mergeOutcomes(...merge: Outcome[][]): Outcome[] { + const map = new Map(); + + 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()]; +} diff --git a/test/client.test.ts b/test/client.test.ts index ee4ff0b39a..b744fbf445 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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'; @@ -14,6 +15,7 @@ import { firstArg, getMockSession, getMockUserFeedback, + getSyncPromiseRejectOnFirstCall, } from './testutils'; const EXAMPLE_DSN = @@ -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([]); + }); + + 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([ + { 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([]); + }); + + 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'); + } + }); }); diff --git a/test/testutils.ts b/test/testutils.ts index a7b9c93b83..51962daa05 100644 --- a/test/testutils.ts +++ b/test/testutils.ts @@ -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'; @@ -51,3 +52,15 @@ export const getMockUserFeedback = (): UserFeedback => ({ name: 'name_test_value', event_id: 'event_id_test_value', }); + +export const getSyncPromiseRejectOnFirstCall = (reason: unknown): jest.Mock => { + let shouldSyncReject = true; + return jest.fn((..._args: Y) => { + if (shouldSyncReject) { + shouldSyncReject = false; + return rejectedSyncPromise(reason); + } else { + return Promise.resolve(); + } + }); +};