mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature: ad attribution capture. ## What is the current behavior? Freebuff ad clicks arrive on supabase.com with a signed click id in `?bfcid=`. Nothing captures it, so those signups are unattributed. ## What is the new behavior? This PR captures `bfcid` and writes it to a cookie that, in production, is scoped so the management API receives it. The conversion is reported server-side on profile creation, in a separate change tracked in GROWTH-1217. Start with `enforceConsentDecision` in `packages/common/consented-url-cookie.ts`. It is the rule everything else hangs off, and `consented-url-cookie.test.ts` covers the state matrix. Capture: - `bfcid` is read on landing and held in `sessionStorage` until the consent decision resolves. Memory alone loses it when someone navigates before answering the banner. - Once consent is granted it goes into a cookie. In production on `*.supabase.com` that cookie is scoped to `domain=supabase.com`, and it is host-only elsewhere. It is written only after consent, which is the signal GROWTH-1217 relies on. - Values are validated with `/^bfc_[A-Za-z0-9._-]{1,508}$/`, the validator Freebuff publishes in their tag, so we never store a value their tag would reject. - `bfcid` is added to the first-touch attribution props, which feed pageview telemetry and are already consent-gated. Consent: - `enforceConsentDecision` reduces the decision to two states. Undecided and declined both clear the cookie, since neither has consent to point at. They differ in the retained value: an undecided visitor may still accept, so it waits for them. - `clearConsentedUrlCookie` drops the cookie. `discardConsentedUrlValue` also drops the retained value. - A module-level valtio subscription registers on import, guarded on `window` so it is inert during SSR. `packages/common/consent-state.ts` gains a generic `isResolved` flag and no vendor knowledge. A consumer acting on a decision needs to tell "not decided yet" from "decided against", which `hasConsented` cannot express alone. `applyPriorDecisionToSDK` now returns its promise chains, so its signature becomes `void | Promise<void>` and initialization awaits settlement before marking the decision resolved. Worth checking the call sites. ## Additional context 160 tests pass in `packages/common`. Typecheck and Prettier are clean locally on the changed files. CI is still running on the latest commit. Unverified: the clearing paths are covered by unit tests only. The consent SDK is short-circuited in local and preview builds, so they cannot be exercised outside production. An end-to-end conversion recorded by Freebuff is also unverified, since it needs the server-side change deployed. GROWTH-1216 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added consent-aware handling for Freebuff Ads click identifiers, retaining valid URL values until consent is resolved and storing them in a cookie after approval. - Added automatic cleanup when consent is denied or withdrawn, while preserving unrelated cookies. - Added support for capturing the click identifier in first-touch attribution data. - **Bug Fixes** - Improved consent initialization tracking so completion is reported after successful or failed resolution. - Added safeguards for restricted browser storage, cookies, and server-rendered environments. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
499 lines
15 KiB
TypeScript
499 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { components } from 'api-types'
|
|
import { useRouter } from 'next/compat/router'
|
|
import { usePathname } from 'next/navigation'
|
|
import Script from 'next/script'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useLatest } from 'react-use'
|
|
|
|
import { useUser } from './auth'
|
|
import { hasConsented, useConsentState } from './consent-state'
|
|
import { createConsentedUrlCookieSync } from './consented-url-cookie'
|
|
import { IS_PLATFORM } from './constants'
|
|
import { useFeatureFlags } from './feature-flags'
|
|
import { post } from './fetchWrappers'
|
|
import type { FirstReferrerData, MwDiagData } from './first-referrer-cookie'
|
|
import {
|
|
isExternalReferrer,
|
|
isOAuthRedirectReferrer,
|
|
parseFirstReferrerCookie,
|
|
parseMwDiagCookie,
|
|
} from './first-referrer-cookie'
|
|
import { ensurePlatformSuffix, isBrowser } from './helpers'
|
|
import { useFirstTouchStore, useParams } from './hooks'
|
|
import {
|
|
buildSessionRecordingConfig,
|
|
posthogClient,
|
|
type CapturedNetworkRequest,
|
|
type ClientTelemetryEvent,
|
|
type SessionRecordingOptions,
|
|
} from './posthog-client'
|
|
import { TelemetryEvent } from './telemetry-constants'
|
|
import {
|
|
clearFirstTouchData,
|
|
getFirstTouchData,
|
|
type SharedTelemetryData,
|
|
} from './telemetry-first-touch-store'
|
|
import { getSharedTelemetryData, getTelemetryCookieOptions } from './telemetry-utils'
|
|
|
|
export {
|
|
buildSessionRecordingConfig,
|
|
posthogClient,
|
|
type CapturedNetworkRequest,
|
|
type ClientTelemetryEvent,
|
|
type SessionRecordingOptions,
|
|
}
|
|
|
|
export const TelemetryTagManager = () => {
|
|
const { hasAccepted } = useConsentState()
|
|
const [syncCookie] = useState(createConsentedUrlCookieSync)
|
|
|
|
const isGTMEnabled = Boolean(
|
|
IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID && hasAccepted
|
|
)
|
|
|
|
// Ad attribution capture is independent of GTM, so this runs even when the
|
|
// container is not configured.
|
|
useEffect(() => {
|
|
syncCookie(hasAccepted)
|
|
}, [hasAccepted, syncCookie])
|
|
|
|
if (!isGTMEnabled) return null
|
|
|
|
return (
|
|
<Script
|
|
id="consent"
|
|
strategy="afterInteractive"
|
|
dangerouslySetInnerHTML={{
|
|
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;j.src="https://ss.supabase.com/4icgbaujh.js?"+i;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','60a389s=aWQ9R1RNLVdDVlJMTU43&page=2');`,
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) {
|
|
const urlString = telemetryData.page_url
|
|
|
|
try {
|
|
const url = new URL(urlString)
|
|
url.hash = ''
|
|
const params = url.searchParams
|
|
|
|
const getParam = (key: string) => {
|
|
const value = params.get(key)
|
|
return value && value.length > 0 ? value : undefined
|
|
}
|
|
|
|
const utmProps = {
|
|
...(getParam('utm_source') && { $utm_source: getParam('utm_source') }),
|
|
...(getParam('utm_medium') && { $utm_medium: getParam('utm_medium') }),
|
|
...(getParam('utm_campaign') && { $utm_campaign: getParam('utm_campaign') }),
|
|
...(getParam('utm_content') && { $utm_content: getParam('utm_content') }),
|
|
...(getParam('utm_term') && { $utm_term: getParam('utm_term') }),
|
|
}
|
|
|
|
const clickIdProps = {
|
|
...(getParam('gclid') && { gclid: getParam('gclid') }), // Google Ads
|
|
...(getParam('gbraid') && { gbraid: getParam('gbraid') }), // Google Ads (iOS)
|
|
...(getParam('wbraid') && { wbraid: getParam('wbraid') }), // Google Ads (iOS)
|
|
...(getParam('msclkid') && { msclkid: getParam('msclkid') }), // Microsoft Ads (Bing)
|
|
...(getParam('fbclid') && { fbclid: getParam('fbclid') }), // Meta (Facebook/Instagram)
|
|
...(getParam('rdt_cid') && { rdt_cid: getParam('rdt_cid') }), // Reddit Ads
|
|
...(getParam('ttclid') && { ttclid: getParam('ttclid') }), // TikTok Ads
|
|
...(getParam('twclid') && { twclid: getParam('twclid') }), // X Ads (Twitter)
|
|
...(getParam('li_fat_id') && { li_fat_id: getParam('li_fat_id') }), // LinkedIn Ads
|
|
...(getParam('bfcid') && { bfcid: getParam('bfcid') }), // Freebuff Ads
|
|
}
|
|
|
|
return {
|
|
...utmProps,
|
|
...clickIdProps,
|
|
first_touch_url: url.href,
|
|
first_touch_pathname: url.pathname,
|
|
...(url.search && { first_touch_search: url.search }),
|
|
}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
interface HandlePageTelemetryOptions {
|
|
apiUrl: string
|
|
pathname?: string
|
|
featureFlags?: Record<string, unknown>
|
|
slug?: string
|
|
ref?: string
|
|
telemetryDataOverride?: SharedTelemetryData
|
|
firstReferrerData?: FirstReferrerData | null
|
|
mwDiagData?: MwDiagData | null
|
|
}
|
|
|
|
function handlePageTelemetry({
|
|
pathname,
|
|
featureFlags,
|
|
slug,
|
|
ref,
|
|
telemetryDataOverride,
|
|
firstReferrerData,
|
|
mwDiagData,
|
|
}: HandlePageTelemetryOptions) {
|
|
if (typeof window !== 'undefined') {
|
|
const livePageData = getSharedTelemetryData(pathname)
|
|
const liveReferrer = livePageData.ph.referrer
|
|
const storedReferrer = telemetryDataOverride?.ph?.referrer
|
|
|
|
const shouldUseStoredReferrer = Boolean(
|
|
storedReferrer &&
|
|
isExternalReferrer(storedReferrer) &&
|
|
!isOAuthRedirectReferrer(storedReferrer) &&
|
|
(!isExternalReferrer(liveReferrer) || isOAuthRedirectReferrer(liveReferrer))
|
|
)
|
|
|
|
const pageData = telemetryDataOverride
|
|
? {
|
|
...livePageData,
|
|
ph: {
|
|
...livePageData.ph,
|
|
referrer: shouldUseStoredReferrer ? storedReferrer! : liveReferrer,
|
|
},
|
|
}
|
|
: { ...livePageData, ph: { ...livePageData.ph } }
|
|
const firstTouchAttributionProps: Record<string, string> = {
|
|
...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}),
|
|
}
|
|
|
|
const firstReferrerCookiePresent = Boolean(firstReferrerData)
|
|
let firstReferrerCookieConsumed = false
|
|
|
|
if (
|
|
firstReferrerData &&
|
|
isExternalReferrer(firstReferrerData.referrer) &&
|
|
!isOAuthRedirectReferrer(firstReferrerData.referrer) &&
|
|
(!isExternalReferrer(pageData.ph.referrer) || isOAuthRedirectReferrer(pageData.ph.referrer))
|
|
) {
|
|
pageData.ph.referrer = firstReferrerData.referrer
|
|
firstReferrerCookieConsumed = true
|
|
|
|
const { utms, click_ids, landing_url } = firstReferrerData
|
|
|
|
Object.entries(utms).forEach(([key, value]) => {
|
|
const phKey = key.startsWith('utm_') ? `$${key}` : key
|
|
firstTouchAttributionProps[phKey] = value
|
|
})
|
|
|
|
Object.entries(click_ids).forEach(([key, value]) => {
|
|
firstTouchAttributionProps[key] = value
|
|
})
|
|
|
|
try {
|
|
const url = new URL(landing_url)
|
|
firstTouchAttributionProps.first_touch_url = url.href
|
|
firstTouchAttributionProps.first_touch_pathname = url.pathname
|
|
|
|
if (url.search) {
|
|
firstTouchAttributionProps.first_touch_search = url.search
|
|
} else {
|
|
delete firstTouchAttributionProps.first_touch_search
|
|
}
|
|
} catch {
|
|
// Skip if landing URL is malformed
|
|
}
|
|
}
|
|
|
|
const $referrer = pageData.ph.referrer
|
|
const $referring_domain = (() => {
|
|
if (!$referrer) return undefined
|
|
try {
|
|
return new URL($referrer).hostname
|
|
} catch {
|
|
return undefined
|
|
}
|
|
})()
|
|
|
|
if (pageData.session_id) {
|
|
document.cookie = `session_id=${pageData.session_id}; ${getTelemetryCookieOptions()}`
|
|
}
|
|
|
|
posthogClient.capturePageView({
|
|
$current_url: pageData.page_url,
|
|
$pathname: pageData.pathname,
|
|
$host: new URL(pageData.page_url).hostname,
|
|
...($referrer && { $referrer }),
|
|
...($referring_domain && { $referring_domain }),
|
|
...firstTouchAttributionProps,
|
|
$groups: {
|
|
...(slug ? { organization: slug } : {}),
|
|
...(ref ? { project: ref } : {}),
|
|
},
|
|
page_title: pageData.page_title,
|
|
...(pageData.session_id && { $session_id: pageData.session_id }),
|
|
...pageData.ph,
|
|
...Object.fromEntries(
|
|
Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v])
|
|
),
|
|
// Only included on the initial pageview — subsequent pageviews omit firstReferrerData entirely
|
|
...(firstReferrerData !== undefined && {
|
|
first_referrer_cookie_present: firstReferrerCookiePresent,
|
|
first_referrer_cookie_consumed: firstReferrerCookieConsumed,
|
|
}),
|
|
...(mwDiagData && {
|
|
mw_diag_hit: mwDiagData.hit,
|
|
mw_diag_would_stamp: mwDiagData.would_stamp,
|
|
mw_diag_has_existing_cookie: mwDiagData.has_existing_cookie,
|
|
}),
|
|
})
|
|
}
|
|
|
|
return Promise.resolve()
|
|
}
|
|
|
|
export function handlePageLeaveTelemetry(
|
|
_API_URL: string,
|
|
pathname: string,
|
|
_featureFlags?: {
|
|
[key: string]: unknown
|
|
},
|
|
_slug?: string,
|
|
_ref?: string
|
|
) {
|
|
if (typeof window !== 'undefined') {
|
|
const pageData = getSharedTelemetryData(pathname)
|
|
posthogClient.capturePageLeave({
|
|
$current_url: pageData.page_url,
|
|
$pathname: pageData.pathname,
|
|
page_title: pageData.page_title,
|
|
...(pageData.session_id && { $session_id: pageData.session_id }),
|
|
})
|
|
}
|
|
|
|
return Promise.resolve()
|
|
}
|
|
|
|
export const PageTelemetry = ({
|
|
API_URL,
|
|
hasAcceptedConsent,
|
|
enabled = true,
|
|
organizationSlug,
|
|
projectRef,
|
|
sessionReplay,
|
|
}: {
|
|
API_URL: string
|
|
hasAcceptedConsent: boolean
|
|
enabled?: boolean
|
|
organizationSlug?: string
|
|
projectRef?: string
|
|
/** Masking policy for session replay. Omit to disable recording. */
|
|
sessionReplay?: SessionRecordingOptions
|
|
}) => {
|
|
const router = useRouter()
|
|
|
|
const pagesPathname = router?.pathname
|
|
const appPathname = usePathname()
|
|
|
|
const params = useParams()
|
|
const slug = organizationSlug || params.slug
|
|
const ref = projectRef || params.ref
|
|
|
|
const featureFlags = useFeatureFlags()
|
|
|
|
useFirstTouchStore({ enabled: enabled && IS_PLATFORM })
|
|
|
|
const pathname =
|
|
pagesPathname ?? appPathname ?? (isBrowser ? window.location.pathname : undefined)
|
|
const pathnameRef = useLatest(pathname)
|
|
const featureFlagsRef = useLatest(featureFlags.posthog)
|
|
|
|
const sendPageTelemetry = useCallback(() => {
|
|
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
|
|
|
|
return handlePageTelemetry({
|
|
apiUrl: API_URL,
|
|
pathname: pathnameRef.current,
|
|
featureFlags: featureFlagsRef.current,
|
|
slug,
|
|
ref,
|
|
}).catch((e) => {
|
|
console.error('Problem sending telemetry page:', e)
|
|
})
|
|
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
|
|
|
|
const sendPageLeaveTelemetry = useCallback(() => {
|
|
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
|
|
|
|
if (!pathnameRef.current) return Promise.resolve()
|
|
|
|
return handlePageLeaveTelemetry(
|
|
API_URL,
|
|
pathnameRef.current,
|
|
featureFlagsRef.current,
|
|
slug,
|
|
ref
|
|
).catch((e) => {
|
|
console.error('Problem sending telemetry page-leave:', e)
|
|
})
|
|
}, [API_URL, enabled, hasAcceptedConsent, slug, ref])
|
|
|
|
const hasSentInitialPageTelemetryRef = useRef(false)
|
|
const previousAppPathnameRef = useRef<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (hasAcceptedConsent && IS_PLATFORM) {
|
|
posthogClient.init({ sessionReplay })
|
|
}
|
|
}, [hasAcceptedConsent, IS_PLATFORM, sessionReplay])
|
|
|
|
// Waiting for router.isReady before sending to avoid dynamic route placeholders
|
|
useEffect(() => {
|
|
if (
|
|
(router?.isReady ?? true) &&
|
|
enabled &&
|
|
hasAcceptedConsent &&
|
|
!hasSentInitialPageTelemetryRef.current
|
|
) {
|
|
const cookieHeader = document.cookie
|
|
const firstReferrerData = parseFirstReferrerCookie(cookieHeader)
|
|
const mwDiagData = parseMwDiagCookie(cookieHeader)
|
|
const firstTouchData = getFirstTouchData()
|
|
|
|
try {
|
|
handlePageTelemetry({
|
|
apiUrl: API_URL,
|
|
pathname: pathnameRef.current,
|
|
featureFlags: featureFlagsRef.current,
|
|
slug,
|
|
ref,
|
|
...(firstTouchData && { telemetryDataOverride: firstTouchData }),
|
|
firstReferrerData,
|
|
mwDiagData,
|
|
})
|
|
} finally {
|
|
clearFirstTouchData()
|
|
hasSentInitialPageTelemetryRef.current = true
|
|
}
|
|
}
|
|
}, [router?.isReady, enabled, hasAcceptedConsent, slug, ref])
|
|
|
|
useEffect(() => {
|
|
if (router === null) return
|
|
|
|
function handleRouteChange() {
|
|
if (!hasSentInitialPageTelemetryRef.current) return
|
|
sendPageTelemetry()
|
|
}
|
|
|
|
router.events.on('routeChangeComplete', handleRouteChange)
|
|
|
|
return () => {
|
|
router.events.off('routeChangeComplete', handleRouteChange)
|
|
}
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
if (router !== null) return
|
|
|
|
if (
|
|
appPathname &&
|
|
previousAppPathnameRef.current !== null &&
|
|
previousAppPathnameRef.current !== appPathname
|
|
) {
|
|
sendPageTelemetry()
|
|
}
|
|
|
|
previousAppPathnameRef.current = appPathname
|
|
}, [appPathname, router, sendPageTelemetry])
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return
|
|
|
|
const handleBeforeUnload = () => sendPageLeaveTelemetry()
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
}, [enabled, sendPageLeaveTelemetry])
|
|
|
|
useTelemetryIdentify(API_URL)
|
|
|
|
return null
|
|
}
|
|
|
|
type EventBody = components['schemas']['TelemetryEventBodyV2']
|
|
|
|
export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathname?: string) {
|
|
const consent = hasConsented()
|
|
if (!consent) return
|
|
|
|
const body: EventBody = {
|
|
...getSharedTelemetryData(pathname),
|
|
action: event.action,
|
|
custom_properties: 'properties' in event ? event.properties : {},
|
|
groups: 'groups' in event ? { ...event.groups } : {},
|
|
}
|
|
|
|
if (body.groups?.project === 'Unknown') {
|
|
delete body.groups.project
|
|
if (body.groups?.organization === 'Unknown') {
|
|
delete body.groups
|
|
}
|
|
}
|
|
|
|
// keepalive lets the request survive the same-tick OAuth redirect after
|
|
// sign_in_submitted, but keepalive requests share a ~64KB in-flight quota
|
|
// page-wide, so it stays scoped to that event. Callers like useTrack
|
|
// fire-and-forget, so rejections are handled here rather than surfacing
|
|
// as unhandled promise rejections.
|
|
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
|
|
headers: { Version: '2' },
|
|
keepalive: event.action === 'sign_in_submitted',
|
|
}).catch((error) => {
|
|
console.error('Problem sending telemetry event:', error)
|
|
})
|
|
}
|
|
|
|
//---
|
|
// TELEMETRY IDENTIFY
|
|
//---
|
|
|
|
type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2']
|
|
|
|
export function sendTelemetryIdentify(API_URL: string, body: IdentifyBody) {
|
|
const consent = hasConsented()
|
|
|
|
if (!consent) return Promise.resolve()
|
|
|
|
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/identify`, body, {
|
|
headers: { Version: '2' },
|
|
})
|
|
}
|
|
|
|
export function useTelemetryIdentify(API_URL: string) {
|
|
const user = useUser()
|
|
|
|
useEffect(() => {
|
|
if (user?.id) {
|
|
const anonymousId = posthogClient.getDistinctId()
|
|
|
|
sendTelemetryIdentify(API_URL, {
|
|
user_id: user.id,
|
|
...(anonymousId && { anonymous_id: anonymousId }),
|
|
})
|
|
|
|
// user.created_at is gotrue's immutable signup timestamp — safe to $set on
|
|
// every identify because the value never changes per user. Lets flag
|
|
// targeting distinguish brand-new signups from returning single-org users.
|
|
posthogClient.identify(user.id, {
|
|
gotrue_id: user.id,
|
|
...(user.created_at && { signup_timestamp: user.created_at }),
|
|
})
|
|
}
|
|
}, [API_URL, user?.id])
|
|
}
|
|
|
|
//---
|
|
// TELEMETRY RESET
|
|
//---
|
|
|
|
export function handleResetTelemetry(API_URL: string) {
|
|
return post(`${API_URL}/telemetry/reset`, {})
|
|
}
|