diff --git a/packages/payload-plugin-ab/.claude/skills/payload-plugin-ab/SKILL.md b/packages/payload-plugin-ab/.claude/skills/payload-plugin-ab/SKILL.md index 8d52c9052..3e06d1b4f 100644 --- a/packages/payload-plugin-ab/.claude/skills/payload-plugin-ab/SKILL.md +++ b/packages/payload-plugin-ab/.claude/skills/payload-plugin-ab/SKILL.md @@ -316,11 +316,11 @@ A: Yes. No migration is needed for MongoDB. SQL adapters (Postgres, SQLite) requ **Q: How are variants created?** A: Via the Variants panel in the sidebar of any original document. Clicking "Add Variant" calls the `POST /_ab/duplicate` endpoint, which duplicates the document with a new slug (`original--xxxx`) and sets initial traffic to 1%. -**Q: How does sticky session routing work?** -A: On first visit, the middleware assigns a bucket (variant or original) based on weighted random selection and writes a session cookie (`payload_ab_bucket_{path}`). On subsequent visits, the middleware reads that cookie and routes to the same variant every time. +**Q: How does sticky variant routing work?** +A: On first visit, the middleware assigns a bucket (variant or original) based on weighted random selection and writes a 90-day cookie (`payload_ab_bucket_{path}`, lifetime set by `bucketCookieMaxAge`, defaulting to `expCookieMaxAge`). On subsequent visits, the middleware reads that cookie and routes to the same variant every time. If that cookie is missing, it restores the bucket from `exp_{path}`; a bucket whose variant no longer exists is drawn again. **Q: What cookies does the plugin set?** -A: Three cookies: `payload_ab_bucket_{path}` (session, bucket assignment), `ab_visitor_id` (365-day, persistent visitor ID for analytics), `exp_{path}` (90-day, client-readable for analytics adapters). +A: Three cookies: `payload_ab_bucket_{path}` (90-day, bucket assignment), `ab_visitor_id` (365-day, persistent visitor ID for analytics), `exp_{path}` (90-day, client-readable for analytics adapters). **Q: How does analytics attribution work?** A: Mount `` on each variant-served page. It stamps three GA4 event-scoped custom dimensions (`fr_ab_experiment`, `fr_ab_variant`, `fr_ab_visitor_id`) on every subsequent event. Register those dimensions in your GA4 property, then use `@focus-reactive/payload-plugin-analytics` to view exposure and conversion-rate results per experiment. diff --git a/packages/payload-plugin-ab/README.md b/packages/payload-plugin-ab/README.md index f698b5fb9..aed2d5371 100644 --- a/packages/payload-plugin-ab/README.md +++ b/packages/payload-plugin-ab/README.md @@ -369,17 +369,17 @@ await resolveAbRewrite(request, pathname, internalPath, internalPath); **Routing:** When `getPassPercentage` is provided, each variant receives its percentage of traffic; the remainder goes to the original. The plugin validates on save that the sum never exceeds 100%. When `getPassPercentage` is omitted, all variants and the original share equal probability. -**Sticky sessions:** Bucket assignment is stored in a per-path cookie so returning users always see the same variant. +**Sticky assignment:** Bucket assignment is stored in a per-path cookie that outlives the browser session, so returning users always see the same variant. If the bucket cookie is gone, the middleware restores the assignment from the experiment cookie; a bucket whose variant no longer exists is drawn again. ### Cookie System | Cookie | Default name | Lifetime | Purpose | | ---------- | -------------------------- | -------- | --------------------------------------------------- | -| Bucket | `payload_ab_bucket_{path}` | Session | Which bucket this user is in | +| Bucket | `payload_ab_bucket_{path}` | 90 days | Which bucket this user is in | | Visitor ID | `ab_visitor_id` | 365 days | Persistent visitor identifier for analytics | | Experiment | `exp_{path}` | 90 days | Bucket name readable client-side by analytics hooks | -Override names by passing a `cookies` config to `createResolveAbRewrite`: +Override names and lifetimes (`visitorIdMaxAge`, `expCookieMaxAge`, `bucketCookieMaxAge`, in seconds) by passing a `cookies` config to `createResolveAbRewrite`: ```ts import type { AbCookieConfig } from "@focus-reactive/payload-plugin-ab/middleware"; diff --git a/packages/payload-plugin-ab/src/middleware/resolveAbRewrite.ts b/packages/payload-plugin-ab/src/middleware/resolveAbRewrite.ts index 3728f263b..fd1544add 100644 --- a/packages/payload-plugin-ab/src/middleware/resolveAbRewrite.ts +++ b/packages/payload-plugin-ab/src/middleware/resolveAbRewrite.ts @@ -27,6 +27,7 @@ export function createResolveAbRewrite( getExpCookieName = defaultGetExpCookieName, visitorIdMaxAge = DEFAULT_VISITOR_MAX_AGE, expCookieMaxAge = DEFAULT_EXP_MAX_AGE, + bucketCookieMaxAge = expCookieMaxAge, } = cookieConfig; return async function resolveAbRewrite( @@ -56,60 +57,47 @@ export function createResolveAbRewrite( const expCookieName = getExpCookieName(manifestKey); - let bucket = existingBucket; - if (!bucket) { - bucket = getPassPercentage - ? pickWeightedBucket(variants, getBucket, getPassPercentage) - : pickUniformBucket(variants, getBucket); - } - - const setAbCookies = (res: NextResponse, assignedBucket: string, isNewAssignment: boolean) => { - if (!existingVisitorId) { - res.cookies.set(visitorIdCookieName, visitorId, { - path: "/", - sameSite: "lax", - maxAge: visitorIdMaxAge, - }); - } - - if (isNewAssignment) { - res.cookies.set(expCookieName, assignedBucket, { - path: "/", - sameSite: "lax", - maxAge: expCookieMaxAge, - }); - } - }; - - if (bucket === "original") { - if (!existingBucket) { - const url = request.nextUrl.clone(); - - url.pathname = originalRewritePath; - - const res = NextResponse.rewrite(url); - - res.cookies.set(bucketCookieName, "original", { path: "/", sameSite: "lax" }); - setAbCookies(res, "original", true); + const isLiveBucket = (value: string | undefined): value is string => + value === "original" || variants.some((v) => getBucket(v) === value); - return res; - } + const savedBucket = [existingBucket, request.cookies.get(expCookieName)?.value].find( + isLiveBucket + ); + const bucket = + savedBucket ?? + (getPassPercentage + ? pickWeightedBucket(variants, getBucket, getPassPercentage) + : pickUniformBucket(variants, getBucket)); - return null; - } + if (bucket === "original" && existingBucket === "original") return null; const match = variants.find((v) => getBucket(v) === bucket); - if (!match) return null; - const url = request.nextUrl.clone(); - url.pathname = getRewritePath(match); + url.pathname = match ? getRewritePath(match) : originalRewritePath; const res = NextResponse.rewrite(url); - if (!existingBucket) { - res.cookies.set(bucketCookieName, bucket, { path: "/", sameSite: "lax" }); - setAbCookies(res, bucket, true); - } else { - setAbCookies(res, bucket, false); + if (existingBucket !== bucket) { + res.cookies.set(bucketCookieName, bucket, { + path: "/", + sameSite: "lax", + maxAge: bucketCookieMaxAge, + }); + } + + if (!existingVisitorId) { + res.cookies.set(visitorIdCookieName, visitorId, { + path: "/", + sameSite: "lax", + maxAge: visitorIdMaxAge, + }); + } + + if (!savedBucket) { + res.cookies.set(expCookieName, bucket, { + path: "/", + sameSite: "lax", + maxAge: expCookieMaxAge, + }); } return res; diff --git a/packages/payload-plugin-ab/src/middleware/types.ts b/packages/payload-plugin-ab/src/middleware/types.ts index 9cdc23e36..3f66e16e6 100644 --- a/packages/payload-plugin-ab/src/middleware/types.ts +++ b/packages/payload-plugin-ab/src/middleware/types.ts @@ -12,6 +12,8 @@ export interface ResolveAbRewriteCookieConfig extends AbCookieConfig { visitorIdMaxAge?: number; /** Max age for the experiment bucket cookie in seconds. Default: 7_776_000 (90 days). */ expCookieMaxAge?: number; + /** Max age for the bucket-assignment cookie in seconds. Default: `expCookieMaxAge`. */ + bucketCookieMaxAge?: number; } export interface ResolveAbRewriteConfig { diff --git a/packages/payload-plugin-ab/tests/middleware/resolveAbRewrite.test.ts b/packages/payload-plugin-ab/tests/middleware/resolveAbRewrite.test.ts new file mode 100644 index 000000000..82c4b3f29 --- /dev/null +++ b/packages/payload-plugin-ab/tests/middleware/resolveAbRewrite.test.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it } from "vitest"; +import { createResolveAbRewrite } from "../../src/middleware/resolveAbRewrite"; +import type { ResolveAbRewriteCookieConfig } from "../../src/middleware/types"; + +interface Variant { + bucket: string; + passPercentage: number; + rewritePath: string; +} + +const PAGE = "/every-view"; +const BUCKET_COOKIE = "payload_ab_bucket_every-view"; +const EXP_COOKIE = "exp_every-view"; +const NINETY_DAYS = 60 * 60 * 24 * 90; + +const variantB: Variant = { + bucket: "every-view--b", + passPercentage: 100, + rewritePath: "/next/every-view--b", +}; + +function resolve(variants: Variant[], cookie: string, cookies: ResolveAbRewriteCookieConfig = {}) { + const resolveAbRewrite = createResolveAbRewrite({ + cookies: { getExpCookieName: (key) => `exp_${key.replace(/^\//u, "")}`, ...cookies }, + getBucket: (v) => v.bucket, + getPassPercentage: (v) => v.passPercentage, + getRewritePath: (v) => v.rewritePath, + storage: { + clear: async () => {}, + read: async () => variants, + write: async () => {}, + }, + }); + const request = new NextRequest(`https://example.com${PAGE}`, { headers: { cookie } }); + + return resolveAbRewrite(request, PAGE, PAGE, "/next/every-view"); +} + +const rewriteTarget = (res: Response | null) => + new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname; + +describe("resolveAbRewrite", () => { + it("keeps the bucket cookie across browser sessions", async () => { + const res = await resolve([variantB], ""); + + expect(res?.cookies.get(BUCKET_COOKIE)).toMatchObject({ + maxAge: NINETY_DAYS, + value: "every-view--b", + }); + }); + + it("uses bucketCookieMaxAge for the bucket cookie lifetime", async () => { + const res = await resolve([variantB], "", { bucketCookieMaxAge: 3600 }); + + expect(res?.cookies.get(BUCKET_COOKIE)?.maxAge).toBe(3600); + }); + + it("restores the variant from the exp cookie when the bucket cookie is gone", async () => { + const res = await resolve([variantB], `ab_visitor_id=v1; ${EXP_COOKIE}=original`); + + expect(rewriteTarget(res)).toBe("/next/every-view"); + expect(res?.cookies.get(BUCKET_COOKIE)).toMatchObject({ + maxAge: NINETY_DAYS, + value: "original", + }); + expect(res?.cookies.get(EXP_COOKIE)).toBeUndefined(); + }); + + it("draws a new variant when the saved bucket no longer exists", async () => { + const res = await resolve( + [variantB], + `ab_visitor_id=v1; ${BUCKET_COOKIE}=every-view--gone; ${EXP_COOKIE}=every-view--gone` + ); + + expect(rewriteTarget(res)).toBe("/next/every-view--b"); + expect(res?.cookies.get(BUCKET_COOKIE)?.value).toBe("every-view--b"); + expect(res?.cookies.get(EXP_COOKIE)?.value).toBe("every-view--b"); + }); + + it("sets no cookies for a returning visitor whose bucket is still live", async () => { + const res = await resolve([variantB], `ab_visitor_id=v1; ${BUCKET_COOKIE}=every-view--b`); + + expect(rewriteTarget(res)).toBe("/next/every-view--b"); + expect(res?.headers.get("set-cookie")).toBeNull(); + }); +});