fix: persist first referrer across app boundaries (#42768)

## 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 <pamelachiamayyee@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-23 13:31:39 -08:00
1 parent e60b6a6a2e
commit 04e63dfb2e
7 files changed
+709 -58

No files matched your search

+33 -11
View File
@@ -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).*)',
],
}
+21 -12
View File
@@ -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).*)'],
}
+15
View File
@@ -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).*)',
],
}
@@ -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 })
})
})
})
+307
View File
@@ -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<string, string>
/** Ad-network click IDs parsed from the landing URL */
click_ids: Record<string, string>
/** 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<string, string> {
const result: Record<string, string> = {}
for (const key of keys) {
const value = searchParams.get(key)
if (value) {
result[key] = value
}
}
return result
}
function toStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).filter(
([key, v]) => typeof key === 'string' && typeof v === 'string'
)
) as Record<string, string>
}
// ---------------------------------------------------------------------------
// 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<string, string> = {}
let click_ids: Record<string, string> = {}
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<string, unknown>
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
}
}
+1
View File
@@ -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'
+103 -35
View File
@@ -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<string, unknown>
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<string, string> = {
...(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