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

feat(expo): Add RNSentrySDK APIs support to @sentry/react-native/expo plugin #4633

Open
wants to merge 6 commits into
base: capture-app-start-errors
Choose a base branch
from
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@

### Features

- Add RNSentrySDK APIs support to @sentry/react-native/expo plugin ([#4633](https://github.com/getsentry/sentry-react-native/pull/4633))

This feature is opt-out to enable it set `useNativeInit` to `true` in your `@sentry/react-native/expo` plugin configuration.

```js
"plugins": [
[
"@sentry/react-native/expo",
{
"useNativeInit": true
}
],
```

- User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435))

To collect user feedback from inside your application call `Sentry.showFeedbackWidget()`.
Expand Down
5 changes: 3 additions & 2 deletions packages/core/plugin/src/withSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface PluginProps {
project?: string;
authToken?: string;
url?: string;
useNativeInit?: boolean;
experimental_android?: SentryAndroidGradlePluginOptions;
}

Expand All @@ -26,7 +27,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
let cfg = config;
if (sentryProperties !== null) {
try {
cfg = withSentryAndroid(cfg, sentryProperties);
cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
} catch (e) {
warnOnce(`There was a problem with configuring your native Android project: ${e}`);
}
Expand All @@ -39,7 +40,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
}
}
try {
cfg = withSentryIOS(cfg, sentryProperties);
cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
} catch (e) {
warnOnce(`There was a problem with configuring your native iOS project: ${e}`);
}
Expand Down
62 changes: 58 additions & 4 deletions packages/core/plugin/src/withSentryAndroid.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type { ExpoConfig } from '@expo/config-types';
import type { ConfigPlugin } from 'expo/config-plugins';
import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins';
import { withAppBuildGradle, withDangerousMod, withMainApplication } from 'expo/config-plugins';
import * as path from 'path';

import { warnOnce, writeSentryPropertiesTo } from './utils';

export const withSentryAndroid: ConfigPlugin<string> = (config, sentryProperties: string) => {
const cfg = withAppBuildGradle(config, config => {
export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
config,
{ sentryProperties, useNativeInit = false },
) => {
const appBuildGradleCfg = withAppBuildGradle(config, config => {
if (config.modResults.language === 'groovy') {
config.modResults.contents = modifyAppBuildGradle(config.modResults.contents);
} else {
throw new Error('Cannot configure Sentry in the app gradle because the build.gradle is not groovy');
}
return config;
});
return withDangerousMod(cfg, [

const mainApplicationCfg = useNativeInit ? modifyMainApplication(appBuildGradleCfg) : appBuildGradleCfg;

return withDangerousMod(mainApplicationCfg, [
'android',
config => {
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties);
Expand Down Expand Up @@ -49,3 +56,50 @@ export function modifyAppBuildGradle(buildGradle: string): string {

return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`);
}

export function modifyMainApplication(config: ExpoConfig): ExpoConfig {
return withMainApplication(config, async config => {
if (!config.modResults || !config.modResults.path) {
warnOnce('Skipping MainApplication modification because the file does not exist.');
return config;
}

const fileName = config.modResults.path.split('/').pop();

if (config.modResults.contents.includes('RNSentrySDK.init')) {
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.init'.`);
return config;
}

if (config.modResults.language === 'java') {
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) {
// Insert import statement after package declaration
config.modResults.contents = config.modResults.contents.replace(
/(package .*;\n\n?)/,
`$1import io.sentry.react.RNSentrySDK;\n`,
);
}
// Add RNSentrySDK.init
config.modResults.contents = config.modResults.contents.replace(
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
`$1\n$2RNSentrySDK.init(this);\n$2`,
);
} else {
// Kotlin
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) {
// Insert import statement after package declaration
config.modResults.contents = config.modResults.contents.replace(
/(package .*\n\n?)/,
`$1import io.sentry.react.RNSentrySDK\n`,
);
}
// Add RNSentrySDK.init
config.modResults.contents = config.modResults.contents.replace(
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
`$1\n$2RNSentrySDK.init(this)\n$2`,
);
}

return config;
});
}
61 changes: 57 additions & 4 deletions packages/core/plugin/src/withSentryIOS.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExpoConfig } from '@expo/config-types';
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins';
import { withDangerousMod, withXcodeProject } from 'expo/config-plugins';
import { withAppDelegate, withDangerousMod, withXcodeProject } from 'expo/config-plugins';
import * as path from 'path';

import { warnOnce, writeSentryPropertiesTo } from './utils';
Expand All @@ -12,8 +13,11 @@ const SENTRY_REACT_NATIVE_XCODE_PATH =
const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH =
"`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";

export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: string) => {
const cfg = withXcodeProject(config, config => {
export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
config,
{ sentryProperties, useNativeInit = false },
) => {
const xcodeProjectCfg = withXcodeProject(config, config => {
const xcodeProject: XcodeProject = config.modResults;

const sentryBuildPhase = xcodeProject.pbxItemByComment(
Expand All @@ -36,7 +40,9 @@ export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: st
return config;
});

return withDangerousMod(cfg, [
const appDelegateCfc = useNativeInit ? modifyAppDelegate(xcodeProjectCfg) : xcodeProjectCfg;

return withDangerousMod(appDelegateCfc, [
'ios',
config => {
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
Expand Down Expand Up @@ -79,3 +85,50 @@ export function addSentryWithBundledScriptsToBundleShellScript(script: string):
(match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`,
);
}

export function modifyAppDelegate(config: ExpoConfig): ExpoConfig {
return withAppDelegate(config, async config => {
if (!config.modResults || !config.modResults.path) {
warnOnce('Skipping AppDelegate modification because the file does not exist.');
return config;
}

const fileName = config.modResults.path.split('/').pop();

if (config.modResults.language === 'swift') {
if (config.modResults.contents.includes('RNSentrySDK.start()')) {
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.start()'.`);
return config;
}
if (!config.modResults.contents.includes('import RNSentry')) {
// Insert import statement after UIKit import
config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`);
}
// Add RNSentrySDK.start() at the beginning of application method
config.modResults.contents = config.modResults.contents.replace(
/(func application\([^)]*\) -> Bool \{)/s,
`$1\n RNSentrySDK.start()`,
);
} else {
// Objective-C
if (config.modResults.contents.includes('[RNSentrySDK start]')) {
warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`);
return config;
}
if (!config.modResults.contents.includes('#import <RNSentry/RNSentry.h>')) {
// Add import after AppDelegate.h
config.modResults.contents = config.modResults.contents.replace(
/(#import "AppDelegate.h"\n)/,
`$1#import <RNSentry/RNSentry.h>\n`,
);
}
// Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method
config.modResults.contents = config.modResults.contents.replace(
/(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s,
`$1$2[RNSentrySDK start];\n$2`,
);
}

return config;
});
}
177 changes: 177 additions & 0 deletions packages/core/test/expo-plugin/modifyAppDelegate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { ExpoConfig } from '@expo/config-types';

import { warnOnce } from '../../plugin/src/utils';
import { modifyAppDelegate } from '../../plugin/src/withSentryIOS';

// Mock dependencies
jest.mock('@expo/config-plugins', () => ({
...jest.requireActual('@expo/config-plugins'),
withAppDelegate: jest.fn((config, callback) => callback(config)),
}));

jest.mock('../../plugin/src/utils', () => ({
warnOnce: jest.fn(),
}));

interface MockedExpoConfig extends ExpoConfig {
modResults: {
path: string;
contents: string;
language: 'swift' | 'objc';
};
}

const objcContents = `#import "AppDelegate.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"main";

// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};

return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end
`;

const objcExpected = `#import "AppDelegate.h"
#import <RNSentry/RNSentry.h>

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[RNSentrySDK start];
self.moduleName = @"main";

// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};

return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end
`;

const swiftContents = `import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit

@main
class AppDelegate: RCTAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
self.moduleName = "sentry-react-native-sample"
self.dependencyProvider = RCTAppDependencyProvider()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}`;

const swiftExpected = `import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit
import RNSentry

@main
class AppDelegate: RCTAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
RNSentrySDK.start()
self.moduleName = "sentry-react-native-sample"
self.dependencyProvider = RCTAppDependencyProvider()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}`;

describe('modifyAppDelegate', () => {
let config: MockedExpoConfig;

beforeEach(() => {
jest.clearAllMocks();
// Reset to a mocked Swift config after each test
config = {
name: 'test',
slug: 'test',
modResults: {
path: 'samples/react-native/ios/AppDelegate.swift',
contents: swiftContents,
language: 'swift',
},
};
});

it('should skip modification if modResults or path is missing', async () => {
config.modResults.path = undefined;

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith('Skipping AppDelegate modification because the file does not exist.');
expect(result).toBe(config); // No modification
});

it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => {
config.modResults.contents = 'RNSentrySDK.start();';

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`);
expect(result).toBe(config); // No modification
});

it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => {
config.modResults.language = 'objc';
config.modResults.path = 'samples/react-native/ios/AppDelegate.mm';
config.modResults.contents = '[RNSentrySDK start];';

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`);
expect(result).toBe(config); // No modification
});

it('should modify a Swift file by adding the RNSentrySDK import and start', async () => {
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

expect(result.modResults.contents).toContain('import RNSentry');
expect(result.modResults.contents).toContain('RNSentrySDK.start()');
expect(result.modResults.contents).toBe(swiftExpected);
});

it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => {
config.modResults.language = 'objc';
config.modResults.contents = objcContents;

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

expect(result.modResults.contents).toContain('#import <RNSentry/RNSentry.h>');
expect(result.modResults.contents).toContain('[RNSentrySDK start];');
expect(result.modResults.contents).toBe(objcExpected);
});

it('should insert import statements only once in an Swift project', async () => {
config.modResults.contents =
'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {';

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length;
expect(importCount).toBe(1);
});

it('should insert import statements only once in an Objective-C project', async () => {
config.modResults.language = 'objc';
config.modResults.contents =
'#import "AppDelegate.h"\n#import <RNSentry/RNSentry.h>\n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {';

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

const importCount = (result.modResults.contents.match(/#import <RNSentry\/RNSentry.h>/g) || []).length;
expect(importCount).toBe(1);
});
});
Loading
Loading