Skip to content

Commit f363541

Browse files
authored
feat(analytics): add Freebuff Ads conversion tracking (#8492)
* feat(analytics): add Freebuff Ads conversion tracking * fix(analytics): drop the Freebuff click id when marketing consent is withdrawn
1 parent 21b2972 commit f363541

9 files changed

Lines changed: 181 additions & 2 deletions

File tree

‎apps/sim/app/(auth)/signup/signup-form.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
55
import { createLogger } from '@sim/logger'
66
import { useRouter, useSearchParams } from 'next/navigation'
77
import { usePostHog } from 'posthog-js/react'
8+
import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
89
import { trackGoogleEvent } from '@/lib/analytics/google'
910
import { client, useSession } from '@/lib/auth/auth-client'
1011
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
@@ -109,7 +110,7 @@ function SignupFormContent({
109110
const searchParams = useSearchParams()
110111
const { refetch: refetchSession } = useSession()
111112
const posthog = usePostHog()
112-
const { measurement } = useTrackingConsent()
113+
const { measurement, marketing } = useTrackingConsent()
113114
const [isLoading, setIsLoading] = useState(false)
114115

115116
useEffect(() => {
@@ -348,6 +349,7 @@ function SignupFormContent({
348349
}
349350

350351
if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
352+
if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
351353

352354
try {
353355
await refetchSession()

‎apps/sim/app/_shell/consent/consent-provider.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
44
import { TrackingConsentProvider } from '@/lib/consent/tracking-consent'
55
import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
66
import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
7+
import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard'
78
import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
89

910
interface ConsentProviderProps {
@@ -22,6 +23,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) {
2223
<TrackingConsentProvider>
2324
{children}
2425
<GoogleAnalyticsPageViewTracker />
26+
<FreebuffClickIdGuard />
2527
<ConsentBanner />
2628
</TrackingConsentProvider>
2729
</ConsentStoreProvider>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { clearFreebuffClickId } from '@/lib/analytics/freebuff'
5+
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
6+
7+
/**
8+
* Drops a stored Freebuff click id once consent resolves without marketing, so
9+
* a withdrawn or expired grant can never be attributed by the server postback,
10+
* which only sees the cookie. Withdrawal reloads the page, so this runs before
11+
* any later signup.
12+
*/
13+
export function FreebuffClickIdGuard() {
14+
const { isResolved, marketing } = useTrackingConsent()
15+
16+
useEffect(() => {
17+
if (isResolved && !marketing) clearFreebuffClickId()
18+
}, [isResolved, marketing])
19+
20+
return null
21+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { sleep } from '@sim/utils/helpers'
3+
import { backoffWithJitter } from '@sim/utils/retry'
4+
import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff'
5+
import { env } from '@/lib/core/config/env'
6+
7+
const logger = createLogger('FreebuffConversions')
8+
9+
const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions'
10+
const MAX_ATTEMPTS = 4
11+
const REQUEST_TIMEOUT_MS = 10_000
12+
13+
/** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */
14+
const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/
15+
16+
interface FreebuffConversion {
17+
clickId: string
18+
eventType: FreebuffConversionEvent
19+
/** Idempotency key, shared with the tag call for the same conversion. */
20+
eventId: string
21+
occurredAt: Date
22+
}
23+
24+
/**
25+
* Server-to-server conversion postback. Network failures and 5xx responses are
26+
* retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A
27+
* `deduped` answer means the tag already reported it and is a success. Never
28+
* throws, so a caller can fire and forget.
29+
*/
30+
export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise<void> {
31+
const apiKey = env.FREEBUFF_API_KEY
32+
if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return
33+
34+
const body = JSON.stringify({
35+
clickId: conversion.clickId,
36+
eventType: conversion.eventType,
37+
eventId: conversion.eventId,
38+
occurredAt: conversion.occurredAt.toISOString(),
39+
})
40+
const context = { eventType: conversion.eventType, eventId: conversion.eventId }
41+
42+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
43+
let status: number | undefined
44+
try {
45+
const response = await fetch(FREEBUFF_CONVERSIONS_URL, {
46+
method: 'POST',
47+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
48+
body,
49+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50+
})
51+
status = response.status
52+
const result = await response.text().catch(() => '')
53+
if (response.ok) {
54+
logger.info('Freebuff conversion recorded', { ...context, result })
55+
return
56+
}
57+
if (status < 500) {
58+
logger.warn('Freebuff conversion rejected', { ...context, status, result })
59+
return
60+
}
61+
} catch (error) {
62+
logger.warn('Freebuff conversion request failed', { ...context, attempt, error })
63+
}
64+
if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null))
65+
else logger.error('Freebuff conversion postback gave up', { ...context, status })
66+
}
67+
}

‎apps/sim/lib/analytics/freebuff.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed
3+
* `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie,
4+
* and each conversion is reported twice with the same `eventId` — once by the
5+
* tag and once by the server postback — so Freebuff dedupes them into one.
6+
*
7+
* @see https://freebuff.com/docs/advertisers/conversions
8+
*/
9+
10+
export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const
11+
12+
/** First-party cookie the tag writes the captured click id to. */
13+
export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const
14+
15+
export type FreebuffConversionEvent = 'signup_completed'
16+
17+
type FreebuffCommand = (
18+
command: 'conversion',
19+
eventType: FreebuffConversionEvent,
20+
options?: { eventId?: string }
21+
) => void
22+
23+
declare global {
24+
interface Window {
25+
freebuff?: FreebuffCommand & { q?: unknown[][] }
26+
}
27+
}
28+
29+
/** Queues commands until the async tag loads and replays them. */
30+
export function installFreebuffStub(): void {
31+
if (window.freebuff) return
32+
const queue: unknown[][] = []
33+
window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue })
34+
}
35+
36+
/** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */
37+
export function clearFreebuffClickId(): void {
38+
if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return
39+
document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`
40+
}
41+
42+
/**
43+
* Reports a conversion from the page. Call only after the caller has verified
44+
* marketing consent; the tag is a no-op for visitors who did not arrive from an
45+
* ad. `eventId` must match the one the server postback sends.
46+
*/
47+
export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void {
48+
window.freebuff?.('conversion', event, { eventId })
49+
}

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
renderPasswordResetEmail,
3636
renderWelcomeEmail,
3737
} from '@/components/emails'
38+
import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff'
39+
import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
3840
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
3941
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
4042
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
@@ -310,11 +312,28 @@ export const auth = betterAuth({
310312
}
311313
return { data: user }
312314
},
313-
after: async (user) => {
315+
after: async (user, context) => {
314316
logger.info('[databaseHooks.user.create.after] User created, initializing stats', {
315317
userId: user.id,
316318
})
317319

320+
/**
321+
* Only the marketing-consent-gated Freebuff tag writes the `bfcid`
322+
* cookie, and `FreebuffClickIdGuard` deletes it once marketing consent
323+
* is withdrawn or expires. Not awaited: the postback
324+
* retries on its own and must never delay signup. The browser tag
325+
* reports the same `eventId` on email signup and Freebuff dedupes.
326+
*/
327+
const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE)
328+
if (freebuffClickId) {
329+
void reportFreebuffConversion({
330+
clickId: freebuffClickId,
331+
eventType: 'signup_completed',
332+
eventId: user.id,
333+
occurredAt: user.createdAt,
334+
})
335+
}
336+
318337
try {
319338
PlatformEvents.userSignedUp({
320339
userId: user.id,

‎apps/sim/lib/consent/scripts.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics'
22
import { gtag } from '@c15t/scripts/google-tag'
33
import { xPixel } from '@c15t/scripts/x-pixel'
4+
import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff'
45

56
export const GOOGLE_ANALYTICS_ID = 'G-DR7YBE70VS' as const
67

@@ -60,6 +61,19 @@ export const GLOBAL_CONSENT_SCRIPTS = [
6061
},
6162
},
6263
ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }),
64+
/**
65+
* Global rather than landing-only: the ad lands on a marketing page but the
66+
* conversion fires from `/signup`. The tag recovers `?bfcid=` from the
67+
* original navigation entry, so a client-side route change before consent
68+
* resolves does not lose the click id.
69+
*/
70+
{
71+
id: 'freebuff-tag',
72+
src: FREEBUFF_TAG_SRC,
73+
category: 'marketing',
74+
async: true,
75+
onBeforeLoad: installFreebuffStub,
76+
},
6377
] as const
6478

6579
/** Marketing-page integrations that should not load on a direct workspace visit. */

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export const env = createEnv({
312312

313313
// Monitoring & Analytics
314314
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
315+
FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them)
315316
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
316317
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
317318
GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics.

‎apps/sim/lib/core/security/csp.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ const STATIC_SCRIPT_SRC = [
114114
// X (Twitter) conversion pixel (landing pages) — the base code injects
115115
// uwt.js as a <script> tag from static.ads-twitter.com
116116
'https://static.ads-twitter.com',
117+
// Freebuff Ads conversion tag — freebuff-tag.js
118+
'https://freebuff.com',
117119
]
118120
: []),
119121
] as const
@@ -160,6 +162,8 @@ const STATIC_CONNECT_SRC = [
160162
// via fetch/sendBeacon. The t.co image-pixel fallback is already
161163
// covered by the `https:` wildcard in img-src.
162164
'https://analytics.twitter.com',
165+
// Freebuff Ads conversion tag — beacons to /api/advertisers/conversions/client
166+
'https://freebuff.com',
163167
]
164168
: []),
165169
] as const

0 commit comments

Comments
 (0)