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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- fix(react-native): Skip native file patching when Expo CNG is detected ([#1211](https://github.com/getsentry/sentry-wizard/pull/1211))
- feat(react-router): Add Instrumentation API support for React Router 7.9.5+ ([#1209](https://github.com/getsentry/sentry-wizard/pull/1209))

## 6.11.0

Expand Down
11 changes: 9 additions & 2 deletions e2e-tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ VOLTA=$(which volta)
# Set cwd to the directory of this script
cd "$(dirname "$0")"

# Append .test.ts to avoid vitest substring-matching unrelated test files
if [ -n "$1" ]; then
TEST_FILTER="$1.test.ts"
else
TEST_FILTER=""
fi

# Run the tests with volta if it is installed
if [ -x "$VOLTA" ]; then
echo "Running tests with volta"
volta run yarn test $@
volta run yarn test $TEST_FILTER
# Otherwise, run the tests without volta
else
echo "Running tests without volta"
yarn test $@
yarn test $TEST_FILTER
fi
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/dev": "^7.8.2",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@react-router/dev": "^7.12.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"isbot": "^4.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.8.2"
"react-router": "^7.12.0"
},
"devDependencies": {
"@types/react": "^18.3.9",
Expand Down
127 changes: 127 additions & 0 deletions e2e-tests/tests/react-router-instrumentation-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterAll, beforeAll, describe, test, expect } from 'vitest';
import { Integration } from '../../lib/Constants';
import {
checkEnvBuildPlugin,
checkFileContents,
checkFileExists,
checkIfBuilds,
checkIfRunsOnDevMode,
checkIfRunsOnProdMode,
checkPackageJson,
createIsolatedTestEnv,
getWizardCommand,
} from '../utils';

//@ts-expect-error - clifty is ESM only
import { KEYS, withEnv } from 'clifty';

// Expects React Router >= 7.9.5
async function runWizardWithInstrumentationAPI(
projectDir: string,
): Promise<number> {
const wizardInteraction = withEnv({
cwd: projectDir,
}).defineInteraction();

wizardInteraction
.whenAsked('Please select your package manager.')
.respondWith(KEYS.DOWN, KEYS.ENTER)
.expectOutput('Installing @sentry/react-router')
.expectOutput('Installed @sentry/react-router', {
timeout: 240_000,
})

.whenAsked('Do you want to enable Tracing')
.respondWith(KEYS.ENTER) // Yes
.whenAsked('Do you want to enable Session Replay')
.respondWith(KEYS.ENTER) // Yes
.whenAsked('Do you want to enable Logs')
.respondWith(KEYS.ENTER) // Yes
.whenAsked('Do you want to enable Profiling')
.respondWith(KEYS.ENTER) // Yes
.whenAsked('Do you want to use the Instrumentation API')
.respondWith(KEYS.ENTER) // Yes
.expectOutput('Installing @sentry/profiling-node')
.expectOutput('Installed @sentry/profiling-node', {
timeout: 240_000,
})
.whenAsked('Do you want to create an example page')
.respondWith(KEYS.ENTER); // Yes

return wizardInteraction
.whenAsked(
'Optionally add a project-scoped MCP server configuration for the Sentry MCP?',
)
.respondWith(KEYS.DOWN, KEYS.ENTER) // No
.expectOutput('Successfully installed the Sentry React Router SDK!')
.run(getWizardCommand(Integration.reactRouter));
}

describe('React Router Instrumentation API', () => {
describe('with Instrumentation API enabled', () => {
let wizardExitCode: number;
const { projectDir, cleanup } = createIsolatedTestEnv(
'react-router-test-app',
);

beforeAll(async () => {
wizardExitCode = await runWizardWithInstrumentationAPI(projectDir);
});

afterAll(() => {
cleanup();
});

test('exits with exit code 0', () => {
expect(wizardExitCode).toBe(0);
});

test('package.json is updated correctly', () => {
checkPackageJson(projectDir, '@sentry/react-router');
});

test('.env.sentry-build-plugin is created and contains the auth token', () => {
checkEnvBuildPlugin(projectDir);
});

test('entry.client file contains Instrumentation API setup', () => {
checkFileContents(`${projectDir}/app/entry.client.tsx`, [
'import * as Sentry from',
'@sentry/react-router',
'const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true });',
'integrations: [tracing',
'unstable_instrumentations={[tracing.clientInstrumentation]}',
]);
});

test('entry.server file contains Sentry server hooks', () => {
checkFileContents(`${projectDir}/app/entry.server.tsx`, [
'import * as Sentry from',
'@sentry/react-router',
'Sentry.wrapSentryHandleRequest(',
'export const handleError = Sentry.createSentryHandleError(',
'export const unstable_instrumentations = [Sentry.createSentryServerInstrumentation()]',
]);
});

test('instrument.server file exists', () => {
checkFileExists(`${projectDir}/instrument.server.mjs`);
});

test('example page exists', () => {
checkFileExists(`${projectDir}/app/routes/sentry-example-page.tsx`);
});

test('builds successfully', async () => {
await checkIfBuilds(projectDir);
}, 60_000);

test('runs on dev mode correctly', async () => {
await checkIfRunsOnDevMode(projectDir, 'to expose');
}, 30_000);

test('runs on prod mode correctly', async () => {
await checkIfRunsOnProdMode(projectDir, 'react-router-serve');
}, 30_000);
});
});
4 changes: 3 additions & 1 deletion e2e-tests/tests/react-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ async function runWizardOnReactRouterProject(
.respondWith(KEYS.ENTER)
.whenAsked('Do you want to enable Profiling')
.respondWith(KEYS.ENTER)
.whenAsked('Do you want to use the Instrumentation API')
.respondWith(KEYS.DOWN, KEYS.ENTER) // No
.expectOutput('Installing @sentry/profiling-node')
.expectOutput('Installed @sentry/profiling-node', {
timeout: 240_000,
Expand Down Expand Up @@ -136,7 +138,7 @@ describe('React Router', () => {

test('package.json scripts are updated correctly', () => {
checkFileContents(`${projectDir}/package.json`, [
`"start": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js"`,
`"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js"`,
`"dev": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router dev"`,
]);
});
Expand Down
121 changes: 108 additions & 13 deletions src/react-router/codemods/client.entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function instrumentClientEntry(
enableTracing: boolean,
enableReplay: boolean,
enableLogs: boolean,
useInstrumentationAPI = false,
): Promise<void> {
const clientEntryAst = await loadFile(clientEntryPath);

Expand All @@ -35,36 +36,130 @@ export async function instrumentClientEntry(
local: 'Sentry',
});

const integrations = [];
if (enableTracing) {
integrations.push('Sentry.reactRouterTracingIntegration()');
}
if (enableReplay) {
integrations.push('Sentry.replayIntegration()');
}
let initContent: string;

if (useInstrumentationAPI && enableTracing) {
const integrations = ['tracing'];
if (enableReplay) {
integrations.push('Sentry.replayIntegration()');
}

initContent = `
const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true });

const initContent = `
Sentry.init({
dsn: "${dsn}",
sendDefaultPii: true,
integrations: [${integrations.join(', ')}],
${enableLogs ? 'enableLogs: true,' : ''}
tracesSampleRate: ${enableTracing ? '1.0' : '0'},${
enableTracing
? '\n tracePropagationTargets: [/^\\//, /^https:\\/\\/yourserver\\.io\\/api/],'
: ''
}${
tracesSampleRate: 1.0,
tracePropagationTargets: [/^\\//, /^https:\\/\\/yourserver\\.io\\/api/],${
enableReplay
? '\n replaysSessionSampleRate: 0.1,\n replaysOnErrorSampleRate: 1.0,'
: ''
}
});`;
} else {
const integrations = [];
if (enableTracing) {
integrations.push('Sentry.reactRouterTracingIntegration()');
}
if (enableReplay) {
integrations.push('Sentry.replayIntegration()');
}

initContent = `
Sentry.init({
dsn: "${dsn}",
sendDefaultPii: true,
integrations: [${integrations.join(', ')}],
${enableLogs ? 'enableLogs: true,' : ''}
tracesSampleRate: ${enableTracing ? '1.0' : '0'},${
enableTracing
? '\n tracePropagationTargets: [/^\\//, /^https:\\/\\/yourserver\\.io\\/api/],'
: ''
}${
enableReplay
? '\n replaysSessionSampleRate: 0.1,\n replaysOnErrorSampleRate: 1.0,'
: ''
}
});`;
}

(clientEntryAst.$ast as t.Program).body.splice(
getAfterImportsInsertionIndex(clientEntryAst.$ast as t.Program),
0,
...recast.parse(initContent).program.body,
);

if (useInstrumentationAPI && enableTracing) {
const hydratedRouterFound = addInstrumentationPropsToHydratedRouter(
clientEntryAst.$ast as t.Program,
);

if (!hydratedRouterFound) {
clack.log.warn(
`Could not find ${chalk.cyan(
'HydratedRouter',
)} component in your client entry file.\n` +
`To use the Instrumentation API, manually add the ${chalk.cyan(
'unstable_instrumentations',
)} prop:\n` +
` ${chalk.green(
'<HydratedRouter unstable_instrumentations={[tracing.clientInstrumentation]} />',
)}`,
);
}
}

await writeFile(clientEntryAst.$ast, clientEntryPath);
}

function addInstrumentationPropsToHydratedRouter(ast: t.Program): boolean {
let found = false;

recast.visit(ast, {
visitJSXElement(path) {
const openingElement = path.node.openingElement;

if (
openingElement.name.type === 'JSXIdentifier' &&
openingElement.name.name === 'HydratedRouter'
) {
found = true;

const hasInstrumentationsProp = openingElement.attributes?.some(
(attr) =>
attr.type === 'JSXAttribute' &&
attr.name.type === 'JSXIdentifier' &&
attr.name.name === 'unstable_instrumentations',
);

if (!hasInstrumentationsProp) {
const instrumentationsProp = recast.types.builders.jsxAttribute(
recast.types.builders.jsxIdentifier('unstable_instrumentations'),
recast.types.builders.jsxExpressionContainer(
recast.types.builders.arrayExpression([
recast.types.builders.memberExpression(
recast.types.builders.identifier('tracing'),
recast.types.builders.identifier('clientInstrumentation'),
),
]),
),
);

if (!openingElement.attributes) {
openingElement.attributes = [];
}
openingElement.attributes.push(instrumentationsProp);
}

return false;
}

this.traverse(path);
},
});

return found;
}
5 changes: 3 additions & 2 deletions src/react-router/codemods/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ function hasCaptureExceptionCall(node: t.Node): boolean {
}

function addCaptureExceptionCall(functionNode: t.Node): void {
const captureExceptionCall = recast.parse(`Sentry.captureException(error);`)
.program.body[0];
const captureExceptionCall = recast.parse(
`if (error && error instanceof Error) {\n Sentry.captureException(error);\n}`,
).program.body[0];

const functionBody = safeGetFunctionBody(functionNode);
if (functionBody) {
Expand Down
Loading
Loading