Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 1 addition & 3 deletions apps/web/app/(ee)/api/track/click/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
182 changes: 89 additions & 93 deletions apps/web/lib/tinybird/record-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof err> => 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<typeof err> => 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;
}
Expand Down