diff --git a/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts b/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts index 9bdcc539a0..59581eb32e 100644 --- a/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts +++ b/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts @@ -1,12 +1,27 @@ import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; -import { inputSearchValueFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; +import { ActionType, ComponentType } from 'libs/base-ui/utils/logEvent'; +import logServerSideEvent, { generateDeviceId } from 'apps/web/src/utils/logServerSideEvent'; import { logger } from 'apps/web/src/utils/logger'; +import { inputSearchValueFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ error: `Search Screen — Method (${req.method}) Not Allowed` }); } + try { + const eventName = 'claim_initiated'; + const deviceId = generateDeviceId(req); + const eventProperties = { + action: ActionType.click, + context: 'basenames_claim_frame', + componentType: ComponentType.button, + }; + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } + try { return res.status(200).setHeader('Content-Type', 'text/html').send(inputSearchValueFrame); } catch (error) { diff --git a/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts b/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts index 3259b6f510..c6e7890506 100644 --- a/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts +++ b/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts @@ -1,6 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; import { FrameRequest } from '@coinbase/onchainkit/frame'; +import { ActionType, ComponentType } from 'libs/base-ui/utils/logEvent'; import { formatDefaultUsername, validateEnsDomainName } from 'apps/web/src/utils/usernames'; +import logServerSideEvent, { generateDeviceId } from 'apps/web/src/utils/logServerSideEvent'; +import { logger } from 'apps/web/src/utils/logger'; import type { IsNameAvailableResponse } from 'apps/web/pages/api/basenames/[name]/isNameAvailable'; import { retryInputSearchValueFrame, @@ -13,6 +16,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ error: `Set Years Screen — Method (${req.method}) Not Allowed` }); } + try { + const eventName = 'selected_name'; + const deviceId = generateDeviceId(req); + const eventProperties = { + action: ActionType.click, + context: 'basenames_claim_frame', + componentType: ComponentType.button, + }; + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } + try { const body = req.body as FrameRequest; const { untrustedData } = body; diff --git a/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts b/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts index 5cb2fc7c53..546579c677 100644 --- a/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts +++ b/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts @@ -1,5 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; import { FrameRequest } from '@coinbase/onchainkit/frame'; +import { ActionType, ComponentType } from 'libs/base-ui/utils/logEvent'; +import logServerSideEvent, { generateDeviceId } from 'apps/web/src/utils/logServerSideEvent'; +import { logger } from 'apps/web/src/utils/logger'; import { confirmationFrame, buttonIndexToYears, @@ -24,6 +27,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ error: `Confirm Screen — Method (${req.method}) Not Allowed` }); } + try { + const eventName = 'selected_years'; + const deviceId = generateDeviceId(req); + const eventProperties = { + action: ActionType.click, + context: 'basenames_claim_frame', + componentType: ComponentType.button, + }; + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } + const body = req.body as FrameRequest; const { untrustedData } = body; const messageState = JSON.parse( diff --git a/apps/web/pages/api/basenames/frame/04_txSubmitted.ts b/apps/web/pages/api/basenames/frame/04_txSubmitted.ts index ff6d00b490..5fd40b8897 100644 --- a/apps/web/pages/api/basenames/frame/04_txSubmitted.ts +++ b/apps/web/pages/api/basenames/frame/04_txSubmitted.ts @@ -1,6 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; import { FrameRequest, getFrameMessage } from '@coinbase/onchainkit/frame'; +import { ActionType, ComponentType } from 'libs/base-ui/utils/logEvent'; import { getTransactionStatus } from 'apps/web/src/utils/frames/basenames'; +import logServerSideEvent, { generateDeviceId } from 'apps/web/src/utils/logServerSideEvent'; +import { logger } from 'apps/web/src/utils/logger'; import { txSucceededFrame, txRevertedFrame, @@ -17,6 +20,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method !== 'POST') { return res.status(405).json({ error: `TxSuccess Screen — Method (${req.method}) Not Allowed` }); } + const deviceId = generateDeviceId(req); + + try { + const eventName = 'tx_submitted'; + const eventProperties = { + action: ActionType.click, + context: 'basenames_claim_frame', + componentType: ComponentType.button, + }; + + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } const body = req.body as FrameRequest; const transactionId: string | undefined = body?.untrustedData?.transactionId; @@ -50,12 +67,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const txStatus = await getTransactionStatus(CHAIN, transactionId); if (txStatus !== 'success') { + try { + const eventName = 'tx_reverted'; + const eventProperties = { + action: ActionType.process, + context: 'basenames_claim_frame', + componentType: ComponentType.service_worker, + }; + + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } + return res .status(200) .setHeader('Content-Type', 'text/html') .send(txRevertedFrame(txStatus as string, transactionId)); } + try { + const eventName = 'tx_succeeded'; + const eventProperties = { + action: ActionType.process, + context: 'basenames_claim_frame', + componentType: ComponentType.service_worker, + }; + + logServerSideEvent(eventName, deviceId, eventProperties); + } catch (error) { + logger.error('Could not log event:', error); + } + return res .status(200) .setHeader('Content-Type', 'text/html') diff --git a/apps/web/src/constants.ts b/apps/web/src/constants.ts index e5c6005f06..49000a12ce 100644 --- a/apps/web/src/constants.ts +++ b/apps/web/src/constants.ts @@ -68,6 +68,21 @@ export const coinbaseSmartWalletABI = [ }, ] as const; +export const analyticsConfig = { + ampDeploymentKey: { + dev: 'client-Wvf63OdaukDZyCBtwgbOvHgGTuASBZFG', + prod: 'client-agFoQg5AOvZ2ZiOChny9RrGk21jG3VrH', + }, + amplitudeApiKey: { + dev: process.env.AMPLITUDE_API_KEY_DEVELOPMENT, + prod: process.env.AMPLITUDE_API_KEY_PRODUCTION, + }, + serverSideAnalyticsURL: { + dev: 'https://analytics-service-dev.cbhq.net/amp', + prod: 'https://cca-lite.coinbase.com/amp', + } +} + export const ampDeploymentKey = isDevelopment ? 'client-Wvf63OdaukDZyCBtwgbOvHgGTuASBZFG' : 'client-agFoQg5AOvZ2ZiOChny9RrGk21jG3VrH'; diff --git a/apps/web/src/utils/logServerSideEvent.ts b/apps/web/src/utils/logServerSideEvent.ts new file mode 100644 index 0000000000..d3d6a3d928 --- /dev/null +++ b/apps/web/src/utils/logServerSideEvent.ts @@ -0,0 +1,136 @@ +import { v5 as uuidv5 } from 'uuid'; +import { logger } from 'apps/web/src/utils/logger'; +import { isDevelopment, analyticsConfig } from 'apps/web/src/constants'; +import { NextApiRequest } from 'apps/web/node_modules/next/dist/shared/lib/utils'; + +type EventProperties = { + action: string; + context: string; + componentType: string; + projectName: string; + pagePath?: string; + error?: string; + platform?: string; + locale?: string | null; + sessionLccId?: string | null; + timeStart?: number; + pageKey?: string; + prevPageKey?: string; + prevPagePath?: string; + hasDoubleFired?: boolean; + sessionUuid?: string; + height?: number; + width?: number; + auth?: number; +}; +type SupplementalEventData = { + userId?: string | null; + timestamp?: number; + eventId?: number; + sessionId?: number; + versionName?: string; + platform?: string; + osName?: string; + osVersion?: number; + deviceModel?: string; + language?: string; + userProperties?: unknown; + uuid?: string; + sequenceNumber?: number; + userAgent?: unknown; +}; + +type ServerSideEvent = { + eventType: string; + deviceId: string; + eventProperties: EventProperties; + timestamp: number; + sessionId: number; + platform: string; + userId?: string | null; + eventId?: number; + versionName?: string; + osName?: string; + osVersion?: number; + deviceModel?: string; + language?: string; + userProperties?: unknown; + uuid?: string; + sequenceNumber?: number; + userAgent?: unknown; +}; + +const SERVER_SIDE_EVENT_NAMESPACE = 'b3456910-afdb-4a15-a498-8bd5885fc956'; + +export default function logServerSideEvent( + eventName: string, + deviceId: string, + eventProperties: Omit, + supplementalEventData: SupplementalEventData = {}, +) { + const event = createEventData(eventName, deviceId, eventProperties, supplementalEventData); + const stringifiedEvent = JSON.stringify([convertKeys(event)]); + const uploadTime = new Date().getTime().toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const checksum = uuidv5(stringifiedEvent + uploadTime, SERVER_SIDE_EVENT_NAMESPACE) as string; + const eventData = { + e: stringifiedEvent, + client: analyticsConfig.amplitudeApiKey.prod, + checksum, + }; + const postUrl = analyticsConfig.serverSideAnalyticsURL.prod; + const fetchConfig = { + method: 'POST', + body: JSON.stringify(eventData), + mode: 'no-cors', + headers: { + 'Content-Type': 'application/json', + }, + }; + fetch(postUrl, fetchConfig).catch((error) => + logger.error('Failed to create server-side event', error), + ); +} + +export function createEventData( + eventName: string, + deviceId: string, + eventProperties: Omit, + supplementalEventData: SupplementalEventData = {}, +): ServerSideEvent { + return { + eventType: eventName, + deviceId: `node-js-${isDevelopment ? 'dev' : 'prod'}-${deviceId}`, + eventProperties: { + ...eventProperties, + projectName: 'base_web', + }, + timestamp: new Date().getTime(), + sessionId: new Date().getTime(), + platform: 'Web', + ...supplementalEventData, + }; +} + +export function generateDeviceId(req: NextApiRequest) { + const userAgent = req.headers['user-agent'] ?? 'No user agent'; + let ip = req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? 'No IP'; + if (typeof ip === 'object') { + ip = ip.join(); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return uuidv5(`${userAgent}|${ip}`, SERVER_SIDE_EVENT_NAMESPACE) as string; +} + +function convertKeys(obj: unknown): unknown { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [camelToSnakeCase(key), convertKeys(value)]), + ); +} + +function camelToSnakeCase(str: string) { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +}