-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat(analytics): add Freebuff Ads conversion tracking #8492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+181
−2
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect } from 'react' | ||
| import { clearFreebuffClickId } from '@/lib/analytics/freebuff' | ||
| import { useTrackingConsent } from '@/lib/consent/tracking-consent' | ||
|
|
||
| /** | ||
| * Drops a stored Freebuff click id once consent resolves without marketing, so | ||
| * a withdrawn or expired grant can never be attributed by the server postback, | ||
| * which only sees the cookie. Withdrawal reloads the page, so this runs before | ||
| * any later signup. | ||
| */ | ||
| export function FreebuffClickIdGuard() { | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| const { isResolved, marketing } = useTrackingConsent() | ||
|
|
||
| useEffect(() => { | ||
| if (isResolved && !marketing) clearFreebuffClickId() | ||
| }, [isResolved, marketing]) | ||
|
|
||
| return null | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { sleep } from '@sim/utils/helpers' | ||
| import { backoffWithJitter } from '@sim/utils/retry' | ||
| import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff' | ||
| import { env } from '@/lib/core/config/env' | ||
|
|
||
| const logger = createLogger('FreebuffConversions') | ||
|
|
||
| const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions' | ||
| const MAX_ATTEMPTS = 4 | ||
| const REQUEST_TIMEOUT_MS = 10_000 | ||
|
|
||
| /** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */ | ||
| const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/ | ||
|
|
||
| interface FreebuffConversion { | ||
| clickId: string | ||
| eventType: FreebuffConversionEvent | ||
| /** Idempotency key, shared with the tag call for the same conversion. */ | ||
| eventId: string | ||
| occurredAt: Date | ||
| } | ||
|
|
||
| /** | ||
| * Server-to-server conversion postback. Network failures and 5xx responses are | ||
| * retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A | ||
| * `deduped` answer means the tag already reported it and is a success. Never | ||
| * throws, so a caller can fire and forget. | ||
| */ | ||
| export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise<void> { | ||
| const apiKey = env.FREEBUFF_API_KEY | ||
| if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return | ||
|
|
||
| const body = JSON.stringify({ | ||
| clickId: conversion.clickId, | ||
| eventType: conversion.eventType, | ||
| eventId: conversion.eventId, | ||
| occurredAt: conversion.occurredAt.toISOString(), | ||
| }) | ||
| const context = { eventType: conversion.eventType, eventId: conversion.eventId } | ||
|
|
||
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | ||
| let status: number | undefined | ||
| try { | ||
| const response = await fetch(FREEBUFF_CONVERSIONS_URL, { | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| method: 'POST', | ||
| headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, | ||
| body, | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }) | ||
| status = response.status | ||
| const result = await response.text().catch(() => '') | ||
| if (response.ok) { | ||
| logger.info('Freebuff conversion recorded', { ...context, result }) | ||
| return | ||
| } | ||
| if (status < 500) { | ||
| logger.warn('Freebuff conversion rejected', { ...context, status, result }) | ||
| return | ||
| } | ||
| } catch (error) { | ||
| logger.warn('Freebuff conversion request failed', { ...context, attempt, error }) | ||
| } | ||
| if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null)) | ||
| else logger.error('Freebuff conversion postback gave up', { ...context, status }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| /** | ||
| * Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed | ||
| * `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie, | ||
| * and each conversion is reported twice with the same `eventId` — once by the | ||
| * tag and once by the server postback — so Freebuff dedupes them into one. | ||
| * | ||
| * @see https://freebuff.com/docs/advertisers/conversions | ||
| */ | ||
|
|
||
| export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const | ||
|
|
||
| /** First-party cookie the tag writes the captured click id to. */ | ||
| export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const | ||
|
|
||
| export type FreebuffConversionEvent = 'signup_completed' | ||
|
|
||
| type FreebuffCommand = ( | ||
| command: 'conversion', | ||
| eventType: FreebuffConversionEvent, | ||
| options?: { eventId?: string } | ||
| ) => void | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| freebuff?: FreebuffCommand & { q?: unknown[][] } | ||
| } | ||
| } | ||
|
|
||
| /** Queues commands until the async tag loads and replays them. */ | ||
| export function installFreebuffStub(): void { | ||
| if (window.freebuff) return | ||
| const queue: unknown[][] = [] | ||
| window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue }) | ||
| } | ||
|
|
||
| /** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */ | ||
| export function clearFreebuffClickId(): void { | ||
| if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return | ||
| document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax` | ||
| } | ||
|
|
||
| /** | ||
| * Reports a conversion from the page. Call only after the caller has verified | ||
| * marketing consent; the tag is a no-op for visitors who did not arrive from an | ||
| * ad. `eventId` must match the one the server postback sends. | ||
| */ | ||
| export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void { | ||
| window.freebuff?.('conversion', event, { eventId }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.