diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa4521ecb4..2515057d33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Features
+
+- Add user feedback ([#2486](https://github.com/getsentry/sentry-react-native/pull/2486))
+
### Fixes
- Add typings for app hang functionality ([#2479](https://github.com/getsentry/sentry-react-native/pull/2479))
diff --git a/sample/src/assets/sentry-announcement.png b/sample/src/assets/sentry-announcement.png
new file mode 100644
index 0000000000..b29992aba6
Binary files /dev/null and b/sample/src/assets/sentry-announcement.png differ
diff --git a/sample/src/components/UserFeedbackModal.tsx b/sample/src/components/UserFeedbackModal.tsx
new file mode 100644
index 0000000000..ddd707cf67
--- /dev/null
+++ b/sample/src/components/UserFeedbackModal.tsx
@@ -0,0 +1,117 @@
+import React, { useState } from 'react';
+import { View, Modal, StyleSheet, Text, TouchableOpacity, TextInput, Image } from 'react-native';
+import * as Sentry from '@sentry/react-native';
+import { UserFeedback } from '@sentry/react-native';
+import { styles as homeScreenStyles } from '../screens/HomeScreen';
+
+export const DEFAULT_COMMENTS = `It's broken again! Please fix it.`;
+
+export function UserFeedbackModal() {
+ const [comments, onChangeComments] = React.useState(DEFAULT_COMMENTS);
+ const [modalVisible, setModalVisible] = useState(false);
+ const clearComments = () => onChangeComments(DEFAULT_COMMENTS);
+
+ return (
+
+ {
+ setModalVisible(!modalVisible);
+ }}
+ >
+
+
+
+ Whoops, what happened?
+
+ {
+ setModalVisible(!modalVisible);
+
+ const sentryId = Sentry.captureMessage('Message that needs user feedback');
+
+ const userFeedback: UserFeedback = {
+ event_id: sentryId,
+ name: 'John Doe',
+ email: 'john@doe.com',
+ comments,
+ };
+
+ Sentry.captureUserFeedback(userFeedback);
+ clearComments();
+ }}>
+ Send feedback
+
+ {
+ setModalVisible(!modalVisible);
+ }}>
+ Close
+
+
+
+
+ {
+ setModalVisible(true);
+ }}>
+ Send user feedback
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ centeredView: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalView: {
+ margin: 5,
+ backgroundColor: "white",
+ borderRadius: 6,
+ padding: 25,
+ alignItems: "center",
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 2
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5
+ },
+ input: {
+ margin: 12,
+ marginBottom: 20,
+ borderWidth: 0.5,
+ borderColor: '#c6becf',
+ padding: 15,
+ borderRadius: 6,
+ height: 100,
+ width: 250,
+ textAlignVertical: 'top',
+ },
+ modalText: {
+ marginBottom: 15,
+ textAlign: "center",
+ fontSize: 18,
+ },
+ modalImage: {
+ marginBottom: 20,
+ width: 80,
+ height: 80,
+ }
+});
diff --git a/sample/src/screens/HomeScreen.tsx b/sample/src/screens/HomeScreen.tsx
index d42c2dd312..f720c45437 100644
--- a/sample/src/screens/HomeScreen.tsx
+++ b/sample/src/screens/HomeScreen.tsx
@@ -18,6 +18,7 @@ import { SENTRY_INTERNAL_DSN } from '../dsn';
import { SeverityLevel } from '@sentry/types';
import { Scope } from '@sentry/react-native';
import { NativeModules } from 'react-native';
+import { UserFeedbackModal } from '../components/UserFeedbackModal';
const {AssetsModule} = NativeModules;
@@ -256,6 +257,8 @@ const HomeScreen = (props: Props) => {
}}>
Get attachment
+
+
{
);
};
-const styles = StyleSheet.create({
+export const styles = StyleSheet.create({
scrollView: {
backgroundColor: '#fff',
flex: 1,
diff --git a/src/js/client.ts b/src/js/client.ts
index f3c322bdc2..f4bd90e617 100644
--- a/src/js/client.ts
+++ b/src/js/client.ts
@@ -2,12 +2,19 @@ import { BrowserClient, defaultStackParser, makeFetchTransport } from '@sentry/b
import { BrowserTransportOptions } from '@sentry/browser/types/transports/types';
import { FetchImpl } from '@sentry/browser/types/transports/utils';
import { BaseClient } from '@sentry/core';
-import { Event, EventHint, SeverityLevel, Transport } from '@sentry/types';
+import {
+ Event,
+ EventHint,
+ SeverityLevel,
+ Transport,
+ UserFeedback,
+} from '@sentry/types';
// @ts-ignore LogBox introduced in RN 0.63
import { Alert, LogBox, YellowBox } from 'react-native';
import { ReactNativeClientOptions } from './options';
import { NativeTransport } from './transports/native';
+import { createUserFeedbackEnvelope } from './utils/envelope';
import { NATIVE } from './wrapper';
/**
@@ -89,6 +96,21 @@ export class ReactNativeClient extends BaseClient {
});
}
+ /**
+ * Sends user feedback to Sentry.
+ */
+ public captureUserFeedback(feedback: UserFeedback): void {
+ const envelope = createUserFeedbackEnvelope(
+ feedback,
+ {
+ metadata: this._options._metadata,
+ dsn: this.getDsn(),
+ tunnel: this._options.tunnel,
+ },
+ );
+ this._sendEnvelope(envelope);
+ }
+
/**
* Starts native client with dsn and options
*/
diff --git a/src/js/index.ts b/src/js/index.ts
index ffe92f6369..b1848707b1 100644
--- a/src/js/index.ts
+++ b/src/js/index.ts
@@ -8,6 +8,7 @@ export {
Stacktrace,
Thread,
User,
+ UserFeedback,
} from '@sentry/types';
export {
@@ -64,6 +65,7 @@ export {
nativeCrash,
flush,
close,
+ captureUserFeedback,
} from './sdk';
export { TouchEventBoundary, withTouchEventBoundary } from './touchevents';
diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx
index 4c4ef00a51..d37748119e 100644
--- a/src/js/sdk.tsx
+++ b/src/js/sdk.tsx
@@ -2,7 +2,7 @@ import { getIntegrationsToSetup, initAndBind, setExtra } from '@sentry/core';
import { Hub, makeMain } from '@sentry/hub';
import { RewriteFrames } from '@sentry/integrations';
import { defaultIntegrations, defaultStackParser, getCurrentHub } from '@sentry/react';
-import { Integration, StackFrame } from '@sentry/types';
+import { Integration, StackFrame, UserFeedback } from '@sentry/types';
import { getGlobalObject, logger, stackParserFromStackParserOptions } from '@sentry/utils';
import * as React from 'react';
@@ -222,3 +222,10 @@ export async function close(): Promise {
logger.error('Failed to close the SDK');
}
}
+
+/**
+ * Captures user feedback and sends it to Sentry.
+ */
+ export function captureUserFeedback(feedback: UserFeedback): void {
+ getCurrentHub().getClient()?.captureUserFeedback(feedback);
+}
diff --git a/src/js/utils/envelope.ts b/src/js/utils/envelope.ts
new file mode 100644
index 0000000000..0f4891a089
--- /dev/null
+++ b/src/js/utils/envelope.ts
@@ -0,0 +1,46 @@
+import {
+ BaseEnvelopeHeaders,
+ DsnComponents,
+ EventEnvelope,
+ EventEnvelopeHeaders,
+ SdkMetadata,
+ UserFeedback,
+ UserFeedbackItem,
+} from '@sentry/types';
+import { createEnvelope, dsnToString } from '@sentry/utils';
+
+/**
+ * Creates an envelope from a user feedback.
+ */
+export function createUserFeedbackEnvelope(
+ feedback: UserFeedback,
+ {
+ metadata,
+ tunnel,
+ dsn,
+ }: {
+ metadata: SdkMetadata | undefined,
+ tunnel: string | undefined,
+ dsn: DsnComponents | undefined,
+ },
+): EventEnvelope {
+ // TODO: Use EventEnvelope[0] when JS sdk fix is released
+ const headers: EventEnvelopeHeaders & BaseEnvelopeHeaders = {
+ event_id: feedback.event_id,
+ sent_at: new Date().toISOString(),
+ ...(metadata && metadata.sdk && { sdk: metadata.sdk }),
+ ...(!!tunnel && !!dsn && { dsn: dsnToString(dsn) }),
+ };
+ const item = createUserFeedbackEnvelopeItem(feedback);
+
+ return createEnvelope(headers, [item]);
+}
+
+function createUserFeedbackEnvelopeItem(
+ feedback: UserFeedback
+): UserFeedbackItem {
+ const feedbackHeaders: UserFeedbackItem[0] = {
+ type: 'user_report',
+ };
+ return [feedbackHeaders, feedback];
+}
diff --git a/test/client.test.ts b/test/client.test.ts
index eabb455ce0..6d11289eb8 100644
--- a/test/client.test.ts
+++ b/test/client.test.ts
@@ -5,17 +5,44 @@ import { ReactNativeClient } from '../src/js/client';
import { ReactNativeClientOptions, ReactNativeOptions } from '../src/js/options';
import { NativeTransport } from '../src/js/transports/native';
import { NATIVE } from '../src/js/wrapper';
+import {
+ envelopeHeader,
+ envelopeItemHeader,
+ envelopeItemPayload,
+ envelopeItems,
+ firstArg,
+} from './testutils';
const EXAMPLE_DSN =
'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053';
+interface MockedReactNative {
+ NativeModules: {
+ RNSentry: {
+ initNativeSdk: jest.Mock;
+ crash: jest.Mock;
+ captureEnvelope: jest.Mock;
+ };
+ };
+ Platform: {
+ OS: 'mock';
+ };
+ LogBox: {
+ ignoreLogs: jest.Mock;
+ };
+ YellowBox: {
+ ignoreWarnings: jest.Mock;
+ };
+}
+
jest.mock(
'react-native',
- () => ({
+ (): MockedReactNative => ({
NativeModules: {
RNSentry: {
initNativeSdk: jest.fn(() => Promise.resolve(true)),
crash: jest.fn(),
+ captureEnvelope: jest.fn(),
},
},
Platform: {
@@ -100,7 +127,7 @@ describe('Tests ReactNativeClient', () => {
// eslint-disable-next-line deprecation/deprecation
await expect(RN.YellowBox.ignoreWarnings).toBeCalled();
});
-
+
test('use custom transport function', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mySend = (request: Envelope) => Promise.resolve();
@@ -149,11 +176,11 @@ describe('Tests ReactNativeClient', () => {
});
test('calls onReady callback with false if Native SDK failed to initialize', (done) => {
- const RN = require('react-native');
+ const RN: MockedReactNative = require('react-native');
- RN.NativeModules.RNSentry.initNativeSdk = async () => {
+ RN.NativeModules.RNSentry.initNativeSdk = jest.fn(() => {
throw new Error();
- };
+ });
new ReactNativeClient({
dsn: EXAMPLE_DSN,
@@ -170,7 +197,7 @@ describe('Tests ReactNativeClient', () => {
describe('nativeCrash', () => {
test('calls NativeModules crash', () => {
- const RN = require('react-native');
+ const RN: MockedReactNative = require('react-native');
const client = new ReactNativeClient({
...DEFAULT_OPTIONS,
@@ -183,4 +210,36 @@ describe('Tests ReactNativeClient', () => {
expect(RN.NativeModules.RNSentry.crash).toBeCalled();
});
});
+
+ describe('UserFeedback', () => {
+ test('sends UserFeedback to native Layer', () => {
+ const mockTransportSend: jest.Mock = jest.fn(() => Promise.resolve());
+ const client = new ReactNativeClient({
+ ...DEFAULT_OPTIONS,
+ dsn: EXAMPLE_DSN,
+ transport: () => ({
+ send: mockTransportSend,
+ flush: jest.fn(),
+ }),
+ } as ReactNativeClientOptions);
+
+ client.captureUserFeedback({
+ comments: 'Test Comments',
+ email: 'test@email.com',
+ name: 'Test User',
+ event_id: 'testEvent123',
+ });
+
+ expect(mockTransportSend.mock.calls[0][firstArg][envelopeHeader].event_id).toEqual('testEvent123');
+ expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemHeader].type).toEqual(
+ 'user_report'
+ );
+ expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload]).toEqual({
+ comments: 'Test Comments',
+ email: 'test@email.com',
+ name: 'Test User',
+ event_id: 'testEvent123',
+ });
+ });
+ });
});
diff --git a/test/testutils.ts b/test/testutils.ts
index 80f04e9838..5afdff6c7b 100644
--- a/test/testutils.ts
+++ b/test/testutils.ts
@@ -17,3 +17,9 @@ export const getMockTransaction = (name: string): Transaction => {
return transaction;
};
+
+export const firstArg = 0;
+export const envelopeHeader = 0;
+export const envelopeItems = 1;
+export const envelopeItemHeader = 0;
+export const envelopeItemPayload = 1;