From d19a307a8cb0a9ddf9657d38a5e6c564a0c0af42 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:42 +0200 Subject: [PATCH 1/3] site: stamp ref=prisma.io/ on Console links for untagged visitors PR #8141 dropped the utm_source=website default from Console CTAs because it froze "website" into organic visitors' $initial_utm_source. It was also the only signal the site sent for visitors without campaign params, so since Aug 11 the console cannot tell a signup that came from prisma.io from one that typed console.prisma.io directly. Website-tagged signups fell from ~1,000 a week to under 10. Reintroduce the signal as a fallback `ref` instead of a UTM: the console already stores `ref` on signups (signup_first_ref / signup_last_ref) and PostHog does not treat it as a campaign parameter, so first-touch stays clean. It only fires when the visitor has no attribution at all, and never overrides a link that already carries some. Co-Authored-By: Claude Fable 5.1 --- apps/blog/src/components/utm-persistence.tsx | 10 ++++- apps/site/src/components/utm-persistence.tsx | 3 +- .../ui/src/components/utm-persistence.tsx | 43 ++++++++++++++----- packages/ui/src/lib/utm.test.ts | 38 +++++++++++++++- packages/ui/src/lib/utm.ts | 36 ++++++++++++++++ 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx index 8ebfe61925f..76ba9645a95 100644 --- a/apps/blog/src/components/utm-persistence.tsx +++ b/apps/blog/src/components/utm-persistence.tsx @@ -1,8 +1,14 @@ "use client"; import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; -import { UTM_ATTRIBUTION_STORAGE_KEY } from "@prisma-docs/ui/lib/utm"; +import { UTM_ATTRIBUTION_STORAGE_KEY, buildSiteRef } from "@prisma-docs/ui/lib/utm"; export function UtmPersistence() { - return ; + return ( + + ); } diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx index cdc816fcb19..3da237d07c3 100644 --- a/apps/site/src/components/utm-persistence.tsx +++ b/apps/site/src/components/utm-persistence.tsx @@ -1,7 +1,7 @@ "use client"; import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; -import { UTM_ATTRIBUTION_STORAGE_KEY } from "@prisma-docs/ui/lib/utm"; +import { UTM_ATTRIBUTION_STORAGE_KEY, buildSiteRef } from "@prisma-docs/ui/lib/utm"; import { CROSS_ZONE_PATHS } from "@/lib/zones"; export function UtmPersistence() { @@ -9,6 +9,7 @@ export function UtmPersistence() { ); } diff --git a/packages/ui/src/components/utm-persistence.tsx b/packages/ui/src/components/utm-persistence.tsx index 745cd412928..95ae33d3778 100644 --- a/packages/ui/src/components/utm-persistence.tsx +++ b/packages/ui/src/components/utm-persistence.tsx @@ -7,6 +7,7 @@ import { getUtmParams, mergeUtmAttribution, readStoredUtmAttribution, + syncFallbackRef, syncUtmAttribution, writeStoredUtmAttribution, } from "../lib/utm"; @@ -28,6 +29,14 @@ interface UtmPersistenceProps { proxiedPaths?: string[]; /** Local storage key for persisting first- and last-touch UTM params. */ storageKey: string; + /** + * Builds the `ref` stamped on Console links when the visitor has no stored + * or current campaign attribution, i.e. organic and direct traffic. Receives + * the current pathname. Omit it and untagged visitors reach the console with + * no attribution at all. See `syncFallbackRef` for why this is `ref` and not + * a `utm_source` default. + */ + fallbackConsoleRef?: (pathname: string) => string; } export function getActiveAttribution(storageKey: string) { @@ -87,7 +96,12 @@ export function getActiveAttribution(storageKey: string) { return attribution; } -export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmPersistenceProps) { +export function UtmPersistence({ + basePath, + proxiedPaths = [], + storageKey, + fallbackConsoleRef, +}: UtmPersistenceProps) { const pathname = usePathname(); const router = useRouter(); @@ -127,10 +141,6 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP } const attribution = getActiveAttribution(storageKey); - if (!attribution) { - return; - } - const targetUrl = new URL(anchor.href, window.location.href); const isInternalLink = targetUrl.origin === window.location.origin; const isConsoleLink = targetUrl.hostname === CONSOLE_HOST; @@ -141,11 +151,22 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP return; } - if ( - !syncUtmAttribution(targetUrl, attribution, { - includeFirstTouch: isConsoleLink || isConsoleRedirect, - }) - ) { + const isConsoleBound = isConsoleLink || isConsoleRedirect; + + let updated: boolean; + if (attribution) { + updated = syncUtmAttribution(targetUrl, attribution, { + includeFirstTouch: isConsoleBound, + }); + } else if (fallbackConsoleRef && isConsoleBound) { + // No campaign ever touched this visitor, so the only thing the console + // would learn is nothing. Tell it which page sent them instead. + updated = syncFallbackRef(targetUrl, fallbackConsoleRef(window.location.pathname)); + } else { + return; + } + + if (!updated) { return; } @@ -179,7 +200,7 @@ export function UtmPersistence({ basePath, proxiedPaths = [], storageKey }: UtmP document.addEventListener("click", handleClick, true); return () => document.removeEventListener("click", handleClick, true); - }, [router, basePath, proxiedPaths, storageKey]); + }, [router, basePath, proxiedPaths, storageKey, fallbackConsoleRef]); return null; } diff --git a/packages/ui/src/lib/utm.test.ts b/packages/ui/src/lib/utm.test.ts index 979d676e216..dcca8e0978d 100644 --- a/packages/ui/src/lib/utm.test.ts +++ b/packages/ui/src/lib/utm.test.ts @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -// @ts-expect-error Node's TypeScript test runner requires the explicit extension. -import { mergeUtmAttribution, syncUtmAttribution, syncUtmParams } from "./utm.ts"; +import { + buildSiteRef, + mergeUtmAttribution, + syncFallbackRef, + syncUtmAttribution, + syncUtmParams, +} from "./utm"; test("preserves first touch and replaces last touch", () => { const firstVisit = mergeUtmAttribution(undefined, { @@ -81,3 +86,32 @@ test("removes stale UTM parameters when forwarding a newer touch", () => { assert.equal(url.searchParams.get("utm_source"), "rebrand-test"); assert.equal(url.searchParams.has("utm_campaign"), false); }); + +test("names the handing-off page as the site ref", () => { + assert.equal(buildSiteRef("/"), "prisma.io"); + assert.equal(buildSiteRef("/postgres"), "prisma.io/postgres"); + assert.equal(buildSiteRef("/blog/some-post"), "prisma.io/blog/some-post"); +}); + +test("stamps the fallback ref on an untagged Console link", () => { + const url = new URL("https://console.prisma.io/sign-up"); + + assert.equal(syncFallbackRef(url, "prisma.io/pricing"), true); + assert.equal(url.searchParams.get("ref"), "prisma.io/pricing"); + assert.equal(url.searchParams.has("utm_source"), false); +}); + +test("never overrides a Console link that already carries attribution", () => { + for (const tagged of [ + "https://console.prisma.io/sign-up?utm_source=docs&utm_medium=login", + "https://console.prisma.io/sign-up?ref=producthunt", + "https://console.prisma.io/sign-up?first_utm_source=x", + "https://console.prisma.io/sign-up?gclid=abc", + ]) { + const url = new URL(tagged); + const before = url.toString(); + + assert.equal(syncFallbackRef(url, "prisma.io"), false, tagged); + assert.equal(url.toString(), before, tagged); + } +}); diff --git a/packages/ui/src/lib/utm.ts b/packages/ui/src/lib/utm.ts index 20247103616..4ddf7d7385a 100644 --- a/packages/ui/src/lib/utm.ts +++ b/packages/ui/src/lib/utm.ts @@ -209,6 +209,42 @@ export function syncUtmAttribution( return updated; } +/** + * The `ref` stamped on Console links for visitors who carry no campaign + * attribution: the prisma.io page that handed them off, e.g. `prisma.io/postgres`. + * The homepage is `prisma.io`. Shaped like the referrer domains partners already + * send in `ref` (`producthunt`, `laconsole.dev`), so it reads naturally next to + * them in the console's `signup_first_ref` / `signup_last_ref`. + */ +export function buildSiteRef(pathname: string) { + return `prisma.io${pathname === "/" ? "" : pathname}`; +} + +/** + * Stamps `ref` on a Console link that carries no attribution at all. + * + * This is the fallback for organic and direct visitors. It deliberately uses + * `ref` and not `utm_source`: PostHog treats `utm_*` and ad click IDs as + * campaign parameters and freezes the first value it sees into the person's + * `$initial_utm_source`, which is what the removed `utm_source=website` default + * used to pollute. `ref` is not a campaign parameter, so the console still + * records it on the signup while the person's first-touch channel stays whatever + * it really was. + * + * A link that already carries any captured key or first-touch key is left + * alone: real attribution always wins over the fallback. + */ +export function syncFallbackRef(url: URL, ref: string) { + for (const key of url.searchParams.keys()) { + if (isCapturedKey(key) || key.startsWith("first_")) { + return false; + } + } + + url.searchParams.set("ref", ref); + return true; +} + export function readStoredUtmAttribution(storageKey: string) { if (typeof window === "undefined") { return undefined; From f87b596d5a49f1bc55691f9f8a51e59e4f8030f4 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:23:06 +0200 Subject: [PATCH 2/3] blog: note the ref fallback next to the no-default-UTM comment Co-Authored-By: Claude Fable 5.1 --- apps/blog/src/components/navigation-wrapper.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx index 072c2e3f0ff..844b4fd00ef 100644 --- a/apps/blog/src/components/navigation-wrapper.tsx +++ b/apps/blog/src/components/navigation-wrapper.tsx @@ -11,8 +11,12 @@ interface NavigationWrapperProps { // UTM propagation, lifted verbatim out of @prisma-docs/ui's WebNavigation so // swapping in the blog-local CF header does not change a single outbound URL. -// Outbound links only ever carry UTMs the visitor arrived with — no defaults -// are added (matching the site-wide drop of default console-CTA UTMs). +// Outbound links only ever carry UTMs the visitor arrived with — no default +// UTMs are added (matching the site-wide drop of default console-CTA UTMs). +// Visitors with no attribution at all still get `ref=prisma.io/blog/` +// on console links, stamped at click time by the document-level UtmPersistence +// listener; see `syncFallbackRef` in @prisma-docs/ui for why that is `ref` and +// not a UTM. function buildHref(base: string, utm?: UtmParams) { if (!utm) return base; const isAbsolute = base.startsWith("http"); From c50c7eb5f88ce2004f003517992d48731f6de18d Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:27:11 +0200 Subject: [PATCH 3/3] attribution: document the persistence carrier and blog nav helpers Co-Authored-By: Claude Fable 5.1 --- apps/blog/src/components/navigation-wrapper.tsx | 3 +++ apps/blog/src/components/utm-persistence.tsx | 4 ++++ apps/site/src/components/utm-persistence.tsx | 4 ++++ packages/ui/src/components/utm-persistence.tsx | 14 ++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx index 844b4fd00ef..afd73aa02c1 100644 --- a/apps/blog/src/components/navigation-wrapper.tsx +++ b/apps/blog/src/components/navigation-wrapper.tsx @@ -17,6 +17,7 @@ interface NavigationWrapperProps { // on console links, stamped at click time by the document-level UtmPersistence // listener; see `syncFallbackRef` in @prisma-docs/ui for why that is `ref` and // not a UTM. +/** Appends the visitor's own `utm_*` params to an internal or absolute link. */ function buildHref(base: string, utm?: UtmParams) { if (!utm) return base; const isAbsolute = base.startsWith("http"); @@ -29,6 +30,7 @@ function buildHref(base: string, utm?: UtmParams) { return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`; } +/** Builds a Console login or sign-up link carrying only the visitor's own `utm_*` params. */ function buildConsoleHref(pathname: "/login" | "/sign-up", utm?: UtmParams) { if (!utm) return `https://console.prisma.io${pathname}`; @@ -43,6 +45,7 @@ function buildConsoleHref(pathname: "/login" | "/sign-up", utm?: UtmParams) { return href.toString(); } +/** Blog header with nav, logo, and Console links that preserve the visitor's UTMs. */ export function NavigationWrapper({ links }: NavigationWrapperProps) { const [mounted, setMounted] = useState(false); diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx index 76ba9645a95..9ab48eedaa4 100644 --- a/apps/blog/src/components/utm-persistence.tsx +++ b/apps/blog/src/components/utm-persistence.tsx @@ -3,6 +3,10 @@ import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; import { UTM_ATTRIBUTION_STORAGE_KEY, buildSiteRef } from "@prisma-docs/ui/lib/utm"; +/** + * Blog attribution carrier. Client-routes only under `/blog`, and stamps + * `ref=prisma.io/blog/` on Console links for untagged visitors. + */ export function UtmPersistence() { return ( ` on Console links for untagged visitors. + */ export function UtmPersistence() { return ( string; } +/** + * Reads the visitor's first- and last-touch attribution: stored touches merged + * with whatever campaign params are on the current URL. Persists and announces + * a touch only when it actually changed, and drops ad click IDs when analytics + * consent is absent. Returns `undefined` for a visitor with no attribution. + */ export function getActiveAttribution(storageKey: string) { const currentUtmParams = getUtmParams(new URLSearchParams(window.location.search), { // Click IDs are advertising identifiers. Without analytics consent nothing @@ -96,6 +102,13 @@ export function getActiveAttribution(storageKey: string) { return attribution; } +/** + * Document-level attribution carrier. On every route change it captures campaign + * params into storage; on every link click it rewrites the target so internal + * links keep the last touch and Console links also receive the first touch. + * With `fallbackConsoleRef`, Console links clicked by a visitor with no + * attribution at all get a `ref` naming the page that sent them. + */ export function UtmPersistence({ basePath, proxiedPaths = [], @@ -117,6 +130,7 @@ export function UtmPersistence({ }, [pathname, storageKey]); useEffect(() => { + /** Rewrites the clicked anchor's href with attribution before navigation. */ function handleClick(event: MouseEvent) { if (event.defaultPrevented || event.button !== 0) { return;