diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index fcdbc0f13b8..aa3d121a4c3 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,6 +5,7 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { trackFreebuffConversion } from '@/lib/analytics/freebuff' import { trackGoogleEvent } from '@/lib/analytics/google' import { client, useSession } from '@/lib/auth/auth-client' import { useTrackingConsent } from '@/lib/consent/tracking-consent' @@ -109,7 +110,7 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const posthog = usePostHog() - const { measurement } = useTrackingConsent() + const { measurement, marketing } = useTrackingConsent() const [isLoading, setIsLoading] = useState(false) useEffect(() => { @@ -348,6 +349,7 @@ function SignupFormContent({ } if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) + if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id) try { await refetchSession() diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index a30745054b2..b3ef3fd9e54 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { TrackingConsentProvider } from '@/lib/consent/tracking-consent' import { ConsentBanner } from '@/app/_shell/consent/consent-banner' import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' +import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard' import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker' interface ConsentProviderProps { @@ -22,6 +23,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) { {children} + diff --git a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx new file mode 100644 index 00000000000..bc221be8fb0 --- /dev/null +++ b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx @@ -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() { + const { isResolved, marketing } = useTrackingConsent() + + useEffect(() => { + if (isResolved && !marketing) clearFreebuffClickId() + }, [isResolved, marketing]) + + return null +} diff --git a/apps/sim/lib/analytics/freebuff.server.ts b/apps/sim/lib/analytics/freebuff.server.ts new file mode 100644 index 00000000000..3db98deb40a --- /dev/null +++ b/apps/sim/lib/analytics/freebuff.server.ts @@ -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 { + 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, { + 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 }) + } +} diff --git a/apps/sim/lib/analytics/freebuff.ts b/apps/sim/lib/analytics/freebuff.ts new file mode 100644 index 00000000000..6f994523d14 --- /dev/null +++ b/apps/sim/lib/analytics/freebuff.ts @@ -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 }) +} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 5e7bc09be32..f1b655e1ca9 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -35,6 +35,8 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' +import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff' +import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' @@ -310,11 +312,28 @@ export const auth = betterAuth({ } return { data: user } }, - after: async (user) => { + after: async (user, context) => { logger.info('[databaseHooks.user.create.after] User created, initializing stats', { userId: user.id, }) + /** + * Only the marketing-consent-gated Freebuff tag writes the `bfcid` + * cookie, and `FreebuffClickIdGuard` deletes it once marketing consent + * is withdrawn or expires. Not awaited: the postback + * retries on its own and must never delay signup. The browser tag + * reports the same `eventId` on email signup and Freebuff dedupes. + */ + const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE) + if (freebuffClickId) { + void reportFreebuffConversion({ + clickId: freebuffClickId, + eventType: 'signup_completed', + eventId: user.id, + occurredAt: user.createdAt, + }) + } + try { PlatformEvents.userSignedUp({ userId: user.id, diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts index b797f580a56..b6118e36d5e 100644 --- a/apps/sim/lib/consent/scripts.ts +++ b/apps/sim/lib/consent/scripts.ts @@ -1,6 +1,7 @@ import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics' import { gtag } from '@c15t/scripts/google-tag' import { xPixel } from '@c15t/scripts/x-pixel' +import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff' export const GOOGLE_ANALYTICS_ID = 'G-DR7YBE70VS' as const @@ -60,6 +61,19 @@ export const GLOBAL_CONSENT_SCRIPTS = [ }, }, ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }), + /** + * Global rather than landing-only: the ad lands on a marketing page but the + * conversion fires from `/signup`. The tag recovers `?bfcid=` from the + * original navigation entry, so a client-side route change before consent + * resolves does not lose the click id. + */ + { + id: 'freebuff-tag', + src: FREEBUFF_TAG_SRC, + category: 'marketing', + async: true, + onBeforeLoad: installFreebuffStub, + }, ] as const /** Marketing-page integrations that should not load on a direct workspace visit. */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5d5c07a1775..1ff55feea98 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -312,6 +312,7 @@ export const env = createEnv({ // Monitoring & Analytics TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint + FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them) COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development) 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. diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 9c6b99370ab..1614c81b756 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -114,6 +114,8 @@ const STATIC_SCRIPT_SRC = [ // X (Twitter) conversion pixel (landing pages) — the base code injects // uwt.js as a