diff --git a/.changeset/dull-steaks-jog.md b/.changeset/dull-steaks-jog.md new file mode 100644 index 0000000000..c6e431b576 --- /dev/null +++ b/.changeset/dull-steaks-jog.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": minor +--- + +- **feat**: Added Telemetry foundation for `Buy`, `Checkout`, `Fund`, `Mint`, `Swap`, `Transaction`, and `Wallet`. By @cpcramer #1942 diff --git a/src/OnchainKitProvider.test.tsx b/src/OnchainKitProvider.test.tsx index a061504074..cb90e73d23 100644 --- a/src/OnchainKitProvider.test.tsx +++ b/src/OnchainKitProvider.test.tsx @@ -199,7 +199,7 @@ describe('OnchainKitProvider', () => { rpcUrl: null, schemaId, projectId: null, - interactionId: expect.any(String), + sessionId: expect.any(String), }), ); }); diff --git a/src/OnchainKitProvider.tsx b/src/OnchainKitProvider.tsx index 3575c2f052..3a57ec2b71 100644 --- a/src/OnchainKitProvider.tsx +++ b/src/OnchainKitProvider.tsx @@ -36,7 +36,7 @@ export function OnchainKitProvider({ throw Error('EAS schemaId must be 64 characters prefixed with "0x"'); } - const interactionId = useMemo(() => crypto.randomUUID(), []); + const sessionId = useMemo(() => crypto.randomUUID(), []); // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore const value = useMemo(() => { @@ -68,7 +68,7 @@ export function OnchainKitProvider({ projectId: projectId ?? null, rpcUrl: rpcUrl ?? null, schemaId: schemaId ?? COINBASE_VERIFIED_ACCOUNT_SCHEMA_ID, - interactionId, + sessionId, }; setOnchainKitConfig(onchainKitConfig); return onchainKitConfig; @@ -81,7 +81,7 @@ export function OnchainKitProvider({ projectId, rpcUrl, schemaId, - interactionId, + sessionId, ]); // Check the React context for WagmiProvider and QueryClientProvider diff --git a/src/core/OnchainKitConfig.ts b/src/core/OnchainKitConfig.ts index 2ed6d260a5..0f999313a5 100644 --- a/src/core/OnchainKitConfig.ts +++ b/src/core/OnchainKitConfig.ts @@ -28,7 +28,7 @@ export const ONCHAIN_KIT_CONFIG: OnchainKitConfig = { rpcUrl: null, schemaId: null, projectId: null, - interactionId: null, + sessionId: null, }; /** diff --git a/src/core/analytics/constants.ts b/src/core/analytics/constants.ts new file mode 100644 index 0000000000..00216efa37 --- /dev/null +++ b/src/core/analytics/constants.ts @@ -0,0 +1,86 @@ +export const ANALYTICS_API_URL = 'https://api.developer.coinbase.com/analytics'; + +/** + * Analytics event names + */ +export const ANALYTICS_EVENTS = { + // Buy events + BUY_FAILURE: 'buyFailure', + BUY_INITIATED: 'buyInitiated', + BUY_OPTION_SELECTED: 'buyOptionSelected', + BUY_SUCCESS: 'buySuccess', + + // Checkout events + CHECKOUT_FAILURE: 'checkoutFailure', + CHECKOUT_INITIATED: 'checkoutInitiated', + CHECKOUT_SUCCESS: 'checkoutSuccess', + + // Error events + COMPONENT_ERROR: 'componentError', + + // Fund events + FUND_AMOUNT_CHANGED: 'fundAmountChanged', + FUND_FAILURE: 'fundFailure', + FUND_INITIATED: 'fundInitiated', + FUND_OPTION_SELECTED: 'fundOptionSelected', + FUND_SUCCESS: 'fundSuccess', + + // Mint events + MINT_FAILURE: 'mintFailure', + MINT_INITIATED: 'mintInitiated', + MINT_QUANTITY_CHANGED: 'mintQuantityChanged', + MINT_SUCCESS: 'mintSuccess', + + // Swap events + SWAP_FAILURE: 'swapFailure', + SWAP_INITIATED: 'swapInitiated', + SWAP_SLIPPAGE_CHANGED: 'swapSlippageChanged', + SWAP_SUCCESS: 'swapSuccess', + SWAP_TOKEN_DROPDOWN_SELECTED: 'swapTokenDropdownSelected', + SWAP_TOKEN_SELECTED: 'swapTokenSelected', + + // Transaction events + TRANSACTION_FAILURE: 'transactionFailure', + TRANSACTION_INITIATED: 'transactionInitiated', + TRANSACTION_SUCCESS: 'transactionSuccess', + + // Wallet events + WALLET_CONNECT_ERROR: 'walletConnectError', + WALLET_CONNECT_INITIATED: 'walletConnectInitiated', + WALLET_CONNECT_SUCCESS: 'walletConnectSuccess', + WALLET_DISCONNECT: 'walletDisconnect', + WALLET_OPTION_SELECTED: 'walletOptionSelected', +} as const; + +/** + * Component names for error tracking + */ +export const COMPONENT_NAMES = { + BUY: 'buy', + CHECKOUT: 'checkout', + FUND: 'fund', + MINT: 'mint', + SWAP: 'swap', + TRANSACTION: 'transaction', + WALLET: 'wallet', +} as const; + +/** + * Buy options + */ +export const BUY_OPTIONS = { + APPLE_PAY: 'apple_pay', + COINBASE: 'coinbase_account', + DEBIT_CARD: 'debit_card', + ETH: 'eth', + USDC: 'usdc', +} as const; + +/** + * Fund options + */ +export const FUND_OPTIONS = { + APPLE_PAY: 'apple_pay', + COINBASE: 'coinbase_account', + DEBIT_CARD: 'debit_card', +} as const; diff --git a/src/core/analytics/hooks/useAnalytics.test.ts b/src/core/analytics/hooks/useAnalytics.test.ts new file mode 100644 index 0000000000..6c9af3a262 --- /dev/null +++ b/src/core/analytics/hooks/useAnalytics.test.ts @@ -0,0 +1,152 @@ +import { ANALYTICS_API_URL } from '@/core/analytics/constants'; +import { WalletEvent } from '@/core/analytics/types'; +import { sendAnalytics } from '@/core/analytics/utils/sendAnalytics'; +import { useOnchainKit } from '@/useOnchainKit'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAnalytics } from './useAnalytics'; + +vi.mock('@/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + +vi.mock('@/core/analytics/utils/sendAnalytics', () => ({ + sendAnalytics: vi.fn(), +})); + +describe('useAnalytics', () => { + const mockApiKey = 'test-api-key'; + const mockSessionId = 'test-session-id'; + const mockAnalyticsUrl = 'https://custom-analytics.example.com'; + + beforeEach(() => { + vi.clearAllMocks(); + + (useOnchainKit as unknown as ReturnType).mockReturnValue({ + apiKey: mockApiKey, + sessionId: mockSessionId, + config: { + analytics: true, + analyticsUrl: mockAnalyticsUrl, + }, + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('should return sendAnalytics function', () => { + const { result } = renderHook(() => useAnalytics()); + expect(result.current.sendAnalytics).toBeDefined(); + }); + + it('should call sendAnalytics with correct payload structure', () => { + const mockTitle = 'Test App'; + Object.defineProperty(global.document, 'title', { + value: mockTitle, + writable: true, + }); + + const { result } = renderHook(() => useAnalytics()); + const event = WalletEvent.ConnectInitiated; + const data = { + component: 'ConnectWallet', + walletProvider: 'TestProvider', + }; + + result.current.sendAnalytics(event, data); + + expect(sendAnalytics).toHaveBeenCalledWith({ + url: mockAnalyticsUrl, + headers: { + 'OnchainKit-App-Name': mockTitle, + }, + body: { + apiKey: mockApiKey, + sessionId: mockSessionId, + timestamp: expect.any(Number), + eventType: event, + data, + }, + }); + }); + + it('should not send analytics when disabled in config', () => { + (useOnchainKit as unknown as ReturnType).mockReturnValue({ + apiKey: mockApiKey, + sessionId: mockSessionId, + config: { + analytics: false, + }, + }); + + const { result } = renderHook(() => useAnalytics()); + const event = WalletEvent.ConnectSuccess; + const data = { + address: '0x0000000000000000000000000000000000000000', + component: 'ConnectWallet', + walletProvider: 'TestProvider', + }; + + result.current.sendAnalytics(event, data); + + expect(sendAnalytics).not.toHaveBeenCalled(); + }); + + it('should use default analytics URL when not provided in config', () => { + (useOnchainKit as unknown as ReturnType).mockReturnValue({ + apiKey: mockApiKey, + sessionId: mockSessionId, + config: { + analytics: true, + }, + }); + + const { result } = renderHook(() => useAnalytics()); + const event = WalletEvent.ConnectError; + const data = { + error: 'Test error message', + metadata: { + connector: 'TestProvider', + }, + }; + + result.current.sendAnalytics(event, data); + + expect(sendAnalytics).toHaveBeenCalledWith( + expect.objectContaining({ + url: ANALYTICS_API_URL, + }), + ); + }); + + it('should handle undefined apiKey and sessionId', () => { + (useOnchainKit as unknown as ReturnType).mockReturnValue({ + apiKey: undefined, + sessionId: undefined, + config: { + analytics: true, + }, + }); + + const { result } = renderHook(() => useAnalytics()); + const event = WalletEvent.ConnectInitiated; + const data = { + component: 'ConnectWallet', + walletProvider: 'TestProvider', + }; + + result.current.sendAnalytics(event, data); + + expect(sendAnalytics).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + apiKey: 'undefined', + sessionId: 'undefined', + }), + }), + ); + }); +}); diff --git a/src/core/analytics/hooks/useAnalytics.ts b/src/core/analytics/hooks/useAnalytics.ts new file mode 100644 index 0000000000..3795f253c4 --- /dev/null +++ b/src/core/analytics/hooks/useAnalytics.ts @@ -0,0 +1,53 @@ +import { ANALYTICS_API_URL } from '@/core/analytics/constants'; +import type { + AnalyticsEvent, + AnalyticsEventData, +} from '@/core/analytics/types'; +import { sendAnalytics } from '@/core/analytics/utils/sendAnalytics'; +import { useOnchainKit } from '@/useOnchainKit'; +import { useEffect, useState } from 'react'; + +/** + * useAnalytics handles analytics events and data preparation + */ +export const useAnalytics = () => { + const { apiKey, sessionId, config } = useOnchainKit(); + const [appName, setAppName] = useState(''); + + useEffect(() => { + setAppName(document.title); + }, []); + + const prepareAnalyticsPayload = ( + event: AnalyticsEvent, + data: AnalyticsEventData[AnalyticsEvent], + ) => { + return { + url: config?.analyticsUrl ?? ANALYTICS_API_URL, + headers: { + 'OnchainKit-App-Name': appName, + }, + body: { + apiKey: apiKey ?? 'undefined', + sessionId: sessionId ?? 'undefined', + timestamp: Date.now(), + eventType: event, + data, + }, + }; + }; + + return { + sendAnalytics: ( + event: AnalyticsEvent, + data: AnalyticsEventData[AnalyticsEvent], + ) => { + // Don't send analytics if disabled + if (!config?.analytics) { + return; + } + const payload = prepareAnalyticsPayload(event, data); + sendAnalytics(payload); + }, + }; +}; diff --git a/src/core/analytics/types.ts b/src/core/analytics/types.ts new file mode 100644 index 0000000000..67bf659dd1 --- /dev/null +++ b/src/core/analytics/types.ts @@ -0,0 +1,377 @@ +/** + * Component-specific events + */ + +/** + * Wallet component events - Tracks all possible wallet interaction states + * Used to monitor wallet connection flow and user interactions + */ +export enum WalletEvent { + /** Wallet connection fails */ + ConnectError = 'walletConnectError', + /** User clicks connect wallet button */ + ConnectInitiated = 'walletConnectInitiated', + /** Wallet successfully connected */ + ConnectSuccess = 'walletConnectSuccess', + /** User disconnects wallet */ + Disconnect = 'walletDisconnect', + /** User selects a wallet option */ + OptionSelected = 'walletOptionSelected', +} + +/** + * Wallet option - Available actions in the wallet interface + * Used to track which wallet features users interact with + */ +export enum WalletOption { + Buy = 'buy', + Explorer = 'explorer', + QR = 'qr', + Refresh = 'refresh', + Send = 'send', + Swap = 'swap', +} + +/** + * Swap component events + */ +export enum SwapEvent { + SlippageChanged = 'swapSlippageChanged', + TokenSelected = 'swapTokenSelected', + SwapSuccess = 'swapSuccess', + SwapInitiated = 'swapInitiated', + SwapFailure = 'swapFailure', + TokenDropdownSelected = 'tokenDropdownSelected', +} + +/** + * Buy option - Available payment methods for buying + * Used to track which payment method users select + */ +export enum BuyOption { + APPLE_PAY = 'apple_pay', + COINBASE = 'coinbase_account', + DEBIT_CARD = 'debit_card', + ETH = 'eth', + USDC = 'usdc', +} + +/** + * Buy component events + */ +export enum BuyEvent { + BuyFailure = 'buyFailure', + BuyInitiated = 'buyInitiated', + BuyOptionSelected = 'buyOptionSelected', + BuySuccess = 'buySuccess', +} + +/** + * Checkout component events + */ +export enum CheckoutEvent { + CheckoutFailure = 'checkoutFailure', + CheckoutInitiated = 'checkoutInitiated', + CheckoutSuccess = 'checkoutSuccess', +} + +/** + * Mint component events + */ +export enum MintEvent { + MintFailure = 'mintFailure', + MintInitiated = 'mintInitiated', + MintQuantityChanged = 'mintQuantityChanged', + MintSuccess = 'mintSuccess', +} + +/** + * Transaction component events + */ +export enum TransactionEvent { + TransactionFailure = 'transactionFailure', + TransactionInitiated = 'transactionInitiated', + TransactionSuccess = 'transactionSuccess', +} + +/** + * Fund component events + */ +export enum FundEvent { + FundAmountChanged = 'fundAmountChanged', + FundFailure = 'fundFailure', + FundInitiated = 'fundInitiated', + FundOptionSelected = 'fundOptionSelected', + FundSuccess = 'fundSuccess', +} + +/** + * Generic error events across components + * Used for error tracking and monitoring + */ +export enum ErrorEvent { + ComponentError = 'componentError', +} + +/** + * Analytics event types + * Combines all possible event types + */ +export type AnalyticsEvent = + | WalletEvent + | SwapEvent + | BuyEvent + | CheckoutEvent + | MintEvent + | TransactionEvent + | FundEvent + | ErrorEvent; + +/** + * Common analytics data included in all events + * Provides basic context for every tracked event + */ +export type CommonAnalyticsData = { + /** Unique identifier for user session */ + sessionId?: string; + timestamp?: number; +}; + +export type WalletEventData = { + [WalletEvent.ConnectInitiated]: CommonAnalyticsData & { + /** Component used to connect wallet */ + component: string; + /** Coinbase, Metamask, Phantom, etc. */ + walletProvider: string; + }; + [WalletEvent.ConnectError]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [WalletEvent.ConnectSuccess]: CommonAnalyticsData & { + address: string; + walletProvider: string; + }; + [WalletEvent.Disconnect]: CommonAnalyticsData & { + component: string; + walletProvider: string; + }; + [WalletEvent.OptionSelected]: CommonAnalyticsData & { + option: WalletOption; + }; +}; + +export type SwapEventData = { + [SwapEvent.SlippageChanged]: CommonAnalyticsData & { + previousSlippage: number; + slippage: number; + }; + [SwapEvent.TokenSelected]: CommonAnalyticsData & { + token: string; + }; + [SwapEvent.SwapSuccess]: CommonAnalyticsData & { + paymaster: boolean; + transactionHash: string; + address: string; + amount: number; + from: string; + to: string; + }; + [SwapEvent.SwapFailure]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [SwapEvent.TokenDropdownSelected]: CommonAnalyticsData & { + position: 'from' | 'to'; + }; + [SwapEvent.SwapInitiated]: CommonAnalyticsData & { + amount: number; + from: string; + to: string; + }; +}; + +export type BuyEventData = { + [BuyEvent.BuyFailure]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [BuyEvent.BuyInitiated]: CommonAnalyticsData & { + amount: number; + token: string; + }; + [BuyEvent.BuyOptionSelected]: CommonAnalyticsData & { + option: BuyOption; + }; + [BuyEvent.BuySuccess]: CommonAnalyticsData & { + address: string; + amount: number; + from: string; + paymaster: boolean; + to: string; + transactionHash: string; + }; +}; + +/** + * Checkout component events data + */ +export type CheckoutEventData = { + [CheckoutEvent.CheckoutSuccess]: CommonAnalyticsData & { + address: string; + amount: number; + productId: string; + chargeHandlerId: string; + isSponsored: boolean; + transactionHash?: string; + }; + [CheckoutEvent.CheckoutFailure]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [CheckoutEvent.CheckoutInitiated]: CommonAnalyticsData & { + amount: number; + productId: string; + chargeHandlerId: string; + }; +}; + +/** + * Mint component events data + */ +export type MintEventData = { + [MintEvent.MintFailure]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [MintEvent.MintInitiated]: CommonAnalyticsData & { + contractAddress: string; + quantity: number; + tokenId: string; + }; + [MintEvent.MintQuantityChanged]: CommonAnalyticsData & { + quantity: number; + previousQuantity: number; + }; + [MintEvent.MintSuccess]: CommonAnalyticsData & { + address: string; + amountMinted: number; + contractAddress: string; + isSponsored: boolean; + tokenId: string; + }; +}; + +/** + * Transaction component events data + */ +export type TransactionEventData = { + [TransactionEvent.TransactionFailure]: CommonAnalyticsData & { + error: string; + contracts: Array<{ + contractAddress: string; + function: string; + }>; + metadata?: Record; + }; + [TransactionEvent.TransactionInitiated]: CommonAnalyticsData & { + address: string; + contracts: Array<{ + contractAddress: string; + function: string; + }>; + }; + [TransactionEvent.TransactionSuccess]: CommonAnalyticsData & { + paymaster: boolean; + address: string; + contracts: Array<{ + contractAddress: string; + function: string; + }>; + transactionHash: string; + }; +}; + +/** + * Fund component events data + */ +export type FundEventData = { + [FundEvent.FundAmountChanged]: CommonAnalyticsData & { + amount: number; + currency: string; + previousAmount: number; + }; + [FundEvent.FundFailure]: CommonAnalyticsData & { + error: string; + metadata?: Record; + }; + [FundEvent.FundInitiated]: CommonAnalyticsData & { + amount: number; + currency: string; + }; + [FundEvent.FundOptionSelected]: CommonAnalyticsData & { + option: string; + }; + [FundEvent.FundSuccess]: CommonAnalyticsData & { + address: string; + amount: number; + currency: string; + transactionHash: string; + }; +}; + +// Update main AnalyticsEventData type to include all component events +export type AnalyticsEventData = { + // Wallet events + [WalletEvent.ConnectError]: WalletEventData[WalletEvent.ConnectError]; + [WalletEvent.ConnectInitiated]: WalletEventData[WalletEvent.ConnectInitiated]; + [WalletEvent.ConnectSuccess]: WalletEventData[WalletEvent.ConnectSuccess]; + [WalletEvent.Disconnect]: WalletEventData[WalletEvent.Disconnect]; + [WalletEvent.OptionSelected]: CommonAnalyticsData & { + option: WalletOption; + }; + + // Swap events + [SwapEvent.SlippageChanged]: SwapEventData[SwapEvent.SlippageChanged]; + [SwapEvent.TokenSelected]: SwapEventData[SwapEvent.TokenSelected]; + [SwapEvent.SwapSuccess]: SwapEventData[SwapEvent.SwapSuccess]; + [SwapEvent.SwapFailure]: SwapEventData[SwapEvent.SwapFailure]; + [SwapEvent.TokenDropdownSelected]: SwapEventData[SwapEvent.TokenDropdownSelected]; + [SwapEvent.SwapInitiated]: SwapEventData[SwapEvent.SwapInitiated]; + + // Buy events + [BuyEvent.BuyFailure]: BuyEventData[BuyEvent.BuyFailure]; + [BuyEvent.BuyInitiated]: BuyEventData[BuyEvent.BuyInitiated]; + [BuyEvent.BuyOptionSelected]: BuyEventData[BuyEvent.BuyOptionSelected]; + [BuyEvent.BuySuccess]: BuyEventData[BuyEvent.BuySuccess]; + + // Checkout events + [CheckoutEvent.CheckoutFailure]: CheckoutEventData[CheckoutEvent.CheckoutFailure]; + [CheckoutEvent.CheckoutInitiated]: CheckoutEventData[CheckoutEvent.CheckoutInitiated]; + [CheckoutEvent.CheckoutSuccess]: CheckoutEventData[CheckoutEvent.CheckoutSuccess]; + + // Mint events + [MintEvent.MintFailure]: MintEventData[MintEvent.MintFailure]; + [MintEvent.MintInitiated]: MintEventData[MintEvent.MintInitiated]; + [MintEvent.MintQuantityChanged]: MintEventData[MintEvent.MintQuantityChanged]; + [MintEvent.MintSuccess]: MintEventData[MintEvent.MintSuccess]; + + // Transaction events + [TransactionEvent.TransactionFailure]: TransactionEventData[TransactionEvent.TransactionFailure]; + [TransactionEvent.TransactionInitiated]: TransactionEventData[TransactionEvent.TransactionInitiated]; + [TransactionEvent.TransactionSuccess]: TransactionEventData[TransactionEvent.TransactionSuccess]; + + // Fund events + [FundEvent.FundAmountChanged]: FundEventData[FundEvent.FundAmountChanged]; + [FundEvent.FundFailure]: FundEventData[FundEvent.FundFailure]; + [FundEvent.FundInitiated]: FundEventData[FundEvent.FundInitiated]; + [FundEvent.FundOptionSelected]: FundEventData[FundEvent.FundOptionSelected]; + [FundEvent.FundSuccess]: FundEventData[FundEvent.FundSuccess]; + + // Error events + [ErrorEvent.ComponentError]: CommonAnalyticsData & { + component: string; + error: string; + metadata?: Record; + }; +}; diff --git a/src/core/analytics/utils/sendAnalytics.test.ts b/src/core/analytics/utils/sendAnalytics.test.ts new file mode 100644 index 0000000000..baac6bbabe --- /dev/null +++ b/src/core/analytics/utils/sendAnalytics.test.ts @@ -0,0 +1,205 @@ +import type { OnchainKitContextType } from '@/core/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { baseSepolia } from 'wagmi/chains'; +import { ANALYTICS_API_URL } from '../../analytics/constants'; +import { JSON_HEADERS } from '../../network/constants'; +import { type AnalyticsRequestParams, sendAnalytics } from './sendAnalytics'; + +vi.mock('@/useOnchainKit', () => ({ + useOnchainKit: vi.fn(() => ({ + config: { analytics: true }, + })), +})); + +import { useOnchainKit } from '@/useOnchainKit'; + +describe('sendAnalytics', () => { + const mockFetch = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = mockFetch; + + ( + useOnchainKit as unknown as MockInstance<() => OnchainKitContextType> + ).mockImplementation(() => ({ + address: null, + apiKey: null, + chain: baseSepolia, + rpcUrl: null, + schemaId: null, + projectId: null, + sessionId: null, + config: { + analytics: true, + analyticsUrl: null, + appearance: { + name: null, + logo: null, + mode: null, + theme: null, + }, + paymaster: null, + wallet: { + display: null, + termsUrl: null, + privacyUrl: null, + }, + }, + })); + }); + + it('should send analytics data with correct parameters', async () => { + const request: AnalyticsRequestParams = { + url: ANALYTICS_API_URL, + headers: { + 'OnchainKit-App-Name': 'TestApp', + }, + body: { + apiKey: 'test-api-key', + sessionId: 'test-session-id', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + mockFetch.mockResolvedValueOnce({}); + + await sendAnalytics(request); + + expect(mockFetch).toHaveBeenCalledWith(request.url, { + method: 'POST', + headers: { + ...JSON_HEADERS, + ...request.headers, + }, + body: JSON.stringify(request.body), + }); + }); + + it('should handle null apiKey by using "undefined" string', async () => { + const request: AnalyticsRequestParams = { + url: ANALYTICS_API_URL, + headers: { + 'OnchainKit-App-Name': 'TestApp', + }, + body: { + apiKey: 'undefined', + sessionId: 'undefined', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + mockFetch.mockResolvedValueOnce({}); + + await sendAnalytics(request); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"apiKey":"undefined"'), + }), + ); + }); + + it('should handle null values by using "undefined" string', async () => { + const request: AnalyticsRequestParams = { + url: ANALYTICS_API_URL, + headers: { + 'OnchainKit-App-Name': 'TestApp', + }, + body: { + apiKey: 'undefined', + sessionId: 'undefined', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + mockFetch.mockResolvedValueOnce({}); + + await sendAnalytics(request); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"sessionId":"undefined"'), + }), + ); + }); + + it('should silently fail and log error when fetch fails', async () => { + const error = new Error('Network error'); + mockFetch.mockRejectedValueOnce(error); + + const request: AnalyticsRequestParams = { + url: ANALYTICS_API_URL, + headers: {}, + body: { + apiKey: 'test-api-key', + sessionId: 'test-session-id', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + await sendAnalytics(request); + + expect(consoleSpy).toHaveBeenCalledWith('Error sending analytics:', error); + }); + + it('should use provided analyticsUrl when specified', async () => { + const customUrl = 'https://custom-analytics.example.com'; + const request: AnalyticsRequestParams = { + url: customUrl, + headers: { + 'OnchainKit-App-Name': 'TestApp', + }, + body: { + apiKey: 'test-api-key', + sessionId: 'test-session-id', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + mockFetch.mockResolvedValueOnce({}); + + await sendAnalytics(request); + + expect(mockFetch).toHaveBeenCalledWith(customUrl, expect.any(Object)); + }); + + it('should use default ANALYTICS_API_URL when analyticsUrl is not provided', async () => { + const request: AnalyticsRequestParams = { + url: ANALYTICS_API_URL, + headers: { + 'OnchainKit-App-Name': 'TestApp', + }, + body: { + apiKey: 'test-api-key', + sessionId: 'test-session-id', + timestamp: Date.now(), + eventType: 'test-event', + data: { foo: 'bar' }, + }, + }; + + mockFetch.mockResolvedValueOnce({}); + + await sendAnalytics(request); + + expect(mockFetch).toHaveBeenCalledWith( + ANALYTICS_API_URL, + expect.any(Object), + ); + }); +}); diff --git a/src/core/analytics/utils/sendAnalytics.ts b/src/core/analytics/utils/sendAnalytics.ts new file mode 100644 index 0000000000..dff6626c22 --- /dev/null +++ b/src/core/analytics/utils/sendAnalytics.ts @@ -0,0 +1,34 @@ +import { JSON_HEADERS } from '@/core/network/constants'; + +export type AnalyticsRequestParams = { + url: string; + headers: Record; + body: { + apiKey: string; + sessionId: string; + timestamp: number; + eventType: string; + data: Record; + }; +}; + +/** + * sendAnalytics sends telemetry data to the specified server endpoint. + */ +export const sendAnalytics = async (request: AnalyticsRequestParams) => { + try { + await fetch(request.url, { + method: 'POST', + headers: { + ...JSON_HEADERS, + ...request.headers, + }, + body: JSON.stringify(request.body), + }); + } catch (error) { + // Silently fail + if (process.env.NODE_ENV !== 'production') { + console.error('Error sending analytics:', error); + } + } +}; diff --git a/src/core/network/constants.ts b/src/core/network/constants.ts index 30430b9181..4d9d033b14 100644 --- a/src/core/network/constants.ts +++ b/src/core/network/constants.ts @@ -7,7 +7,6 @@ export const JSON_HEADERS = { }; export const CONTEXT_HEADER = 'OnchainKit-Context'; export const JSON_RPC_VERSION = '2.0'; -export const ANALYTICS_API_URL = 'https://api.developer.coinbase.com/analytics'; /** * Internal - The context where the request originated diff --git a/src/core/network/sendAnalytics.test.ts b/src/core/network/sendAnalytics.test.ts deleted file mode 100644 index a03745a293..0000000000 --- a/src/core/network/sendAnalytics.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { OnchainKitContextType } from '@/core/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MockInstance } from 'vitest'; -import { baseSepolia } from 'wagmi/chains'; -import { ANALYTICS_API_URL, JSON_HEADERS } from './constants'; -import { sendAnalytics } from './sendAnalytics'; - -vi.mock('@/useOnchainKit', () => ({ - useOnchainKit: vi.fn(() => ({ - config: { analytics: true }, - })), -})); - -import { useOnchainKit } from '@/useOnchainKit'; - -describe('sendAnalytics', () => { - const mockFetch = vi.fn(); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - beforeEach(() => { - vi.clearAllMocks(); - global.fetch = mockFetch; - - ( - useOnchainKit as unknown as MockInstance<() => OnchainKitContextType> - ).mockImplementation(() => ({ - address: null, - apiKey: null, - chain: baseSepolia, - rpcUrl: null, - schemaId: null, - projectId: null, - interactionId: null, - config: { - analytics: true, - analyticsUrl: null, - appearance: { - name: null, - logo: null, - mode: null, - theme: null, - }, - paymaster: null, - wallet: { - display: null, - termsUrl: null, - privacyUrl: null, - }, - }, - })); - }); - - it('should send analytics data with correct parameters', async () => { - const params = { - appName: 'TestApp', - apiKey: 'test-api-key', - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - mockFetch.mockResolvedValueOnce({}); - - await sendAnalytics(params); - - expect(mockFetch).toHaveBeenCalledWith(ANALYTICS_API_URL, { - method: 'POST', - headers: { - ...JSON_HEADERS, - 'OnchainKit-App-Name': params.appName, - }, - body: JSON.stringify({ - apiKey: params.apiKey, - interactionId: params.interactionId, - eventType: params.event, - data: params.data, - }), - }); - }); - - it('should handle null apiKey by using "undefined" string', async () => { - const params = { - appName: 'TestApp', - apiKey: null, - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - mockFetch.mockResolvedValueOnce({}); - - await sendAnalytics(params); - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"apiKey":"undefined"'), - }), - ); - }); - - it('should handle null apiKey by using "undefined" string', async () => { - const params = { - appName: 'TestApp', - apiKey: null, - data: { foo: 'bar' }, - event: 'test-event', - interactionId: null, - }; - - mockFetch.mockResolvedValueOnce({}); - - await sendAnalytics(params); - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining('"interactionId":"undefined"'), - }), - ); - }); - - it('should silently fail and log error when fetch fails', async () => { - const error = new Error('Network error'); - mockFetch.mockRejectedValueOnce(error); - - const params = { - appName: 'TestApp', - apiKey: 'test-api-key', - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - await sendAnalytics(params); - - expect(consoleSpy).toHaveBeenCalledWith('Error sending analytics:', error); - }); - - it('should use provided analyticsUrl when specified', async () => { - const customUrl = 'https://custom-analytics.example.com'; - const params = { - analyticsUrl: customUrl, - appName: 'TestApp', - apiKey: 'test-api-key', - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - mockFetch.mockResolvedValueOnce({}); - - await sendAnalytics(params); - - expect(mockFetch).toHaveBeenCalledWith(customUrl, expect.any(Object)); - }); - - it('should use default ANALYTICS_API_URL when analyticsUrl is not provided', async () => { - const params = { - appName: 'TestApp', - apiKey: 'test-api-key', - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - mockFetch.mockResolvedValueOnce({}); - - await sendAnalytics(params); - - expect(mockFetch).toHaveBeenCalledWith( - ANALYTICS_API_URL, - expect.any(Object), - ); - }); - - it('should not send analytics when config.analytics is false', async () => { - ( - useOnchainKit as unknown as MockInstance<() => OnchainKitContextType> - ).mockImplementation(() => ({ - address: null, - apiKey: null, - chain: baseSepolia, - rpcUrl: null, - schemaId: null, - projectId: null, - interactionId: null, - config: { - analytics: false, - analyticsUrl: null, - appearance: { - name: null, - logo: null, - mode: null, - theme: null, - }, - paymaster: null, - wallet: { - display: null, - termsUrl: null, - privacyUrl: null, - }, - }, - })); - - const params = { - appName: 'TestApp', - apiKey: 'test-api-key', - data: { foo: 'bar' }, - event: 'test-event', - interactionId: 'test-interaction-id', - }; - - await sendAnalytics(params); - - expect(mockFetch).not.toHaveBeenCalled(); - }); -}); diff --git a/src/core/network/sendAnalytics.ts b/src/core/network/sendAnalytics.ts deleted file mode 100644 index 79217848b7..0000000000 --- a/src/core/network/sendAnalytics.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ANALYTICS_API_URL, JSON_HEADERS } from '@/core/network/constants'; -import { useOnchainKit } from '@/useOnchainKit'; - -interface AnalyticsParams { - analyticsUrl?: string; - appName: string; - apiKey: string | null; - data: Record; - event: string; - interactionId: string | null; -} - -export const sendAnalytics = async ({ - analyticsUrl = ANALYTICS_API_URL, - appName, - apiKey, - data, - event, - interactionId, -}: AnalyticsParams) => { - const { config } = useOnchainKit(); - // If analytics is set to false in OnchainKitProvider config, - // all telemetry collection is disabled and no data will be sent - if (config?.analytics === false) { - return; - } - - try { - await fetch(analyticsUrl, { - method: 'POST', - headers: { - ...JSON_HEADERS, - 'OnchainKit-App-Name': appName, - }, - body: JSON.stringify({ - apiKey: apiKey ?? 'undefined', - interactionId: interactionId ?? 'undefined', - eventType: event, - data, - }), - }); - } catch (error) { - // Silently fail - console.error('Error sending analytics:', error); - } -}; diff --git a/src/core/types.ts b/src/core/types.ts index 6c8847ee5e..a2fc4220e8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -102,8 +102,8 @@ export type OnchainKitConfig = { schemaId: EASSchemaUid | null; /** ProjectId from Coinbase Developer Platform, only required for Coinbase Onramp support */ projectId: string | null; - /** InteractionId, used for analytics */ - interactionId: string | null; + /** SessionId, used for analytics */ + sessionId: string | null; }; export type SetOnchainKitConfig = Partial; diff --git a/src/internal/hooks/useAnalytics.test.ts b/src/internal/hooks/useAnalytics.test.ts deleted file mode 100644 index ca649bfd42..0000000000 --- a/src/internal/hooks/useAnalytics.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { sendAnalytics } from '@/core/network/sendAnalytics'; -import { AnalyticsEvent } from '@/internal/types'; -import { useOnchainKit } from '@/useOnchainKit'; -import { cleanup, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useAnalytics } from './useAnalytics'; - -vi.mock('@/useOnchainKit', () => ({ - useOnchainKit: vi.fn(), -})); - -vi.mock('@/core/network/sendAnalytics', () => ({ - sendAnalytics: vi.fn(), -})); - -describe('useAnalytics', () => { - const mockApiKey = 'test-api-key'; - const mockInteractionId = 'test-interaction-id'; - - beforeEach(() => { - vi.clearAllMocks(); - - (useOnchainKit as unknown as ReturnType).mockReturnValue({ - apiKey: mockApiKey, - interactionId: mockInteractionId, - }); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it('should return sendAnalytics function', () => { - const { result } = renderHook(() => useAnalytics()); - expect(result.current.sendAnalytics).toBeDefined(); - }); - - it('should call sendAnalytics with correct parameters', () => { - const { result } = renderHook(() => useAnalytics()); - const event = AnalyticsEvent.WALLET_CONNECTED; - const data = { address: '0x0000000000000000000000000000000000000000' }; - - result.current.sendAnalytics(event, data); - - expect(sendAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - event, - data, - appName: expect.any(String), - apiKey: mockApiKey, - interactionId: mockInteractionId, - }), - ); - }); - - it('should use document.title as appName in browser environment', () => { - const mockTitle = 'Test App'; - Object.defineProperty(global.document, 'title', { - value: mockTitle, - writable: true, - }); - - const { result } = renderHook(() => useAnalytics()); - const event = AnalyticsEvent.WALLET_CONNECTED; - const data = { address: '0x0000000000000000000000000000000000000000' }; - - result.current.sendAnalytics(event, data); - - expect(sendAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - event, - data, - appName: mockTitle, - apiKey: mockApiKey, - interactionId: mockInteractionId, - }), - ); - }); - - it('should handle analyticsUrl from config correctly', () => { - const mockTitle = 'Test App'; - const customAnalyticsUrl = 'https://custom-analytics.example.com'; - - Object.defineProperty(global.document, 'title', { - value: mockTitle, - writable: true, - }); - - (useOnchainKit as unknown as ReturnType).mockReturnValue({ - apiKey: mockApiKey, - interactionId: mockInteractionId, - config: { - analyticsUrl: customAnalyticsUrl, - }, - }); - - const { result: resultWithUrl } = renderHook(() => useAnalytics()); - const event = AnalyticsEvent.WALLET_CONNECTED; - const data = { address: '0x0000000000000000000000000000000000000000' }; - - resultWithUrl.current.sendAnalytics(event, data); - - expect(sendAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - analyticsUrl: customAnalyticsUrl, - event, - data, - appName: mockTitle, - apiKey: mockApiKey, - interactionId: mockInteractionId, - }), - ); - - (useOnchainKit as unknown as ReturnType).mockReturnValue({ - apiKey: mockApiKey, - interactionId: mockInteractionId, - config: { - analyticsUrl: null, - }, - }); - - const { result: resultWithNull } = renderHook(() => useAnalytics()); - resultWithNull.current.sendAnalytics(event, data); - - expect(sendAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - analyticsUrl: undefined, - event, - data, - appName: mockTitle, - apiKey: mockApiKey, - interactionId: mockInteractionId, - }), - ); - }); -}); diff --git a/src/internal/hooks/useAnalytics.ts b/src/internal/hooks/useAnalytics.ts deleted file mode 100644 index f277a71039..0000000000 --- a/src/internal/hooks/useAnalytics.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { sendAnalytics } from '@/core/network/sendAnalytics'; -import type { AnalyticsEvent, AnalyticsEventData } from '@/internal/types'; -import { useOnchainKit } from '@/useOnchainKit'; -import { useEffect, useState } from 'react'; - -export const useAnalytics = () => { - const { apiKey, interactionId, config } = useOnchainKit(); - const [appName, setAppName] = useState(''); - - useEffect(() => { - setAppName(document.title); - }, []); - - return { - sendAnalytics: ( - event: AnalyticsEvent, - data: AnalyticsEventData[AnalyticsEvent], - ) => { - sendAnalytics({ - analyticsUrl: config?.analyticsUrl ?? undefined, - appName, - apiKey, - data, - event, - interactionId, - }); - }, - }; -}; diff --git a/src/internal/types.ts b/src/internal/types.ts index 57a0c20c97..9f8a12717e 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -10,16 +10,6 @@ type GenericStatus = { statusData: T; }; -export enum AnalyticsEvent { - WALLET_CONNECTED = 'walletConnected', -} - -export type AnalyticsEventData = { - [AnalyticsEvent.WALLET_CONNECTED]: { - address: string; - }; -}; - // biome-ignore lint/suspicious/noExplicitAny: generic status can be any type export type AbstractLifecycleStatus = ErrorStatus | GenericStatus; diff --git a/src/types.ts b/src/types.ts index 998caddb68..f508431f5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,7 @@ export type OnchainKitProviderReact = { chain: Chain; children: ReactNode; config?: AppConfig; - interactionId?: string; + sessionId?: string; projectId?: string; rpcUrl?: string; schemaId?: EASSchemaUid; diff --git a/src/wallet/components/ConnectWallet.test.tsx b/src/wallet/components/ConnectWallet.test.tsx index 8aed92530d..5bbd3dbcca 100644 --- a/src/wallet/components/ConnectWallet.test.tsx +++ b/src/wallet/components/ConnectWallet.test.tsx @@ -3,6 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAccount, useConnect } from 'wagmi'; +import { useAnalytics } from '../../core/analytics/hooks/useAnalytics'; +import { WalletEvent } from '../../core/analytics/types'; import { useOnchainKit } from '../../useOnchainKit'; import { ConnectWallet } from './ConnectWallet'; import { ConnectWalletText } from './ConnectWalletText'; @@ -43,7 +45,13 @@ vi.mock('@/useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); +vi.mock('../../core/analytics/hooks/useAnalytics', () => ({ + useAnalytics: vi.fn(), +})); + describe('ConnectWallet', () => { + const mockSendAnalytics = vi.fn(); + beforeEach(() => { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -76,6 +84,9 @@ describe('ConnectWallet', () => { vi.mocked(useOnchainKit).mockReturnValue({ config: { wallet: { display: undefined } }, }); + vi.mocked(useAnalytics).mockReturnValue({ + sendAnalytics: mockSendAnalytics, + }); }); it('should render connect button when disconnected', () => { @@ -116,22 +127,54 @@ describe('ConnectWallet', () => { it('should call connect function when connect button is clicked', () => { const connectMock = vi.fn(); + const mockSendAnalytics = vi.fn(); + vi.mocked(useConnect).mockReturnValue({ - connectors: [{ id: 'mockConnector' }], + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], connect: connectMock, status: 'idle', }); + + vi.mocked(useAnalytics).mockReturnValue({ + sendAnalytics: mockSendAnalytics, + }); + render(); + const button = screen.getByTestId('ockConnectButton'); fireEvent.click(button); - expect(connectMock).toHaveBeenCalledWith( + + expect(mockSendAnalytics).toHaveBeenCalledWith( + WalletEvent.ConnectInitiated, { - connector: { id: 'mockConnector' }, + component: 'ConnectWallet', + walletProvider: 'TestConnector', }, + ); + + expect(connectMock).toHaveBeenCalledWith( + { connector: { name: 'TestConnector', id: 'mockConnector' } }, { onSuccess: expect.any(Function), + onError: expect.any(Function), }, ); + + connectMock.mock.calls[0][1].onSuccess(); + expect(mockSendAnalytics).toHaveBeenCalledWith(WalletEvent.ConnectSuccess, { + address: '', + walletProvider: 'TestConnector', + }); + + const error = new Error('Test error'); + connectMock.mock.calls[0][1].onError(error); + expect(mockSendAnalytics).toHaveBeenCalledWith(WalletEvent.ConnectError, { + error: 'Test error', + metadata: { + connector: 'TestConnector', + component: 'ConnectWallet', + }, + }); }); it('should toggle wallet modal on button click when connected', () => { @@ -301,7 +344,10 @@ describe('ConnectWallet', () => { expect(connectMock).toHaveBeenCalledWith( { connector: { id: 'mockConnector' } }, - { onSuccess: expect.any(Function) }, + { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }, ); }); }); @@ -345,7 +391,10 @@ describe('ConnectWallet', () => { expect(connectMock).toHaveBeenCalledWith( { connector: { id: 'mockConnector' } }, - { onSuccess: expect.any(Function) }, + { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }, ); connectMock.mock.calls[0][1].onSuccess(); @@ -543,4 +592,125 @@ describe('ConnectWallet', () => { expect(onConnectMock).toHaveBeenCalledTimes(1); }); }); + + describe('analytics', () => { + it('should send analytics when connect button is clicked in modal mode', () => { + vi.mocked(useOnchainKit).mockReturnValue({ + config: { wallet: { display: 'modal' } }, + }); + + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], + connect: vi.fn(), + status: 'idle', + }); + + render(); + + const button = screen.getByTestId('ockConnectButton'); + fireEvent.click(button); + + expect(mockSendAnalytics).toHaveBeenCalledWith( + WalletEvent.ConnectInitiated, + { + component: 'ConnectWallet', + walletProvider: 'TestConnector', + }, + ); + }); + + it('should send analytics when direct connect is initiated', () => { + const connectMock = vi.fn(); + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], + connect: connectMock, + status: 'idle', + }); + + render(); + + const button = screen.getByTestId('ockConnectButton'); + fireEvent.click(button); + + expect(mockSendAnalytics).toHaveBeenCalledWith( + WalletEvent.ConnectInitiated, + { + component: 'ConnectWallet', + walletProvider: 'TestConnector', + }, + ); + }); + + it('should send analytics on successful connection', () => { + const connectMock = vi.fn(); + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], + connect: connectMock, + status: 'idle', + }); + + render(); + + const button = screen.getByTestId('ockConnectButton'); + fireEvent.click(button); + + connectMock.mock.calls[0][1].onSuccess(); + + expect(mockSendAnalytics).toHaveBeenCalledWith( + WalletEvent.ConnectSuccess, + { + address: '', + walletProvider: 'TestConnector', + }, + ); + }); + + it('should send analytics on connection error', () => { + const connectMock = vi.fn(); + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], + connect: connectMock, + status: 'idle', + }); + + render(); + + const button = screen.getByTestId('ockConnectButton'); + fireEvent.click(button); + + connectMock.mock.calls[0][1].onError(new Error('Test error')); + + expect(mockSendAnalytics).toHaveBeenCalledWith(WalletEvent.ConnectError, { + error: 'Test error', + metadata: { + connector: 'TestConnector', + component: 'ConnectWallet', + }, + }); + }); + + it('should send analytics when account is connected with address', () => { + const mockUseAccount = vi.mocked(useAccount); + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ name: 'TestConnector', id: 'mockConnector' }], + connect: vi.fn(), + status: 'idle', + }); + + mockUseAccount.mockReturnValue({ + address: '0x123', + status: 'connected', + }); + + render(); + + expect(mockSendAnalytics).toHaveBeenCalledWith( + WalletEvent.ConnectSuccess, + { + address: '0x123', + walletProvider: 'TestConnector', + }, + ); + }); + }); }); diff --git a/src/wallet/components/ConnectWallet.tsx b/src/wallet/components/ConnectWallet.tsx index 55d28e283c..58e5b6fe44 100644 --- a/src/wallet/components/ConnectWallet.tsx +++ b/src/wallet/components/ConnectWallet.tsx @@ -4,6 +4,8 @@ import { Children, isValidElement, useCallback, useMemo } from 'react'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useAccount, useConnect } from 'wagmi'; +import { useAnalytics } from '../../core/analytics/hooks/useAnalytics'; +import { WalletEvent } from '../../core/analytics/types'; import { IdentityProvider } from '../../identity/components/IdentityProvider'; import { Spinner } from '../../internal/components/Spinner'; import { findComponent } from '../../internal/utils/findComponent'; @@ -40,6 +42,7 @@ export function ConnectWallet({ } = useWalletContext(); const { address: accountAddress, status } = useAccount(); const { connectors, connect, status: connectStatus } = useConnect(); + const { sendAnalytics } = useAnalytics(); // State const [hasClickedConnect, setHasClickedConnect] = useState(false); @@ -88,6 +91,39 @@ export function ConnectWallet({ setHasClickedConnect(true); }, [setIsConnectModalOpen]); + const handleAnalyticsInitiated = useCallback( + (connectorName: string, component: string) => { + sendAnalytics(WalletEvent.ConnectInitiated, { + component, + walletProvider: connectorName, + }); + }, + [sendAnalytics], + ); + + const handleAnalyticsSuccess = useCallback( + (connectorName: string, walletAddress: string | undefined) => { + sendAnalytics(WalletEvent.ConnectSuccess, { + address: walletAddress ?? '', + walletProvider: connectorName, + }); + }, + [sendAnalytics], + ); + + const handleAnalyticsError = useCallback( + (connectorName: string, errorMessage: string, component: string) => { + sendAnalytics(WalletEvent.ConnectError, { + error: errorMessage, + metadata: { + connector: connectorName, + component, + }, + }); + }, + [sendAnalytics], + ); + // Effects useEffect(() => { if (hasClickedConnect && status === 'connected' && onConnect) { @@ -96,6 +132,12 @@ export function ConnectWallet({ } }, [status, hasClickedConnect, onConnect]); + useEffect(() => { + if (status === 'connected' && accountAddress && connector) { + handleAnalyticsSuccess(connector.name, accountAddress); + } + }, [status, accountAddress, connector, handleAnalyticsSuccess]); + if (status === 'disconnected') { if (config?.wallet?.display === 'modal') { return ( @@ -106,6 +148,7 @@ export function ConnectWallet({ onClick={() => { handleOpenConnectModal(); setHasClickedConnect(true); + handleAnalyticsInitiated(connector.name, 'ConnectWallet'); }} text={text} /> @@ -119,11 +162,21 @@ export function ConnectWallet({ className={className} connectWalletText={connectWalletText} onClick={() => { + handleAnalyticsInitiated(connector.name, 'ConnectWallet'); + connect( { connector }, { onSuccess: () => { onConnect?.(); + handleAnalyticsSuccess(connector.name, accountAddress); + }, + onError: (error) => { + handleAnalyticsError( + connector.name, + error.message, + 'ConnectWallet', + ); }, }, );