UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

250 lines 10.7 kB
import { reportAnalyticsEvent } from './analytics.js'; import * as path from './path.js'; import { fanoutHooks } from './plugins.js'; import * as metadata from './metadata.js'; import { AbortSilentError, CancelExecution, errorMapper, shouldReportErrorAsUnexpected, handler, cleanSingleStackTracePath, } from './error.js'; import { getEnvironmentData } from '../../private/node/analytics.js'; import { outputDebug, outputInfo } from '../../public/node/output.js'; import { bugsnagApiKey, reportingRateLimit } from '../../private/node/constants.js'; import { CLI_KIT_VERSION } from '../common/version.js'; import { runWithRateLimit } from '../../private/node/conf-store.js'; import { settings } from '@oclif/core'; import StackTracey from 'stacktracey'; import Bugsnag from '@bugsnag/js'; import { realpath } from 'fs/promises'; export async function errorHandler(error, config) { if (error instanceof CancelExecution) { if (error.message && error.message !== '') { outputInfo(`✨ ${error.message}`); } } else if (error instanceof AbortSilentError) { /* empty */ } else { return errorMapper(error) .then((error) => { return handler(error); }) .then((mappedError) => { return reportError(mappedError, config); }); } } const reportError = async (error, config) => { // categorise the error first let exitMode = 'expected_error'; if (shouldReportErrorAsUnexpected(error)) exitMode = 'unexpected_error'; if (config !== undefined) { // Log an analytics event when there's an error await reportAnalyticsEvent({ config, errorMessage: error instanceof Error ? error.message : undefined, exitMode }); } await sendErrorToBugsnag(error, exitMode); }; /** * Sends an error to Bugsnag. This is configured automatically for uncaught errors from CLI commands, but can also be used to manually record an error. * * @returns the reported error (this may have been tweaked for better reporting), and a bool to indicate if the error was actually submitted or not */ export async function sendErrorToBugsnag(error, exitMode) { try { if (settings.debug) { outputDebug(`Skipping Bugsnag report`); return { reported: false, error, unhandled: undefined }; } // If the error was unexpected, we flag it as "unhandled" in Bugsnag. This is a helpful distinction. const unhandled = exitMode === 'unexpected_error'; let reportableError; let stacktrace; let report = false; if (error instanceof Error) { report = true; reportableError = new Error(error.message); stacktrace = error.stack; /** * Some errors that reach this point have an empty string. For example: * https://app.bugsnag.com/shopify/cli/errors/62cd5d31fd5040000814086c?filters[event.since]=30d&filters[error.status]=new&filters[release.seen_in]=3.1.0 * * Because at this point we have neither the error message nor a stack trace reporting them * to Bugsnag is pointless and adds noise. */ } else if (typeof error === 'string' && error.trim().length !== 0) { report = true; reportableError = new Error(error); stacktrace = reportableError.stack; } else { report = false; reportableError = new Error('Unknown error'); } const formattedStacktrace = new StackTracey(stacktrace ?? '') .clean() .items.map((item) => { const filePath = cleanSingleStackTracePath(item.file); return ` at ${item.callee} (${filePath}:${item.line}:${item.column})`; }) .join('\n'); reportableError.stack = `Error: ${reportableError.message}\n${formattedStacktrace}`; let withinRateLimit = false; await runWithRateLimit({ key: 'send-error-to-bugsnag', ...reportingRateLimit, task: async () => { withinRateLimit = true; }, }); if (!withinRateLimit) { outputDebug(`Skipping Bugsnag report due to rate limiting`); report = false; } if (report) { initializeBugsnag(); await new Promise((resolve, reject) => { outputDebug(`Reporting ${unhandled ? 'unhandled' : 'handled'} error to Bugsnag: ${reportableError.message}`); const eventHandler = (event) => { event.severity = 'error'; event.unhandled = unhandled; }; const errorHandler = (error) => { if (error) { reject(error); } else { resolve(reportableError); } }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Bugsnag.notify(reportableError, eventHandler, errorHandler); }); } return { error: reportableError, reported: report, unhandled }; // eslint-disable-next-line no-catch-all/no-catch-all } catch (err) { outputDebug(`Error reporting to Bugsnag: ${err}`); return { error, reported: false, unhandled: undefined }; } } /** * If the given file path is within a node_modules folder, remove prefix up * to and including the node_modules folder. * * This gives us very consistent paths for errors generated by the CLI. */ export function cleanStackFrameFilePath({ currentFilePath, projectRoot, pluginLocations, }) { const fullLocation = path.isAbsolutePath(currentFilePath) ? currentFilePath : path.joinPath(projectRoot, currentFilePath); const matchingPluginPath = pluginLocations.filter(({ pluginPath }) => fullLocation.startsWith(pluginPath))[0]; if (matchingPluginPath !== undefined) { // the plugin name (e.g. @shopify/cli-kit), plus the relative path of the error line from within the plugin's code (e.g. dist/something.js ) return path.joinPath(matchingPluginPath.name, path.relativePath(matchingPluginPath.pluginPath, fullLocation)); } // strip prefix up to node_modules folder, so we can normalize error reporting return currentFilePath.replace(/.*node_modules\//, ''); } /** * Register a Bugsnag error listener to clean up stack traces for errors within plugin code. * */ export async function registerCleanBugsnagErrorsFromWithinPlugins(config) { // Bugsnag have their own plug-ins that use this private field // eslint-disable-next-line @typescript-eslint/no-explicit-any const bugsnagConfigProjectRoot = Bugsnag?._client?._config?.projectRoot ?? path.cwd(); const projectRoot = path.normalizePath(bugsnagConfigProjectRoot); const pluginLocations = await Promise.all([...config.plugins].map(async ([_, plugin]) => { const followSymlinks = await realpath(plugin.root); return { name: plugin.name, pluginPath: path.normalizePath(followSymlinks) }; })); initializeBugsnag(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Bugsnag.addOnError(async (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any event.errors.forEach((error) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any error.stacktrace.forEach((stackFrame) => { stackFrame.file = cleanStackFrameFilePath({ currentFilePath: stackFrame.file, projectRoot, pluginLocations }); }); }); try { await addBugsnagMetadata(event, config); // eslint-disable-next-line no-catch-all/no-catch-all } catch (metadataError) { outputDebug(`There was an error adding metadata to the Bugsnag report; Ignoring and carrying on ${metadataError}`); } }); } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any export async function addBugsnagMetadata(event, config) { const publicData = metadata.getAllPublicMetadata(); const { commandStartOptions } = metadata.getAllSensitiveMetadata(); const { startCommand } = commandStartOptions ?? {}; const { '@shopify/app': appPublic, ...otherPluginsPublic } = await fanoutHooks(config, 'public_command_metadata', {}); const environment = await getEnvironmentData(config); const allMetadata = { command: startCommand, ...appPublic, ...publicData, ...environment, pluginData: otherPluginsPublic, }; const appData = {}; const commandData = {}; const environmentData = {}; const miscData = {}; const appKeys = ['api_key', 'business_platform_id', 'partner_id', 'project_type']; const commandKeys = ['command']; const environmentKeys = ['cli_version', 'node_version', 'uname']; Object.entries(allMetadata).forEach(([key, value]) => { if (key.startsWith('app_') || appKeys.includes(key)) { appData[key] = value; } else if (key.startsWith('cmd_') || commandKeys.includes(key)) { commandData[key] = value; } else if (key.startsWith('env_') || environmentKeys) { environmentData[key] = value; } else { miscData[key] = value; } }); // app, command, environment, misc const bugsnagMetadata = { 'Shopify App': appData, Command: commandData, Environment: environmentData, Misc: miscData, }; Object.entries(bugsnagMetadata).forEach(([section, values]) => { event.addMetadata(section, values); }); } function initializeBugsnag() { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (Bugsnag.isStarted()) { return; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Bugsnag.start({ appType: 'node', apiKey: bugsnagApiKey, logger: null, appVersion: CLI_KIT_VERSION, autoTrackSessions: false, autoDetectErrors: false, enabledReleaseStages: ['production'], endpoints: { notify: 'https://error-analytics-production.shopifysvc.com', sessions: 'https://error-analytics-sessions-production.shopifysvc.com', }, }); } //# sourceMappingURL=error-handler.js.map