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
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: 2 additions & 0 deletions src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
Stacktrace,
Thread,
User,
UserFeedback,
} from '@sentry/types';

export {
Expand Down Expand Up @@ -64,6 +65,7 @@ export {
nativeCrash,
flush,
close,
captureUserFeedback,
} from './sdk';
export { TouchEventBoundary, withTouchEventBoundary } from './touchevents';

Expand Down
9 changes: 8 additions & 1 deletion src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -222,3 +222,10 @@ export async function close(): Promise<void> {
logger.error('Failed to close the SDK');
}
}

/**
* Captures user feedback and sends it to Sentry.
*/
export function captureUserFeedback(feedback: UserFeedback): void {
getCurrentHub().getClient<ReactNativeClient>()?.captureUserFeedback(feedback);
}
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
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