From 20f3b09fc0dcae49a66caa251117cfcaaa8201b4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 21 Jun 2025 11:42:02 -0700 Subject: [PATCH 1/4] Use Fluid compute for /track/click --- apps/web/app/(ee)/api/track/click/route.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/app/(ee)/api/track/click/route.ts b/apps/web/app/(ee)/api/track/click/route.ts index 9aff771ab22..b76584557fa 100644 --- a/apps/web/app/(ee)/api/track/click/route.ts +++ b/apps/web/app/(ee)/api/track/click/route.ts @@ -20,8 +20,6 @@ import { AxiomRequest, withAxiom } from "next-axiom"; import { NextResponse } from "next/server"; import { z } from "zod"; -export const runtime = "edge"; - const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", @@ -52,7 +50,7 @@ const trackClickResponseSchema = z.object({ }).nullish(), }); -// POST /api/track/click – Track a click event from the client-side +// POST /api/track/click – Track a click event for a link export const POST = withAxiom(async (req: AxiomRequest) => { try { const { domain, key, url, referrer } = trackClickSchema.parse( From 81aba4d602828ec0293fec1f03c2948a727d11be Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 21 Jun 2025 11:49:22 -0700 Subject: [PATCH 2/4] Refactor recordClick to use waitUntil for async ops Moved non-blocking side effects in recordClick to a waitUntil callback, improving response time and reliability. Now only caches clickId synchronously if needed, while all other operations (Tinybird ingestion, Redis cache, DB updates, and webhook dispatch) are handled asynchronously. Updated error logging and workspace usage checks accordingly. --- apps/web/lib/tinybird/record-click.ts | 182 +++++++++++++------------- 1 file changed, 89 insertions(+), 93 deletions(-) diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index 6849f7aac4d..1eec79d5ff3 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -6,7 +6,7 @@ import { getDomainWithoutWWW, } from "@dub/utils"; import { EU_COUNTRY_CODES } from "@dub/utils/src/constants/countries"; -import { geolocation, ipAddress } from "@vercel/functions"; +import { geolocation, ipAddress, waitUntil } from "@vercel/functions"; import { userAgent } from "next/server"; import { recordClickCache } from "../api/links/record-click-cache"; import { ExpandedLink, transformLink } from "../api/links/utils/transform-link"; @@ -153,103 +153,99 @@ export async function recordClick({ const hasWebhooks = webhookIds && webhookIds.length > 0; - const response = await Promise.allSettled([ - fetchWithRetry( - `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`, - }, - body: JSON.stringify(clickData), - }, - ).then((res) => res.json()), - - // cache the recorded click for the corresponding IP address in Redis for 1 hour - recordClickCache.set({ domain, key, ip, clickId }), - + if (shouldCacheClickId) { // cache the click ID and its corresponding click data in Redis for 5 mins // we're doing this because ingested click events are not available immediately in Tinybird - shouldCacheClickId && - redis.set(`clickIdCache:${clickId}`, clickData, { - ex: 60 * 5, - }), - - // increment the click count for the link (based on their ID) - // we have to use planetscale connection directly (not prismaEdge) because of connection pooling - conn.execute( - "UPDATE Link SET clicks = clicks + 1, lastClicked = NOW() WHERE id = ?", - [linkId], - ), - // if the link has a destination URL, increment the usage count for the workspace - // and then we have a cron that will reset it at the start of new billing cycle - url && - conn.execute( - "UPDATE Project p JOIN Link l ON p.id = l.projectId SET p.usage = p.usage + 1, p.totalClicks = p.totalClicks + 1 WHERE l.id = ?", - [linkId], - ), - - // fetch the workspace usage for the workspace - workspaceId && hasWebhooks - ? conn.execute( - "SELECT usage, usageLimit FROM Project WHERE id = ? LIMIT 1", - [workspaceId], - ) - : null, - ]); - - // Find the rejected promises and log them - if (response.some((result) => result.status === "rejected")) { - const errors = response - .map((result, index) => { - if (result.status === "rejected") { - const operations = [ - "Tinybird click event ingestion", - "Redis click cache set", - "Redis click ID cache set", - "Link clicks increment", - "Workspace usage increment", - "Workspace usage fetch", - ]; - return { - operation: operations[index] || `Operation ${index}`, - error: result.reason, - errorString: JSON.stringify(result.reason, null, 2), - }; - } - return null; - }) - .filter((err): err is NonNullable => err !== null); - - console.error("[Record click] - Rejected promises:", { - totalErrors: errors.length, - errors: errors.map((err) => ({ - operation: err.operation, - error: err.error, - errorString: err.errorString, - })), + await redis.set(`clickIdCache:${clickId}`, clickData, { + ex: 60 * 5, }); } - const [, , , , workspaceRows] = response; - - const workspace = - workspaceRows.status === "fulfilled" && - workspaceRows.value && - workspaceRows.value.rows.length > 0 - ? (workspaceRows.value.rows[0] as Pick< - WorkspaceProps, - "usage" | "usageLimit" - >) - : null; - - const hasExceededUsageLimit = - workspace && workspace.usage >= workspace.usageLimit; - - // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit - if (hasWebhooks && !hasExceededUsageLimit) { - await sendLinkClickWebhooks({ webhookIds, linkId, clickData }); - } + waitUntil( + (async () => { + const response = await Promise.allSettled([ + fetchWithRetry( + `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`, + }, + body: JSON.stringify(clickData), + }, + ).then((res) => res.json()), + + // cache the recorded click for the corresponding IP address in Redis for 1 hour + recordClickCache.set({ domain, key, ip, clickId }), + + // increment the click count for the link (based on their ID) + // we have to use planetscale connection directly (not prismaEdge) because of connection pooling + conn.execute( + "UPDATE Link SET clicks = clicks + 1, lastClicked = NOW() WHERE id = ?", + [linkId], + ), + // if the link has a destination URL, increment the usage count for the workspace + // and then we have a cron that will reset it at the start of new billing cycle + url && + conn.execute( + "UPDATE Project p JOIN Link l ON p.id = l.projectId SET p.usage = p.usage + 1, p.totalClicks = p.totalClicks + 1 WHERE l.id = ?", + [linkId], + ), + ]); + + // Find the rejected promises and log them + if (response.some((result) => result.status === "rejected")) { + const errors = response + .map((result, index) => { + if (result.status === "rejected") { + const operations = [ + "Tinybird click event ingestion", + "recordClickCache set", + "Link clicks increment", + "Workspace usage increment", + ]; + return { + operation: operations[index] || `Operation ${index}`, + error: result.reason, + errorString: JSON.stringify(result.reason, null, 2), + }; + } + return null; + }) + .filter((err): err is NonNullable => err !== null); + + console.error("[Record click] - Rejected promises:", { + totalErrors: errors.length, + errors: errors.map((err) => ({ + operation: err.operation, + error: err.error, + errorString: err.errorString, + })), + }); + } + + // if the link has webhooks enabled, we need to check if the workspace usage has exceeded the limit + if (workspaceId && hasWebhooks) { + const workspaceRows = await conn.execute( + "SELECT usage, usageLimit FROM Project WHERE id = ? LIMIT 1", + [workspaceId], + ); + + const hasExceededUsageLimit = + workspaceRows.rows.length > 0 + ? (workspaceRows.rows[0] as Pick< + WorkspaceProps, + "usage" | "usageLimit" + >) + : null; + + // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit + if (hasWebhooks && !hasExceededUsageLimit) { + await sendLinkClickWebhooks({ webhookIds, linkId, clickData }); + } + } + })(), + ); return clickData; } From 735b1284a0fb16ee61bd9e8bc8fee505c1d72628 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 21 Jun 2025 12:11:21 -0700 Subject: [PATCH 3/4] fix hasExceededUsageLimit logic --- apps/web/lib/tinybird/record-click.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index 1eec79d5ff3..c2a14753df2 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -231,7 +231,7 @@ export async function recordClick({ [workspaceId], ); - const hasExceededUsageLimit = + const workspaceData = workspaceRows.rows.length > 0 ? (workspaceRows.rows[0] as Pick< WorkspaceProps, @@ -239,6 +239,9 @@ export async function recordClick({ >) : null; + const hasExceededUsageLimit = + workspaceData && workspaceData.usage >= workspaceData.usageLimit; + // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit if (hasWebhooks && !hasExceededUsageLimit) { await sendLinkClickWebhooks({ webhookIds, linkId, clickData }); From bcbc14e35e589c4b3653bd2a2abb01048fe2468a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 21 Jun 2025 12:25:47 -0700 Subject: [PATCH 4/4] final change --- apps/web/app/(ee)/api/track/click/route.ts | 4 ++-- apps/web/lib/tinybird/record-click.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/track/click/route.ts b/apps/web/app/(ee)/api/track/click/route.ts index b76584557fa..82b333f78d3 100644 --- a/apps/web/app/(ee)/api/track/click/route.ts +++ b/apps/web/app/(ee)/api/track/click/route.ts @@ -92,7 +92,7 @@ export const POST = withAxiom(async (req: AxiomRequest) => { if (!cachedLink.projectId) { throw new DubApiError({ code: "not_found", - message: "Link not found.", + message: "Link does not belong to a workspace.", }); } @@ -106,7 +106,7 @@ export const POST = withAxiom(async (req: AxiomRequest) => { if (!cachedClickId) { if (!cachedAllowedHostnames) { const workspace = await getWorkspaceViaEdge({ - workspaceId: cachedLink.projectId!, + workspaceId: cachedLink.projectId, includeDomains: true, }); diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index c2a14753df2..8e19078019e 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -151,8 +151,6 @@ export async function recordClick({ referer_url: referer || "(direct)", }; - const hasWebhooks = webhookIds && webhookIds.length > 0; - if (shouldCacheClickId) { // cache the click ID and its corresponding click data in Redis for 5 mins // we're doing this because ingested click events are not available immediately in Tinybird @@ -225,6 +223,7 @@ export async function recordClick({ } // if the link has webhooks enabled, we need to check if the workspace usage has exceeded the limit + const hasWebhooks = webhookIds && webhookIds.length > 0; if (workspaceId && hasWebhooks) { const workspaceRows = await conn.execute( "SELECT usage, usageLimit FROM Project WHERE id = ? LIMIT 1", @@ -243,7 +242,7 @@ export async function recordClick({ workspaceData && workspaceData.usage >= workspaceData.usageLimit; // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit - if (hasWebhooks && !hasExceededUsageLimit) { + if (!hasExceededUsageLimit) { await sendLinkClickWebhooks({ webhookIds, linkId, clickData }); } }