diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fce80de..c656658a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - feat(reactnative): Use Xcode scripts bundled with Sentry RN SDK (#499) - feat(reactnative): Make `pod install` step optional (#501) - feat(remix): Add Vite support (#495) +- feat(reactnative): Add Sentry Metro serializer (#502) ## 3.16.5 diff --git a/src/react-native/metro.ts b/src/react-native/metro.ts new file mode 100644 index 00000000..c327d0c0 --- /dev/null +++ b/src/react-native/metro.ts @@ -0,0 +1,409 @@ +// @ts-ignore - clack is ESM and TS complains about that. It works though +import * as clack from '@clack/prompts'; +// @ts-ignore - magicast is ESM and TS complains about that. It works though +import { ProxifiedModule, parseModule, writeFile } from 'magicast'; +import * as fs from 'fs'; +import * as Sentry from '@sentry/node'; + +import { + getLastRequireIndex, + hasSentryContent, + removeRequire, +} from '../utils/ast-utils'; +import { + abortIfCancelled, + makeCodeSnippet, + showCopyPasteInstructions, +} from '../utils/clack-utils'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; +import chalk from 'chalk'; + +const b = recast.types.builders; + +const metroConfigPath = 'metro.config.js'; + +export async function patchMetroConfig() { + const mod = await parseMetroConfig(); + + const showInstructions = () => + showCopyPasteInstructions(metroConfigPath, getMetroConfigSnippet(true)); + + if (hasSentryContent(mod.$ast as t.Program)) { + const shouldContinue = await confirmPathMetroConfig(); + if (!shouldContinue) { + return await showInstructions(); + } + } + + const configObj = getMetroConfigObject(mod.$ast as t.Program); + if (!configObj) { + clack.log.warn( + 'Could not find Metro config object, please follow the manual steps.', + ); + return showInstructions(); + } + + const addedSentrySerializer = addSentrySerializerToMetroConfig(configObj); + if (!addedSentrySerializer) { + clack.log.warn( + 'Could not add Sentry serializer to Metro config, please follow the manual steps.', + ); + return await showInstructions(); + } + + const addedSentrySerializerImport = addSentrySerializerRequireToMetroConfig( + mod.$ast as t.Program, + ); + if (!addedSentrySerializerImport) { + clack.log.warn( + 'Could not add Sentry serializer import to Metro config, please follow the manual steps.', + ); + return await showInstructions(); + } + + clack.log.success( + `Added Sentry Metro plugin to ${chalk.cyan(metroConfigPath)}.`, + ); + + const saved = await writeMetroConfig(mod); + if (saved) { + clack.log.success( + chalk.green(`${chalk.cyan(metroConfigPath)} changes saved.`), + ); + } else { + clack.log.warn( + `Could not save changes to ${chalk.cyan( + metroConfigPath, + )}, please follow the manual steps.`, + ); + return await showInstructions(); + } +} + +export async function unPatchMetroConfig() { + const mod = await parseMetroConfig(); + + const removedAtLeastOneRequire = removeSentryRequire(mod.$ast as t.Program); + const removedSerializerConfig = removeSentrySerializerFromMetroConfig( + mod.$ast as t.Program, + ); + + if (removedAtLeastOneRequire || removedSerializerConfig) { + const saved = await writeMetroConfig(mod); + if (saved) { + clack.log.success( + `Removed Sentry Metro plugin from ${chalk.cyan(metroConfigPath)}.`, + ); + } + } else { + clack.log.warn( + `No Sentry Metro plugin found in ${chalk.cyan(metroConfigPath)}.`, + ); + } +} + +export function removeSentrySerializerFromMetroConfig( + program: t.Program, +): boolean { + const configObject = getMetroConfigObject(program); + if (!configObject) { + return false; + } + + const serializerProp = getSerializerProp(configObject); + if ('invalid' === serializerProp || 'undefined' === serializerProp) { + return false; + } + + const customSerializerProp = getCustomSerializerProp(serializerProp); + if ( + 'invalid' === customSerializerProp || + 'undefined' === customSerializerProp + ) { + return false; + } + + if ( + serializerProp.value.type === 'ObjectExpression' && + customSerializerProp.value.type === 'CallExpression' && + customSerializerProp.value.callee.type === 'Identifier' && + customSerializerProp.value.callee.name === 'createSentryMetroSerializer' + ) { + if (customSerializerProp.value.arguments.length === 0) { + // FROM serializer: { customSerializer: createSentryMetroSerializer() } + // TO serializer: {} + let removed = false; + serializerProp.value.properties = serializerProp.value.properties.filter( + (p) => { + if ( + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'customSerializer' + ) { + removed = true; + return false; + } + return true; + }, + ); + + if (removed) { + return true; + } + } else { + if (customSerializerProp.value.arguments[0].type !== 'SpreadElement') { + // FROM serializer: { customSerializer: createSentryMetroSerializer(wrapperSerializer) } + // TO serializer: { customSerializer: wrapperSerializer } + customSerializerProp.value = customSerializerProp.value.arguments[0]; + return true; + } + } + } + + return false; +} + +export function removeSentryRequire(program: t.Program): boolean { + return removeRequire(program, '@sentry'); +} + +async function parseMetroConfig(): Promise { + const metroConfigContent = ( + await fs.promises.readFile(metroConfigPath) + ).toString(); + + return parseModule(metroConfigContent); +} + +async function writeMetroConfig(mod: ProxifiedModule): Promise { + try { + await writeFile(mod.$ast, metroConfigPath); + } catch (e) { + clack.log.error( + `Failed to write to ${chalk.cyan(metroConfigPath)}: ${JSON.stringify(e)}`, + ); + return false; + } + return true; +} + +export function addSentrySerializerToMetroConfig( + configObj: t.ObjectExpression, +): boolean { + const serializerProp = getSerializerProp(configObj); + if ('invalid' === serializerProp) { + return false; + } + + // case 1: serializer property doesn't exist yet, so we can just add it + if ('undefined' === serializerProp) { + configObj.properties.push( + b.objectProperty( + b.identifier('serializer'), + b.objectExpression([ + b.objectProperty( + b.identifier('customSerializer'), + b.callExpression(b.identifier('createSentryMetroSerializer'), []), + ), + ]), + ), + ); + return true; + } + + const customSerializerProp = getCustomSerializerProp(serializerProp); + // case 2: serializer.customSerializer property doesn't exist yet, so we just add it + if ( + 'undefined' === customSerializerProp && + serializerProp.value.type === 'ObjectExpression' + ) { + serializerProp.value.properties.push( + b.objectProperty( + b.identifier('customSerializer'), + b.callExpression(b.identifier('createSentryMetroSerializer'), []), + ), + ); + return true; + } + + return false; +} + +function getCustomSerializerProp( + prop: t.ObjectProperty, +): t.ObjectProperty | 'undefined' | 'invalid' { + const customSerializerProp = + prop.value.type === 'ObjectExpression' && + prop.value.properties.find( + (p: t.ObjectProperty) => + p.key.type === 'Identifier' && p.key.name === 'customSerializer', + ); + + if (!customSerializerProp) { + return 'undefined'; + } + + if (customSerializerProp.type === 'ObjectProperty') { + return customSerializerProp; + } + + return 'invalid'; +} + +function getSerializerProp( + obj: t.ObjectExpression, +): t.ObjectProperty | 'undefined' | 'invalid' { + const serializerProp = obj.properties.find( + (p: t.ObjectProperty) => + p.key.type === 'Identifier' && p.key.name === 'serializer', + ); + + if (!serializerProp) { + return 'undefined'; + } + + if (serializerProp.type === 'ObjectProperty') { + return serializerProp; + } + + return 'invalid'; +} + +export function addSentrySerializerRequireToMetroConfig( + program: t.Program, +): boolean { + const lastRequireIndex = getLastRequireIndex(program); + const sentrySerializerRequire = createSentrySerializerRequire(); + const sentryImportIndex = lastRequireIndex + 1; + if (sentryImportIndex < program.body.length) { + // insert after last require + program.body.splice(lastRequireIndex + 1, 0, sentrySerializerRequire); + } else { + // insert at the end + program.body.push(sentrySerializerRequire); + } + return true; +} + +/** + * Creates const {createSentryMetroSerializer} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer'); + */ +function createSentrySerializerRequire() { + return b.variableDeclaration('const', [ + b.variableDeclarator( + b.objectPattern([ + b.objectProperty.from({ + key: b.identifier('createSentryMetroSerializer'), + value: b.identifier('createSentryMetroSerializer'), + shorthand: true, + }), + ]), + b.callExpression(b.identifier('require'), [ + b.literal('@sentry/react-native/dist/js/tools/sentryMetroSerializer'), + ]), + ), + ]); +} + +async function confirmPathMetroConfig() { + const shouldContinue = await abortIfCancelled( + clack.select({ + message: `Metro Config already contains Sentry-related code. Should the wizard modify it anyway?`, + options: [ + { + label: 'Yes, add the Sentry Metro plugin', + value: true, + }, + { + label: 'No, show me instructions to manually add the plugin', + value: false, + }, + ], + initialValue: true, + }), + ); + + if (!shouldContinue) { + Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); + } + + return shouldContinue; +} + +/** + * Returns value from `module.exports = value` or `const config = value` + */ +export function getMetroConfigObject( + program: t.Program, +): t.ObjectExpression | undefined { + // check config variable + const configVariable = program.body.find((s) => { + if ( + s.type === 'VariableDeclaration' && + s.declarations.length === 1 && + s.declarations[0].type === 'VariableDeclarator' && + s.declarations[0].id.type === 'Identifier' && + s.declarations[0].id.name === 'config' + ) { + return true; + } + return false; + }) as t.VariableDeclaration | undefined; + + if ( + configVariable?.declarations[0].type === 'VariableDeclarator' && + configVariable?.declarations[0].init?.type === 'ObjectExpression' + ) { + Sentry.setTag('metro-config', 'config-variable'); + return configVariable.declarations[0].init; + } + + // check module.exports + const moduleExports = program.body.find((s) => { + if ( + s.type === 'ExpressionStatement' && + s.expression.type === 'AssignmentExpression' && + s.expression.left.type === 'MemberExpression' && + s.expression.left.object.type === 'Identifier' && + s.expression.left.object.name === 'module' && + s.expression.left.property.type === 'Identifier' && + s.expression.left.property.name === 'exports' + ) { + return true; + } + return false; + }) as t.ExpressionStatement | undefined; + + if ( + (moduleExports?.expression as t.AssignmentExpression).right.type === + 'ObjectExpression' + ) { + Sentry.setTag('metro-config', 'module-exports'); + return (moduleExports?.expression as t.AssignmentExpression) + .right as t.ObjectExpression; + } + + Sentry.setTag('metro-config', 'not-found'); + return undefined; +} + +function getMetroConfigSnippet(colors: boolean) { + return makeCodeSnippet(colors, (unchanged, plus, _) => + unchanged(`const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');"; +${plus( + "const {createSentryMetroSerializer} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer');", +)} + +const config = { + ${plus(`serializer: { + customSerializer: createSentryMetroSerializer(), + },`)} +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); +`), + ); +} diff --git a/src/react-native/react-native-wizard.ts b/src/react-native/react-native-wizard.ts index b0b4d170..fc8cd450 100644 --- a/src/react-native/react-native-wizard.ts +++ b/src/react-native/react-native-wizard.ts @@ -50,6 +50,7 @@ import { traceStep, withTelemetry } from '../telemetry'; import * as Sentry from '@sentry/node'; import { fulfillsVersionRange } from '../utils/semver'; import { getIssueStreamUrl } from '../utils/url'; +import { patchMetroConfig } from './metro'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const xcode = require('xcode'); @@ -65,6 +66,9 @@ export const SUPPORTED_RN_RANGE = '>=0.69.0'; // which simplifies the Xcode Build Phases setup. export const SDK_XCODE_SCRIPTS_SUPPORTED_SDK_RANGE = '>=5.11.0'; +// The following SDK version ship with Sentry Metro plugin +export const SDK_SENTRY_METRO_PLUGIN_SUPPORTED_SDK_RANGE = '>=5.11.0'; + export type RNCliSetupConfigContent = Pick< Required, 'authToken' | 'org' | 'project' | 'url' @@ -137,6 +141,10 @@ export async function runReactNativeWizardWithTelemetry( addSentryInit({ dsn: selectedProject.keys[0].dsn.public }), ); + await traceStep('patch-metro-config', () => + addSentryToMetroConfig({ sdkVersion }), + ); + if (fs.existsSync('ios')) { Sentry.setTag('patch-ios', true); await traceStep('patch-xcode-files', () => @@ -173,6 +181,25 @@ export async function runReactNativeWizardWithTelemetry( } } +async function addSentryToMetroConfig({ + sdkVersion, +}: { + sdkVersion: string | undefined; +}) { + if ( + !sdkVersion || + !fulfillsVersionRange({ + version: sdkVersion, + acceptableVersions: SDK_SENTRY_METRO_PLUGIN_SUPPORTED_SDK_RANGE, + canBeLatest: true, + }) + ) { + return; + } + + await patchMetroConfig(); +} + async function addSentryInit({ dsn }: { dsn: string }) { const prefixGlob = '{.,./src}'; const suffixGlob = '@(j|t|cj|mj)s?(x)'; diff --git a/src/react-native/uninstall.ts b/src/react-native/uninstall.ts index 41efe660..6a53e9d3 100644 --- a/src/react-native/uninstall.ts +++ b/src/react-native/uninstall.ts @@ -21,6 +21,7 @@ import { writeAppBuildGradle, } from './gradle'; import { ReactNativeWizardOptions } from './options'; +import { unPatchMetroConfig } from './metro'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const xcode = require('xcode'); @@ -36,6 +37,8 @@ export async function runReactNativeUninstall( await confirmContinueIfNoOrDirtyGitRepo(); + await unPatchMetroConfig(); + unPatchXcodeFiles(); unPatchAndroidFiles(); diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts index c4ee8d3a..d9547e19 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -216,3 +216,55 @@ export function printJsonC(ast: t.Program): string { const js = recast.print(ast).code; return js.substring(1, js.length - 1); } + +/** + * Walks the program body and returns index of the last variable assignment initialized by require statement. + * Only counts top level require statements. + * + * @returns index of the last `const foo = require('bar');` statement + */ +export function getLastRequireIndex(program: t.Program): number { + let lastRequireIdex = 0; + program.body.forEach((s, i) => { + if ( + s.type === 'VariableDeclaration' && + s.declarations[0].type === 'VariableDeclarator' && + s.declarations[0].init !== null && + typeof s.declarations[0].init !== 'undefined' && + s.declarations[0].init.type === 'CallExpression' && + s.declarations[0].init.callee.type === 'Identifier' && + s.declarations[0].init.callee.name === 'require' + ) { + lastRequireIdex = i; + } + }); + return lastRequireIdex; +} + +/** + * Walks the statements and removes require statements which first argument includes the predicate. + * Only removes top level require statements like `const foo = require('bar');` + * + * @returns True if any require statement was removed. + */ +export function removeRequire(program: t.Program, predicate: string): boolean { + let removedAtLeastOne = false; + program.body = program.body.filter((s) => { + if ( + s.type === 'VariableDeclaration' && + s.declarations[0].type === 'VariableDeclarator' && + s.declarations[0].init !== null && + typeof s.declarations[0].init !== 'undefined' && + s.declarations[0].init.type === 'CallExpression' && + s.declarations[0].init.callee.type === 'Identifier' && + s.declarations[0].init.callee.name === 'require' && + s.declarations[0].init.arguments[0].type === 'StringLiteral' && + s.declarations[0].init.arguments[0].value.includes(predicate) + ) { + removedAtLeastOne = true; + return false; + } + return true; + }); + return removedAtLeastOne; +} diff --git a/test/react-native/metro.test.ts b/test/react-native/metro.test.ts new file mode 100644 index 00000000..2b8f1164 --- /dev/null +++ b/test/react-native/metro.test.ts @@ -0,0 +1,283 @@ +// @ts-ignore - magicast is ESM and TS complains about that. It works though +import { generateCode, type ProxifiedModule, parseModule } from 'magicast'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +import { + addSentrySerializerRequireToMetroConfig, + addSentrySerializerToMetroConfig, + getMetroConfigObject, + removeSentryRequire, + removeSentrySerializerFromMetroConfig, +} from '../../src/react-native/metro'; + +describe('patch metro config - sentry serializer', () => { + describe('addSentrySerializerToMetroConfig', () => { + it('add to empty config', () => { + const mod = parseModule(`module.exports = { + other: 'config' + }`); + const configObject = getModuleExportsObject(mod); + const result = addSentrySerializerToMetroConfig(configObject); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe(`module.exports = { + other: 'config', + + serializer: { + customSerializer: createSentryMetroSerializer() + } +}`); + }); + + it('add to existing serializer config', () => { + const mod = parseModule(`module.exports = { + other: 'config', + serializer: { + other: 'config' + } +}`); + const configObject = getModuleExportsObject(mod); + const result = addSentrySerializerToMetroConfig(configObject); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe(`module.exports = { + other: 'config', + serializer: { + other: 'config', + customSerializer: createSentryMetroSerializer() + } +}`); + }); + + it('not add to existing customSerializer config', () => { + const mod = parseModule(`module.exports = { + other: 'config', + serializer: { + other: 'config', + customSerializer: 'existing-serializer' + } +}`); + const configObject = getModuleExportsObject(mod); + const result = addSentrySerializerToMetroConfig(configObject); + expect(result).toBe(false); + expect(generateCode(mod.$ast).code).toBe(`module.exports = { + other: 'config', + serializer: { + other: 'config', + customSerializer: 'existing-serializer' + } +}`); + }); + }); + + describe('addSentrySerializerImportToMetroConfig', () => { + it('add import', () => { + const mod = + parseModule(`const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +module.exports = { + other: 'config' +}`); + const result = addSentrySerializerRequireToMetroConfig( + mod.$ast as t.Program, + ); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code) + .toBe(`const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +const { + createSentryMetroSerializer +} = require("@sentry/react-native/dist/js/tools/sentryMetroSerializer"); + +module.exports = { + other: 'config' +}`); + }); + }); + + describe('getMetroConfigObject', () => { + it('get config object from variable called config', () => { + const mod = parseModule(`var config = { some: 'config' };`); + const configObject = getMetroConfigObject(mod.$ast as t.Program); + expect( + ((configObject?.properties[0] as t.ObjectProperty).key as t.Identifier) + .name, + ).toBe('some'); + expect( + ( + (configObject?.properties[0] as t.ObjectProperty) + .value as t.StringLiteral + ).value, + ).toBe('config'); + }); + + it('get config object from const called config', () => { + const mod = parseModule(`const config = { some: 'config' };`); + const configObject = getMetroConfigObject(mod.$ast as t.Program); + expect( + ((configObject?.properties[0] as t.ObjectProperty).key as t.Identifier) + .name, + ).toBe('some'); + expect( + ( + (configObject?.properties[0] as t.ObjectProperty) + .value as t.StringLiteral + ).value, + ).toBe('config'); + }); + + it('get config oject from let called config', () => { + const mod = parseModule(`let config = { some: 'config' };`); + const configObject = getMetroConfigObject(mod.$ast as t.Program); + expect( + ((configObject?.properties[0] as t.ObjectProperty).key as t.Identifier) + .name, + ).toBe('some'); + expect( + ( + (configObject?.properties[0] as t.ObjectProperty) + .value as t.StringLiteral + ).value, + ).toBe('config'); + }); + + it('get config object from module.exports', () => { + const mod = parseModule(`module.exports = { some: 'config' };`); + const configObject = getMetroConfigObject(mod.$ast as t.Program); + expect( + ((configObject?.properties[0] as t.ObjectProperty).key as t.Identifier) + .name, + ).toBe('some'); + expect( + ( + (configObject?.properties[0] as t.ObjectProperty) + .value as t.StringLiteral + ).value, + ).toBe('config'); + }); + }); + + describe('remove @sentry require', () => { + it('nothing to remove', () => { + const mod = parseModule(`let config = { some: 'config' };`); + const result = removeSentryRequire(mod.$ast as t.Program); + expect(result).toBe(false); + expect(generateCode(mod.$ast).code).toBe( + `let config = { some: 'config' };`, + ); + }); + + it('remove metro serializer import', () => { + const mod = parseModule(`const { + createSentryMetroSerializer, +} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer'); +let config = { some: 'config' };`); + const result = removeSentryRequire(mod.$ast as t.Program); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe( + `let config = { some: 'config' };`, + ); + }); + + it('remove all sentry imports', () => { + const mod = parseModule(`const { + createSentryMetroSerializer, +} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer'); +var Sentry = require('@sentry/react-native'); +let SentryIntegrations = require('@sentry/integrations'); + +let config = { some: 'config' };`); + const result = removeSentryRequire(mod.$ast as t.Program); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe( + `let config = { some: 'config' };`, + ); + }); + }); + + describe('remove sentryMetroSerializer', () => { + it('no custom serializer to remove', () => { + const mod = parseModule(`let config = { some: 'config' };`); + const result = removeSentrySerializerFromMetroConfig( + mod.$ast as t.Program, + ); + expect(result).toBe(false); + expect(generateCode(mod.$ast).code).toBe( + `let config = { some: 'config' };`, + ); + }); + + it('no Sentry custom serializer to remove', () => { + const mod = parseModule(`let config = { + serializer: { + customSerializer: 'existing-serializer', + other: 'config', + }, + other: 'config', +};`); + const result = removeSentrySerializerFromMetroConfig( + mod.$ast as t.Program, + ); + expect(result).toBe(false); + expect(generateCode(mod.$ast).code).toBe(`let config = { + serializer: { + customSerializer: 'existing-serializer', + other: 'config', + }, + other: 'config', +};`); + }); + + it('Sentry serializer to remove', () => { + const mod = parseModule(`let config = { + serializer: { + customSerializer: createSentryMetroSerializer(), + other: 'config', + }, + other: 'config', +};`); + const result = removeSentrySerializerFromMetroConfig( + mod.$ast as t.Program, + ); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe(`let config = { + serializer: { + other: 'config' + }, + other: 'config', +};`); + }); + + it('Sentry serializer to remove with wrapped serializer', () => { + const mod = parseModule(`let config = { + serializer: { + customSerializer: createSentryMetroSerializer(wrappedSerializer()), + other: 'config', + }, + other: 'config', +};`); + const result = removeSentrySerializerFromMetroConfig( + mod.$ast as t.Program, + ); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe(`let config = { + serializer: { + customSerializer: wrappedSerializer(), + other: 'config', + }, + other: 'config', +};`); + }); + }); +}); + +function getModuleExportsObject( + mod: ProxifiedModule, + index = 0, +): t.ObjectExpression { + return ( + ((mod.$ast as t.Program).body[index] as t.ExpressionStatement) + .expression as t.AssignmentExpression + ).right as t.ObjectExpression; +}