Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -348,6 +349,7 @@ function SignupFormContent({
}

if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
Comment thread
waleedlatif1 marked this conversation as resolved.

try {
await refetchSession()
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/_shell/consent/consent-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,6 +23,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) {
<TrackingConsentProvider>
{children}
<GoogleAnalyticsPageViewTracker />
<FreebuffClickIdGuard />
<ConsentBanner />
</TrackingConsentProvider>
</ConsentStoreProvider>
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx
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() {
Comment thread
waleedlatif1 marked this conversation as resolved.
const { isResolved, marketing } = useTrackingConsent()

useEffect(() => {
if (isResolved && !marketing) clearFreebuffClickId()
}, [isResolved, marketing])

return null
}
67 changes: 67 additions & 0 deletions apps/sim/lib/analytics/freebuff.server.ts
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, {
Comment thread
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 })
}
}
49 changes: 49 additions & 0 deletions apps/sim/lib/analytics/freebuff.ts
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 })
}
21 changes: 20 additions & 1 deletion apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Comment thread
waleedlatif1 marked this conversation as resolved.
void reportFreebuffConversion({
Comment thread
waleedlatif1 marked this conversation as resolved.
clickId: freebuffClickId,
eventType: 'signup_completed',
eventId: user.id,
occurredAt: user.createdAt,
})
}

try {
PlatformEvents.userSignedUp({
userId: user.id,
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/consent/scripts.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/core/security/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ const STATIC_SCRIPT_SRC = [
// X (Twitter) conversion pixel (landing pages) — the base code injects
// uwt.js as a <script> tag from static.ads-twitter.com
'https://static.ads-twitter.com',
// Freebuff Ads conversion tag — freebuff-tag.js
'https://freebuff.com',
]
: []),
] as const
Expand Down Expand Up @@ -160,6 +162,8 @@ const STATIC_CONNECT_SRC = [
// via fetch/sendBeacon. The t.co image-pixel fallback is already
// covered by the `https:` wildcard in img-src.
'https://analytics.twitter.com',
// Freebuff Ads conversion tag — beacons to /api/advertisers/conversions/client
'https://freebuff.com',
]
: []),
] as const
Expand Down
Loading