From 04e63dfb2e00c43f8c57e538b8056aa647f4e5b4 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:31:39 -0800 Subject: [PATCH] fix: persist first referrer across app boundaries (#42768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes GROWTH-625. Preserves first-touch attribution across app boundaries by persisting external referrer context at the edge and consuming it on Studio's initial pageview. When users come from an external source to www/docs and then navigate to Studio, Studio often only sees the internal `supabase.com` hop. This change preserves the original external context so first-touch attribution is retained. ## What changed - **Shared first-referrer cookie utilities** (`packages/common/first-referrer-cookie.ts`): - `isExternalReferrer`, `buildFirstReferrerData`, `serializeFirstReferrerCookie`, `parseFirstReferrerCookie` - `hasPaidSignals` — detects click IDs (gclid, fbclid, etc.) and paid utm_medium values - `shouldRefreshCookie` — centralizes stamp-or-skip decision for all apps - `stampFirstReferrerCookie` — shared middleware helper used by all apps (extracted from duplicated inline logic) - **Edge middleware on all apps** — stamps cookie for external visitors, refreshes on paid signals: - `apps/www/middleware.ts` (simplified to use shared helper) - `apps/docs/middleware.ts` (simplified to use shared helper) - `apps/studio/proxy.ts` (integrated into existing proxy file) - **Docs middleware matcher** — broadened from `/reference/:path*` to all non-static paths so the first-referrer cookie is stamped on all docs pages, not just reference paths - **Telemetry** — Studio consumes cookie on initial pageview (`packages/common/telemetry.tsx`). `handlePageTelemetry` refactored from 7 positional params to an options object for readability. - **Tests** — 22 unit tests covering all utilities and edge cases (including direct-navigation scenario) ## Behavior - Writes `_sb_first_referrer` cookie when: - cookie is not already set and request has an external referrer, OR - cookie exists but incoming URL has paid traffic signals (click IDs or paid utm_medium) - Cookie: 365-day TTL, `domain=supabase.com`, `sameSite=lax`, `secure=true` in production - On first Studio pageview, if current referrer is internal and cookie has external context: - use persisted external referrer - apply persisted UTM/click-id/landing-url attribution props - Measurement properties: `first_referrer_cookie_present`, `first_referrer_cookie_consumed` ## Manual testing 1. Visit `supabase.com/pricing?utm_source=google&utm_medium=cpc` from an external referrer (or use DevTools to set a `Referer` header) 2. Check `_sb_first_referrer` cookie is set in Application > Cookies 3. Navigate to Studio (`supabase.com/dashboard`) 4. In PostHog (or browser network tab), verify the first `$pageview` event has: - `first_referrer_cookie_present: true` - `first_referrer_cookie_consumed: true` - `$utm_source: "google"`, `$utm_medium: "cpc"` - `$referrer` points to the external source, not `supabase.com` 5. Verify subsequent route changes do NOT include `first_referrer_cookie_*` properties ## Review feedback addressed - Added `secure: true` flag on production cookies (Pam's first comment) - Fixed inaccurate JSDoc on `utms` field — keys retain `utm_` prefix (Pam's fourth comment) - Added test coverage for edge cases: malformed URLs, multi-cookie headers, http:// referrers (Pam's sixth comment) - Docs matcher broadening: fast-path exit on cookie-exists check keeps overhead minimal, exclusion list is correct - Extracted shared middleware helper to eliminate duplication across 3 apps - Refactored `handlePageTelemetry` from positional params to options object - Removed redundant null check in `hasPaidSignals` - Added direct-navigation test case - Deleted dead `apps/learn/middleware.ts` - Fixed studio build: integrated cookie stamping into existing `proxy.ts` (Next.js 16 rejects both middleware.ts and proxy.ts) --------- Co-authored-by: pamelachia <26612111+pamelachia@users.noreply.github.com> Co-authored-by: Pamela Chia --- apps/docs/middleware.ts | 44 ++- apps/studio/proxy.ts | 33 +- apps/www/middleware.ts | 15 + packages/common/first-referrer-cookie.test.ts | 229 +++++++++++++ packages/common/first-referrer-cookie.ts | 307 ++++++++++++++++++ packages/common/index.tsx | 1 + packages/common/telemetry.tsx | 138 ++++++-- 7 files changed, 709 insertions(+), 58 deletions(-) create mode 100644 apps/www/middleware.ts create mode 100644 packages/common/first-referrer-cookie.test.ts create mode 100644 packages/common/first-referrer-cookie.ts diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index ea55aafa52e..f4812f9e620 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,17 +1,23 @@ -import { isbot } from 'isbot' -import { NextResponse, type NextRequest } from 'next/server' - 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' 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)) { - return NextResponse.next() + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response } + // 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('/') @@ -24,7 +30,9 @@ export function middleware(request: NextRequest) { if (slug.length > 0) { const rewriteUrl = new URL(url) rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers' - return NextResponse.rewrite(rewriteUrl) + const response = NextResponse.rewrite(rewriteUrl) + stampFirstReferrerCookie(request, response) + return response } } } @@ -33,28 +41,42 @@ export function middleware(request: NextRequest) { if (lib === 'cli') { const rewritePath = [REFERENCE_PATH, 'cli'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (lib === 'api') { const rewritePath = [REFERENCE_PATH, 'api'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (lib?.startsWith('self-hosting-')) { const rewritePath = [REFERENCE_PATH, lib].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (clientSdkIds.includes(lib)) { const version = /v\d+/.test(maybeVersion) ? maybeVersion : null const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } - return NextResponse.next() + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response } export const config = { - matcher: '/reference/:path*', + 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).*)', + ], } diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 27c941fb1c1..5461699c889 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,10 +1,8 @@ +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', @@ -29,13 +27,24 @@ const HOSTED_SUPPORTED_API_URLS = [ ] export function proxy(request: NextRequest) { - 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 } - ) + // 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 } + ) + } } + + 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 new file mode 100644 index 00000000000..72a74634d8a --- /dev/null +++ b/apps/www/middleware.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000000..ce308e2d244 --- /dev/null +++ b/packages/common/first-referrer-cookie.test.ts @@ -0,0 +1,229 @@ +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 new file mode 100644 index 00000000000..cfa7d6b7131 --- /dev/null +++ b/packages/common/first-referrer-cookie.ts @@ -0,0 +1,307 @@ +/** + * 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 87b60f4e6f9..ee8ebe13307 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -10,5 +10,6 @@ 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 29c416c8bd9..36b3ea4ee01 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -12,6 +12,8 @@ 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' @@ -96,25 +98,25 @@ function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) { } } -function isExternalReferrer(referrer: string) { - try { - const hostname = new URL(referrer).hostname - return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') - } catch { - return false - } +interface HandlePageTelemetryOptions { + apiUrl: string + pathname?: string + featureFlags?: Record + slug?: string + ref?: string + telemetryDataOverride?: SharedTelemetryData + firstReferrerData?: FirstReferrerData | null } -function handlePageTelemetry( - API_URL: string, - pathname?: string, - featureFlags?: { - [key: string]: unknown - }, - slug?: string, - ref?: string, - telemetryDataOverride?: SharedTelemetryData -) { +function handlePageTelemetry({ + apiUrl: API_URL, + pathname, + featureFlags, + slug, + ref, + telemetryDataOverride, + firstReferrerData, +}: HandlePageTelemetryOptions) { // Send to PostHog client-side (only in browser) if (typeof window !== 'undefined') { const livePageData = getSharedTelemetryData(pathname) @@ -133,10 +135,51 @@ function handlePageTelemetry( referrer: shouldUseCookieReferrer ? cookieReferrer! : liveReferrer, }, } - : livePageData - const firstTouchAttributionProps = telemetryDataOverride - ? getFirstTouchAttributionProps(telemetryDataOverride) - : {} + : { ...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 + } + } const $referrer = pageData.ph.referrer const $referring_domain = (() => { @@ -170,6 +213,13 @@ 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, + }), }) } @@ -234,13 +284,13 @@ export const PageTelemetry = ({ const sendPageTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry( - API_URL, - pathnameRef.current, - featureFlagsRef.current, + return handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, slug, - ref - ).catch((e) => { + ref, + }).catch((e) => { console.error('Problem sending telemetry page:', e) }) }, [API_URL, enabled, hasAcceptedConsent, slug, ref]) @@ -283,6 +333,9 @@ 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()) @@ -294,24 +347,39 @@ export const PageTelemetry = ({ const telemetryData = JSON.parse( decodeURIComponent(telemetryCookieValue) ) as SharedTelemetryData - handlePageTelemetry( - API_URL, - pathnameRef.current, - featureFlagsRef.current, + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, slug, ref, - telemetryData - ) + telemetryDataOverride: telemetryData, + firstReferrerData, + }) } catch (error) { if (!IS_PROD) { console.warn('Invalid telemetry cookie data:', error) } - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, + slug, + ref, + firstReferrerData, + }) } finally { clearTelemetryDataCookie() } } else { - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, + slug, + ref, + firstReferrerData, + }) } hasSentInitialPageTelemetryRef.current = true