From b90eee1106eb70a0820603553007d9cce518db83 Mon Sep 17 00:00:00 2001 From: r0c0y Date: Thu, 29 Oct 2026 12:06:31 +0530 Subject: [PATCH 1/2] feat(web): save UTM parameters on signup UTM parameters from the landing page are kept in a first-party cookie and written to the new UTM columns on the user when NextAuth creates the user, so they survive the trip through /login and the OAuth or email sign in flow. Closes #75 --- TRACKING.md | 8 ++ .../migration.sql | 7 ++ apps/web/prisma/schema.prisma | 7 ++ apps/web/src/lib/utm.test.ts | 104 ++++++++++++++++++ apps/web/src/lib/utm.ts | 83 ++++++++++++++ apps/web/src/pages/_app.tsx | 8 +- apps/web/src/pages/api/auth/[...nextauth].ts | 25 ++++- 7 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 apps/web/prisma/migrations/20261029062000_add_utm_params_to_user/migration.sql create mode 100644 apps/web/src/lib/utm.test.ts create mode 100644 apps/web/src/lib/utm.ts diff --git a/TRACKING.md b/TRACKING.md index b3236750..7a46bffa 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -14,3 +14,11 @@ our API. | Feature Flag Created | a user creates a new feature flag on the dashboard | Feature Flag Type: The type of feature flag created (Boolean, String, Number, Json) | | Devtools Opened | a user tries the devtools for the first time | None | | API Project Data Retrieved | an API call to the endpoint for retrieving the Abby settings for a project, has been sent (We do not track the User-Agent, in this case) | | + +## UTM parameters + +When someone visits the website with UTM parameters (`utm_source`, +`utm_medium`, `utm_campaign`, `utm_term`, `utm_content`), they are stored in a +first-party cookie (`abby_utm`) for 30 days. If that person signs up, the +parameters are saved on their user so we know which campaign brought them in. +The cookie is never sent to third parties. diff --git a/apps/web/prisma/migrations/20261029062000_add_utm_params_to_user/migration.sql b/apps/web/prisma/migrations/20261029062000_add_utm_params_to_user/migration.sql new file mode 100644 index 00000000..27376868 --- /dev/null +++ b/apps/web/prisma/migrations/20261029062000_add_utm_params_to_user/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE `User` ADD COLUMN `utmCampaign` VARCHAR(191) NULL, + ADD COLUMN `utmContent` VARCHAR(191) NULL, + ADD COLUMN `utmMedium` VARCHAR(191) NULL, + ADD COLUMN `utmSource` VARCHAR(191) NULL, + ADD COLUMN `utmTerm` VARCHAR(191) NULL; + diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 687de0e6..5f0bfc56 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -81,6 +81,13 @@ model User { technologies Json? // gonna be an array of strings experienceLevelFlags Int? experienceLevelTests Int? + + // UTM parameters the user arrived with when signing up + utmSource String? + utmMedium String? + utmCampaign String? + utmTerm String? + utmContent String? } model VerificationToken { diff --git a/apps/web/src/lib/utm.test.ts b/apps/web/src/lib/utm.test.ts new file mode 100644 index 00000000..63f15760 --- /dev/null +++ b/apps/web/src/lib/utm.test.ts @@ -0,0 +1,104 @@ +import { + UTM_COOKIE_NAME, + getUtmParams, + parseUtmCookie, + rememberUtmParams, +} from "./utm"; + +const getUtmCookie = () => + document.cookie + .split("; ") + .find((cookie) => cookie.startsWith(`${UTM_COOKIE_NAME}=`)) + ?.slice(UTM_COOKIE_NAME.length + 1); + +afterEach(() => { + document.cookie = `${UTM_COOKIE_NAME}=; path=/; max-age=0`; +}); + +describe("getUtmParams", () => { + it("maps all UTM parameters", () => { + const params = new URLSearchParams( + "utm_source=twitter&utm_medium=social&utm_campaign=launch&utm_term=feature+flags&utm_content=hero" + ); + + expect(getUtmParams(params)).toEqual({ + utmSource: "twitter", + utmMedium: "social", + utmCampaign: "launch", + utmTerm: "feature flags", + utmContent: "hero", + }); + }); + + it("ignores other and empty parameters", () => { + const params = new URLSearchParams( + "utm_source=newsletter&utm_medium=&ref=producthunt" + ); + + expect(getUtmParams(params)).toEqual({ utmSource: "newsletter" }); + }); + + it("returns null without UTM parameters", () => { + expect(getUtmParams(new URLSearchParams("ref=producthunt"))).toBeNull(); + expect(getUtmParams(new URLSearchParams("utm_source=%20"))).toBeNull(); + }); + + it("truncates values that do not fit into the database", () => { + const params = new URLSearchParams({ utm_campaign: "a".repeat(500) }); + + expect(getUtmParams(params)?.utmCampaign).toHaveLength(191); + }); +}); + +describe("rememberUtmParams", () => { + it("stores the UTM parameters in a cookie", () => { + rememberUtmParams("?utm_source=github&utm_campaign=hacktoberfest"); + + expect(parseUtmCookie(decodeURIComponent(getUtmCookie()!))).toEqual({ + utmSource: "github", + utmCampaign: "hacktoberfest", + }); + }); + + it("keeps the stored parameters on visits without UTM parameters", () => { + rememberUtmParams("?utm_source=github"); + rememberUtmParams("?foo=bar"); + rememberUtmParams(""); + + expect(parseUtmCookie(decodeURIComponent(getUtmCookie()!))).toEqual({ + utmSource: "github", + }); + }); + + it("overwrites the stored parameters with newer ones", () => { + rememberUtmParams("?utm_source=github&utm_medium=readme"); + rememberUtmParams("?utm_source=twitter"); + + expect(parseUtmCookie(decodeURIComponent(getUtmCookie()!))).toEqual({ + utmSource: "twitter", + }); + }); +}); + +describe("parseUtmCookie", () => { + it("returns null for missing or malformed cookies", () => { + expect(parseUtmCookie(undefined)).toBeNull(); + expect(parseUtmCookie("")).toBeNull(); + expect(parseUtmCookie("not json")).toBeNull(); + expect(parseUtmCookie("[]")).toBeNull(); + expect(parseUtmCookie("{}")).toBeNull(); + }); + + it("rejects invalid values", () => { + expect(parseUtmCookie(JSON.stringify({ utmSource: 42 }))).toBeNull(); + expect( + parseUtmCookie(JSON.stringify({ utmSource: "a".repeat(192) })) + ).toBeNull(); + }); + + it("drops unknown keys", () => { + expect( + parseUtmCookie(JSON.stringify({ utmSource: "github", id: "admin" })) + ).toEqual({ utmSource: "github" }); + }); +}); diff --git a/apps/web/src/lib/utm.ts b/apps/web/src/lib/utm.ts new file mode 100644 index 00000000..16bd421c --- /dev/null +++ b/apps/web/src/lib/utm.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +export const UTM_COOKIE_NAME = "abby_utm"; + +// keep the UTM parameters around long enough for people who come back later to sign up +const UTM_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; + +// Prisma maps `String` to VARCHAR(191) on MySQL +const MAX_UTM_VALUE_LENGTH = 191; + +const UTM_PARAMS = { + utm_source: "utmSource", + utm_medium: "utmMedium", + utm_campaign: "utmCampaign", + utm_term: "utmTerm", + utm_content: "utmContent", +} as const; + +type UtmField = (typeof UTM_PARAMS)[keyof typeof UTM_PARAMS]; + +export type UtmParams = Partial>; + +const utmValueSchema = z.string().min(1).max(MAX_UTM_VALUE_LENGTH).optional(); + +const utmParamsSchema = z.object({ + utmSource: utmValueSchema, + utmMedium: utmValueSchema, + utmCampaign: utmValueSchema, + utmTerm: utmValueSchema, + utmContent: utmValueSchema, +}); + +export function getUtmParams(searchParams: URLSearchParams): UtmParams | null { + const params: UtmParams = {}; + + for (const [param, field] of Object.entries(UTM_PARAMS) as Array< + [string, UtmField] + >) { + const value = searchParams.get(param)?.trim(); + if (value) { + params[field] = value.slice(0, MAX_UTM_VALUE_LENGTH); + } + } + + return Object.keys(params).length > 0 ? params : null; +} + +/** + * Stores the UTM parameters of the current URL in a cookie, so they survive + * the trip through the login page and the OAuth / email sign in flow. + * Visits without UTM parameters keep the previously stored ones. + */ +export function rememberUtmParams(search: string) { + const params = getUtmParams(new URLSearchParams(search)); + if (!params) return; + + const secure = window.location.protocol === "https:" ? "; secure" : ""; + // `lax` is needed so the cookie is sent when the OAuth provider redirects back + document.cookie = `${UTM_COOKIE_NAME}=${encodeURIComponent( + JSON.stringify(params) + )}; path=/; max-age=${UTM_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secure}`; +} + +/** + * Parses the (already URI decoded) UTM cookie value. + * The cookie is user controlled, so anything unexpected is ignored. + */ +export function parseUtmCookie(value: string | undefined): UtmParams | null { + if (!value) return null; + + try { + const result = utmParamsSchema.safeParse(JSON.parse(value)); + if (!result.success) return null; + + const params = Object.fromEntries( + Object.entries(result.data).filter(([, v]) => v !== undefined) + ) as UtmParams; + + return Object.keys(params).length > 0 ? params : null; + } catch { + return null; + } +} diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index dec9bbdd..122d43fb 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -10,9 +10,10 @@ import { OpenPanelComponent } from "@openpanel/nextjs"; import { TooltipProvider } from "components/Tooltip"; import { env } from "env/client.mjs"; import { AbbyDevtools, AbbyProvider, withAbby } from "lib/abby"; +import { rememberUtmParams } from "lib/utm"; import type { NextPage } from "next"; import { useRouter } from "next/router"; -import type { ReactElement, ReactNode } from "react"; +import { type ReactElement, type ReactNode, useEffect } from "react"; import "@fontsource/martian-mono/600.css"; import "../styles/shadcn.css"; @@ -36,6 +37,11 @@ const MyApp: AppType<{ session: Session | null }> = ({ }: AppPropsWithLayout) => { const router = useRouter(); + // biome-ignore lint/correctness/useExhaustiveDependencies: run again on client side navigation + useEffect(() => { + rememberUtmParams(window.location.search); + }, [router.asPath]); + const currentPageUrl = `https://www.tryabby.com${router.asPath}`; // Use the layout defined at the page level, if available const getLayout = Component.getLayout ?? ((page) => page); diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index aeaafe32..88f4d6f3 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -5,6 +5,7 @@ import GithubProvider from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; import { trackSignup } from "lib/logsnag"; +import { UTM_COOKIE_NAME, parseUtmCookie } from "lib/utm"; import { env } from "../../../env/server.mjs"; import { prisma } from "../../../server/db/client"; import { ProjectService } from "../../../server/services/ProjectService"; @@ -121,5 +122,27 @@ export default (req: NextApiRequest, res: NextApiResponse) => { console.log("Suspicous request to /api/auth/callback/email", req.method); return res.status(200).end(); } - return NextAuth(authOptions)(req, res); + return NextAuth({ + ...authOptions, + events: { + ...authOptions.events, + // the request is only available here, so the UTM cookie is read per request + async createUser(message) { + await authOptions.events?.createUser?.(message); + await saveUtmParams(message.user.id, req.cookies[UTM_COOKIE_NAME]); + }, + }, + })(req, res); }; + +async function saveUtmParams(userId: string, cookieValue: string | undefined) { + const utmParams = parseUtmCookie(cookieValue); + if (!utmParams) return; + + try { + await prisma.user.update({ where: { id: userId }, data: utmParams }); + } catch (error) { + // attribution is nice to have, it should never break a signup + console.error("Failed to save UTM parameters", error); + } +} From 3e1f4977315e1b498bae62450612ae8811587500 Mon Sep 17 00:00:00 2001 From: r0c0y Date: Thu, 29 Oct 2026 12:59:49 +0530 Subject: [PATCH 2/2] fix(web): keep UTM cookie within size limit and save UTMs first - don't write the cookie if the encoded value would go over the browser cookie size limit, and remove the old one so the signup isn't attributed to an older campaign - save the UTMs before the existing createUser event, so a failing project creation or LogSnag call doesn't skip them - mention the UTM cookie next to the privacy note in the README --- README.md | 1 + apps/web/src/lib/utm.test.ts | 32 ++++++++++++++++++++ apps/web/src/lib/utm.ts | 16 ++++++++-- apps/web/src/pages/api/auth/[...nextauth].ts | 3 +- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aa813d02..891a0b1b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Abby is a open source feature management and A/B testing platform. It is designe Besides its official SDKS it also offers a friendly and easy to understand Dashboard for the non-developers in your team. Abby focuses on privacy. This means it won't track any personalized user data and uses no marketing cookies. +Signups on tryabby.com also store the UTM parameters they came with, see [TRACKING.md](./TRACKING.md). You can self host it or use the hosted version available at [tryabby.com](https://www.tryabby.com). ## Getting Started diff --git a/apps/web/src/lib/utm.test.ts b/apps/web/src/lib/utm.test.ts index 63f15760..8d471f6d 100644 --- a/apps/web/src/lib/utm.test.ts +++ b/apps/web/src/lib/utm.test.ts @@ -78,6 +78,38 @@ describe("rememberUtmParams", () => { utmSource: "twitter", }); }); + + it("stores long ASCII values", () => { + const value = "a".repeat(191); + rememberUtmParams( + `?${new URLSearchParams({ + utm_source: value, + utm_medium: value, + utm_campaign: value, + utm_term: value, + utm_content: value, + })}` + ); + + expect( + parseUtmCookie(decodeURIComponent(getUtmCookie()!))?.utmContent + ).toBe(value); + }); + + it("removes the stored parameters if the new ones don't fit in a cookie", () => { + rememberUtmParams("?utm_source=github"); + // every character takes 9 bytes once it's percent encoded + const value = "\u0800".repeat(191); + rememberUtmParams( + `?${new URLSearchParams({ + utm_source: value, + utm_medium: value, + utm_campaign: value, + })}` + ); + + expect(getUtmCookie()).toBeUndefined(); + }); }); describe("parseUtmCookie", () => { diff --git a/apps/web/src/lib/utm.ts b/apps/web/src/lib/utm.ts index 16bd421c..9b9cdb17 100644 --- a/apps/web/src/lib/utm.ts +++ b/apps/web/src/lib/utm.ts @@ -8,6 +8,9 @@ const UTM_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // Prisma maps `String` to VARCHAR(191) on MySQL const MAX_UTM_VALUE_LENGTH = 191; +// browsers ignore cookies with more than 4096 bytes, this leaves some room +const MAX_UTM_COOKIE_LENGTH = 4000; + const UTM_PARAMS = { utm_source: "utmSource", utm_medium: "utmMedium", @@ -54,11 +57,18 @@ export function rememberUtmParams(search: string) { const params = getUtmParams(new URLSearchParams(search)); if (!params) return; + // non-ASCII values grow a lot when encoded, so the cookie can get too big + const value = encodeURIComponent(JSON.stringify(params)); + if (UTM_COOKIE_NAME.length + 1 + value.length > MAX_UTM_COOKIE_LENGTH) { + // the browser would drop it, so remove the old one instead of + // attributing the signup to an older campaign + document.cookie = `${UTM_COOKIE_NAME}=; path=/; max-age=0`; + return; + } + const secure = window.location.protocol === "https:" ? "; secure" : ""; // `lax` is needed so the cookie is sent when the OAuth provider redirects back - document.cookie = `${UTM_COOKIE_NAME}=${encodeURIComponent( - JSON.stringify(params) - )}; path=/; max-age=${UTM_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secure}`; + document.cookie = `${UTM_COOKIE_NAME}=${value}; path=/; max-age=${UTM_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secure}`; } /** diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 88f4d6f3..4fa2b18b 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -128,8 +128,9 @@ export default (req: NextApiRequest, res: NextApiResponse) => { ...authOptions.events, // the request is only available here, so the UTM cookie is read per request async createUser(message) { - await authOptions.events?.createUser?.(message); + // saved first, so a failing project creation or LogSnag call doesn't skip it await saveUtmParams(message.user.id, req.cookies[UTM_COOKIE_NAME]); + await authOptions.events?.createUser?.(message); }, }, })(req, res);