Skip to content

Commit 7167714

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@0d342808d8c6c43c7e7560419eacebd59b3d1b11
1 parent 8a754f3 commit 7167714

5 files changed

Lines changed: 415 additions & 1 deletion

File tree

common/src/constants/analytics-events.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,12 @@ export enum AnalyticsEvent {
251251
FREEBUFF_REDDIT_FUNNEL_CLI_INSTALLED = 'freebuff.reddit_funnel.cli_installed',
252252
FREEBUFF_REDDIT_FUNNEL_LOGIN = 'freebuff.reddit_funnel.login',
253253
FREEBUFF_REDDIT_FUNNEL_SIGN_UP = 'freebuff.reddit_funnel.sign_up',
254-
FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT = 'freebuff.reddit_funnel.first_prompt',
254+
FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_CLI = 'freebuff.reddit_funnel.first_prompt_cli',
255+
FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_WEB = 'freebuff.reddit_funnel.first_prompt_web',
256+
FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_CHAT = 'freebuff.reddit_funnel.first_prompt_chat',
257+
FREEBUFF_REDDIT_FUNNEL_RETENTION_1D_CLI = 'freebuff.reddit_funnel.retention_1d_cli',
258+
FREEBUFF_REDDIT_FUNNEL_RETENTION_7D_CLI = 'freebuff.reddit_funnel.retention_7d_cli',
259+
FREEBUFF_REDDIT_FUNNEL_RETENTION_24D_CLI = 'freebuff.reddit_funnel.retention_24d_cli',
255260
FREEBUFF_REDDIT_FUNNEL_GRAVITY_AD_CLICK = 'freebuff.reddit_funnel.gravity_ad_click',
256261

257262
// Freebuff web /chat ads experiment (server-rendered Gravity ads vs the

common/src/reddit-capi.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { createHash } from 'node:crypto'
2+
3+
import { IS_PROD } from '@codebuff/common/env'
4+
import { extractClientIp } from '@codebuff/common/util/rate-limit'
5+
import type { Logger } from '@codebuff/common/types/contracts/logger'
6+
import type {
7+
RedditFirstPromptCapiEventName,
8+
RedditRetentionCapiEventName,
9+
} from '@codebuff/common/util/reddit-capi-events'
10+
11+
export type { RedditFirstPromptCapiEventName, RedditRetentionCapiEventName }
12+
13+
/** Reddit Ads pixel ID (public, also used for CAPI endpoint). */
14+
export const REDDIT_PIXEL_ID = 'a2_j6o59svbxzzn'
15+
16+
const REDDIT_CAPI_ENDPOINT = `https://ads-api.reddit.com/api/v3/pixels/${REDDIT_PIXEL_ID}/conversion_events`
17+
18+
export type RedditActionSource = 'WEBSITE' | 'APP' | 'PHYSICAL_STORE' | 'OTHER'
19+
20+
export type RedditCapiUser = {
21+
email?: string | null
22+
externalId?: string | null
23+
ipAddress?: string | null
24+
userAgent?: string | null
25+
clickId?: string | null
26+
uuid?: string | null
27+
}
28+
29+
export type RedditCapiCustomEvent =
30+
| RedditFirstPromptCapiEventName
31+
| RedditRetentionCapiEventName
32+
33+
function sha256(value: string): string {
34+
return createHash('sha256').update(value).digest('hex')
35+
}
36+
37+
function normalizeEmail(email: string): string {
38+
return email.trim().toLowerCase()
39+
}
40+
41+
function buildUserPayload(user: RedditCapiUser) {
42+
const payload: Record<string, string> = {}
43+
44+
if (user.email) {
45+
payload.email = sha256(normalizeEmail(user.email))
46+
}
47+
if (user.externalId) {
48+
payload.external_id = sha256(user.externalId.trim())
49+
}
50+
if (user.ipAddress) {
51+
payload.ip_address = user.ipAddress
52+
}
53+
if (user.userAgent) {
54+
payload.user_agent = user.userAgent
55+
}
56+
if (user.clickId) {
57+
payload.click_id = user.clickId
58+
}
59+
if (user.uuid) {
60+
payload.uuid = user.uuid
61+
}
62+
63+
return Object.keys(payload).length > 0 ? payload : undefined
64+
}
65+
66+
export type SendRedditCustomConversionParams = {
67+
/** Conversion access token from Reddit Events Manager (env-provided; no-op when unset). */
68+
accessToken: string | undefined
69+
customEventName: RedditCapiCustomEvent
70+
conversionId: string
71+
actionSource: RedditActionSource
72+
eventSourceUrl?: string
73+
user: RedditCapiUser
74+
fetchImpl?: typeof fetch
75+
logger?: Logger
76+
}
77+
78+
/** Fire-and-forget Reddit Conversions API custom event. Never throws. */
79+
export async function sendRedditCustomConversion(
80+
params: SendRedditCustomConversionParams,
81+
): Promise<void> {
82+
if (!IS_PROD || !params.accessToken) {
83+
return
84+
}
85+
86+
const fetchImpl = params.fetchImpl ?? fetch
87+
const eventAt = Date.now()
88+
const user = buildUserPayload(params.user)
89+
90+
const body = {
91+
data: {
92+
events: [
93+
{
94+
event_at: eventAt,
95+
action_source: params.actionSource,
96+
...(params.eventSourceUrl
97+
? { event_source_url: params.eventSourceUrl }
98+
: {}),
99+
type: {
100+
tracking_type: 'CUSTOM',
101+
custom_event_name: params.customEventName,
102+
},
103+
...(params.user.clickId ? { click_id: params.user.clickId } : {}),
104+
metadata: {
105+
conversion_id: params.conversionId,
106+
},
107+
...(user ? { user } : {}),
108+
},
109+
],
110+
partner: 'FREEBUFF',
111+
partner_version: '1.0.0',
112+
},
113+
}
114+
115+
try {
116+
const response = await fetchImpl(REDDIT_CAPI_ENDPOINT, {
117+
method: 'POST',
118+
headers: {
119+
Authorization: `Bearer ${params.accessToken}`,
120+
'Content-Type': 'application/json',
121+
'User-Agent': 'freebuff/1.0 (reddit-capi)',
122+
},
123+
body: JSON.stringify(body),
124+
signal: AbortSignal.timeout(3_000),
125+
})
126+
if (!response.ok) {
127+
params.logger?.warn(
128+
{
129+
status: response.status,
130+
responseBody: (await response.text().catch(() => '')).slice(0, 500),
131+
customEventName: params.customEventName,
132+
conversionId: params.conversionId,
133+
},
134+
'Reddit CAPI rejected conversion event',
135+
)
136+
}
137+
} catch (error) {
138+
// Best-effort ad attribution; never block product flows.
139+
params.logger?.warn(
140+
{
141+
error,
142+
customEventName: params.customEventName,
143+
conversionId: params.conversionId,
144+
},
145+
'Reddit CAPI conversion request failed',
146+
)
147+
}
148+
}
149+
150+
export function redditConversionId(
151+
event: RedditCapiCustomEvent,
152+
userId: string,
153+
suffix?: string,
154+
): string {
155+
return [event.toLowerCase(), userId, suffix ?? String(Date.now())].join('_')
156+
}
157+
158+
/**
159+
* Identity fields shared by every server-side Reddit conversion call, built
160+
* from the incoming request's headers. Surface-specific attribution (click id,
161+
* uuid) is layered on by callers that have it.
162+
*/
163+
export function redditUserFromRequestHeaders(params: {
164+
userId: string
165+
email?: string | null
166+
headers: { get(name: string): string | null }
167+
}): RedditCapiUser {
168+
const ip = extractClientIp(params.headers)
169+
return {
170+
email: params.email,
171+
externalId: params.userId,
172+
ipAddress: ip === 'unknown' ? undefined : ip,
173+
userAgent: params.headers.get('user-agent')?.trim() || undefined,
174+
}
175+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
getFreebuffRetentionMilestonesToFire,
5+
isFirstFreebuffPrompt,
6+
planFreebuffRedditConversionEvents,
7+
} from '@codebuff/common/util/reddit-freebuff-retention'
8+
9+
describe('isFirstFreebuffPrompt', () => {
10+
test('returns true on first-ever usage day', () => {
11+
expect(
12+
isFirstFreebuffPrompt({
13+
previousUsageDays: [],
14+
newUsageDayRecorded: true,
15+
}),
16+
).toBe(true)
17+
})
18+
19+
test('returns false on repeat prompts same day', () => {
20+
expect(
21+
isFirstFreebuffPrompt({
22+
previousUsageDays: ['2026-06-30'],
23+
newUsageDayRecorded: false,
24+
}),
25+
).toBe(false)
26+
})
27+
28+
test('returns false on a later usage day', () => {
29+
expect(
30+
isFirstFreebuffPrompt({
31+
previousUsageDays: ['2026-06-30'],
32+
newUsageDayRecorded: true,
33+
}),
34+
).toBe(false)
35+
})
36+
})
37+
38+
describe('getFreebuffRetentionMilestonesToFire', () => {
39+
test('returns nothing on first-ever usage day', () => {
40+
expect(
41+
getFreebuffRetentionMilestonesToFire({
42+
previousUsageDays: [],
43+
todayDateKey: '2026-06-30',
44+
newUsageDayRecorded: true,
45+
}),
46+
).toEqual([])
47+
})
48+
49+
test('returns nothing when no new usage day was recorded', () => {
50+
expect(
51+
getFreebuffRetentionMilestonesToFire({
52+
previousUsageDays: ['2026-06-30'],
53+
todayDateKey: '2026-07-01',
54+
newUsageDayRecorded: false,
55+
}),
56+
).toEqual([])
57+
})
58+
59+
test('fires 1d retention on day 1', () => {
60+
expect(
61+
getFreebuffRetentionMilestonesToFire({
62+
previousUsageDays: ['2026-06-30'],
63+
todayDateKey: '2026-07-01',
64+
newUsageDayRecorded: true,
65+
}),
66+
).toEqual([1])
67+
})
68+
69+
test('does not repeat 1d on day 2', () => {
70+
expect(
71+
getFreebuffRetentionMilestonesToFire({
72+
previousUsageDays: ['2026-06-30', '2026-07-01'],
73+
todayDateKey: '2026-07-02',
74+
newUsageDayRecorded: true,
75+
}),
76+
).toEqual([])
77+
})
78+
79+
test('fires 7d retention on day 7', () => {
80+
expect(
81+
getFreebuffRetentionMilestonesToFire({
82+
previousUsageDays: ['2026-06-30', '2026-07-01'],
83+
todayDateKey: '2026-07-07',
84+
newUsageDayRecorded: true,
85+
}),
86+
).toEqual([7])
87+
})
88+
89+
test('fires 1d, 7d, and 24d when user returns after a long gap', () => {
90+
expect(
91+
getFreebuffRetentionMilestonesToFire({
92+
previousUsageDays: ['2026-06-01'],
93+
todayDateKey: '2026-07-01',
94+
newUsageDayRecorded: true,
95+
}),
96+
).toEqual([1, 7, 24])
97+
})
98+
})
99+
100+
describe('planFreebuffRedditConversionEvents', () => {
101+
test('first prompt only on day 0', () => {
102+
expect(
103+
planFreebuffRedditConversionEvents({
104+
previousUsageDays: [],
105+
todayDateKey: '2026-06-30',
106+
newUsageDayRecorded: true,
107+
}),
108+
).toEqual({ fireFirstPrompt: true, retentionMilestones: [] })
109+
})
110+
111+
test('1d retention without first prompt on day 1', () => {
112+
expect(
113+
planFreebuffRedditConversionEvents({
114+
previousUsageDays: ['2026-06-30'],
115+
todayDateKey: '2026-07-01',
116+
newUsageDayRecorded: true,
117+
}),
118+
).toEqual({ fireFirstPrompt: false, retentionMilestones: [1] })
119+
})
120+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
3+
import type { FreebuffRedditRetentionMilestoneDays } from '@codebuff/common/util/reddit-freebuff-retention'
4+
5+
export type RedditFirstPromptSurface = 'cli' | 'web' | 'chat'
6+
7+
/** PostHog/analytics event fired alongside the Reddit CAPI first-prompt conversion. */
8+
export const REDDIT_FIRST_PROMPT_ANALYTICS_EVENTS: Record<
9+
RedditFirstPromptSurface,
10+
AnalyticsEvent
11+
> = {
12+
cli: AnalyticsEvent.FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_CLI,
13+
web: AnalyticsEvent.FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_WEB,
14+
chat: AnalyticsEvent.FREEBUFF_REDDIT_FUNNEL_FIRST_PROMPT_CHAT,
15+
}
16+
17+
export type RedditFirstPromptCapiEventName =
18+
| 'FirstPromptCli'
19+
| 'FirstPromptWeb'
20+
| 'FirstPromptChat'
21+
22+
export type RedditRetentionCapiEventName =
23+
| 'Retention1dCli'
24+
| 'Retention7dCli'
25+
| 'Retention24dCli'
26+
27+
export function redditFirstPromptCapiEventName(
28+
surface: RedditFirstPromptSurface,
29+
): RedditFirstPromptCapiEventName {
30+
switch (surface) {
31+
case 'cli':
32+
return 'FirstPromptCli'
33+
case 'web':
34+
return 'FirstPromptWeb'
35+
case 'chat':
36+
return 'FirstPromptChat'
37+
}
38+
}
39+
40+
export function redditRetentionCapiEventName(
41+
milestone: FreebuffRedditRetentionMilestoneDays,
42+
): RedditRetentionCapiEventName {
43+
return `Retention${milestone}dCli`
44+
}

0 commit comments

Comments
 (0)