Skip to content

Commit e10892d

Browse files
committed
Track ad clicks
1 parent 33ccf45 commit e10892d

7 files changed

Lines changed: 196 additions & 6 deletions

File tree

cli/src/chat.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export const Chat = ({
174174
})
175175
const hasSubscription = subscriptionData?.hasSubscription ?? false
176176

177-
const { ads, recordImpression } = useGravityAd({
177+
const { ads, recordClick, recordImpression } = useGravityAd({
178178
enabled: IS_FREEBUFF || !hasSubscription,
179179
provider: 'gravity',
180180
fallbackProvider: 'zeroclick',
@@ -1464,7 +1464,11 @@ export const Chat = ({
14641464
)}
14651465

14661466
{ads && (IS_FREEBUFF || getAdsEnabled()) && (
1467-
<ChoiceAdBanner ads={ads} onImpression={recordImpression} />
1467+
<ChoiceAdBanner
1468+
ads={ads}
1469+
onClick={recordClick}
1470+
onImpression={recordImpression}
1471+
/>
14681472
)}
14691473

14701474
{reviewMode ? (

cli/src/components/choice-ad-banner.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { AdResponse } from '../hooks/use-gravity-ad'
1111

1212
interface ChoiceAdBannerProps {
1313
ads: AdResponse[]
14+
onClick?: (ad: AdResponse) => void
1415
onImpression?: (ad: AdResponse) => void
1516
}
1617

@@ -61,7 +62,11 @@ function columnWidths(count: number, availableWidth: number): number[] {
6162
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0))
6263
}
6364

64-
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpression }) => {
65+
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({
66+
ads,
67+
onClick,
68+
onImpression,
69+
}) => {
6570
const theme = useTheme()
6671
const { terminalWidth } = useTerminalDimensions()
6772
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
@@ -115,7 +120,9 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpressio
115120
<Button
116121
key={ad.impUrl}
117122
onClick={() => {
118-
if (ad.clickUrl) safeOpen(ad.clickUrl)
123+
if (!ad.clickUrl) return
124+
onClick?.(ad)
125+
safeOpen(ad.clickUrl)
119126
}}
120127
onMouseOver={() => setHoveredIndex(i)}
121128
onMouseOut={() => setHoveredIndex(null)}

cli/src/components/waiting-room-screen.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
299299
// forceStart bypasses the "wait for first user message" gate inside the hook,
300300
// which would otherwise block ads here since no conversation exists yet.
301301
// Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill.
302-
const { ads, recordImpression } = useGravityAd({
302+
const { ads, recordClick, recordImpression } = useGravityAd({
303303
enabled: true,
304304
forceStart: true,
305305
provider: 'gravity',
@@ -733,7 +733,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
733733
}}
734734
>
735735
{ads ? (
736-
<ChoiceAdBanner ads={ads} onImpression={recordImpression} />
736+
<ChoiceAdBanner
737+
ads={ads}
738+
onClick={recordClick}
739+
onImpression={recordImpression}
740+
/>
737741
) : (
738742
<text style={{ fg: theme.muted }}>
739743
{'─'.repeat(terminalWidth)}

cli/src/hooks/use-gravity-ad.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type AdSurface = 'waiting_room'
4242
export type GravityAdState = {
4343
ads: AdResponse[] | null
4444
isLoading: boolean
45+
recordClick: (ad: AdResponse) => void
4546
recordImpression: (ad: AdResponse) => void
4647
}
4748

@@ -231,6 +232,35 @@ export const useGravityAd = (options?: {
231232
})
232233
}
233234

235+
const recordClick = (ad: AdResponse): void => {
236+
const authToken = getAuthToken()
237+
if (!authToken) {
238+
logger.warn('[ads] No auth token, skipping ad click recording')
239+
return
240+
}
241+
242+
void fetch(`${WEBSITE_URL}/api/v1/ads/click`, {
243+
method: 'POST',
244+
headers: {
245+
'Content-Type': 'application/json',
246+
Authorization: `Bearer ${authToken}`,
247+
'User-Agent': getCliAdRequestUserAgent(),
248+
},
249+
body: JSON.stringify({ impUrl: ad.impUrl, surface: surface ?? 'chat' }),
250+
})
251+
.then((res) => {
252+
if (!res.ok) {
253+
logger.debug(
254+
{ status: res.status },
255+
'[ads] Failed to record ad click',
256+
)
257+
}
258+
})
259+
.catch((err) => {
260+
logger.debug({ err }, '[ads] Failed to record ad click')
261+
})
262+
}
263+
234264
type FetchAdResult = { ads: AdResponse[] } | null
235265

236266
// Fetch an ad via web API
@@ -411,6 +441,7 @@ export const useGravityAd = (options?: {
411441
return {
412442
ads: visible ? ads : null,
413443
isLoading,
444+
recordClick,
414445
recordImpression: recordImpressionOnce,
415446
}
416447
}

common/src/constants/analytics-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export enum AnalyticsEvent {
136136

137137
// Web - Ads API
138138
ADS_API_AUTH_ERROR = 'api.ads_auth_error',
139+
ADS_CLICKED = 'ads.clicked',
139140

140141
// Web - Token Count API
141142
TOKEN_COUNT_REQUEST = 'api.token_count_request',
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
import db from '@codebuff/internal/db'
3+
import * as schema from '@codebuff/internal/db/schema'
4+
import { and, eq, isNull } from 'drizzle-orm'
5+
import { NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
8+
import { requireUserFromApiKey } from '../../_helpers'
9+
10+
import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics'
11+
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'
12+
import type {
13+
Logger,
14+
LoggerWithContextFn,
15+
} from '@codebuff/common/types/contracts/logger'
16+
import type { NextRequest } from 'next/server'
17+
18+
const bodySchema = z.object({
19+
impUrl: z.url(),
20+
surface: z.enum(['chat', 'waiting_room']).optional(),
21+
})
22+
23+
export async function postAdClick(params: {
24+
req: NextRequest
25+
getUserInfoFromApiKey: GetUserInfoFromApiKeyFn
26+
logger: Logger
27+
loggerWithContext: LoggerWithContextFn
28+
trackEvent: TrackEventFn
29+
}) {
30+
const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent } = params
31+
const baseLogger = params.logger
32+
33+
let impUrl: string
34+
let surface: z.infer<typeof bodySchema>['surface']
35+
try {
36+
const json = await req.json()
37+
const parsed = bodySchema.safeParse(json)
38+
if (!parsed.success) {
39+
return NextResponse.json(
40+
{ error: 'Invalid request body', details: parsed.error.format() },
41+
{ status: 400 },
42+
)
43+
}
44+
impUrl = parsed.data.impUrl
45+
surface = parsed.data.surface
46+
} catch {
47+
return NextResponse.json(
48+
{ error: 'Invalid JSON in request body' },
49+
{ status: 400 },
50+
)
51+
}
52+
53+
const authed = await requireUserFromApiKey({
54+
req,
55+
getUserInfoFromApiKey,
56+
logger: baseLogger,
57+
loggerWithContext,
58+
trackEvent,
59+
authErrorEvent: AnalyticsEvent.ADS_API_AUTH_ERROR,
60+
})
61+
if (!authed.ok) return authed.response
62+
63+
const { userId, logger } = authed.data
64+
65+
const adRecord = await db.query.adImpression.findFirst({
66+
where: eq(schema.adImpression.imp_url, impUrl),
67+
})
68+
69+
if (!adRecord || adRecord.user_id !== userId) {
70+
logger.warn(
71+
{
72+
userId,
73+
adUserId: adRecord?.user_id,
74+
impUrl,
75+
},
76+
'[ads] Ad click not found for user',
77+
)
78+
return NextResponse.json(
79+
{ success: false, error: 'Ad not found' },
80+
{ status: 404 },
81+
)
82+
}
83+
84+
trackEvent({
85+
event: AnalyticsEvent.ADS_CLICKED,
86+
userId,
87+
properties: {
88+
ad_impression_id: adRecord.id,
89+
provider: adRecord.provider,
90+
title: adRecord.title,
91+
cta: adRecord.cta,
92+
ad_url: adRecord.url,
93+
already_clicked: Boolean(adRecord.clicked_at),
94+
impression_recorded: Boolean(adRecord.impression_fired_at),
95+
surface,
96+
},
97+
logger,
98+
})
99+
100+
try {
101+
await db
102+
.update(schema.adImpression)
103+
.set({ clicked_at: new Date() })
104+
.where(
105+
and(
106+
eq(schema.adImpression.id, adRecord.id),
107+
isNull(schema.adImpression.clicked_at),
108+
),
109+
)
110+
} catch (error) {
111+
logger.error(
112+
{
113+
userId,
114+
impUrl,
115+
error:
116+
error instanceof Error
117+
? { name: error.name, message: error.message }
118+
: error,
119+
},
120+
'[ads] Failed to update ad click record',
121+
)
122+
}
123+
124+
return NextResponse.json({ success: true })
125+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { trackEvent } from '@codebuff/common/analytics'
2+
3+
import { postAdClick } from './_post'
4+
5+
import type { NextRequest } from 'next/server'
6+
7+
import { getUserInfoFromApiKey } from '@/db/user'
8+
import { logger, loggerWithContext } from '@/util/logger'
9+
10+
export async function POST(req: NextRequest) {
11+
return postAdClick({
12+
req,
13+
getUserInfoFromApiKey,
14+
logger,
15+
loggerWithContext,
16+
trackEvent,
17+
})
18+
}

0 commit comments

Comments
 (0)