@sentry/wizard
Version:
Sentry wizard helping you to configure your project
455 lines (372 loc) • 12.3 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { Program } from '@babel/types';
// @ts-expect-error - magicast is ESM and TS complains about that. It works though
import type { ProxifiedModule } from 'magicast';
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import * as childProcess from 'child_process';
// @ts-expect-error - clack is ESM and TS complains about that. It works though
import clack from '@clack/prompts';
import chalk from 'chalk';
import { gte, minVersion } from 'semver';
import {
builders,
generateCode,
loadFile,
parseModule,
writeFile,
// @ts-expect-error - magicast is ESM and TS complains about that. It works though
} from 'magicast';
import type { PackageDotJson } from '../utils/package-json';
import { getPackageVersion } from '../utils/package-json';
import {
getAfterImportsInsertionIndex,
hasSentryContent,
serverHasInstrumentationImport,
} from './utils';
import { instrumentRootRouteV1 } from './codemods/root-v1';
import { instrumentRootRouteV2 } from './codemods/root-v2';
import { instrumentHandleError } from './codemods/handle-error';
import { getPackageDotJson } from '../utils/clack-utils';
import { findCustomExpressServerImplementation } from './codemods/express-server';
export type PartialRemixConfig = {
unstable_dev?: boolean;
future?: {
v2_dev?: boolean;
v2_errorBoundary?: boolean;
v2_headers?: boolean;
v2_meta?: boolean;
v2_normalizeFormMethod?: boolean;
v2_routeConvention?: boolean;
};
};
const REMIX_CONFIG_FILE = 'remix.config.js';
const REMIX_REVEAL_COMMAND = 'npx remix reveal';
export function runRemixReveal(isTS: boolean): void {
// Check if entry files already exist
const clientEntryFilename = `entry.client.${isTS ? 'tsx' : 'jsx'}`;
const serverEntryFilename = `entry.server.${isTS ? 'tsx' : 'jsx'}`;
const clientEntryPath = path.join(process.cwd(), 'app', clientEntryFilename);
const serverEntryPath = path.join(process.cwd(), 'app', serverEntryFilename);
if (fs.existsSync(clientEntryPath) && fs.existsSync(serverEntryPath)) {
clack.log.info(
`Found entry files ${chalk.cyan(clientEntryFilename)} and ${chalk.cyan(
serverEntryFilename,
)}.`,
);
} else {
clack.log.info(
`Couldn't find entry files in your project. Trying to run ${chalk.cyan(
REMIX_REVEAL_COMMAND,
)}...`,
);
clack.log.info(childProcess.execSync(REMIX_REVEAL_COMMAND).toString());
}
}
function insertClientInitCall(
dsn: string,
originalHooksMod: ProxifiedModule<any>,
): void {
const initCall = builders.functionCall('Sentry.init', {
dsn,
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
builders.functionCall(
'Sentry.browserTracingIntegration',
builders.raw('{ useEffect, useLocation, useMatches }'),
),
builders.functionCall('Sentry.replayIntegration'),
],
});
const originalHooksModAST = originalHooksMod.$ast as Program;
const initCallInsertionIndex =
getAfterImportsInsertionIndex(originalHooksModAST);
originalHooksModAST.body.splice(
initCallInsertionIndex,
0,
// @ts-expect-error - string works here because the AST is proxified by magicast
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
generateCode(initCall).code,
);
}
export async function createServerInstrumentationFile(dsn: string) {
// create an empty file named `instrument.server.mjs`
const instrumentationFile = 'instrumentation.server.mjs';
const instrumentationFileMod = parseModule('');
instrumentationFileMod.imports.$add({
from: '@sentry/remix',
imported: '*',
local: 'Sentry',
});
const initCall = builders.functionCall('Sentry.init', {
dsn,
tracesSampleRate: 1.0,
autoInstrumentRemix: true,
});
const instrumentationFileModAST = instrumentationFileMod.$ast as Program;
const initCallInsertionIndex = getAfterImportsInsertionIndex(
instrumentationFileModAST,
);
instrumentationFileModAST.body.splice(
initCallInsertionIndex,
0,
// @ts-expect-error - string works here because the AST is proxified by magicast
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
generateCode(initCall).code,
);
await writeFile(instrumentationFileModAST, instrumentationFile);
return instrumentationFile;
}
export async function insertServerInstrumentationFile(dsn: string) {
const instrumentationFile = await createServerInstrumentationFile(dsn);
const expressServerPath = await findCustomExpressServerImplementation();
if (!expressServerPath) {
return false;
}
const originalExpressServerMod = await loadFile(expressServerPath);
if (
serverHasInstrumentationImport(
expressServerPath,
originalExpressServerMod.$code,
)
) {
clack.log.warn(
`File ${chalk.cyan(
path.basename(expressServerPath),
)} already contains instrumentation import.
Skipping adding instrumentation functionality to ${chalk.cyan(
path.basename(expressServerPath),
)}.`,
);
return true;
}
originalExpressServerMod.$code = `import './${instrumentationFile}';\n${originalExpressServerMod.$code}`;
fs.writeFileSync(expressServerPath, originalExpressServerMod.$code);
return true;
}
export function isRemixV2(
remixConfig: PartialRemixConfig,
packageJson: PackageDotJson,
): boolean {
const remixVersion = getPackageVersion('@remix-run/react', packageJson);
if (!remixVersion) {
return false;
}
const minVer = minVersion(remixVersion);
if (!minVer) {
return false;
}
const isV2Remix = gte(minVer, '2.0.0');
return isV2Remix || remixConfig?.future?.v2_errorBoundary || false;
}
export async function loadRemixConfig(): Promise<PartialRemixConfig> {
const configFilePath = path.join(process.cwd(), REMIX_CONFIG_FILE);
try {
if (!fs.existsSync(configFilePath)) {
return {};
}
const configUrl = url.pathToFileURL(configFilePath).href;
const remixConfigModule = (await import(configUrl)) as {
default: PartialRemixConfig;
};
return remixConfigModule?.default || {};
} catch (e: unknown) {
clack.log.error(`Couldn't load ${REMIX_CONFIG_FILE}.`);
clack.log.info(
chalk.dim(
typeof e === 'object' && e != null && 'toString' in e
? e.toString()
: typeof e === 'string'
? e
: 'Unknown error',
),
);
return {};
}
}
export async function instrumentRootRoute(
isV2?: boolean,
isTS?: boolean,
): Promise<void> {
const rootFilename = `root.${isTS ? 'tsx' : 'jsx'}`;
if (isV2) {
await instrumentRootRouteV2(rootFilename);
} else {
await instrumentRootRouteV1(rootFilename);
}
clack.log.success(
`Successfully instrumented root route ${chalk.cyan(rootFilename)}.`,
);
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
}
export async function updateBuildScript(args: {
org: string;
project: string;
url?: string;
isHydrogen: boolean;
}): Promise<void> {
const packageJson = await getPackageDotJson();
if (!packageJson.scripts) {
packageJson.scripts = {};
}
const buildCommand = args.isHydrogen
? 'shopify hydrogen build'
: 'remix build';
const instrumentedBuildCommand =
`${buildCommand} --sourcemap && sentry-upload-sourcemaps --org ${args.org} --project ${args.project}` +
(args.url ? ` --url ${args.url}` : '') +
(args.isHydrogen ? ' --buildPath ./dist' : '');
if (!packageJson.scripts.build) {
packageJson.scripts.build = instrumentedBuildCommand;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
} else if (packageJson.scripts.build.includes(buildCommand)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
packageJson.scripts.build = packageJson.scripts.build.replace(
buildCommand,
instrumentedBuildCommand,
);
} else {
throw new Error(
"`build` script doesn't contain a known build command. Please update it manually.",
);
}
await fs.promises.writeFile(
path.join(process.cwd(), 'package.json'),
JSON.stringify(packageJson, null, 2),
);
clack.log.success(
`Successfully updated ${chalk.cyan('build')} script in ${chalk.cyan(
'package.json',
)} to generate and upload sourcemaps.`,
);
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
}
export async function initializeSentryOnEntryClient(
dsn: string,
isTS: boolean,
): Promise<void> {
const clientEntryFilename = `entry.client.${isTS ? 'tsx' : 'jsx'}`;
const originalEntryClient = path.join(
process.cwd(),
'app',
clientEntryFilename,
);
const originalEntryClientMod = await loadFile(originalEntryClient);
if (hasSentryContent(originalEntryClient, originalEntryClientMod.$code)) {
return;
}
originalEntryClientMod.imports.$add({
from: '@sentry/remix',
imported: '*',
local: 'Sentry',
});
originalEntryClientMod.imports.$add({
from: 'react',
imported: 'useEffect',
local: 'useEffect',
});
originalEntryClientMod.imports.$add({
from: '@remix-run/react',
imported: 'useLocation',
local: 'useLocation',
});
originalEntryClientMod.imports.$add({
from: '@remix-run/react',
imported: 'useMatches',
local: 'useMatches',
});
insertClientInitCall(dsn, originalEntryClientMod);
await writeFile(
originalEntryClientMod.$ast,
path.join(process.cwd(), 'app', clientEntryFilename),
);
clack.log.success(
`Successfully initialized Sentry on client entry point ${chalk.cyan(
clientEntryFilename,
)}`,
);
}
export async function updateStartScript(instrumentationFile: string) {
const packageJson = await getPackageDotJson();
if (!packageJson.scripts || !packageJson.scripts.start) {
throw new Error(
"Couldn't find a `start` script in your package.json. Please add one manually.",
);
}
if (packageJson.scripts.start.includes('NODE_OPTIONS')) {
clack.log.warn(
`Found existing NODE_OPTIONS in ${chalk.cyan(
'start',
)} script. Skipping adding Sentry initialization.`,
);
return;
}
if (
!packageJson.scripts.start.includes('remix-serve') &&
// Adding a following empty space not to match a path that includes `node`
!packageJson.scripts.start.includes('node ')
) {
clack.log.warn(
`Found a ${chalk.cyan('start')} script that doesn't use ${chalk.cyan(
'remix-serve',
)} or ${chalk.cyan('node')}. Skipping adding Sentry initialization.`,
);
return;
}
const startCommand = packageJson.scripts.start;
packageJson.scripts.start = `NODE_OPTIONS='--import ./${instrumentationFile}' ${startCommand}`;
await fs.promises.writeFile(
path.join(process.cwd(), 'package.json'),
JSON.stringify(packageJson, null, 2),
);
clack.log.success(
`Successfully updated ${chalk.cyan('start')} script in ${chalk.cyan(
'package.json',
)} to include Sentry initialization on start.`,
);
}
export async function instrumentSentryOnEntryServer(
isV2: boolean,
isTS: boolean,
): Promise<void> {
const serverEntryFilename = `entry.server.${isTS ? 'tsx' : 'jsx'}`;
const originalEntryServer = path.join(
process.cwd(),
'app',
serverEntryFilename,
);
const originalEntryServerMod = await loadFile(originalEntryServer);
if (hasSentryContent(originalEntryServer, originalEntryServerMod.$code)) {
return;
}
originalEntryServerMod.imports.$add({
from: '@sentry/remix',
imported: '*',
local: 'Sentry',
});
if (isV2) {
const handleErrorInstrumented = instrumentHandleError(
originalEntryServerMod,
serverEntryFilename,
);
if (handleErrorInstrumented) {
clack.log.success(
`Instrumented ${chalk.cyan('handleError')} in ${chalk.cyan(
`${serverEntryFilename}`,
)}`,
);
}
}
await writeFile(
originalEntryServerMod.$ast,
path.join(process.cwd(), 'app', serverEntryFilename),
);
clack.log.success(
`Successfully initialized Sentry on server entry point ${chalk.cyan(
serverEntryFilename,
)}.`,
);
}