Skip to content

Commit

Permalink
Feat: Server-side events for basenames claim frame (#999)
Browse files Browse the repository at this point in the history
* utility for logging server-side events

* server side events

* amp api keys reference env vars

* added helper fn for deviceId creation

* fixed env var naming issue

* uuidV5 for deviceId

* disabled lint for uuidV5
  • Loading branch information
brendan-defi authored Sep 26, 2024
1 parent 94a61a8 commit 671da72
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 1 deletion.
17 changes: 16 additions & 1 deletion apps/web/pages/api/basenames/frame/01_inputSearchValue.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions apps/web/pages/api/basenames/frame/04_txSubmitted.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/utils/logServerSideEvent.ts
Original file line number Diff line number Diff line change
@@ -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<EventProperties, 'projectName'>,
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<EventProperties, 'projectName'>,
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()}`);
}

0 comments on commit 671da72

Please sign in to comment.