Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UserFeedback #2486

Merged
merged 10 commits into from
Sep 22, 2022
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Binary file added sample/src/assets/sentry-announcement.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions sample/src/components/UserFeedbackModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View>
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => {
setModalVisible(!modalVisible);
}}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Image
source={require('../assets/sentry-announcement.png')}
style={styles.modalImage}
/>
<Text style={styles.modalText}>Whoops, what happened?</Text>
<TextInput
style={styles.input}
onChangeText={onChangeComments}
value={comments}
multiline={true}
numberOfLines={4}
/>
<TouchableOpacity
onPress={async () => {
setModalVisible(!modalVisible);

const sentryId = Sentry.captureMessage('Message that needs user feedback');

const userFeedback: UserFeedback = {
event_id: sentryId,
name: 'John Doe',
email: '[email protected]',
comments,
};

Sentry.captureUserFeedback(userFeedback);
clearComments();
}}>
<Text style={homeScreenStyles.buttonText}>Send feedback</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={async () => {
setModalVisible(!modalVisible);
}}>
<Text style={homeScreenStyles.buttonText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
<TouchableOpacity
onPress={async () => {
setModalVisible(true);
}}>
<Text style={homeScreenStyles.buttonText}>Send user feedback</Text>
</TouchableOpacity>
</View>
);
}

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,
}
});
5 changes: 4 additions & 1 deletion sample/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -256,6 +257,8 @@ const HomeScreen = (props: Props) => {
}}>
<Text style={styles.buttonText}>Get attachment</Text>
</TouchableOpacity>
<View style={styles.spacer} />
<UserFeedbackModal/>
</View>
<View style={styles.buttonArea}>
<TouchableOpacity
Expand Down Expand Up @@ -304,7 +307,7 @@ const HomeScreen = (props: Props) => {
);
};

const styles = StyleSheet.create({
export const styles = StyleSheet.create({
scrollView: {
backgroundColor: '#fff',
flex: 1,
Expand Down
24 changes: 23 additions & 1 deletion src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -89,6 +96,21 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
});
}

/**
* 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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export {
StackFrame,
Stacktrace,
Thread,
User,
} from '@sentry/types';

export {
Expand Down Expand Up @@ -49,6 +48,7 @@ export {
withProfiler,
} from '@sentry/react';

export { User, UserFeedback, captureUserFeedback } from './user';
import * as Integrations from './integrations';
import { SDK_NAME, SDK_VERSION } from './version';
export { ReactNativeOptions } from './options';
Expand Down
13 changes: 13 additions & 0 deletions src/js/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getCurrentHub } from '@sentry/hub';
import { UserFeedback } from '@sentry/types';

import { ReactNativeClient } from './client';

/**
* Captures a user feedback and sends it to Sentry.
*/
export function captureUserFeedback(feedback: UserFeedback): void {
krystofwoldrich marked this conversation as resolved.
Show resolved Hide resolved
getCurrentHub().getClient<ReactNativeClient>()?.captureUserFeedback(feedback);
}

export { User, UserFeedback } from '@sentry/types';
46 changes: 46 additions & 0 deletions src/js/utils/envelope.ts
Original file line number Diff line number Diff line change
@@ -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
krystofwoldrich marked this conversation as resolved.
Show resolved Hide resolved
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];
}
71 changes: 65 additions & 6 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://[email protected]/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: {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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: '[email protected]',
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: '[email protected]',
name: 'Test User',
event_id: 'testEvent123',
});
});
});
});
Loading