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;