Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ExperimentTracker>` 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.
Expand Down
6 changes: 3 additions & 3 deletions packages/payload-plugin-ab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
82 changes: 35 additions & 47 deletions packages/payload-plugin-ab/src/middleware/resolveAbRewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function createResolveAbRewrite<TVariantData extends object>(
getExpCookieName = defaultGetExpCookieName,
visitorIdMaxAge = DEFAULT_VISITOR_MAX_AGE,
expCookieMaxAge = DEFAULT_EXP_MAX_AGE,
bucketCookieMaxAge = expCookieMaxAge,
} = cookieConfig;

return async function resolveAbRewrite(
Expand Down Expand Up @@ -56,60 +57,47 @@ export function createResolveAbRewrite<TVariantData extends object>(

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;
Expand Down
2 changes: 2 additions & 0 deletions packages/payload-plugin-ab/src/middleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TVariantData extends object = object> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Variant>({
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();
});
});
Loading