Repository navigation
feat(web): save UTM parameters on signup #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+20
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the public privacy claim. This change stores campaign attribution in a cookie and associates it with a user. The README still says Abby uses no marketing cookies and tracks no personalized user data. Update that claim so users receive an accurate description of the new behavior. (raw.githubusercontent.com)
|
||
| The cookie is never sent to third parties. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| 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", | ||
| }); | ||
| }); | ||
|
|
||
| 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", () => { | ||
| 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" }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| 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; | ||
|
|
||
| // 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", | ||
| utm_campaign: "utmCampaign", | ||
| utm_term: "utmTerm", | ||
| utm_content: "utmContent", | ||
| } as const; | ||
|
|
||
| type UtmField = (typeof UTM_PARAMS)[keyof typeof UTM_PARAMS]; | ||
|
|
||
| export type UtmParams = Partial<Record<UtmField, string>>; | ||
|
|
||
| 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; | ||
|
|
||
| // 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}=${value}; path=/; max-age=${UTM_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secure}`; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: tryabby/abby
Length of output: 1709
Qualify the privacy claim for the new UTM attribution.
The sentence before the new line says Abby "won't track any personalized user data and uses no marketing cookies." That claim no longer holds for tryabby.com signups. The new
abby_utmcookie stores campaign attribution, and the UTM values are saved on the user record. The new line 10 describes this behavior, but it does not qualify line 9.📝 Suggested fix