From e201cdd36aeb6442a5000b2efcf7e62b1f84cb72 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Fri, 17 Oct 2025 13:58:25 -0700 Subject: [PATCH 1/2] Larger updates, fixes and changes to the a/b testing Will add this. --- apps/web/lib/api/links/complete-ab-tests.ts | 141 ++++++++++++-- apps/web/ui/links/destination-url-input.tsx | 15 +- apps/web/ui/links/link-tests.tsx | 57 +++++- apps/web/ui/links/tests-badge.tsx | 31 ++- .../modals/link-builder/ab-testing-modal.tsx | 117 ++++++----- .../ab-testing/ab-testing-modal.tsx | 121 +++++++----- .../ab-testing/end-ab-testing-modal.tsx | 184 ++++++++++++++---- 7 files changed, 505 insertions(+), 161 deletions(-) diff --git a/apps/web/lib/api/links/complete-ab-tests.ts b/apps/web/lib/api/links/complete-ab-tests.ts index 17d42c306a..316e16be05 100644 --- a/apps/web/lib/api/links/complete-ab-tests.ts +++ b/apps/web/lib/api/links/complete-ab-tests.ts @@ -3,6 +3,7 @@ import { recordLink } from "@/lib/tinybird"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { ABTestVariantsSchema, linkEventSchema } from "@/lib/zod/schemas/links"; import { prisma } from "@dub/prisma"; +import { normalizeUrl } from "@dub/utils"; import { Link } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { linkCache } from "./cache"; @@ -15,8 +16,15 @@ export async function completeABTests(link: Link) { const testVariants = ABTestVariantsSchema.parse(link.testVariants); - const analytics: { url: string; leads: number }[] = await getAnalytics({ - event: "leads", + // Fetch composite analytics (clicks, leads, sales, saleAmount) grouped by URLs + const analytics: { + url: string; + clicks: number; + leads: number; + sales: number; + saleAmount: number; + }[] = await getAnalytics({ + event: "composite", groupBy: "top_urls", linkId: link.id, workspaceId: link.projectId, @@ -24,34 +32,125 @@ export async function completeABTests(link: Link) { end: link.testCompletedAt, }); - const max = Math.max( - ...testVariants.map( - (test) => analytics.find(({ url }) => url === test.url)?.leads || 0, - ), - ); + // Aggregate analytics by normalized URL for stable matching with variants + const analyticsByNormalizedUrl = new Map< + string, + { clicks: number; leads: number; sales: number; saleAmount: number } + >(); - // 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; + for (const row of analytics || []) { + const key = normalizeUrl(row.url); + const existing = analyticsByNormalizedUrl.get(key); + if (existing) { + analyticsByNormalizedUrl.set(key, { + clicks: (existing.clicks || 0) + (row.clicks || 0), + leads: (existing.leads || 0) + (row.leads || 0), + sales: (existing.sales || 0) + (row.sales || 0), + saleAmount: (existing.saleAmount || 0) + (row.saleAmount || 0), + }); + } else { + analyticsByNormalizedUrl.set(key, { + clicks: row.clicks || 0, + leads: row.leads || 0, + sales: row.sales || 0, + saleAmount: row.saleAmount || 0, + }); + } } - const winners = testVariants.filter( - (test) => - (analytics.find(({ url }) => url === test.url)?.leads || 0) === max, - ); + type VariantMetrics = { + url: string; + clicks: number; + leads: number; + conversions: number; // sales + }; + + const variants: VariantMetrics[] = testVariants.map((tv) => { + const key = normalizeUrl(tv.url); + const a = analyticsByNormalizedUrl.get(key); + return { + url: tv.url, + clicks: a?.clicks ?? 0, + leads: a?.leads ?? 0, + conversions: a?.sales ?? 0, + }; + }); + + const safeRate = (num: number, den: number) => (den > 0 ? num / den : 0); + + const maxConversions = Math.max(0, ...variants.map((v) => v.conversions)); + + let candidateUrls: string[] = []; + + if (maxConversions > 0) { + // Primary path: conversions -> conversion rate -> clicks + const maxConv = maxConversions; + const convCandidates = variants.filter((v) => v.conversions === maxConv); + if (convCandidates.length === 1) { + candidateUrls = [convCandidates[0].url]; + } else { + const maxConvRate = Math.max( + 0, + ...convCandidates.map((v) => safeRate(v.conversions, v.clicks)), + ); + const rateCandidates = convCandidates.filter( + (v) => safeRate(v.conversions, v.clicks) === maxConvRate, + ); + if (rateCandidates.length === 1) { + candidateUrls = [rateCandidates[0].url]; + } else { + const maxClicks = Math.max(0, ...rateCandidates.map((v) => v.clicks)); + const clickCandidates = rateCandidates.filter( + (v) => v.clicks === maxClicks, + ); + if (clickCandidates.length === 1) { + candidateUrls = [clickCandidates[0].url]; + } else { + // Still tied after all tie-breakers: do not pick randomly + candidateUrls = []; + } + } + } + } else { + // Fallback path: all variants have zero conversions + // leads -> lead rate -> clicks + const maxLeads = Math.max(0, ...variants.map((v) => v.leads)); + const leadCandidates = variants.filter((v) => v.leads === maxLeads); + if (leadCandidates.length === 1) { + candidateUrls = [leadCandidates[0].url]; + } else { + const maxLeadRate = Math.max( + 0, + ...leadCandidates.map((v) => safeRate(v.leads, v.clicks)), + ); + const rateCandidates = leadCandidates.filter( + (v) => safeRate(v.leads, v.clicks) === maxLeadRate, + ); + if (rateCandidates.length === 1) { + candidateUrls = [rateCandidates[0].url]; + } else { + const maxClicks = Math.max(0, ...rateCandidates.map((v) => v.clicks)); + const clickCandidates = rateCandidates.filter( + (v) => v.clicks === maxClicks, + ); + if (clickCandidates.length === 1) { + candidateUrls = [clickCandidates[0].url]; + } else { + // Still tied after all tie-breakers: do not pick randomly + candidateUrls = []; + } + } + } + } - // this should NEVER happen, but just in case - if (winners.length === 0) { + if (candidateUrls.length !== 1) { console.log( - `AB Test completed but failed to find winners based on max leads for link ${link.id}, doing nothing.`, + `AB Test completed for ${link.id} but no deterministic winner after tie-breakers. Keeping original destination URL.`, ); return; } - const winner = winners[Math.floor(Math.random() * winners.length)]; + const winner = { url: candidateUrls[0] }; if (winner.url === link.url) { return; diff --git a/apps/web/ui/links/destination-url-input.tsx b/apps/web/ui/links/destination-url-input.tsx index ab427dc45d..f813684bf3 100644 --- a/apps/web/ui/links/destination-url-input.tsx +++ b/apps/web/ui/links/destination-url-input.tsx @@ -112,7 +112,20 @@ export const DestinationUrlInput = forwardRef< const url = getUrlFromString(e.target.value); if (url) { // remove trailing slash and set the https:// prefix - formContext.setValue("url", url.replace(/\/$/, "")); + const normalizedUrl = url.replace(/\/$/, ""); + formContext.setValue("url", normalizedUrl, { + shouldDirty: true, + }); + + // If an A/B test exists, keep the first variant in sync + const testVariants = formContext.getValues("testVariants"); + if (Array.isArray(testVariants) && testVariants.length > 0) { + formContext.setValue( + "testVariants.0.url" as any, + normalizedUrl, + { shouldDirty: true }, + ); + } } }} /> diff --git a/apps/web/ui/links/link-tests.tsx b/apps/web/ui/links/link-tests.tsx index 7cfae99ea6..75cf32096a 100644 --- a/apps/web/ui/links/link-tests.tsx +++ b/apps/web/ui/links/link-tests.tsx @@ -1,6 +1,6 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { ABTestVariantsSchema } from "@/lib/zod/schemas/links"; -import { fetcher } from "@dub/utils"; +import { fetcher, normalizeUrl } from "@dub/utils"; import { motion } from "motion/react"; import { memo, useMemo } from "react"; import useSWR from "swr"; @@ -54,6 +54,42 @@ export const LinkTests = memo(({ link }: { link: ResponseLink }) => { }, ); + const analyticsByNormalizedUrl = useMemo(() => { + if (!data) return null; + + const map = new Map< + string, + { + clicks: number; + leads: number; + sales: number; + saleAmount: number; + } + >(); + + for (const row of data) { + const key = normalizeUrl(row.url); + const existing = map.get(key); + if (existing) { + map.set(key, { + clicks: existing.clicks + (row.clicks ?? 0), + leads: existing.leads + (row.leads ?? 0), + sales: existing.sales + (row.sales ?? 0), + saleAmount: existing.saleAmount + (row.saleAmount ?? 0), + }); + } else { + map.set(key, { + clicks: row.clicks ?? 0, + leads: row.leads ?? 0, + sales: row.sales ?? 0, + saleAmount: row.saleAmount ?? 0, + }); + } + } + + return map; + }, [data]); + if (!testVariants || !testVariants.length) return null; return ( @@ -65,7 +101,8 @@ export const LinkTests = memo(({ link }: { link: ResponseLink }) => { >