import * as Sentry from '@sentry/browser'; import { Dedupe, ExtraErrorData } from '@sentry/integrations'; import { AllProperties } from '../../../shared/modules/object.utils'; import { FilterEvents } from './sentry-filter-events'; import extractEthjsErrorMessage from './extractEthjsErrorMessage'; /* eslint-disable prefer-destructuring */ // Destructuring breaks the inlining of the environment variables const METAMASK_DEBUG = process.env.METAMASK_DEBUG; const METAMASK_ENVIRONMENT = process.env.METAMASK_ENVIRONMENT; const SENTRY_DSN_DEV = process.env.SENTRY_DSN_DEV || 'https://f59f3dd640d2429d9d0e2445a87ea8e1@sentry.io/273496'; const METAMASK_BUILD_TYPE = process.env.METAMASK_BUILD_TYPE; const IN_TEST = process.env.IN_TEST; /* eslint-enable prefer-destructuring */ export const ERROR_URL_ALLOWLIST = { CRYPTOCOMPARE: 'cryptocompare.com', COINGECKO: 'coingecko.com', ETHERSCAN: 'etherscan.io', CODEFI: 'codefi.network', SEGMENT: 'segment.io', }; // This describes the subset of background controller state attached to errors // sent to Sentry These properties have some potential to be useful for // debugging, and they do not contain any identifiable information. export const SENTRY_BACKGROUND_STATE = { AccountTracker: { accounts: false, currentBlockGasLimit: true, }, AddressBookController: { addressBook: false, }, AlertController: { alertEnabledness: true, unconnectedAccountAlertShownOrigins: false, web3ShimUsageOrigins: false, }, AnnouncementController: { announcements: false, }, AppMetadataController: { currentAppVersion: true, currentMigrationVersion: true, previousAppVersion: true, previousMigrationVersion: true, }, ApprovalController: { approvalFlows: false, pendingApprovals: false, pendingApprovalCount: false, }, AppStateController: { browserEnvironment: true, connectedStatusPopoverHasBeenShown: true, currentPopupId: false, defaultHomeActiveTabName: true, fullScreenGasPollTokens: true, hadAdvancedGasFeesSetPriorToMigration92_3: true, nftsDetectionNoticeDismissed: true, nftsDropdownState: true, notificationGasPollTokens: true, outdatedBrowserWarningLastShown: true, popupGasPollTokens: true, qrHardware: true, recoveryPhraseReminderHasBeenShown: true, recoveryPhraseReminderLastShown: true, serviceWorkerLastActiveTime: true, showBetaHeader: true, showProductTour: true, showTestnetMessageInDropdown: true, snapsInstallPrivacyWarningShown: true, termsOfUseLastAgreed: true, timeoutMinutes: true, trezorModel: true, usedNetworks: true, }, CachedBalancesController: { cachedBalances: false, }, CronjobController: { jobs: false, }, CurrencyController: { conversionDate: true, conversionRate: true, currentCurrency: true, nativeCurrency: true, pendingCurrentCurrency: true, pendingNativeCurrency: true, usdConversionRate: true, }, DecryptMessageController: { unapprovedDecryptMsgs: false, unapprovedDecryptMsgCount: true, }, EncryptionPublicKeyController: { unapprovedEncryptionPublicKeyMsgs: false, unapprovedEncryptionPublicKeyMsgCount: true, }, EnsController: { ensResolutionsByAddress: false, }, GasFeeController: { estimatedGasFeeTimeBounds: true, gasEstimateType: true, gasFeeEstimates: true, }, IncomingTransactionsController: { incomingTransactions: false, incomingTxLastFetchedBlockByChainId: true, }, KeyringController: { encryptionKey: false, isUnlocked: true, keyrings: false, keyringTypes: false, }, MetaMetricsController: { eventsBeforeMetricsOptIn: false, fragments: false, metaMetricsId: true, participateInMetaMetrics: true, previousUserTraits: false, segmentApiCalls: false, traits: false, }, NetworkController: { networkConfigurations: false, networkDetails: false, networkId: true, networkStatus: true, providerConfig: { chainId: true, id: true, nickname: true, rpcPrefs: false, rpcUrl: false, ticker: true, type: true, }, }, NftController: { allNftContracts: false, allNfts: false, ignoredNfts: false, }, NotificationController: { notifications: false, }, OnboardingController: { completedOnboarding: true, firstTimeFlowType: true, onboardingTabs: false, seedPhraseBackedUp: true, }, PermissionController: { subjects: false, }, PermissionLogController: { permissionActivityLog: false, permissionHistory: false, }, PhishingController: {}, PreferencesController: { advancedGasFee: true, currentLocale: true, disabledRpcMethodPreferences: true, dismissSeedBackUpReminder: true, featureFlags: true, forgottenPassword: true, identities: false, infuraBlocked: true, ipfsGateway: false, isLineaMainnetReleased: true, knownMethodData: false, ledgerTransportType: true, lostIdentities: false, openSeaEnabled: true, preferences: { autoLockTimeLimit: true, hideZeroBalanceTokens: true, showFiatInTestnets: true, showTestNetworks: true, useNativeCurrencyAsPrimaryCurrency: true, }, selectedAddress: false, snapRegistryList: false, theme: true, transactionSecurityCheckEnabled: true, useBlockie: true, useCurrencyRateCheck: true, useMultiAccountBalanceChecker: true, useNftDetection: true, useNonceField: true, usePhishDetect: true, useTokenDetection: true, }, SignatureController: { unapprovedMsgCount: true, unapprovedMsgs: false, unapprovedPersonalMsgCount: true, unapprovedPersonalMsgs: false, unapprovedTypedMessages: false, unapprovedTypedMessagesCount: true, }, SmartTransactionsController: { smartTransactionsState: { fees: { approvalTxFees: true, tradeTxFees: true, }, liveness: true, smartTransactions: false, userOptIn: true, }, }, SnapController: { snapErrors: false, snapStates: false, snaps: false, }, SnapsRegistry: { database: false, lastUpdated: false, }, SubjectMetadataController: { subjectMetadata: false, }, SwapsController: { swapsState: { approveTxId: false, customApproveTxData: false, customGasPrice: true, customMaxFeePerGas: true, customMaxGas: true, customMaxPriorityFeePerGas: true, errorKey: true, fetchParams: true, quotes: false, quotesLastFetched: true, quotesPollingLimitEnabled: true, routeState: true, saveFetchedQuotes: true, selectedAggId: true, swapsFeatureFlags: true, swapsFeatureIsLive: true, swapsQuotePrefetchingRefreshTime: true, swapsQuoteRefreshTime: true, swapsStxBatchStatusRefreshTime: true, swapsStxGetTransactionsRefreshTime: true, swapsStxMaxFeeMultiplier: true, swapsUserFeeLevel: true, tokens: false, topAggId: false, tradeTxId: false, }, }, TokenListController: { preventPollingOnNetworkRestart: true, tokenList: false, tokensChainsCache: { [AllProperties]: false, }, }, TokenRatesController: { contractExchangeRates: false, }, TokensController: { allDetectedTokens: { [AllProperties]: false, }, allIgnoredTokens: { [AllProperties]: false, }, allTokens: { [AllProperties]: false, }, detectedTokens: false, ignoredTokens: false, tokens: false, }, TransactionController: { currentNetworkTxList: false, lastFetchedBlockNumbers: false, }, TxController: { currentNetworkTxList: false, unapprovedTxs: false, }, }; const flattenedBackgroundStateMask = Object.values( SENTRY_BACKGROUND_STATE, ).reduce((partialBackgroundState, controllerState) => { return { ...partialBackgroundState, ...controllerState, }; }, {}); // This describes the subset of Redux state attached to errors sent to Sentry // These properties have some potential to be useful for debugging, and they do // not contain any identifiable information. export const SENTRY_UI_STATE = { gas: true, history: true, metamask: { ...flattenedBackgroundStateMask, // This property comes from the background but isn't in controller state isInitialized: true, // These properties are in the `metamask` slice but not in the background state customNonceValue: true, isAccountMenuOpen: true, isNetworkMenuOpen: true, nextNonce: true, pendingTokens: false, welcomeScreenSeen: true, }, unconnectedAccount: true, }; /** * Returns whether MetaMetrics is enabled, given the application state. * * @param {{ state: unknown} | { persistedState: unknown }} appState - Application state * @returns `true` if MetaMask's state has been initialized, and MetaMetrics * is enabled, `false` otherwise. */ function getMetaMetricsEnabledFromAppState(appState) { // during initialization after loading persisted state if (appState.persistedState) { return getMetaMetricsEnabledFromPersistedState(appState.persistedState); // After initialization } else if (appState.state) { // UI if (appState.state.metamask) { return Boolean(appState.state.metamask.participateInMetaMetrics); } // background return Boolean( appState.state.MetaMetricsController?.participateInMetaMetrics, ); } // during initialization, before first persisted state is read return false; } /** * Returns whether MetaMetrics is enabled, given the persisted state. * * @param {unknown} persistedState - Application state * @returns `true` if MetaMask's state has been initialized, and MetaMetrics * is enabled, `false` otherwise. */ function getMetaMetricsEnabledFromPersistedState(persistedState) { return Boolean( persistedState?.data?.MetaMetricsController?.participateInMetaMetrics, ); } /** * Returns whether onboarding has completed, given the application state. * * @param {Record} appState - Application state * @returns `true` if MetaMask's state has been initialized, and MetaMetrics * is enabled, `false` otherwise. */ function getOnboardingCompleteFromAppState(appState) { // during initialization after loading persisted state if (appState.persistedState) { return Boolean( appState.persistedState.data?.OnboardingController?.completedOnboarding, ); // After initialization } else if (appState.state) { // UI if (appState.state.metamask) { return Boolean(appState.state.metamask.completedOnboarding); } // background return Boolean(appState.state.OnboardingController?.completedOnboarding); } // during initialization, before first persisted state is read return false; } export default function setupSentry({ release, getState }) { if (!release) { throw new Error('Missing release'); } else if (METAMASK_DEBUG && !IN_TEST) { /** * Workaround until the following issue is resolved * https://github.com/MetaMask/metamask-extension/issues/15691 * The IN_TEST condition allows the e2e tests to run with both * yarn start:test and yarn build:test */ return undefined; } const environment = METAMASK_BUILD_TYPE === 'main' ? METAMASK_ENVIRONMENT : `${METAMASK_ENVIRONMENT}-${METAMASK_BUILD_TYPE}`; let sentryTarget; if (METAMASK_ENVIRONMENT === 'production') { if (!process.env.SENTRY_DSN) { throw new Error( `Missing SENTRY_DSN environment variable in production environment`, ); } console.log( `Setting up Sentry Remote Error Reporting for '${environment}': SENTRY_DSN`, ); sentryTarget = process.env.SENTRY_DSN; } else { console.log( `Setting up Sentry Remote Error Reporting for '${environment}': SENTRY_DSN_DEV`, ); sentryTarget = SENTRY_DSN_DEV; } /** * Returns whether MetaMetrics is enabled. If the application hasn't yet * been initialized, the persisted state will be used (if any). * * @returns `true` if MetaMetrics is enabled, `false` otherwise. */ async function getMetaMetricsEnabled() { const appState = getState(); if (appState.state || appState.persistedState) { return getMetaMetricsEnabledFromAppState(appState); } // If we reach here, it means the error was thrown before initialization // completed, and before we loaded the persisted state for the first time. try { const persistedState = await globalThis.stateHooks.getPersistedState(); return getMetaMetricsEnabledFromPersistedState(persistedState); } catch (error) { console.error(error); return false; } } Sentry.init({ dsn: sentryTarget, debug: METAMASK_DEBUG, environment, integrations: [ new FilterEvents({ getMetaMetricsEnabled }), new Dedupe(), new ExtraErrorData(), ], release, beforeSend: (report) => rewriteReport(report, getState), beforeBreadcrumb: beforeBreadcrumb(getState), }); return Sentry; } /** * Receives a string and returns that string if it is a * regex match for a url with a `chrome-extension` or `moz-extension` * protocol, and an empty string otherwise. * * @param {string} url - The URL to check. * @returns {string} An empty string if the URL was internal, or the unmodified URL otherwise. */ function hideUrlIfNotInternal(url) { const re = /^(chrome-extension|moz-extension):\/\//u; if (!url.match(re)) { return ''; } return url; } /** * Returns a method that handles the Sentry breadcrumb using a specific method to get the extension state * * @param {Function} getState - A method that returns the state of the extension * @returns {(breadcrumb: object) => object} A method that modifies a Sentry breadcrumb object */ export function beforeBreadcrumb(getState) { return (breadcrumb) => { if (!getState) { return null; } const appState = getState(); if ( !getMetaMetricsEnabledFromAppState(appState) || !getOnboardingCompleteFromAppState(appState) || breadcrumb?.category === 'ui.input' ) { return null; } const newBreadcrumb = removeUrlsFromBreadCrumb(breadcrumb); return newBreadcrumb; }; } /** * Receives a Sentry breadcrumb object and potentially removes urls * from its `data` property, it particular those possibly found at * data.from, data.to and data.url * * @param {object} breadcrumb - A Sentry breadcrumb object: https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ * @returns {object} A modified Sentry breadcrumb object. */ export function removeUrlsFromBreadCrumb(breadcrumb) { if (breadcrumb?.data?.url) { breadcrumb.data.url = hideUrlIfNotInternal(breadcrumb.data.url); } if (breadcrumb?.data?.to) { breadcrumb.data.to = hideUrlIfNotInternal(breadcrumb.data.to); } if (breadcrumb?.data?.from) { breadcrumb.data.from = hideUrlIfNotInternal(breadcrumb.data.from); } return breadcrumb; } /** * Receives a Sentry event object and modifies it before the * error is sent to Sentry. Modifications include both sanitization * of data via helper methods and addition of state data from the * return value of the second parameter passed to the function. * * @param {object} report - A Sentry event object: https://develop.sentry.dev/sdk/event-payloads/ * @param {Function} getState - A function that should return an object representing some amount * of app state that we wish to submit with our error reports * @returns {object} A modified Sentry event object. */ export function rewriteReport(report, getState) { try { // simplify certain complex error messages (e.g. Ethjs) simplifyErrorMessages(report); // remove urls from error message sanitizeUrlsFromErrorMessages(report); // Remove evm addresses from error message. // Note that this is redundent with data scrubbing we do within our sentry dashboard, // but putting the code here as well gives public visibility to how we are handling // privacy with respect to sentry. sanitizeAddressesFromErrorMessages(report); // modify report urls rewriteReportUrls(report); // append app state if (getState) { const appState = getState(); if (!report.extra) { report.extra = {}; } report.extra.appState = appState; } } catch (err) { console.warn(err); } return report; } /** * Receives a Sentry event object and modifies it so that urls are removed from any of its * error messages. * * @param {object} report - the report to modify */ function sanitizeUrlsFromErrorMessages(report) { rewriteErrorMessages(report, (errorMessage) => { let newErrorMessage = errorMessage; const re = /(([-.+a-zA-Z]+:\/\/)|(www\.))\S+[@:.]\S+/gu; const urlsInMessage = newErrorMessage.match(re) || []; urlsInMessage.forEach((url) => { try { const urlObj = new URL(url); const { hostname } = urlObj; if ( !Object.values(ERROR_URL_ALLOWLIST).some( (allowedHostname) => hostname === allowedHostname || hostname.endsWith(`.${allowedHostname}`), ) ) { newErrorMessage = newErrorMessage.replace(url, '**'); } } catch (e) { newErrorMessage = newErrorMessage.replace(url, '**'); } }); return newErrorMessage; }); } /** * Receives a Sentry event object and modifies it so that ethereum addresses are removed from * any of its error messages. * * @param {object} report - the report to modify */ function sanitizeAddressesFromErrorMessages(report) { rewriteErrorMessages(report, (errorMessage) => { const newErrorMessage = errorMessage.replace(/0x[A-Fa-f0-9]{40}/u, '0x**'); return newErrorMessage; }); } function simplifyErrorMessages(report) { rewriteErrorMessages(report, (errorMessage) => { // simplify ethjs error messages let simplifiedErrorMessage = extractEthjsErrorMessage(errorMessage); // simplify 'Transaction Failed: known transaction' if ( simplifiedErrorMessage.indexOf( 'Transaction Failed: known transaction', ) === 0 ) { // cut the hash from the error message simplifiedErrorMessage = 'Transaction Failed: known transaction'; } return simplifiedErrorMessage; }); } function rewriteErrorMessages(report, rewriteFn) { // rewrite top level message if (typeof report.message === 'string') { report.message = rewriteFn(report.message); } // rewrite each exception message if (report.exception && report.exception.values) { report.exception.values.forEach((item) => { if (typeof item.value === 'string') { item.value = rewriteFn(item.value); } }); } } function rewriteReportUrls(report) { if (report.request?.url) { // update request url report.request.url = toMetamaskUrl(report.request.url); } // update exception stack trace if (report.exception && report.exception.values) { report.exception.values.forEach((item) => { if (item.stacktrace) { item.stacktrace.frames.forEach((frame) => { frame.filename = toMetamaskUrl(frame.filename); }); } }); } } function toMetamaskUrl(origUrl) { if (!globalThis.location?.origin) { return origUrl; } const filePath = origUrl?.split(globalThis.location.origin)[1]; if (!filePath) { return origUrl; } const metamaskUrl = `/metamask${filePath}`; return metamaskUrl; }