Skip to content

Commit

Permalink
feat: Add Telemetry foundation for Buy, Checkout, Fund, Mint, Swap, T…
Browse files Browse the repository at this point in the history
…ransaction, and Wallet. (#1942)
  • Loading branch information
cpcramer authored Feb 10, 2025
1 parent 3dc21ec commit 3f0cee6
Show file tree
Hide file tree
Showing 20 changed files with 1,148 additions and 454 deletions.
5 changes: 5 additions & 0 deletions .changeset/dull-steaks-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": minor
---

- **feat**: Added Telemetry foundation for `Buy`, `Checkout`, `Fund`, `Mint`, `Swap`, `Transaction`, and `Wallet`. By @cpcramer #1942
2 changes: 1 addition & 1 deletion src/OnchainKitProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe('OnchainKitProvider', () => {
rpcUrl: null,
schemaId,
projectId: null,
interactionId: expect.any(String),
sessionId: expect.any(String),
}),
);
});
Expand Down
6 changes: 3 additions & 3 deletions src/OnchainKitProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -81,7 +81,7 @@ export function OnchainKitProvider({
projectId,
rpcUrl,
schemaId,
interactionId,
sessionId,
]);

// Check the React context for WagmiProvider and QueryClientProvider
Expand Down
2 changes: 1 addition & 1 deletion src/core/OnchainKitConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const ONCHAIN_KIT_CONFIG: OnchainKitConfig = {
rpcUrl: null,
schemaId: null,
projectId: null,
interactionId: null,
sessionId: null,
};

/**
Expand Down
86 changes: 86 additions & 0 deletions src/core/analytics/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
152 changes: 152 additions & 0 deletions src/core/analytics/hooks/useAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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',
}),
}),
);
});
});
53 changes: 53 additions & 0 deletions src/core/analytics/hooks/useAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
};
Loading

0 comments on commit 3f0cee6

Please sign in to comment.