diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 8433e9399e37..a9797293dddc 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1579,6 +1579,24 @@ "developerOptionsResetStatesOnboarding": { "message": "Resets various states related to onboarding and redirects to the \"Secure Your Wallet\" onboarding page." }, + "developerOptionsSentryButtonGenerateBackgroundError": { + "message": "Generate Background Error" + }, + "developerOptionsSentryButtonGenerateTrace": { + "message": "Generate Trace" + }, + "developerOptionsSentryButtonGenerateUIError": { + "message": "Generate UI Error" + }, + "developerOptionsSentryDescriptionGenerateBackgroundError": { + "message": "Generate an unhandled $1 in the service worker." + }, + "developerOptionsSentryDescriptionGenerateTrace": { + "message": " Generate a $1 Sentry trace." + }, + "developerOptionsSentryDescriptionGenerateUIError": { + "message": "Generate an unhandled $1 in this window." + }, "developerOptionsServiceWorkerKeepAlive": { "message": "Results in a timestamp being continuously saved to session.storage" }, diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 6ed825615af4..fa4dbbd072e9 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -7,11 +7,13 @@ import { filterEvents } from './sentry-filter-events'; const projectLogger = createProjectLogger('sentry'); -const log = createModuleLogger( +export const log = createModuleLogger( projectLogger, globalThis.document ? 'ui' : 'background', ); +const internalLog = createModuleLogger(log, 'internal'); + /* eslint-disable prefer-destructuring */ // Destructuring breaks the inlining of the environment variables const METAMASK_BUILD_TYPE = process.env.METAMASK_BUILD_TYPE; @@ -466,6 +468,7 @@ export default function setupSentry() { return { ...Sentry, + getMetaMetricsEnabled, }; } @@ -482,6 +485,7 @@ function getClientOptions() { integrations: [ Sentry.dedupeIntegration(), Sentry.extraErrorDataIntegration(), + Sentry.browserTracingIntegration(), filterEvents({ getMetaMetricsEnabled, log }), ], release: RELEASE, @@ -492,6 +496,7 @@ function getClientOptions() { // we can safely turn them off by setting the `sendClientReports` option to // `false`. sendClientReports: false, + tracesSampleRate: 0.01, transport: makeTransport, }; } @@ -648,6 +653,7 @@ function setSentryClient() { release, }); + Sentry.registerSpanErrorInstrumentation(); Sentry.init(clientOptions); addDebugListeners(); @@ -872,7 +878,7 @@ function integrateLogging() { for (const loggerType of ['log', 'error']) { logger[loggerType] = (...args) => { const message = args[0].replace(`Sentry Logger [${loggerType}]: `, ''); - log(message, ...args.slice(1)); + internalLog(message, ...args.slice(1)); }; } @@ -887,18 +893,14 @@ function addDebugListeners() { const client = Sentry.getClient(); client?.on('beforeEnvelope', (event) => { - const type = event?.[1]?.[0]?.[0]?.type; - const data = event?.[1]?.[0]?.[1] ?? {}; - - if (type !== 'session' || data.status !== 'exited') { - return; + if (isCompletedSessionEnvelope(event)) { + log('Completed session', event); } - - log('Completed session', data); }); client?.on('afterSendEvent', (event) => { - log('Event', event); + const type = getEventType(event); + log(type, event); }); log('Added debug listeners'); @@ -915,3 +917,22 @@ function makeTransport(options) { return await fetch(...args); }); } + +function isCompletedSessionEnvelope(envelope) { + const type = envelope?.[1]?.[0]?.[0]?.type; + const data = envelope?.[1]?.[0]?.[1] ?? {}; + + return type === 'session' && data.status === 'exited'; +} + +function getEventType(event) { + if (event.type === 'transaction') { + return 'Trace'; + } + + if (event.level === 'error') { + return 'Error'; + } + + return 'Event'; +} diff --git a/development/README.md b/development/README.md index 21422e72b3de..b9247f66aa6f 100644 --- a/development/README.md +++ b/development/README.md @@ -55,39 +55,22 @@ You can inspect the requests in the `Network` tab of your browser's Developer To by filtering for `POST` requests to `/v1/batch`. The full url will be `http://localhost:9090/v1/batch` or `https://api.segment.io/v1/batch` respectively. -## Sentry +## Debugging Sentry -### Debugging in Sentry +1. Set `SENTRY_DSN_DEV`, or `SENTRY_DSN` if using a production build, in `.metamaskrc` to a suitable Sentry URL. + - The example value specified in `.metamaskrc.dist` uses the `test-metamask` project in the MetaMask account. + - Alternatively, create a free Sentry account with a new organization and project. + - The DSN is specified in: `Settings > Projects > [Project Name] > Client Keys (DSN)`. -To debug in a production Sentry environment: +2. To display Sentry logs, include `DEBUG=metamask:sentry:*` in `.metamaskrc`. -- If you have not already got a Sentry account, you can create a free account on [Sentry](https://sentry.io/) -- Create a New Sentry Organization - - If you already have an existing Sentry account and workspace, open the sidebar drop down menu, then click `Switch organization` followed by `Create a new organization` -- Create a New Project -- Copy the `Public Key` and `Project ID` from the Client Keys section under your projects Settings - - Select `Settings` in the sidebar menu, then select `Projects` in the secondary menu. Click your project then select `Client Keys (DSN)` from the secondary menu. Click the `Configure` button on the `Client Keys` page and copy your `Project Id` and `Public Key` -- Add/replace the `SENTRY_DSN` and `SENTRY_DSN_DEV` variables in `.metamaskrc` - ``` - SENTRY_DSN_DEV=https://{SENTRY_PUBLIC_KEY}@sentry.io/{SENTRY_PROJECT_ID} - SENTRY_DSN=https://{SENTRY_PUBLIC_KEY}@sentry.io/{SENTRY_PROJECT_ID} - ``` -- Build the project to the `./dist/` folder with `yarn dist` +3. To display more verbose logs if not in a developer build, include `METAMASK_DEBUG=true` in `.metamaskrc`. -Errors reported whilst using the extension will be displayed in Sentry's `Issues` page. +4. Ensure metrics are enabled during onboarding or via `Settings > Security & privacy > Participate in MetaMetrics`. -To debug in test build we need to comment out the below:-
-- `setupSentry` function comment the return statement in the `app/scripts/lib -/setupSentry.js` https://github.com/MetaMask/metamask-extension/blob/e3c76ca699e94bacfc43793d28291fa5ddf06752/app/scripts/lib/setupSentry.js#L496 -- `setupStateHooks` function set the if condition to true in the `ui -/index.js` https://github.com/MetaMask/metamask-extension/blob/e3c76ca699e94bacfc43793d28291fa5ddf06752/ui/index.js#L242 +5. To test Sentry via the developer options in the UI, include `ENABLE_SETTINGS_PAGE_DEV_OPTIONS=true` in `.metamaskrc`. -How to trigger Sentry error:- -1. Open the background console. -2. Load the extension app and open the developer console. -3. Toggle the `Participate in MetaMetrics` menu option to the `ON` position. -4. Enter `window.stateHooks.throwTestBackgroundError()` into the developer console. -5. There should now be requests sent to sentry in the background network tab. +6. Alternatively, call `window.stateHooks.throwTestError()` or `window.stateHooks.throwTestBackgroundError()` via the UI console. ## Source Maps diff --git a/shared/lib/trace.test.ts b/shared/lib/trace.test.ts new file mode 100644 index 000000000000..a1845cb2fbd7 --- /dev/null +++ b/shared/lib/trace.test.ts @@ -0,0 +1,112 @@ +import { Span, startSpan, withIsolationScope } from '@sentry/browser'; +import { trace } from './trace'; + +jest.mock('@sentry/browser', () => ({ + withIsolationScope: jest.fn(), + startSpan: jest.fn(), +})); + +const NAME_MOCK = 'testTransaction'; +const PARENT_CONTEXT_MOCK = {} as Span; + +const TAGS_MOCK = { + tag1: 'value1', + tag2: true, + tag3: 123, +}; + +const DATA_MOCK = { + data1: 'value1', + data2: true, + data3: 123, +}; + +function mockGetMetaMetricsEnabled(enabled: boolean) { + global.sentry = { + getMetaMetricsEnabled: () => Promise.resolve(enabled), + }; +} + +describe('Trace', () => { + const startSpanMock = jest.mocked(startSpan); + const withIsolationScopeMock = jest.mocked(withIsolationScope); + const setTagsMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + startSpanMock.mockImplementation((_, fn) => fn({} as Span)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + withIsolationScopeMock.mockImplementation((fn: any) => + fn({ setTags: setTagsMock }), + ); + }); + + describe('trace', () => { + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + ['enabled', true], + ['disabled', false], + ])( + 'executes callback if Sentry is %s', + async (_: string, sentryEnabled: boolean) => { + let callbackExecuted = false; + + mockGetMetaMetricsEnabled(sentryEnabled); + + await trace({ name: NAME_MOCK }, async () => { + callbackExecuted = true; + }); + + expect(callbackExecuted).toBe(true); + }, + ); + + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + ['enabled', true], + ['disabled', false], + ])( + 'returns value from callback if Sentry is %s', + async (_: string, sentryEnabled: boolean) => { + mockGetMetaMetricsEnabled(sentryEnabled); + + const result = await trace({ name: NAME_MOCK }, async () => { + return true; + }); + + expect(result).toBe(true); + }, + ); + + it('invokes Sentry if enabled', async () => { + mockGetMetaMetricsEnabled(true); + + await trace( + { + name: NAME_MOCK, + tags: TAGS_MOCK, + data: DATA_MOCK, + parentContext: PARENT_CONTEXT_MOCK, + }, + async () => Promise.resolve(), + ); + + expect(withIsolationScopeMock).toHaveBeenCalledTimes(1); + + expect(startSpanMock).toHaveBeenCalledTimes(1); + expect(startSpanMock).toHaveBeenCalledWith( + { + name: NAME_MOCK, + parentSpan: PARENT_CONTEXT_MOCK, + attributes: DATA_MOCK, + }, + expect.any(Function), + ); + + expect(setTagsMock).toHaveBeenCalledTimes(1); + expect(setTagsMock).toHaveBeenCalledWith(TAGS_MOCK); + }); + }); +}); diff --git a/shared/lib/trace.ts b/shared/lib/trace.ts new file mode 100644 index 000000000000..c585edb981ba --- /dev/null +++ b/shared/lib/trace.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/browser'; +import { Primitive } from '@sentry/types'; +import { createModuleLogger } from '@metamask/utils'; +import { log as sentryLogger } from '../../app/scripts/lib/setupSentry'; + +const log = createModuleLogger(sentryLogger, 'trace'); + +export type TraceRequest = { + data?: Record; + name: string; + parentContext?: unknown; + tags?: Record; +}; + +export async function trace( + request: TraceRequest, + fn: (context?: unknown) => Promise, +): Promise { + const { data: attributes, name, parentContext, tags } = request; + const parentSpan = (parentContext ?? null) as Sentry.Span | null; + + const isSentryEnabled = + (await globalThis.sentry.getMetaMetricsEnabled()) as boolean; + + const callback = async (span: Sentry.Span | null) => { + log('Starting trace', name, request); + + const start = Date.now(); + let error; + + try { + return await fn(span); + } catch (currentError) { + error = currentError; + throw currentError; + } finally { + const end = Date.now(); + const duration = end - start; + + log('Finished trace', name, duration, { error, request }); + } + }; + + if (!isSentryEnabled) { + log('Skipping Sentry trace as metrics disabled', name, request); + return callback(null); + } + + return await Sentry.withIsolationScope(async (scope) => { + scope.setTags(tags as Record); + + return await Sentry.startSpan({ name, parentSpan, attributes }, callback); + }); +} diff --git a/types/global.d.ts b/types/global.d.ts index fc5a6e5573e6..ed081139dcf2 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -1,6 +1,10 @@ +// Many of the state hooks return untyped raw state. +/* eslint-disable @typescript-eslint/no-explicit-any */ + // In order for variables to be considered on the global scope they must be // declared using var and not const or let, which is why this rule is disabled /* eslint-disable no-var */ + import * as Sentry from '@sentry/browser'; import { Success, @@ -224,13 +228,32 @@ declare class Chrome { runtime: Runtime; } -type SentryObject = Sentry; +type SentryObject = Sentry & { + getMetaMetricsEnabled: () => Promise; +}; type HttpProvider = { host: string; timeout: number; }; +type StateHooks = { + getCleanAppState?: () => Promise; + getLogs?: () => any[]; + getMostRecentPersistedState?: () => any; + getPersistedState: () => Promise; + getSentryAppState?: () => any; + getSentryState: () => { + browser: string; + version: string; + state?: any; + persistedState?: any; + }; + metamaskGetState?: () => Promise; + throwTestBackgroundError?: (msg?: string) => Promise; + throwTestError?: (msg?: string) => void; +}; + export declare global { var platform: Platform; // Sentry is undefined in dev, so use optional chaining @@ -240,6 +263,8 @@ export declare global { var ethereumProvider: HttpProvider; + var stateHooks: StateHooks; + namespace jest { // The interface is being used for declaration merging, which is an acceptable exception to this rule. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions diff --git a/ui/index.js b/ui/index.js index 9902ee197de8..207d863cb526 100644 --- a/ui/index.js +++ b/ui/index.js @@ -241,7 +241,11 @@ async function startApp(metamaskState, backgroundConnection, opts) { * @param {object} store - The Redux store. */ function setupStateHooks(store) { - if (process.env.METAMASK_DEBUG || process.env.IN_TEST) { + if ( + process.env.METAMASK_DEBUG || + process.env.IN_TEST || + process.env.ENABLE_SETTINGS_PAGE_DEV_OPTIONS + ) { /** * The following stateHook is a method intended to throw an error, used in * our E2E test to ensure that errors are attempted to be sent to sentry. @@ -263,7 +267,7 @@ function setupStateHooks(store) { window.stateHooks.throwTestBackgroundError = async function ( msg = 'Test Error', ) { - store.dispatch(actions.throwTestBackgroundError(msg)); + await actions.throwTestBackgroundError(msg); }; } diff --git a/ui/pages/settings/developer-options-tab/__snapshots__/developer-options-tab.test.tsx.snap b/ui/pages/settings/developer-options-tab/__snapshots__/developer-options-tab.test.tsx.snap index 262707e4c051..f7f9b38a0235 100644 --- a/ui/pages/settings/developer-options-tab/__snapshots__/developer-options-tab.test.tsx.snap +++ b/ui/pages/settings/developer-options-tab/__snapshots__/developer-options-tab.test.tsx.snap @@ -308,6 +308,147 @@ exports[`Develop options tab should match snapshot 1`] = ` +

+ Sentry +

+
+
+
+
+ + + Generate an unhandled + + TestError + + in this window. + + +
+
+
+ +
+
+
+
+
+
+
+
+
+ + + Generate an unhandled + + TestError + + in the service worker. + + +
+
+
+ +
+
+
+
+
+
+
+
+
+ + + Generate a + + Developer Test + + Sentry trace. + + +
+
+
+ +
+
+
+
+
+
+
`; diff --git a/ui/pages/settings/developer-options-tab/developer-options-tab.tsx b/ui/pages/settings/developer-options-tab/developer-options-tab.tsx index dcfdad78828f..1637fec74ce0 100644 --- a/ui/pages/settings/developer-options-tab/developer-options-tab.tsx +++ b/ui/pages/settings/developer-options-tab/developer-options-tab.tsx @@ -36,6 +36,7 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import { getIsRedesignedConfirmationsDeveloperEnabled } from '../../confirmations/selectors/confirm'; import ToggleRow from './developer-options-toggle-row-component'; +import { SentryTest } from './sentry-test'; const DeveloperOptionsTab = () => { const t = useI18nContext(); @@ -272,6 +273,7 @@ const DeveloperOptionsTab = () => { {renderNetworkMenuRedesign()} {renderEnableConfirmationsRedesignToggle()} + ); }; diff --git a/ui/pages/settings/developer-options-tab/sentry-test.tsx b/ui/pages/settings/developer-options-tab/sentry-test.tsx new file mode 100644 index 000000000000..ab9a7702fcb5 --- /dev/null +++ b/ui/pages/settings/developer-options-tab/sentry-test.tsx @@ -0,0 +1,187 @@ +import React, { useState, useCallback, ReactElement } from 'react'; +import { ButtonVariant } from '@metamask/snaps-sdk'; +import { + Box, + Button, + Icon, + IconName, + IconSize, + Text, +} from '../../../components/component-library'; +import { + AlignItems, + Display, + FlexDirection, + IconColor, + JustifyContent, +} from '../../../helpers/constants/design-system'; +import { trace } from '../../../../shared/lib/trace'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export function SentryTest() { + return ( + <> + + Sentry + +
+ + + +
+ + ); +} + +function GenerateUIError() { + const t = useI18nContext(); + + const handleClick = useCallback(async () => { + await window.stateHooks.throwTestError?.('Developer Options'); + }, []); + + return ( + TestError, + ])} + onClick={handleClick} + expectError + /> + ); +} + +function GenerateBackgroundError() { + const t = useI18nContext(); + + const handleClick = useCallback(async () => { + await window.stateHooks.throwTestBackgroundError?.('Developer Options'); + }, []); + + return ( + TestError], + )} + onClick={handleClick} + expectError + /> + ); +} + +function GenerateTrace() { + const t = useI18nContext(); + + const handleClick = useCallback(async () => { + await trace( + { + name: 'Developer Test', + data: { 'test.data.number': 123 }, + tags: { 'test.tag.number': 123 }, + }, + async (context) => { + await trace( + { + name: 'Nested Test 1', + data: { 'test.data.boolean': true }, + tags: { 'test.tag.boolean': true }, + parentContext: context, + }, + () => sleep(1000), + ); + + await trace( + { + name: 'Nested Test 2', + data: { 'test.data.string': 'test' }, + tags: { 'test.tag.string': 'test' }, + parentContext: context, + }, + () => sleep(500), + ); + }, + ); + }, []); + + return ( + Developer Test, + ])} + onClick={handleClick} + /> + ); +} + +function TestButton({ + name, + description, + onClick, + expectError, +}: { + name: string; + description: ReactElement; + onClick: () => Promise; + expectError?: boolean; +}) { + const [isComplete, setIsComplete] = useState(false); + + const handleClick = useCallback(async () => { + let hasError = false; + + try { + await onClick(); + } catch (error) { + hasError = true; + throw error; + } finally { + if (expectError || !hasError) { + setIsComplete(true); + } + } + }, [onClick]); + + return ( + +
+
{description}
+
+
+ +
+
+ + +
+
+ ); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 1810b03d6130..4e345e14510a 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -5174,7 +5174,7 @@ export function setName( * Throw an error in the background for testing purposes. * * @param message - The error message. - * @deprecated This is only mean to facilitiate E2E testing. We should not use + * @deprecated This is only meant to facilitiate E2E testing. We should not use * this for handling errors. */ export async function throwTestBackgroundError(message: string): Promise {