diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index f4812f9e620..ea55aafa52e 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,23 +1,17 @@ -import { clientSdkIds } from '~/content/navigation.references' -import { BASE_PATH } from '~/lib/constants' -import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { isbot } from 'isbot' import { NextResponse, type NextRequest } from 'next/server' +import { clientSdkIds } from '~/content/navigation.references' +import { BASE_PATH } from '~/lib/constants' + const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` export function middleware(request: NextRequest) { const url = new URL(request.url) - - // Non-reference paths: just handle the first-referrer cookie and pass through if (!url.pathname.startsWith(REFERENCE_PATH)) { - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + return NextResponse.next() } - // Reference paths: existing rewrite logic with cookie stamping on every response - if (isbot(request.headers.get('user-agent'))) { let [, lib, maybeVersion, ...slug] = url.pathname.replace(REFERENCE_PATH, '').split('/') @@ -30,9 +24,7 @@ export function middleware(request: NextRequest) { if (slug.length > 0) { const rewriteUrl = new URL(url) rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers' - const response = NextResponse.rewrite(rewriteUrl) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(rewriteUrl) } } } @@ -41,42 +33,28 @@ export function middleware(request: NextRequest) { if (lib === 'cli') { const rewritePath = [REFERENCE_PATH, 'cli'].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (lib === 'api') { const rewritePath = [REFERENCE_PATH, 'api'].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (lib?.startsWith('self-hosting-')) { const rewritePath = [REFERENCE_PATH, lib].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (clientSdkIds.includes(lib)) { const version = /v\d+/.test(maybeVersion) ? maybeVersion : null const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + return NextResponse.next() } export const config = { - matcher: [ - // Broadened from `/reference/:path*` to stamp first-referrer cookies on all - // docs pages, not just reference paths. Excludes Next.js internals and static files. - '/((?!api|_next/static|_next/image|favicon.ico|__nextjs).*)', - ], + matcher: '/reference/:path*', } diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 5461699c889..27c941fb1c1 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,8 +1,10 @@ -import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { IS_PLATFORM } from 'lib/constants' -import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +export const config = { + matcher: '/api/:function*', +} + // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/generate-v4', @@ -27,24 +29,13 @@ const HOSTED_SUPPORTED_API_URLS = [ ] export function proxy(request: NextRequest) { - // API route filtering for hosted platform - if (request.nextUrl.pathname.startsWith('/api/')) { - if ( - IS_PLATFORM && - !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) - ) { - return Response.json( - { success: false, message: 'Endpoint not supported on hosted' }, - { status: 404 } - ) - } + if ( + IS_PLATFORM && + !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) + ) { + return Response.json( + { success: false, message: 'Endpoint not supported on hosted' }, + { status: 404 } + ) } - - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response -} - -export const config = { - matcher: ['/((?!_next/static|_next/image|favicon.ico|__nextjs).*)'], } diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts deleted file mode 100644 index 72a74634d8a..00000000000 --- a/apps/www/middleware.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' -import { NextResponse, type NextRequest } from 'next/server' - -export function middleware(request: NextRequest) { - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response -} - -export const config = { - matcher: [ - // Match all paths except Next.js internals and static files - '/((?!api|_next/static|_next/image|favicon.ico|__nextjs).*)', - ], -} diff --git a/packages/common/first-referrer-cookie.test.ts b/packages/common/first-referrer-cookie.test.ts deleted file mode 100644 index ce308e2d244..00000000000 --- a/packages/common/first-referrer-cookie.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - buildFirstReferrerData, - FIRST_REFERRER_COOKIE_NAME, - hasPaidSignals, - isExternalReferrer, - parseFirstReferrerCookie, - serializeFirstReferrerCookie, - shouldRefreshCookie, -} from './first-referrer-cookie' - -describe('first-referrer-cookie', () => { - describe('isExternalReferrer', () => { - it('returns false for supabase domains', () => { - expect(isExternalReferrer('https://supabase.com')).toBe(false) - expect(isExternalReferrer('https://www.supabase.com')).toBe(false) - expect(isExternalReferrer('https://docs.supabase.com')).toBe(false) - }) - - it('returns true for external domains', () => { - expect(isExternalReferrer('https://google.com')).toBe(true) - expect(isExternalReferrer('https://chatgpt.com')).toBe(true) - }) - - it('returns true for http:// referrers', () => { - expect(isExternalReferrer('http://google.com')).toBe(true) - expect(isExternalReferrer('http://example.org/page')).toBe(true) - }) - - it('returns false for invalid values', () => { - expect(isExternalReferrer('')).toBe(false) - expect(isExternalReferrer('not-a-url')).toBe(false) - }) - }) - - describe('buildFirstReferrerData', () => { - it('handles malformed landing URL gracefully', () => { - const data = buildFirstReferrerData({ - referrer: 'https://google.com', - landingUrl: 'not-a-valid-url', - }) - - expect(data.referrer).toBe('https://google.com') - expect(data.landing_url).toBe('not-a-valid-url') - expect(data.utms).toEqual({}) - expect(data.click_ids).toEqual({}) - }) - - it('extracts utm and click-id params from landing url', () => { - const data = buildFirstReferrerData({ - referrer: 'https://www.google.com/', - landingUrl: - 'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456', - }) - - expect(data.referrer).toBe('https://www.google.com/') - expect(data.landing_url).toBe( - 'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456' - ) - - expect(data.utms).toEqual({ - utm_source: 'google', - utm_medium: 'cpc', - utm_campaign: 'test', - }) - - expect(data.click_ids).toEqual({ - gclid: 'abc123', - msclkid: 'xyz456', - }) - }) - }) - - describe('serialize / parse', () => { - it('round-trips valid cookie payloads', () => { - const input = buildFirstReferrerData({ - referrer: 'https://www.google.com/', - landingUrl: 'https://supabase.com/pricing?utm_source=google', - }) - - const encoded = serializeFirstReferrerCookie(input) - const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`) - - expect(parsed).toEqual(input) - }) - - it('returns null for empty string', () => { - expect(parseFirstReferrerCookie('')).toBeNull() - }) - - it('parses cookie from header with multiple cookies', () => { - const input = buildFirstReferrerData({ - referrer: 'https://google.com/', - landingUrl: 'https://supabase.com/', - }) - const encoded = serializeFirstReferrerCookie(input) - const header = `session=abc123; ${FIRST_REFERRER_COOKIE_NAME}=${encoded}; theme=dark` - - expect(parseFirstReferrerCookie(header)).toEqual(input) - }) - - it('returns null for malformed json', () => { - expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=%7Bnot-json`)).toBeNull() - }) - - it('returns null for invalid payload shape', () => { - const encoded = encodeURIComponent(JSON.stringify({ foo: 'bar' })) - expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`)).toBeNull() - }) - - it('drops non-string values in utms/click_ids', () => { - const encoded = encodeURIComponent( - JSON.stringify({ - referrer: 'https://www.google.com/', - landing_url: 'https://supabase.com/pricing', - utms: { utm_source: 'google', utm_medium: 123 }, - click_ids: { gclid: 'abc', msclkid: null }, - ts: 123, - }) - ) - - const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`) - - expect(parsed).toEqual({ - referrer: 'https://www.google.com/', - landing_url: 'https://supabase.com/pricing', - utms: { utm_source: 'google' }, - click_ids: { gclid: 'abc' }, - ts: 123, - }) - }) - }) - - describe('hasPaidSignals', () => { - it('detects click IDs', () => { - expect(hasPaidSignals(new URL('https://supabase.com/?gclid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?fbclid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?msclkid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?gbraid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?wbraid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?rdt_cid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?ttclid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?twclid=abc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?li_fat_id=abc'))).toBe(true) - }) - - it('detects paid utm_medium values', () => { - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=cpc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=ppc'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_search'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paidsocial'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_social'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=display'))).toBe(true) - }) - - it('is case-insensitive for utm_medium', () => { - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=CPC'))).toBe(true) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=Paid_Search'))).toBe(true) - }) - - it('returns false for organic traffic', () => { - expect(hasPaidSignals(new URL('https://supabase.com/'))).toBe(false) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_source=google'))).toBe(false) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=email'))).toBe(false) - expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=organic'))).toBe(false) - }) - }) - - describe('shouldRefreshCookie', () => { - it('stamps when no cookie and external referrer', () => { - expect( - shouldRefreshCookie(false, { - referrer: 'https://google.com', - url: 'https://supabase.com/', - }) - ).toEqual({ stamp: true }) - }) - - it('skips when no cookie and internal referrer', () => { - expect( - shouldRefreshCookie(false, { - referrer: 'https://supabase.com/docs', - url: 'https://supabase.com/dashboard', - }) - ).toEqual({ stamp: false }) - }) - - it('skips when cookie exists and no paid signals', () => { - expect( - shouldRefreshCookie(true, { - referrer: 'https://google.com', - url: 'https://supabase.com/', - }) - ).toEqual({ stamp: false }) - }) - - it('refreshes when cookie exists but URL has paid signals', () => { - expect( - shouldRefreshCookie(true, { - referrer: 'https://google.com', - url: 'https://supabase.com/?gclid=abc123', - }) - ).toEqual({ stamp: true }) - - expect( - shouldRefreshCookie(true, { - referrer: 'https://google.com', - url: 'https://supabase.com/?utm_medium=cpc&utm_source=google', - }) - ).toEqual({ stamp: true }) - }) - - it('skips when no cookie and no referrer (direct navigation)', () => { - expect(shouldRefreshCookie(false, { referrer: '', url: 'https://supabase.com/' })).toEqual({ - stamp: false, - }) - }) - - it('handles malformed URL gracefully', () => { - expect( - shouldRefreshCookie(true, { - referrer: 'https://google.com', - url: 'not-a-valid-url', - }) - ).toEqual({ stamp: false }) - }) - }) -}) diff --git a/packages/common/first-referrer-cookie.ts b/packages/common/first-referrer-cookie.ts deleted file mode 100644 index cfa7d6b7131..00000000000 --- a/packages/common/first-referrer-cookie.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Shared utilities for the cross-app first-referrer handoff cookie. - * - * The `_sb_first_referrer` cookie is written by edge middleware on `apps/www`, - * `apps/docs`, and `apps/studio` when a user arrives from an - * external source. Studio reads it on the first telemetry pageview to recover - * external attribution context that would otherwise be lost at the app boundary. - * - * The cookie is normally write-once (365-day TTL, domain=supabase.com), but is - * refreshed when a returning visitor arrives with paid traffic signals (click IDs - * or paid UTM medium values) to ensure paid attribution overrides stale organic data. - */ - -// --------------------------------------------------------------------------- -// Structural types for Next.js middleware request/response -// --------------------------------------------------------------------------- -// Using structural interfaces instead of importing NextRequest/NextResponse -// avoids version conflicts when different apps pin different Next.js versions -// (e.g. studio on Next 15, docs/www on Next 16). - -interface MiddlewareRequest { - headers: { get(name: string): string | null } - cookies: { has(name: string): boolean } - url: string - nextUrl: { hostname: string } -} - -interface MiddlewareResponse { - cookies: { - set( - name: string, - value: string, - options?: { - path?: string - sameSite?: 'lax' | 'strict' | 'none' - secure?: boolean - domain?: string - maxAge?: number - } - ): void - } -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -export const FIRST_REFERRER_COOKIE_NAME = '_sb_first_referrer' - -/** 365 days in seconds */ -export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60 - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface FirstReferrerData { - /** The external referrer URL (e.g. https://www.google.com/) */ - referrer: string - /** The landing URL on our site when the external referrer was captured */ - landing_url: string - /** UTM params parsed from the landing URL (e.g. utm_source, utm_medium) */ - utms: Record - /** Ad-network click IDs parsed from the landing URL */ - click_ids: Record - /** Unix timestamp (ms) when the cookie was written */ - ts: number -} - -// --------------------------------------------------------------------------- -// Referrer classification -// --------------------------------------------------------------------------- - -/** - * Returns true if the referrer URL points to an external (non-Supabase) domain. - * Handles malformed URLs gracefully by returning false. - */ -export function isExternalReferrer(referrer: string): boolean { - if (!referrer) return false - try { - const hostname = new URL(referrer).hostname - return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') - } catch { - return false - } -} - -// --------------------------------------------------------------------------- -// UTM + click-ID extraction -// --------------------------------------------------------------------------- - -const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const - -const CLICK_ID_KEYS = [ - 'gclid', // Google Ads - 'gbraid', // Google Ads (iOS) - 'wbraid', // Google Ads (iOS) - 'msclkid', // Microsoft Ads (Bing) - 'fbclid', // Meta (Facebook/Instagram) - 'rdt_cid', // Reddit Ads - 'ttclid', // TikTok Ads - 'twclid', // X Ads (Twitter) - 'li_fat_id', // LinkedIn Ads -] as const - -function pickParams( - searchParams: URLSearchParams, - keys: readonly string[] -): Record { - const result: Record = {} - for (const key of keys) { - const value = searchParams.get(key) - if (value) { - result[key] = value - } - } - return result -} - -function toStringRecord(value: unknown): Record { - if (!value || typeof value !== 'object') return {} - - return Object.fromEntries( - Object.entries(value as Record).filter( - ([key, v]) => typeof key === 'string' && typeof v === 'string' - ) - ) as Record -} - -// --------------------------------------------------------------------------- -// Build cookie payload from a request (edge-compatible) -// --------------------------------------------------------------------------- - -/** - * Build a `FirstReferrerData` payload from raw request values. - * Intended for use in Next.js middleware where `document` is not available. - */ -export function buildFirstReferrerData({ - referrer, - landingUrl, -}: { - referrer: string - landingUrl: string -}): FirstReferrerData { - let utms: Record = {} - let click_ids: Record = {} - - try { - const url = new URL(landingUrl) - utms = pickParams(url.searchParams, UTM_KEYS) - click_ids = pickParams(url.searchParams, CLICK_ID_KEYS) - } catch { - // If landing URL is malformed, just skip param extraction - } - - return { - referrer, - landing_url: landingUrl, - utms, - click_ids, - ts: Date.now(), - } -} - -// --------------------------------------------------------------------------- -// Serialize / parse -// --------------------------------------------------------------------------- - -export function serializeFirstReferrerCookie(data: FirstReferrerData): string { - return encodeURIComponent(JSON.stringify(data)) -} - -// --------------------------------------------------------------------------- -// Paid-signal detection -// --------------------------------------------------------------------------- - -const PAID_UTM_MEDIUMS = new Set([ - 'cpc', - 'ppc', - 'paid_search', - 'paidsocial', - 'paid_social', - 'display', -]) - -/** - * Returns true if the URL contains ad-network click IDs or paid UTM medium values. - * These indicate the user arrived via a paid campaign, which should override - * stale organic attribution. - */ -export function hasPaidSignals(url: URL): boolean { - for (const key of CLICK_ID_KEYS) { - if (url.searchParams.has(key)) return true - } - const medium = url.searchParams.get('utm_medium')?.toLowerCase() - return medium !== undefined && PAID_UTM_MEDIUMS.has(medium) -} - -/** - * Decides whether the first-referrer cookie should be (re-)stamped. - * - * - No cookie + external referrer → stamp (first visit attribution) - * - Cookie exists + paid signals in URL → stamp (paid traffic refresh) - * - Otherwise → skip - */ -export function shouldRefreshCookie( - existingCookie: boolean, - request: { referrer: string; url: string } -): { stamp: boolean } { - if (!existingCookie) { - return { stamp: isExternalReferrer(request.referrer) } - } - - try { - const url = new URL(request.url) - return { stamp: hasPaidSignals(url) } - } catch { - return { stamp: false } - } -} - -// --------------------------------------------------------------------------- -// Middleware helper — shared across apps/www, apps/docs, and apps/studio -// --------------------------------------------------------------------------- - -/** - * Stamp the first-referrer cookie on a Next.js middleware response if the - * request warrants it. This is the single entry point for all app middleware - * files — call it with the incoming request and outgoing response. - * - * On *.supabase.com the cookie is set with `domain=supabase.com` so it's - * readable across all subdomains (www, docs, studio). On other hosts - * (localhost, preview deploys) the domain is left unset so the browser - * stores a host-only cookie instead of rejecting an invalid domain. - */ -export function stampFirstReferrerCookie(request: MiddlewareRequest, response: MiddlewareResponse): void { - const referrer = request.headers.get('referer') ?? '' - - const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), { - referrer, - url: request.url, - }) - - if (!stamp) return - - const data = buildFirstReferrerData({ - referrer, - landingUrl: request.url, - }) - - response.cookies.set(FIRST_REFERRER_COOKIE_NAME, serializeFirstReferrerCookie(data), { - path: '/', - sameSite: 'lax', - ...(request.nextUrl.hostname === 'supabase.com' || - request.nextUrl.hostname.endsWith('.supabase.com') - ? { domain: 'supabase.com', secure: true } - : {}), - maxAge: FIRST_REFERRER_COOKIE_MAX_AGE, - }) -} - -// --------------------------------------------------------------------------- -// Parse cookie from document.cookie header (client-side) -// --------------------------------------------------------------------------- - -export function parseFirstReferrerCookie(cookieHeader: string): FirstReferrerData | null { - try { - const cookies = cookieHeader.split(';') - const match = cookies - .map((c) => c.trim()) - .find((c) => c.startsWith(`${FIRST_REFERRER_COOKIE_NAME}=`)) - - if (!match) return null - - const value = match.slice(`${FIRST_REFERRER_COOKIE_NAME}=`.length) - const parsed = JSON.parse(decodeURIComponent(value)) as unknown - - if (!parsed || typeof parsed !== 'object') return null - - const parsedRecord = parsed as Record - const referrer = parsedRecord.referrer - const landingUrl = parsedRecord.landing_url - - if (typeof referrer !== 'string' || typeof landingUrl !== 'string') { - return null - } - - const utmsRaw = parsedRecord.utms - const clickIdsRaw = parsedRecord.click_ids - const tsRaw = parsedRecord.ts - - const utms = toStringRecord(utmsRaw) - const click_ids = toStringRecord(clickIdsRaw) - - const ts = typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : Date.now() - - return { - referrer, - landing_url: landingUrl, - utms, - click_ids, - ts, - } - } catch { - return null - } -} diff --git a/packages/common/index.tsx b/packages/common/index.tsx index ee8ebe13307..87b60f4e6f9 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -10,6 +10,5 @@ export * from './helpers' export * from './hooks' export * from './MetaFavicons/pages-router' export * from './Providers' -export * from './first-referrer-cookie' export * from './telemetry' export * from './telemetry-utils' diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 36b3ea4ee01..29c416c8bd9 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -12,8 +12,6 @@ import { hasConsented } from './consent-state' import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' -import type { FirstReferrerData } from './first-referrer-cookie' -import { isExternalReferrer, parseFirstReferrerCookie } from './first-referrer-cookie' import { ensurePlatformSuffix, isBrowser } from './helpers' import { useParams, useTelemetryCookie } from './hooks' import { posthogClient, type ClientTelemetryEvent } from './posthog-client' @@ -98,25 +96,25 @@ function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) { } } -interface HandlePageTelemetryOptions { - apiUrl: string - pathname?: string - featureFlags?: Record - slug?: string - ref?: string - telemetryDataOverride?: SharedTelemetryData - firstReferrerData?: FirstReferrerData | null +function isExternalReferrer(referrer: string) { + try { + const hostname = new URL(referrer).hostname + return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') + } catch { + return false + } } -function handlePageTelemetry({ - apiUrl: API_URL, - pathname, - featureFlags, - slug, - ref, - telemetryDataOverride, - firstReferrerData, -}: HandlePageTelemetryOptions) { +function handlePageTelemetry( + API_URL: string, + pathname?: string, + featureFlags?: { + [key: string]: unknown + }, + slug?: string, + ref?: string, + telemetryDataOverride?: SharedTelemetryData +) { // Send to PostHog client-side (only in browser) if (typeof window !== 'undefined') { const livePageData = getSharedTelemetryData(pathname) @@ -135,51 +133,10 @@ function handlePageTelemetry({ referrer: shouldUseCookieReferrer ? cookieReferrer! : liveReferrer, }, } - : { ...livePageData, ph: { ...livePageData.ph } } - const firstTouchAttributionProps: Record = { - ...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}), - } - - // --- First-referrer edge cookie handoff --- - // If the edge cookie has external context and the current referrer is internal, - // override the referrer so PostHog gets the real acquisition source. - const firstReferrerCookiePresent = Boolean(firstReferrerData) - let firstReferrerCookieConsumed = false - - if ( - firstReferrerData && - isExternalReferrer(firstReferrerData.referrer) && - !isExternalReferrer(pageData.ph.referrer) - ) { - pageData.ph.referrer = firstReferrerData.referrer - firstReferrerCookieConsumed = true - - // Prefer attribution context captured at the external entry point. - 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 - } - } + : livePageData + const firstTouchAttributionProps = telemetryDataOverride + ? getFirstTouchAttributionProps(telemetryDataOverride) + : {} const $referrer = pageData.ph.referrer const $referring_domain = (() => { @@ -213,13 +170,6 @@ function handlePageTelemetry({ ...Object.fromEntries( Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v]) ), - // Measurement properties for handoff observability - // Only included on the initial pageview (when firstReferrerData is explicitly - // passed as null or a value — subsequent pageviews leave it as undefined) - ...(firstReferrerData !== undefined && { - first_referrer_cookie_present: firstReferrerCookiePresent, - first_referrer_cookie_consumed: firstReferrerCookieConsumed, - }), }) } @@ -284,13 +234,13 @@ export const PageTelemetry = ({ const sendPageTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry({ - apiUrl: API_URL, - pathname: pathnameRef.current, - featureFlags: featureFlagsRef.current, + return handlePageTelemetry( + API_URL, + pathnameRef.current, + featureFlagsRef.current, slug, - ref, - }).catch((e) => { + ref + ).catch((e) => { console.error('Problem sending telemetry page:', e) }) }, [API_URL, enabled, hasAcceptedConsent, slug, ref]) @@ -333,9 +283,6 @@ export const PageTelemetry = ({ hasAcceptedConsent && !hasSentInitialPageTelemetryRef.current ) { - // Read the edge-set first-referrer cookie (cross-app handoff) - const firstReferrerData = parseFirstReferrerCookie(document.cookie) - const cookies = document.cookie.split(';') const telemetryCookieValue = cookies .map((cookie) => cookie.trim()) @@ -347,39 +294,24 @@ export const PageTelemetry = ({ const telemetryData = JSON.parse( decodeURIComponent(telemetryCookieValue) ) as SharedTelemetryData - handlePageTelemetry({ - apiUrl: API_URL, - pathname: pathnameRef.current, - featureFlags: featureFlagsRef.current, + handlePageTelemetry( + API_URL, + pathnameRef.current, + featureFlagsRef.current, slug, ref, - telemetryDataOverride: telemetryData, - firstReferrerData, - }) + telemetryData + ) } catch (error) { if (!IS_PROD) { console.warn('Invalid telemetry cookie data:', error) } - handlePageTelemetry({ - apiUrl: API_URL, - pathname: pathnameRef.current, - featureFlags: featureFlagsRef.current, - slug, - ref, - firstReferrerData, - }) + handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) } finally { clearTelemetryDataCookie() } } else { - handlePageTelemetry({ - apiUrl: API_URL, - pathname: pathnameRef.current, - featureFlags: featureFlagsRef.current, - slug, - ref, - firstReferrerData, - }) + handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) } hasSentInitialPageTelemetryRef.current = true