diff --git a/src/app/common/app-analytics.ts b/src/app/common/app-analytics.ts index ac4a8a69065..cfd5468c78c 100644 --- a/src/app/common/app-analytics.ts +++ b/src/app/common/app-analytics.ts @@ -1,14 +1,21 @@ import { useEffect } from 'react'; +import { z } from 'zod'; + import { HIRO_API_BASE_URL_MAINNET, HIRO_API_BASE_URL_TESTNET } from '@leather.io/models'; import { IS_TEST_ENV, SEGMENT_WRITE_KEY } from '@shared/environment'; -import { decorateAnalyticsEventsWithContext, initAnalytics } from '@shared/utils/analytics'; +import { + analytics, + decorateAnalyticsEventsWithContext, + initAnalytics, +} from '@shared/utils/analytics'; import { store } from '@app/store'; import { selectWalletType } from '@app/store/common/wallet-type.selectors'; import { selectCurrentNetwork } from '@app/store/networks/networks.selectors'; +import { useOnMount } from './hooks/use-on-mount'; import { flow, origin } from './initial-search-params'; const defaultStaticAnalyticContext = { @@ -57,3 +64,32 @@ decorateAnalyticsEventsWithContext(() => ({ ...defaultStaticAnalyticContext, ...getDerivedStateAnalyticsContext(), })); + +const analyticsQueueItemSchema = z.object({ + eventName: z.string(), + properties: z.record(z.unknown()).optional(), +}); + +const analyicsQueueSchema = z.array(analyticsQueueItemSchema); + +const analyticsEventKey = 'backgroundAnalyticsRequests'; + +export function useHandleQueuedBackgroundAnalytics() { + useOnMount(() => { + async function handleQueuedAnalytics() { + const queuedEventsStore = await chrome.storage.local.get(analyticsEventKey); + + try { + const events = analyicsQueueSchema.parse(queuedEventsStore[analyticsEventKey] ?? []); + if (!events.length) return; + await chrome.storage.local.remove(analyticsEventKey); + await Promise.all( + events.map(({ eventName, properties }) => analytics.track(eventName, properties)) + ); + } catch (e) { + void analytics.track('background_analytics_schema_fail'); + } + } + void handleQueuedAnalytics(); + }); +} diff --git a/src/app/features/container/container.tsx b/src/app/features/container/container.tsx index 6c0a578154e..3baa38b0fca 100644 --- a/src/app/features/container/container.tsx +++ b/src/app/features/container/container.tsx @@ -12,7 +12,10 @@ import { RouteUrls } from '@shared/route-urls'; import { closeWindow } from '@shared/utils'; import { analytics } from '@shared/utils/analytics'; -import { useInitalizeAnalytics } from '@app/common/app-analytics'; +import { + useHandleQueuedBackgroundAnalytics, + useInitalizeAnalytics, +} from '@app/common/app-analytics'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { CurrentAccountAvatar } from '@app/features/current-account/current-account-avatar'; import { CurrentAccountName } from '@app/features/current-account/current-account-name'; @@ -60,6 +63,7 @@ export function Container() { useOnSignOut(() => closeWindow()); useRestoreFormState(); useInitalizeAnalytics(); + useHandleQueuedBackgroundAnalytics(); useEffect(() => void analytics.page('view', `${pathname}`), [pathname]); diff --git a/src/background/background-analytics.ts b/src/background/background-analytics.ts new file mode 100644 index 00000000000..8a5f9ba0dad --- /dev/null +++ b/src/background/background-analytics.ts @@ -0,0 +1,19 @@ +// Segment/Mixpanel libraries are not compatible with extension background +// scripts. This function adds analytics requests to chrome.storage.local so +// that, when opened, an extension frame (that does support analyics) can read +// and fire the requests. +const queueStore = 'backgroundAnalyticsRequests'; + +export async function queueAnalyticsRequest( + eventName: string, + properties: Record = {} +) { + const currentQueue = await chrome.storage.local.get(queueStore); + const queue = currentQueue[queueStore] ?? []; + return chrome.storage.local.set({ + [queueStore]: [ + ...queue, + { eventName, properties: { ...properties, backgroundQueuedMessage: true } }, + ], + }); +} diff --git a/src/background/messaging/rpc-message-handler.ts b/src/background/messaging/rpc-message-handler.ts index 4efb3119b29..f863fbef2a8 100644 --- a/src/background/messaging/rpc-message-handler.ts +++ b/src/background/messaging/rpc-message-handler.ts @@ -2,6 +2,7 @@ import { RpcErrorCode } from '@btckit/types'; import { WalletRequests, makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { queueAnalyticsRequest } from '@background/background-analytics'; import { rpcSignStacksTransaction } from '@background/messaging/rpc-methods/sign-stacks-transaction'; import { getTabIdFromPort } from './messaging-utils'; @@ -63,3 +64,18 @@ export async function rpcMessageHandler(message: WalletRequests, port: chrome.ru break; } } + +interface TrackRpcRequestSuccess { + endpoint: WalletRequests['method']; +} +export async function trackRpcRequestSuccess(args: TrackRpcRequestSuccess) { + return queueAnalyticsRequest('rpc_request_successful', { ...args }); +} + +interface TrackRpcRequestError { + endpoint: WalletRequests['method']; + error: string; +} +export async function trackRpcRequestError(args: TrackRpcRequestError) { + return queueAnalyticsRequest('rpc_request_error', { ...args }); +} diff --git a/src/background/messaging/rpc-methods/get-addresses.ts b/src/background/messaging/rpc-methods/get-addresses.ts index 396478563a2..0582ee9dce3 100644 --- a/src/background/messaging/rpc-methods/get-addresses.ts +++ b/src/background/messaging/rpc-methods/get-addresses.ts @@ -8,10 +8,13 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestSuccess } from '../rpc-message-handler'; export async function rpcGetAddresses(message: GetAddressesRequest, port: chrome.runtime.Port) { const { urlParams, tabId } = makeSearchParamsWithDefaults(port, [['requestId', message.id]]); const { id } = await triggerRequestWindowOpen(RouteUrls.RpcGetAddresses, urlParams); + void trackRpcRequestSuccess({ endpoint: message.method }); + listenForPopupClose({ tabId, id, diff --git a/src/background/messaging/rpc-methods/send-transfer.ts b/src/background/messaging/rpc-methods/send-transfer.ts index ad48b3fd787..973cf733465 100644 --- a/src/background/messaging/rpc-methods/send-transfer.ts +++ b/src/background/messaging/rpc-methods/send-transfer.ts @@ -21,12 +21,15 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; export async function rpcSendTransfer( message: RpcRequest<'sendTransfer', RpcSendTransferParams | SendTransferRequestParams>, port: chrome.runtime.Port ) { if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: 'sendTransfer', error: 'Undefined parameters' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('sendTransfer', { @@ -43,6 +46,8 @@ export async function rpcSendTransfer( : (message.params as RpcSendTransferParams); if (!validateRpcSendTransferParams(params)) { + void trackRpcRequestError({ endpoint: 'sendTransfer', error: 'Invalid parameters' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('sendTransfer', { @@ -56,6 +61,8 @@ export async function rpcSendTransfer( return; } + void trackRpcRequestSuccess({ endpoint: message.method }); + const recipients: [string, string][] = params.recipients.map(({ address }) => [ 'recipient', address, diff --git a/src/background/messaging/rpc-methods/sign-message.ts b/src/background/messaging/rpc-methods/sign-message.ts index b475c62a8cf..e795c7ab135 100644 --- a/src/background/messaging/rpc-methods/sign-message.ts +++ b/src/background/messaging/rpc-methods/sign-message.ts @@ -18,9 +18,11 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; export async function rpcSignMessage(message: SignMessageRequest, port: chrome.runtime.Port) { if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: 'signMessage', error: 'Undefined parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('signMessage', { @@ -32,6 +34,8 @@ export async function rpcSignMessage(message: SignMessageRequest, port: chrome.r } if (!validateRpcSignMessageParams(message.params)) { + void trackRpcRequestError({ endpoint: 'signMessage', error: 'Invalid parameters' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('signMessage', { @@ -49,6 +53,8 @@ export async function rpcSignMessage(message: SignMessageRequest, port: chrome.r (message.params as any).paymentType ?? 'p2wpkh'; if (!isSupportedMessageSigningPaymentType(paymentType)) { + void trackRpcRequestError({ endpoint: 'signMessage', error: 'Unsupported payment type' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('signMessage', { @@ -63,6 +69,8 @@ export async function rpcSignMessage(message: SignMessageRequest, port: chrome.r return; } + void trackRpcRequestSuccess({ endpoint: message.method }); + const requestParams: RequestParams = [ ['message', message.params.message], ['network', (message.params as any).network ?? 'mainnet'], diff --git a/src/background/messaging/rpc-methods/sign-psbt.ts b/src/background/messaging/rpc-methods/sign-psbt.ts index 4b57dc92cfc..51567ef8502 100644 --- a/src/background/messaging/rpc-methods/sign-psbt.ts +++ b/src/background/messaging/rpc-methods/sign-psbt.ts @@ -19,6 +19,7 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; function validatePsbt(hex: string) { try { @@ -31,9 +32,10 @@ function validatePsbt(hex: string) { export async function rpcSignPsbt(message: SignPsbtRequest, port: chrome.runtime.Port) { if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Undefined parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), - makeRpcErrorResponse('signPsbt', { + makeRpcErrorResponse(message.method, { id: message.id, error: { code: RpcErrorCode.INVALID_REQUEST, message: 'Parameters undefined' }, }) @@ -42,9 +44,10 @@ export async function rpcSignPsbt(message: SignPsbtRequest, port: chrome.runtime } if (!validateRpcSignPsbtParams(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), - makeRpcErrorResponse('signPsbt', { + makeRpcErrorResponse(message.method, { id: message.id, error: { code: RpcErrorCode.INVALID_PARAMS, @@ -56,6 +59,8 @@ export async function rpcSignPsbt(message: SignPsbtRequest, port: chrome.runtime } if (!validatePsbt(message.params.hex)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid PSBT' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('signPsbt', { @@ -88,6 +93,8 @@ export async function rpcSignPsbt(message: SignPsbtRequest, port: chrome.runtime requestParams.push(['signAtIndex', index.toString()]) ); + void trackRpcRequestSuccess({ endpoint: message.method }); + const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams); const { id } = await triggerRequestWindowOpen(RouteUrls.RpcSignPsbt, urlParams); diff --git a/src/background/messaging/rpc-methods/sign-stacks-message.ts b/src/background/messaging/rpc-methods/sign-stacks-message.ts index ba01a3779de..d9eede1020c 100644 --- a/src/background/messaging/rpc-methods/sign-stacks-message.ts +++ b/src/background/messaging/rpc-methods/sign-stacks-message.ts @@ -17,12 +17,14 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; export async function rpcSignStacksMessage( message: SignStacksMessageRequest, port: chrome.runtime.Port ) { if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Undefined parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('stx_signMessage', { @@ -34,6 +36,7 @@ export async function rpcSignStacksMessage( } if (!validateRpcSignStacksMessageParams(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('stx_signMessage', { @@ -47,6 +50,8 @@ export async function rpcSignStacksMessage( return; } + void trackRpcRequestSuccess({ endpoint: message.method }); + const requestParams: RequestParams = [ ['message', message.params.message], ['messageType', message.params.messageType], diff --git a/src/background/messaging/rpc-methods/sign-stacks-transaction.ts b/src/background/messaging/rpc-methods/sign-stacks-transaction.ts index bdc66e1552d..c408382a80d 100644 --- a/src/background/messaging/rpc-methods/sign-stacks-transaction.ts +++ b/src/background/messaging/rpc-methods/sign-stacks-transaction.ts @@ -36,6 +36,7 @@ import { makeSearchParamsWithDefaults, triggerRequestWindowOpen, } from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; const MEMO_DESERIALIZATION_STUB = '\u0000'; @@ -114,6 +115,7 @@ export async function rpcSignStacksTransaction( port: chrome.runtime.Port ) { if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Undefined parameters' }); chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('stx_signTransaction', { @@ -125,6 +127,8 @@ export async function rpcSignStacksTransaction( } if (!validateRpcSignStacksTransactionParams(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid parameters' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('stx_signTransaction', { @@ -139,6 +143,8 @@ export async function rpcSignStacksTransaction( } if (!validateStacksTransaction(message.params.txHex!)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid Stacks transaction' }); + chrome.tabs.sendMessage( getTabIdFromPort(port), makeRpcErrorResponse('stx_signTransaction', { @@ -160,6 +166,8 @@ export async function rpcSignStacksTransaction( const isMultisig = hashMode === AddressHashMode.SerializeP2SH || hashMode === AddressHashMode.SerializeP2WSH; + void trackRpcRequestSuccess({ endpoint: message.method }); + const requestParams = [ ['txHex', message.params.txHex], ['requestId', message.id],