Skip to content

Commit

Permalink
refactor: add performance tracing infrastructure (#26044) [cherry-pic…
Browse files Browse the repository at this point in the history
…k] (#26624)

Cherry-pick of (#26044) for v12.1.0. original description: 

## **Description**

Add initial tracing and performance metrics infrastructure.

Specifically:

- Add the `trace` method, to be used anywhere in the client to generate
a Sentry trace to monitor and analyse performance metrics.
- Add buttons to the developer settings as an easy mechanism to generate
Sentry errors (in the UI or background) and traces.
- Add the `Sentry.browserTracingIntegration` to automatically generate
traces for each page navigation, including nested spans for any HTTP
requests.
- Update the Sentry debugging documentation in the `README`.

Note that the previously used `browserProfilingIntegration` does not
generate transactions or traces itself, but rather attempts to add
profiling data to any generated transactions. This does not appear to be
compatible given it requires the Self-Profiling API object which is only
available in Chrome and in our case, the UI. In addition, it requires
the `js-profiling` document policy which also appears to currently not
be supported in browser extensions.

Sentry also provides React specific automated instrumentation via
`@sentry/react` and the
`Sentry.reactRouterV5BrowserTracingInstrumentation` integration, but
this should be pursued in a separate ticket if needed given the required
further refactor.

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/26044?quickstart=1)

## **Related issues**

Fixes:
[#2711](MetaMask/MetaMask-planning#2711)

## **Manual testing steps**

1. Enable metrics in the settings.
2. Login to Sentry account.
3. Go to `Traces` or `Performance` section.
4. Note new transactions or traces named `popup.html` with nested spans
including HTTP requests.

## **Screenshots/Recordings**

### **Before**

### **After**

<img width="250" alt="Sentry Developer Options"
src="https://github.com/user-attachments/assets/620cbfb8-ab4d-4ed0-b5f6-ba04ef975ddc">

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

Co-authored-by: Matthew Walsh <matthew.walsh@consensys.net>
  • Loading branch information
Gudahtt and matthewwalsh0 authored Aug 22, 2024
1 parent 6927746 commit 42555d3
Show file tree
Hide file tree
Showing 11 changed files with 589 additions and 42 deletions.
18 changes: 18 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 32 additions & 11 deletions app/scripts/lib/setupSentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { isManifestV3 } from '../../../shared/modules/mv3.utils';
import { filterEvents } from './sentry-filter-events';
import extractEthjsErrorMessage from './extractEthjsErrorMessage';

let installType = 'unknown';

const projectLogger = createProjectLogger('sentry');

const log = createModuleLogger(
export const log = createModuleLogger(
projectLogger,
globalThis.document ? 'ui' : 'background',
);

let installType = 'unknown';
const internalLog = createModuleLogger(log, 'internal');

/* eslint-disable prefer-destructuring */
// Destructuring breaks the inlining of the environment variables
Expand Down Expand Up @@ -51,6 +53,7 @@ export default function setupSentry() {

return {
...Sentry,
getMetaMetricsEnabled,
};
}

Expand All @@ -68,6 +71,7 @@ function getClientOptions() {
integrations: [
Sentry.dedupeIntegration(),
Sentry.extraErrorDataIntegration(),
Sentry.browserTracingIntegration(),
filterEvents({ getMetaMetricsEnabled, log }),
],
release: RELEASE,
Expand All @@ -78,6 +82,7 @@ function getClientOptions() {
// we can safely turn them off by setting the `sendClientReports` option to
// `false`.
sendClientReports: false,
tracesSampleRate: 0.01,
transport: makeTransport,
};
}
Expand Down Expand Up @@ -247,6 +252,7 @@ function setSentryClient() {
release,
});

Sentry.registerSpanErrorInstrumentation();
Sentry.init(clientOptions);

addDebugListeners();
Expand Down Expand Up @@ -475,7 +481,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));
};
}

Expand All @@ -490,18 +496,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');
Expand All @@ -518,3 +520,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';
}
37 changes: 10 additions & 27 deletions development/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:- <br>
- `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
Expand Down
112 changes: 112 additions & 0 deletions shared/lib/trace.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
54 changes: 54 additions & 0 deletions shared/lib/trace.ts
Original file line number Diff line number Diff line change
@@ -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<string, number | string | boolean>;
name: string;
parentContext?: unknown;
tags?: Record<string, number | string | boolean>;
};

export async function trace<T>(
request: TraceRequest,
fn: (context?: unknown) => Promise<T>,
): Promise<T> {
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<string, Primitive>);

return await Sentry.startSpan({ name, parentSpan, attributes }, callback);
});
}
27 changes: 26 additions & 1 deletion types/global.d.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -224,7 +228,26 @@ declare class Chrome {
runtime: Runtime;
}

type SentryObject = Sentry;
type SentryObject = Sentry & {
getMetaMetricsEnabled: () => Promise<boolean>;
};

type StateHooks = {
getCleanAppState?: () => Promise<any>;
getLogs?: () => any[];
getMostRecentPersistedState?: () => any;
getPersistedState: () => Promise<any>;
getSentryAppState?: () => any;
getSentryState: () => {
browser: string;
version: string;
state?: any;
persistedState?: any;
};
metamaskGetState?: () => Promise<any>;
throwTestBackgroundError?: (msg?: string) => Promise<void>;
throwTestError?: (msg?: string) => void;
};

export declare global {
var platform: Platform;
Expand All @@ -233,6 +256,8 @@ export declare global {

var chrome: Chrome;

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
Expand Down
Loading

0 comments on commit 42555d3

Please sign in to comment.