From 8fa2cf2d725fbff5f92a93a9c075d9638c47aec2 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Tue, 1 Sep 2026 17:23:02 +0200 Subject: [PATCH] feat: add shared trackError util --- packages/snap-networks-utils/CHANGELOG.md | 3 + packages/snap-networks-utils/src/index.ts | 8 + .../src/utils/errors/index.ts | 9 + .../src/utils/errors/trackError.test.ts | 245 ++++++++++++++++++ .../src/utils/errors/trackError.ts | 143 ++++++++++ packages/solana-wallet-snap/CHANGELOG.md | 1 + .../solana-wallet-snap/snap.manifest.json | 2 +- .../src/core/utils/errors.test.ts | 4 +- .../src/core/utils/errors.ts | 47 +--- .../solana-wallet-snap/src/core/utils/snap.ts | 11 + 10 files changed, 431 insertions(+), 42 deletions(-) create mode 100644 packages/snap-networks-utils/src/utils/errors/trackError.test.ts create mode 100644 packages/snap-networks-utils/src/utils/errors/trackError.ts create mode 100644 packages/solana-wallet-snap/src/core/utils/snap.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 1e17788f..2ee1a0e5 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createWithCatchAndThrowSnapError` for handler-boundary error catching, logging, and Snap RPC normalization - `normalizeError` for converting caught values into Snap RPC errors, with optional custom normalizers via `createWithCatchAndThrowSnapError`'s `normalizeErrorFn` option - `isSnapRpcError` type guard and `SnapRpcError` union type +- Add shared Sentry error tracking utilities for network snaps + - `createTrackError` for reporting errors to MetaMask via `snap_trackError` + - `createSnapErrorHandling` to wire `createTrackError` and `createWithCatchAndThrowSnapError` - Add `InFlightCoalescer`, exported from a new `./dedupe` entry point, which coalesces concurrent async operations by key so callers share one in-flight run ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) - Add shared async batching utilities. ([#211](https://github.com/MetaMask/internal-snaps/pull/211)) - Add origin permission helpers ([#193](https://github.com/MetaMask/internal-snaps/pull/193)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index f075b974..1737f83f 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -42,14 +42,22 @@ export { export type { CreateOriginPermissionsParams } from './utils/originPermissions/createOriginPermissions'; export { validateOrigin } from './utils/originPermissions/validateOrigin'; export { + createSnapErrorHandling, + createTrackError, createWithCatchAndThrowSnapError, isSnapRpcError, normalizeError, } from './utils/errors'; export type { + CreateSnapErrorHandlingOptions, + CreateTrackErrorOptions, CreateWithCatchAndThrowSnapErrorOptions, LogErrorFn, NormalizeErrorFn, + PrepareErrorForTrackingFn, + ShouldTrackErrorFn, SnapRpcError, + SnapTrackErrorRequest, + TrackErrorCapableProvider, TrackErrorFn, } from './utils/errors'; diff --git a/packages/snap-networks-utils/src/utils/errors/index.ts b/packages/snap-networks-utils/src/utils/errors/index.ts index 582480c9..f057effe 100644 --- a/packages/snap-networks-utils/src/utils/errors/index.ts +++ b/packages/snap-networks-utils/src/utils/errors/index.ts @@ -1,5 +1,14 @@ +export { createSnapErrorHandling, createTrackError } from './trackError'; export { createWithCatchAndThrowSnapError, normalizeError } from './errors'; export { isSnapRpcError } from './snapRpcError'; +export type { + CreateSnapErrorHandlingOptions, + CreateTrackErrorOptions, + PrepareErrorForTrackingFn, + ShouldTrackErrorFn, + SnapTrackErrorRequest, + TrackErrorCapableProvider, +} from './trackError'; export type { CreateWithCatchAndThrowSnapErrorOptions, LogErrorFn, diff --git a/packages/snap-networks-utils/src/utils/errors/trackError.test.ts b/packages/snap-networks-utils/src/utils/errors/trackError.test.ts new file mode 100644 index 00000000..8f84ebe0 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/errors/trackError.test.ts @@ -0,0 +1,245 @@ +import { SnapError, UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { mockLogger } from '../logger/__mocks__/Logger'; +import type { TrackErrorFn } from './errors'; +import { createSnapErrorHandling, createTrackError } from './trackError'; +import type { TrackErrorCapableProvider } from './trackError'; + +const setupTrackErrorTest = (): { + request: jest.Mock; + trackError: TrackErrorFn; +} => { + jest.clearAllMocks(); + + const request = jest.fn(); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + return { request, trackError }; +}; + +describe('trackError', () => { + describe('createTrackError', () => { + it('does not track UserRejectedRequestError', async () => { + const { request, trackError } = setupTrackErrorTest(); + + expect(await trackError(new UserRejectedRequestError())).toBeUndefined(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('tracks errors and returns the Sentry event id', async () => { + const { request, trackError } = setupTrackErrorTest(); + const originalError = new Error('Test error'); + request.mockResolvedValue('tracked-error-id'); + + expect(await trackError(originalError)).toBe('tracked-error-id'); + + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: originalError.message, + }), + }, + }); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('does not throw when tracking fails', async () => { + const { request, trackError } = setupTrackErrorTest(); + const originalError = new Error('Test error'); + const trackingError = new Error('Tracking failed'); + request.mockRejectedValue(trackingError); + + expect(await trackError(originalError)).toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: trackingError }, + 'Failed to track error', + ); + }); + + it('uses prepareError before serializing', async () => { + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const prepareError = jest.fn( + (error: unknown): Error => new Error(`wrapped: ${String(error)}`), + ); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + prepareError, + }); + + await trackError('raw error'); + + expect(prepareError).toHaveBeenCalledWith('raw error'); + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: 'wrapped: raw error', + }), + }, + }); + }); + + it('uses shouldTrack to skip tracking', async () => { + const request = jest.fn(); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + shouldTrack: (error: unknown): boolean => error instanceof SnapError, + }); + + expect(await trackError(new Error('ignored'))).toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + + request.mockResolvedValue('tracked-error-id'); + expect(await trackError(new SnapError('tracked'))).toBe( + 'tracked-error-id', + ); + }); + + it('coerces non-Error values with the default prepareError', async () => { + const { request, trackError } = setupTrackErrorTest(); + request.mockResolvedValue('tracked-error-id'); + + expect(await trackError('string error')).toBe('tracked-error-id'); + + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + cause: expect.objectContaining({ + message: 'string error', + }), + }), + }, + }); + }); + + it('resolves the snap provider on each call', async () => { + const firstRequest = jest.fn().mockResolvedValue('first-id'); + const secondRequest = jest.fn().mockResolvedValue('second-id'); + const getSnapProvider = jest + .fn() + .mockReturnValueOnce({ request: firstRequest }) + .mockReturnValueOnce({ request: secondRequest }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + expect(await trackError(new Error('first'))).toBe('first-id'); + expect(await trackError(new Error('second'))).toBe('second-id'); + expect(getSnapProvider).toHaveBeenCalledTimes(2); + }); + }); + + describe('createSnapErrorHandling', () => { + it('wires trackError into withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const { trackError, withCatchAndThrowSnapError } = + createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(await trackError(originalError)).toBe('tracked-error-id'); + }); + + it('forwards prepareError and shouldTrack to trackError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const prepareError = jest.fn( + (error: unknown): Error => new Error(`prepared: ${String(error)}`), + ); + const shouldTrack = jest.fn( + (error: unknown): boolean => error instanceof Error, + ); + const { trackError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + prepareError, + shouldTrack, + }); + + expect(await trackError('skip me')).toBeUndefined(); + expect(shouldTrack).toHaveBeenCalledWith('skip me'); + expect(prepareError).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + + expect(await trackError(new Error('track me'))).toBe('tracked-error-id'); + expect(prepareError).toHaveBeenCalledWith(expect.any(Error)); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('uses a custom normalizeErrorFn in withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const normalizeErrorFn = jest.fn( + () => new UserRejectedRequestError('normalized'), + ); + const { withCatchAndThrowSnapError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + normalizeErrorFn, + }); + + await expect( + withCatchAndThrowSnapError(async () => { + throw new Error('original'); + }), + ).rejects.toThrow(UserRejectedRequestError); + + expect(normalizeErrorFn).toHaveBeenCalledWith( + expect.objectContaining({ message: 'original' }), + ); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('uses a custom logError override in withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const customLogError = jest.fn(); + const { withCatchAndThrowSnapError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + const originalError = new Error('Test error'); + + await expect( + withCatchAndThrowSnapError(async () => { + throw originalError; + }, customLogError), + ).rejects.toThrow(SnapError); + + expect(customLogError).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/errors/trackError.ts b/packages/snap-networks-utils/src/utils/errors/trackError.ts new file mode 100644 index 00000000..e772ca04 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/errors/trackError.ts @@ -0,0 +1,143 @@ +import { getJsonError, UserRejectedRequestError } from '@metamask/snaps-sdk'; +import type { TrackErrorParams, TrackErrorResult } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { + CreateWithCatchAndThrowSnapErrorOptions, + LogErrorFn, + TrackErrorFn, +} from './errors'; +import { createWithCatchAndThrowSnapError } from './errors'; + +/** + * Converts a caught value into an {@link Error} before Sentry serialization. + */ +export type PrepareErrorForTrackingFn = (error: unknown) => Error; + +/** + * Determines whether an error should be reported through `snap_trackError`. + */ +export type ShouldTrackErrorFn = (error: unknown) => boolean; + +/** + * `snap_trackError` request shape from `@metamask/snaps-sdk`. + */ +export type SnapTrackErrorRequest = { + method: 'snap_trackError'; + params: TrackErrorParams; +}; + +/** + * Snap provider surface required for {@link createTrackError}, derived from SDK + * `snap_trackError` types. + */ +export type TrackErrorCapableProvider = { + request: ( + args: SnapTrackErrorRequest, + ) => Promise; +}; + +export type CreateTrackErrorOptions< + TProvider extends TrackErrorCapableProvider = TrackErrorCapableProvider, +> = { + getSnapProvider: () => TProvider; + logError: LogErrorFn; + prepareError?: PrepareErrorForTrackingFn; + shouldTrack?: ShouldTrackErrorFn; +}; + +export type CreateSnapErrorHandlingOptions< + TProvider extends TrackErrorCapableProvider = TrackErrorCapableProvider, +> = Omit & + CreateTrackErrorOptions; + +function defaultPrepareError(error: unknown): Error { + return error instanceof Error ? error : ensureError(error); +} + +function defaultShouldTrack(error: unknown): boolean { + return !(error instanceof UserRejectedRequestError); +} + +/** + * Creates a snap-bound error reporter for MetaMask Sentry (`snap_trackError`). + * + * RPC failures are caught and logged but never rethrown, so the returned function + * is safe to call from already-failing error-handling paths. + * + * @param options - Snap provider accessor, logger, and optional hooks. + * @param options.getSnapProvider - Returns the Snap provider used for `snap_trackError`. + * @param options.logError - Logger method used when the tracking RPC fails. + * @param options.prepareError - Optional error normalizer before Sentry serialization. + * @param options.shouldTrack - Optional filter; defaults to skipping `UserRejectedRequestError`. + * @returns A function that reports errors to Sentry when {@link ShouldTrackErrorFn} allows it. + */ +export function createTrackError({ + getSnapProvider, + logError, + prepareError = defaultPrepareError, + shouldTrack = defaultShouldTrack, +}: CreateTrackErrorOptions): TrackErrorFn { + return async (error: unknown): Promise => { + if (!shouldTrack(error)) { + return undefined; + } + + const params: TrackErrorParams = { + error: getJsonError(prepareError(error)), + }; + + try { + return await getSnapProvider().request({ + method: 'snap_trackError', + params, + }); + } catch (trackingError) { + logError({ error: trackingError }, 'Failed to track error'); + return undefined; + } + }; +} + +/** + * Creates snap error handling utilities wired with a shared transport and logger setup. + * + * @param options - Options for both {@link createTrackError} and + * {@link createWithCatchAndThrowSnapError}. + * @param options.logError - Logger method used for handler and tracking failures. + * @param options.normalizeErrorFn - Optional Snap RPC error normalizer for handler boundaries. + * @param options.getSnapProvider - Returns the Snap provider used for `snap_trackError`. + * @param options.prepareError - Optional error normalizer before Sentry serialization. + * @param options.shouldTrack - Optional tracking filter passed to {@link createTrackError}. + * @returns Bound `trackError` and `withCatchAndThrowSnapError` functions. + */ +export function createSnapErrorHandling< + TProvider extends TrackErrorCapableProvider, +>({ + logError, + normalizeErrorFn, + getSnapProvider, + prepareError, + shouldTrack, +}: CreateSnapErrorHandlingOptions): { + trackError: TrackErrorFn; + withCatchAndThrowSnapError: ReturnType< + typeof createWithCatchAndThrowSnapError + >; +} { + const trackError = createTrackError({ + getSnapProvider, + logError, + prepareError, + shouldTrack, + }); + + return { + trackError, + withCatchAndThrowSnapError: createWithCatchAndThrowSnapError({ + logError, + trackError, + normalizeErrorFn, + }), + }; +} diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 91f8d49a..4d5db480 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Migrate `trackError` and `withCatchAndThrowSnapError` to `@metamask/snap-networks-utils` `createSnapErrorHandling`, and add `getSnapProvider` for Snap RPC access - Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads through the adapter (no Core routing yet). ([#121](https://github.com/MetaMask/internal-snaps/pull/121)) - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.9` ([#161](https://github.com/MetaMask/internal-snaps/pull/161)) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 6e042893..683017b2 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "yxd4HvExG2dvJVH5uajNfTzGfaybRnywIRZk8orqZqo=", + "shasum": "RP8K+BdAavCCQ3ZxXRx/CgH4ipA4Y2K/yCmqQTgHB8w=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/utils/errors.test.ts b/packages/solana-wallet-snap/src/core/utils/errors.test.ts index af526720..e9da9e4f 100644 --- a/packages/solana-wallet-snap/src/core/utils/errors.test.ts +++ b/packages/solana-wallet-snap/src/core/utils/errors.test.ts @@ -47,7 +47,7 @@ describe('errors', () => { }), }, }); - expect(mockLogger.warn).toHaveBeenCalledWith( + expect(mockLogger.error).toHaveBeenCalledWith( { error: trackingError, }, @@ -71,7 +71,7 @@ describe('errors', () => { }), }, }); - expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/solana-wallet-snap/src/core/utils/errors.ts b/packages/solana-wallet-snap/src/core/utils/errors.ts index b4c1bb94..53dfbf6b 100644 --- a/packages/solana-wallet-snap/src/core/utils/errors.ts +++ b/packages/solana-wallet-snap/src/core/utils/errors.ts @@ -1,44 +1,13 @@ -import { - createWithCatchAndThrowSnapError, - isSnapRpcError, -} from '@metamask/snap-networks-utils'; -import { UserRejectedRequestError, getJsonError } from '@metamask/snaps-sdk'; +import { createSnapErrorHandling } from '@metamask/snap-networks-utils'; import logger from './logger'; +import { getSnapProvider } from './snap'; -export { isSnapRpcError }; +export { isSnapRpcError } from '@metamask/snap-networks-utils'; export type { SnapRpcError } from '@metamask/snap-networks-utils'; -/** - * Reports an error to MetaMask via Sentry (`snap_trackError`). - * - * Skips user rejections. Callers decide whether to invoke this for a given - * error in a given context. - * - * @param error - The error to report. - * @returns The Sentry event ID on success, or `undefined` on failure or if the error is skipped. - */ -export const trackError = async ( - error: unknown, -): Promise => { - if (error instanceof UserRejectedRequestError) { - return undefined; - } - - try { - return await snap.request({ - method: 'snap_trackError', - params: { - error: getJsonError(error), - }, - }); - } catch (trackingError) { - logger.warn({ error: trackingError }, 'Failed to track error'); - return undefined; - } -}; - -export const withCatchAndThrowSnapError = createWithCatchAndThrowSnapError({ - logError: logger.error.bind(logger), - trackError, -}); +export const { trackError, withCatchAndThrowSnapError } = + createSnapErrorHandling({ + getSnapProvider, + logError: logger.error.bind(logger), + }); diff --git a/packages/solana-wallet-snap/src/core/utils/snap.ts b/packages/solana-wallet-snap/src/core/utils/snap.ts new file mode 100644 index 00000000..fffb00f1 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/snap.ts @@ -0,0 +1,11 @@ +import type { SnapsProvider } from '@metamask/snaps-sdk'; + +/** + * Returns the Snap provider. + * + * @returns The Snap provider. + */ +export function getSnapProvider(): SnapsProvider { + // snap is a global variable provided by the Snap SDK + return snap; +}