Skip to content

Link A/B testing #2173

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

Merged
merged 99 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
17097ed
Update schemas
TWilson023 Mar 17, 2025
b850b69
Update links.ts
TWilson023 Mar 17, 2025
e1a372e
WIP modal
TWilson023 Mar 17, 2025
9cd5e72
Update ab-testing-modal.tsx
TWilson023 Mar 17, 2025
699b418
Update ab-testing-modal.tsx
TWilson023 Mar 17, 2025
620fe9a
WIP
TWilson023 Mar 17, 2025
2783b8c
Update ab-testing-modal.tsx
TWilson023 Mar 17, 2025
c10d347
Update ab-testing-modal.tsx
TWilson023 Mar 17, 2025
ff9fd35
More menu updates + keyboard shortcuts
TWilson023 Mar 17, 2025
c14e58d
Add warning
TWilson023 Mar 17, 2025
8d6fa17
Merge branch 'main' into ab-testing
TWilson023 Mar 17, 2025
bc42f93
Reorganize link builder options
TWilson023 Mar 18, 2025
ffbd4e7
Move webhooks into modal
TWilson023 Mar 18, 2025
07f029b
Move expiration back
TWilson023 Mar 18, 2025
66a7675
WIP shortcuts
TWilson023 Mar 18, 2025
119e6e9
Merge branch 'main' into ab-testing
TWilson023 Mar 18, 2025
846b8bb
Update use-keyboard-shortcut.tsx
TWilson023 Mar 18, 2025
3c5b42e
Redis + proxy updates
TWilson023 Mar 18, 2025
486657f
Proxy updates
TWilson023 Mar 18, 2025
09f6167
Update ab-testing-modal.tsx
TWilson023 Mar 18, 2025
d471b78
Update ab-testing-modal.tsx
TWilson023 Mar 18, 2025
cebcc30
Merge branch 'main' into ab-testing
TWilson023 Mar 19, 2025
b6de218
Add limits/restrictions
TWilson023 Mar 19, 2025
a38c4b0
Update ab-testing-modal.tsx
TWilson023 Mar 19, 2025
d7026c6
WIP end test modal
TWilson023 Mar 19, 2025
367f40a
Add end testing modal
TWilson023 Mar 19, 2025
644740f
Move things around
TWilson023 Mar 19, 2025
8b628e9
Add A/B test complete modal
TWilson023 Mar 19, 2025
a0ab8eb
Add TestsBadge to link card
TWilson023 Mar 19, 2025
1bf35f1
Add tests to link card
TWilson023 Mar 19, 2025
b6c7a99
Add analytics badges
TWilson023 Mar 19, 2025
e3b058c
Update tests-badge.tsx
TWilson023 Mar 19, 2025
bddc109
Update ab-testing-modal.tsx
TWilson023 Mar 19, 2025
6069c1d
Test completion
TWilson023 Mar 19, 2025
6c73617
Fix types
TWilson023 Mar 19, 2025
f56c0a7
Add `testsStartedAt`
TWilson023 Mar 19, 2025
04f9da2
Update bulk-create-links.ts
TWilson023 Mar 19, 2025
cad3246
Update test start logic
TWilson023 Mar 19, 2025
a2b65b5
Merge branch 'main' into ab-testing
steven-tey Mar 19, 2025
9a29429
Update bulk-update-links.ts
TWilson023 Mar 20, 2025
d91b1f7
Merge branch 'main' into ab-testing
steven-tey Mar 20, 2025
d59637a
Merge branch 'main' into ab-testing
steven-tey Mar 20, 2025
97c6c3e
Merge branch 'main' into ab-testing
steven-tey Mar 20, 2025
7fe6037
Merge branch 'main' into ab-testing
steven-tey Mar 21, 2025
42aba25
Merge branch 'main' into ab-testing
steven-tey Mar 21, 2025
b6db405
Add link ID to completion endpoint route
TWilson023 Mar 21, 2025
e469d69
Merge branch 'main' into ab-testing
steven-tey Mar 22, 2025
689b987
rename: tests → testVariants, testsStartedAt → testStartedAt, testsCo…
steven-tey Mar 24, 2025
7499bcb
Refactor A/B test completion logic: replace completeTests with comple…
devkiran Mar 24, 2025
d863e9a
some cleanups
devkiran Mar 24, 2025
e848fdb
update the tests
devkiran Mar 24, 2025
d17c385
Update ab-testing-modal.tsx
devkiran Mar 24, 2025
f225477
Merge branch 'main' into ab-testing
devkiran Mar 24, 2025
6779e6b
add resolveABTestURL
devkiran Mar 24, 2025
ea7a457
Update resolve-ab-test-url.ts
devkiran Mar 24, 2025
6a15bd4
Merge branch 'optimize-link' into ab-testing
devkiran Mar 24, 2025
3e58ea6
update transformLink to remove unused properties
devkiran Mar 24, 2025
16a00fe
Merge branch 'main' into ab-testing
steven-tey Mar 24, 2025
57a20b7
Merge branch 'main' into ab-testing
steven-tey Mar 24, 2025
72f5888
Merge branch 'main' into ab-testing
steven-tey Mar 24, 2025
7df219b
Merge branch 'main' into ab-testing
steven-tey Mar 25, 2025
093614c
Merge branch 'main' into ab-testing
steven-tey Mar 25, 2025
4b0b0c0
Merge branch 'main' into ab-testing
steven-tey Mar 25, 2025
c09d651
add abTesting flag
devkiran Mar 25, 2025
f88599b
fix the webhook tests
devkiran Mar 25, 2025
b1d5984
Merge branch 'main' into ab-testing
steven-tey Mar 26, 2025
a24d081
Merge branch 'main' into ab-testing
steven-tey Mar 27, 2025
2db27a8
Merge branch 'main' into ab-testing
steven-tey Mar 27, 2025
32128bd
Merge branch 'main' into ab-testing
steven-tey Mar 27, 2025
dda3c73
Merge branch 'main' into ab-testing
steven-tey Mar 28, 2025
50109ac
Merge branch 'main' into ab-testing
steven-tey Mar 28, 2025
82af650
remove conversionsEnabled
steven-tey Mar 28, 2025
f117ab8
add plan-features-check
steven-tey Mar 28, 2025
d120362
remove cancelScheduledABTestCompletion
steven-tey Mar 28, 2025
846647d
update scheduleABTestCompletion
steven-tey Mar 28, 2025
c3bc41b
simplify completeABTests
steven-tey Mar 28, 2025
4bf206a
simplify
steven-tey Mar 28, 2025
257bbc0
15 min window
steven-tey Mar 28, 2025
10f0fe3
Merge branch 'main' into ab-testing
steven-tey Mar 29, 2025
3ecafa9
Merge branch 'main' into ab-testing
steven-tey Mar 29, 2025
08e579e
Merge branch 'main' into ab-testing
steven-tey Mar 29, 2025
fbb77f2
Merge branch 'main' into ab-testing
steven-tey Mar 29, 2025
b3fb9c9
Merge branch 'main' into ab-testing
steven-tey Apr 1, 2025
bba722f
Merge branch 'main' into ab-testing
steven-tey Apr 1, 2025
27fda59
Merge branch 'main' into ab-testing
steven-tey Apr 3, 2025
21ca79c
Merge branch 'main' into ab-testing
TWilson023 Apr 4, 2025
09d6892
Merge fixes
TWilson023 Apr 4, 2025
ca43a97
Post-merge tweaks/fixes
TWilson023 Apr 4, 2025
e13ae53
More fixes
TWilson023 Apr 4, 2025
11f3097
Merge branch 'main' into ab-testing
steven-tey Apr 4, 2025
ad9e3e8
Update advanced-modal.tsx
TWilson023 Apr 4, 2025
1832b92
Merge branch 'ab-testing' of github.com:dubinc/dub into ab-testing
TWilson023 Apr 4, 2025
517ddde
Merge branch 'main' into ab-testing
steven-tey Apr 6, 2025
06babfc
createResponseWithCookies
steven-tey Apr 6, 2025
debd2ae
Merge branch 'main' into ab-testing
steven-tey Apr 7, 2025
39011c5
Merge branch 'main' into ab-testing
steven-tey Apr 7, 2025
bd23127
Merge branch 'main' into ab-testing
steven-tey Apr 8, 2025
595c840
Merge branch 'main' into ab-testing
steven-tey Apr 8, 2025
8300d87
Merge branch 'main' into ab-testing
steven-tey Apr 8, 2025
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
48 changes: 48 additions & 0 deletions apps/web/app/api/cron/links/[linkId]/complete-tests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { completeABTests } from "@/lib/api/links/complete-ab-tests";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { prisma } from "@dub/prisma";

// POST - /api/cron/links/[linkId]/complete-tests
// Completes a link's AB tests if they're ready, scheduled by QStash
export async function POST(
req: Request,
{
params: { linkId },
}: {
params: { linkId: string };
},
) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });

const link = await prisma.link.findUnique({
where: {
id: linkId,
},
include: {
project: true,
},
});

if (!link) {
return new Response(`Link ${linkId} not found. Skipping...`);
}

if (
link.testVariants &&
link.testCompletedAt &&
link.testCompletedAt < new Date() &&
Date.now() - link.testCompletedAt.getTime() < 2 * 60 * 60 * 1000 // Limit to two hour window
) {
await completeABTests(link as any);

return new Response(`Tests completed for link ${linkId}.`);
}

return new Response(`No test completion necessary for link ${linkId}.`);
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}
1 change: 1 addition & 0 deletions apps/web/app/api/links/[linkId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const PATCH = withWorkspace(
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],

...body,
// for UTM tags, we only pass them to processLink if they have changed from their previous value
// or else they will override any changes to the UTM params in the destination URL
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/api/links/bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,15 @@ export const PATCH = withWorkspace(
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
testVariants: link.testVariants as NewLinkProps["testVariants"],
testCompletedAt:
link.testCompletedAt instanceof Date
? link.testCompletedAt.toISOString()
: link.testCompletedAt,
testStartedAt:
link.testStartedAt instanceof Date
? link.testStartedAt.toISOString()
: link.testStartedAt,
...data,
},
workspace,
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/api/links/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export const PUT = withWorkspace(
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
testVariants: link.testVariants as NewLinkProps["testVariants"],
testCompletedAt:
link.testCompletedAt instanceof Date
? link.testCompletedAt.toISOString()
: link.testCompletedAt,
testStartedAt:
link.testStartedAt instanceof Date
? link.testStartedAt.toISOString()
: link.testStartedAt,
...body,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export const PATCH = withPartnerProfile(
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
testVariants: link.testVariants as NewLinkProps["testVariants"],
testCompletedAt:
link.testCompletedAt instanceof Date
? link.testCompletedAt.toISOString()
: link.testCompletedAt,
testStartedAt:
link.testStartedAt instanceof Date
? link.testStartedAt.toISOString()
: link.testStartedAt,

// merge in new props
key: key || undefined,
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/api/partners/links/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export const PUT = withWorkspace(
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
testVariants: link.testVariants as NewLinkProps["testVariants"],
testCompletedAt:
link.testCompletedAt instanceof Date
? link.testCompletedAt.toISOString()
: link.testCompletedAt,
testStartedAt:
link.testStartedAt instanceof Date
? link.testStartedAt.toISOString()
: link.testStartedAt,
// merge in new props
...linkProps,
// set default fields
Expand Down
52 changes: 52 additions & 0 deletions apps/web/lib/api/links/ab-test-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { qstash } from "@/lib/cron";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { ExpandedLink } from "./utils";

// Schedules a job to complete a link's AB test
export async function scheduleABTestCompletion(
link: Pick<ExpandedLink, "id" | "testVariants" | "testCompletedAt">,
) {
await cancelScheduledABTestCompletion(link.id);

if (!link.testVariants || !link.testCompletedAt) {
return;
}

const url = `${APP_DOMAIN_WITH_NGROK}/api/cron/links/${link.id}/complete-tests`;
const testCompletedAt = new Date(link.testCompletedAt);

// Tests are not complete yet, schedule a job for completion
if (testCompletedAt > new Date()) {
await qstash.publishJSON({
url,
delay: (testCompletedAt.getTime() - new Date().getTime()) / 1000,
deduplicationId: link.id,
});
}
}

// Cancels a scheduled AB test completion for a link (Eg: if the link is updated or deleted)
export async function cancelScheduledABTestCompletion(linkId: string) {
const url = `${APP_DOMAIN_WITH_NGROK}/api/cron/links/${linkId}/complete-tests`;

const response = await fetch("https://qstash.upstash.io/v2/messages", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
}),
});

if (!response.ok) {
console.error(
`Failed to cancel scheduled AB test completion for link ${linkId}`,
{
status: response.status,
statusText: response.statusText,
},
);
}
}
2 changes: 2 additions & 0 deletions apps/web/lib/api/links/bulk-create-links.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ProcessedLinkProps } from "@/lib/types";
import { prisma } from "@dub/prisma";
import { getParamsFromURL, linkConstructorSimple, truncate } from "@dub/utils";
import { Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { createId } from "../create-id";
import { combineTagIds } from "../tags/combine-tag-ids";
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function bulkCreateLinks({
utm_content,
expiresAt: link.expiresAt ? new Date(link.expiresAt) : null,
geo: link.geo || undefined,
testVariants: link.testVariants || Prisma.JsonNull,
};
}),
skipDuplicates: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/api/links/bulk-update-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export async function bulkUpdateLinks(
proxy,
expiresAt,
geo,
testVariants,
tagId,
tagIds,
tagNames,
Expand Down Expand Up @@ -54,6 +55,8 @@ export async function bulkUpdateLinks(
: image,
expiresAt: expiresAt ? new Date(expiresAt) : null,
geo: geo || Prisma.JsonNull,
testVariants: testVariants || Prisma.JsonNull,

...(url && getParamsFromURL(url)),
// Associate tags by tagNames
...(tagNames &&
Expand Down
121 changes: 121 additions & 0 deletions apps/web/lib/api/links/complete-ab-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { getAnalytics } from "@/lib/analytics/get-analytics";
import { NewLinkProps, WorkspaceProps } from "@/lib/types";
import { sendWorkspaceWebhook } from "@/lib/webhook/publish";
import { ABTestVariantsSchema, linkEventSchema } from "@/lib/zod/schemas/links";
import { Link, Project } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { DubApiError, ErrorCodes } from "../errors";
import { processLink } from "./process-link";
import { updateLink } from "./update-link";

export async function completeABTests(link: Link & { project: Project }) {
if (!link.testVariants || !link.testCompletedAt || !link.projectId) {
return;
}

const testVariants = ABTestVariantsSchema.parse(link.testVariants);

const analytics: { url: string; leads: number }[] = await getAnalytics({
event: "leads",
groupBy: "top_urls",
linkId: link.id,
workspaceId: link.projectId,
dataAvailableFrom: link.project.createdAt,
start: link.testStartedAt ? new Date(link.testStartedAt) : undefined,
end: link.testCompletedAt,
});

const max = Math.max(
...testVariants.map(
(test) => analytics.find(({ url }) => url === test.url)?.leads || 0,
),
);

// There are no leads generated for any test variant, do nothing
if (max === 0) {
console.log(
`AB Test completed but all results are zero for ${link.id}, doing nothing.`,
);

return;
}

const winners = testVariants.filter(
(test) =>
(analytics.find(({ url }) => url === test.url)?.leads || 0) === max,
);

if (winners.length === 0) {
throw new Error(
`AB Test completed but failed to find winners based on max leads for link ${link.id}.`,
);
}

const winner = winners[Math.floor(Math.random() * winners.length)];

if (winner.url === link.url) {
return;
}

// Update the link's URL to the winner
const { project, ...originalLink } = link;

const updatedLink = {
...originalLink,
expiresAt:
link.expiresAt instanceof Date
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
testVariants: link.testVariants as NewLinkProps["testVariants"],
testCompletedAt:
link.testCompletedAt instanceof Date
? link.testCompletedAt.toISOString()
: link.testCompletedAt,
testStartedAt:
link.testStartedAt instanceof Date
? link.testStartedAt.toISOString()
: link.testStartedAt,
url: winner.url,
...(link.key === "_root" && {
domain: link.domain,
key: link.key,
}),
};

const {
link: processedLink,
error,
code,
} = await processLink({
payload: updatedLink,
workspace: link.project as WorkspaceProps,
skipKeyChecks: true,
skipExternalIdChecks: true,
skipFolderChecks: true,
});

if (error) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}

const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});

waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace: link.project,
data: linkEventSchema.parse(response),
}),
);
}
10 changes: 10 additions & 0 deletions apps/web/lib/api/links/create-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { linkConstructorSimple } from "@dub/utils/src/functions/link-constructor
import { waitUntil } from "@vercel/functions";
import { createId } from "../create-id";
import { combineTagIds } from "../tags/combine-tag-ids";
import { scheduleABTestCompletion } from "./ab-test-scheduler";
import { linkCache } from "./cache";
import { encodeKeyIfCaseSensitive } from "./case-sensitivity";
import { includeTags } from "./include-tags";
Expand All @@ -32,6 +33,9 @@ export async function createLink(link: ProcessedLinkProps) {
proxy,
geo,
publicStats,
testVariants,
testStartedAt,
testCompletedAt,
} = link;

const combinedTagIds = combineTagIds(link);
Expand Down Expand Up @@ -64,6 +68,10 @@ export async function createLink(link: ProcessedLinkProps) {
expiresAt: expiresAt ? new Date(expiresAt) : null,
geo: geo || Prisma.JsonNull,

testVariants: testVariants || Prisma.JsonNull,
testCompletedAt: testCompletedAt ? new Date(testCompletedAt) : null,
testStartedAt: testStartedAt ? new Date(testStartedAt) : null,

// Associate tags by tagNames
...(tagNames?.length &&
link.projectId && {
Expand Down Expand Up @@ -173,6 +181,8 @@ export async function createLink(link: ProcessedLinkProps) {
propagateWebhookTriggerChanges({
webhookIds,
}),

testVariants && testCompletedAt && scheduleABTestCompletion(response),
]),
);

Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/api/links/delete-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { recordLinkTB, transformLinkTB } from "@/lib/tinybird";
import { prisma } from "@dub/prisma";
import { R2_URL } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { cancelScheduledABTestCompletion } from "./ab-test-scheduler";
import { linkCache } from "./cache";
import { includeTags } from "./include-tags";
import { transformLink } from "./utils";
Expand Down Expand Up @@ -32,6 +33,7 @@ export async function deleteLink(linkId: string) {
...transformLinkTB(link),
deleted: true,
}),

link.projectId &&
prisma.project.update({
where: {
Expand All @@ -41,6 +43,10 @@ export async function deleteLink(linkId: string) {
totalLinks: { decrement: 1 },
},
}),

link.testVariants &&
link.testCompletedAt &&
cancelScheduledABTestCompletion(link.id),
]),
);

Expand Down
Loading