Skip to content

Commit

Permalink
refactor: sentry integration (#1420)
Browse files Browse the repository at this point in the history
Closes #1404 

# Changes

- Refactored Sentry Implementation:
- Use single global instantiation, fixes previous global state pollution
- Sentry now uses recommended React integrations (e.g. react dom router)
- Error handling logic centralized in Report Error Machine
- Errors are now asynchronously fed into our error Indexed DB from every
source (i.e. Service Worker, setup page, extension)
- Error Machine watches errors indexedDB for changes and automatically
updates its own state
- Extension no longer completely reloads when dismissing/ignoring
errors, now gracefully recovers from the error state.
- Audit check now ignores vulnerabilities with no known patched version
- Fixed broken links to `@fuels/react` in docs after it migrated to
Connectors

# Features
- New error review screen
  - User can dismiss individual errors before sending them to Sentry
- User can review the Error's contents and identify if any private
information is being sent
- Error data is automatically sanitized for keys and other obviously
private information
- Aside from protected/required properties (i.e. message & stack), other
properties can be deleted by the user to avoid leaking private
information



# Evidence
## Error Review Screen
![CleanShot 2024-08-15 at 18 57
14](https://github.com/user-attachments/assets/4bf43afa-2616-4fb2-90ff-ee75d5141d1f)

## Error Review Flow (Outdated) - Required error properties protection
disabled:

https://github.com/user-attachments/assets/d54f80b4-3da4-4aa8-be0d-60399c1d92b8

## Example Sentry error with proper stack trace and source mapping:

![image](https://github.com/user-attachments/assets/701a3ed6-1b2c-4962-a6be-cb047facd4de)

---------

Co-authored-by: Luiz Gomes <[email protected]>
  • Loading branch information
arthurgeron and LuizAsFight authored Aug 24, 2024
1 parent 3486bee commit 737652b
Show file tree
Hide file tree
Showing 44 changed files with 6,235 additions and 8,793 deletions.
5 changes: 5 additions & 0 deletions .changeset/light-pumpkins-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-wallet/connections": patch
---

Fixed broken links to `@fuels/react` in docs
6 changes: 6 additions & 0 deletions .changeset/proud-pillows-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fuel-wallet/types": minor
"fuels-wallet": minor
---

Refactored Sentry implementation, error handling, and report logic
5 changes: 5 additions & 0 deletions .changeset/wet-balloons-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fuels-wallet": patch
---

Include "Error Review" screen allowing the user to review and report screens
2 changes: 1 addition & 1 deletion examples/cra-dapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.2.1",
"typescript": "5.2.2",
"vite": "5.1.4"
"vite": "5.3.5"
}
}
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"vite@<2.9.16": ">=2.9.16",
"semver@<7.5.2": ">=7.5.2",
"node-fetch@<2.6.7": ">=2.6.7",
"node-fetch@>=3.0.0": "<3.0.0",
"word-wrap": "npm:@aashutoshrathi/word-wrap",
"cross-fetch": "4.0.0",
"pnpm@>=8.0.0 <8.6.8": ">=8.6.8",
Expand All @@ -115,10 +116,8 @@
"@adobe/css-tools@<4.3.2": ">=4.3.2",
"@babel/traverse@<7.23.2": ">=7.23.2",
"braces@<3.0.3": ">=3.0.3",
"ws@>=8.0.0 <8.17.1": ">=8.17.1",
"ws@>=7.0.0 <7.5.10": ">=7.5.10",
"ws@>=6.0.0 <6.2.3": ">=6.2.3",
"fast-xml-parser@<4.4.1": ">=4.4.1",
"ws@>=8.0.0 <8.17.1": ">=8.17.1",
"elliptic@>=4.0.0 <=6.5.6": ">=6.5.7",
"elliptic@>=2.0.0 <=6.5.6": ">=6.5.7",
"elliptic@>=5.2.1 <=6.5.6": ">=6.5.7",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ dist-ssr
*.sw?

cypress/videos

# Sentry Config File
.env.sentry-build-plugin
7 changes: 4 additions & 3 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@fuels/react-xstore": "0.20.0",
"@hookform/resolvers": "3.3.2",
"@react-aria/utils": "3.21.0",
"@sentry/browser": "7.73.0",
"@sentry/react": "8.21.0",
"@storybook/addon-viewport": "7.4.6",
"@storybook/jest": "0.2.3",
"@tanstack/react-query": "5.28.4",
Expand All @@ -46,6 +46,7 @@
"fake-indexeddb": "4.0.2",
"framer-motion": "10.16.4",
"fuels": "0.94.0",
"json-edit-react": "1.13.3",
"json-rpc-2.0": "1.7.0",
"lodash.debounce": "4.0.8",
"react": "18.3.1",
Expand All @@ -64,8 +65,8 @@
"@crxjs/vite-plugin": "1.0.14",
"@fuel-wallet/types": "workspace:*",
"@fuels/connectors": "0.5.0",
"@playwright/test": "^1.46.1",
"@sentry/cli": "2.21.2",
"@playwright/test": "1.46.1",
"@sentry/cli": "2.33.1",
"@storybook/addon-a11y": "7.4.6",
"@storybook/addon-actions": "7.4.6",
"@storybook/addon-essentials": "7.4.6",
Expand Down
211 changes: 181 additions & 30 deletions packages/app/playwright/e2e/ReportError.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import test, { chromium, expect } from '@playwright/test';

import { getButtonByText, hasText, reload, visit } from '../commons';
import {
getButtonByText,
getByAriaLabel,
hasText,
reload,
visit,
} from '../commons';
import { mockData } from '../mocks';

test.describe('ReportError', () => {
Expand All @@ -18,87 +24,232 @@ test.describe('ReportError', () => {

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
async function getPageErrors(page: Page): Promise<any> {
return page.evaluate(async () => {
return await page.evaluate(async () => {
const fuelDB = window.fuelDB;
const errors = (await fuelDB?.errors?.toArray()) ?? [];
const errors = (await fuelDB?.errors?.toArray?.()) ?? [];
return errors;
});
}

test('should show Error page when there is a js error in React', async () => {
test('should show Error page when there is a unhandled js error in React', async () => {
await visit(page, '/');
await page.evaluate(() => {
window.testCrash();
});

await hasText(page, /Unexpected errors detected/i);
await expect(page.locator(`textarea[name="reports"]`)).toHaveText(
/componentStack/i
);
await hasText(page, /Unexpected error/i);

// get errors from indexedDB
const errors = await getPageErrors(page);
expect(errors.length).toBeGreaterThan(0);

// report error
await getButtonByText(page, 'Send reports').click();
await expect(page.getByText(/Unexpected errors detected/)).toHaveCount(0);
await getByAriaLabel(page, 'Send error reports').click();
await expect(page.getByText(/Unexpected error/)).toHaveCount(0);

const errorsAfterReporting = await getPageErrors(page);
expect(errorsAfterReporting.length).toBe(0);
});

test('should show Error page when there is a error in the database', async () => {
test('should show floating error button when there is a error in the database', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
await window.fuelDB.errors.add({
id: '12345',
timestamp: Date.now(),
error: {
name: 'React error',
message: 'Test Error',
stack: ['Line error 1'],
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
});

await reload(page);
await hasText(page, /Unexpected errors detected/i);
await expect(page.locator(`textarea[name="reports"]`)).toHaveText(
/Test Error/i
const floatingButton = await page.waitForSelector(
'[data-testid="ErrorFloatingButton"]',
{ state: 'visible' }
);
expect(floatingButton.isVisible).toBeTruthy();
});

test('should be able to ignore a error', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
await window.fuelDB.errors.add({
id: '12345',
error: {
name: 'React error',
message: 'Test Error',
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
});
await reload(page);
(
await page.waitForSelector('[data-testid="ErrorFloatingButton"]', {
state: 'visible',
})
).click();
await hasText(page, /Unexpected error/i);

// report error
await getButtonByText(page, 'Send reports').click();
await expect(page.getByText(/Unexpected errors detected/i)).toHaveCount(0);
await getByAriaLabel(page, 'Ignore error(s)').click();
await expect(page.getByText(/Unexpected error/i)).toHaveCount(0);

const errorsAfterReporting = await getPageErrors(page);
await expect(errorsAfterReporting.length).toBe(0);
expect(errorsAfterReporting.length).toBe(1);
});

test('should be able to ignore a error', async () => {
test('should be able to dismiss all errors', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
await window.fuelDB.errors.add({
id: '12345',
timestamp: Date.now(),
error: {
name: 'React error',
message: 'Test Error',
stack: ['Line error 1'],
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
});
await reload(page);
(
await page.waitForSelector('[data-testid="ErrorFloatingButton"]', {
state: 'visible',
})
).click();
await hasText(page, /Unexpected error/i);

await hasText(page, /Unexpected errors detected/i);
await expect(page.locator(`textarea[name="reports"]`)).toHaveText(
/Test Error/i
);
// report error
await getByAriaLabel(
page,
'Ignore and dismiss all errors permanently'
).click();
await expect(page.getByText(/Unexpected error/i)).toHaveCount(0);

const errorsAfterReporting = await getPageErrors(page);
expect(errorsAfterReporting.length).toBe(0);
});
test('should hide when the single error is dismissed', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
await window.fuelDB.errors.add({
id: '12345',
error: {
name: 'React error',
message: 'Test Error',
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
});
await reload(page);
(
await page.waitForSelector('[data-testid="ErrorFloatingButton"]', {
state: 'visible',
})
).click();
await hasText(page, /Unexpected error/i);

// report error
await getButtonByText(page, /Ignore/i).click();
await expect(page.getByText(/Unexpected errors detected/i)).toHaveCount(0);
await getByAriaLabel(page, 'Dismiss error').click();
await expect(page.getByText(/Unexpected error/i)).toHaveCount(0);

const errorsAfterReporting = await getPageErrors(page);
await expect(errorsAfterReporting.length).toBe(0);
expect(errorsAfterReporting.length).toBe(0);
});
test('should detect and capture global errors', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
console.error(new Error('Test Error'));
});
await reload(page);
(
await page.waitForSelector('[data-testid="ErrorFloatingButton"]', {
state: 'visible',
})
).click();
await hasText(page, /Unexpected error/i);

const errorsAfterReporting = await getPageErrors(page);
expect(errorsAfterReporting.length).toBe(1);
});

test('should deduplicate errors', async () => {
await visit(page, '/');
await page.evaluate(async () => {
await window.fuelDB.errors.clear();
await window.fuelDB.errors.add({
id: '12345',
error: {
name: 'React error',
message: 'Test Error',
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
await window.fuelDB.errors.add({
id: '12345',
error: {
name: 'React error',
message: 'Test Error',
stack: 'Line error 1',
},
extra: {
timestamp: Date.now(),
location: 'http://localhost:3000',
pathname: '/',
hash: '#',
counts: 0,
},
});
});
await reload(page);
(
await page.waitForSelector('[data-testid="ErrorFloatingButton"]', {
state: 'visible',
})
).click();
await hasText(page, /Unexpected error/i);
await reload(page);
const errorsAfterReporting = await getPageErrors(page);
expect(errorsAfterReporting.length).toBe(1);
});
});
2 changes: 2 additions & 0 deletions packages/app/playwright/mocks/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export async function mockData(
accounts.map((account) => account.address)
);

await new Promise((resolve) => setTimeout(resolve, 300));

await page.evaluate(
([accounts, networks, connections, assets, vault, password]: [
Array<WalletAccount>,
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@fontsource/source-code-pro';
import { createRoot } from 'react-dom/client';
import './sentry';

import { App } from './App';
import './exports';
Expand Down
Loading

0 comments on commit 737652b

Please sign in to comment.