Skip to content

Commit

Permalink
improve /track/click and /track/visit
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey committed Feb 24, 2025
1 parent d28f05e commit 6a98e09
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 41 deletions.
43 changes: 17 additions & 26 deletions apps/web/app/api/track/click/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { parseRequestBody } from "@/lib/api/utils";
import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames";
import { recordClick } from "@/lib/tinybird";
import { ratelimit, redis } from "@/lib/upstash";
import { redis } from "@/lib/upstash";
import { isValidUrl, LOCALHOST_IP, nanoid } from "@dub/utils";
import { ipAddress, waitUntil } from "@vercel/functions";
import { AxiomRequest, withAxiom } from "next-axiom";
Expand All @@ -21,7 +21,7 @@ const CORS_HEADERS = {
export const POST = withAxiom(
async (req: AxiomRequest) => {
try {
const { domain, key, url } = await parseRequestBody(req);
const { domain, key, url, referrer } = await parseRequestBody(req);

if (!domain || !key) {
throw new DubApiError({
Expand All @@ -32,34 +32,25 @@ export const POST = withAxiom(

const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP;

const { success } = await ratelimit().limit(
`track-click:${domain}-${key}:${ip}`,
);

if (!success) {
throw new DubApiError({
code: "rate_limit_exceeded",
message: "Don't DDoS me pls 🥺",
});
}
const cacheKey = `recordClick:${domain}:${key}:${ip}`;
let clickId = await redis.get<string>(cacheKey);

const link = await getLinkWithAllowedHostnames(domain, key);
// only generate + record a new click ID if it's not already cached in Redis
if (!clickId) {
clickId = nanoid(16);

if (!link) {
throw new DubApiError({
code: "not_found",
message: `Link not found for domain: ${domain} and key: ${key}.`,
});
}
const link = await getLinkWithAllowedHostnames(domain, key);

const allowedHostnames = link.allowedHostnames;
verifyAnalyticsAllowedHostnames({ allowedHostnames, req });
if (!link) {
throw new DubApiError({
code: "not_found",
message: `Link not found for domain: ${domain} and key: ${key}.`,
});
}

const cacheKey = `recordClick:${link.id}:${ip}`;
let clickId = await redis.get<string>(cacheKey);
const allowedHostnames = link.allowedHostnames;
verifyAnalyticsAllowedHostnames({ allowedHostnames, req });

if (!clickId) {
clickId = nanoid(16);
const finalUrl = isValidUrl(url) ? url : link.url;

waitUntil(
Expand All @@ -68,8 +59,8 @@ export const POST = withAxiom(
clickId,
linkId: link.id,
url: finalUrl,
skipRatelimit: true,
workspaceId: link.projectId,
...(referrer && { referrer }),
}),
);
}
Expand Down
101 changes: 101 additions & 0 deletions apps/web/app/api/track/visit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { verifyAnalyticsAllowedHostnames } from "@/lib/analytics/verify-analytics-allowed-hostnames";
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { parseRequestBody } from "@/lib/api/utils";
import { getLinkWithAllowedHostnames } from "@/lib/planetscale/get-link-with-allowed-hostnames";
import { recordClick } from "@/lib/tinybird";
import { redis } from "@/lib/upstash";
import { isValidUrl, LOCALHOST_IP, nanoid } from "@dub/utils";
import { ipAddress, waitUntil } from "@vercel/functions";
import { AxiomRequest, withAxiom } from "next-axiom";
import { NextResponse } from "next/server";

export const runtime = "edge";

const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};

// POST /api/track/visit – Track a visit event from the client-side
export const POST = withAxiom(
async (req: AxiomRequest) => {
try {
const { domain, url, referrer } = await parseRequestBody(req);

if (!domain || !url) {
throw new DubApiError({
code: "bad_request",
message: "Missing domain or url",
});
}

const urlObj = new URL(url);

let linkKey = urlObj.pathname.slice(1);
if (linkKey === "") {
linkKey = "_root";
}

const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP;
const cacheKey = `recordClick:${domain}:${linkKey}:${ip}`;

let clickId = await redis.get<string>(cacheKey);

// only generate + record a new click ID if it's not already cached in Redis
if (!clickId) {
clickId = nanoid(16);

let link = await getLinkWithAllowedHostnames(domain, linkKey);

if (!link) {
return NextResponse.json(
{
clickId: null,
},
{
headers: CORS_HEADERS,
},
);
}

const allowedHostnames = link.allowedHostnames;
verifyAnalyticsAllowedHostnames({ allowedHostnames, req });

const finalUrl = isValidUrl(url) ? url : link.url;

waitUntil(
recordClick({
req,
clickId,
linkId: link.id,
url: finalUrl,
workspaceId: link.projectId,
...(referrer && { referrer }),
}),
);
}

return NextResponse.json(
{
clickId,
},
{
headers: CORS_HEADERS,
},
);
} catch (error) {
return handleAndReturnErrorResponse(error, CORS_HEADERS);
}
},
{
logRequestDetails: ["body", "nextUrl"],
},
);

export const OPTIONS = () => {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
};
28 changes: 21 additions & 7 deletions apps/web/lib/middleware/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url,
webhookIds,
workspaceId,
Expand Down Expand Up @@ -241,8 +243,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url,
webhookIds,
workspaceId,
Expand Down Expand Up @@ -275,8 +279,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url,
webhookIds,
workspaceId,
Expand Down Expand Up @@ -311,8 +317,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url: ios,
webhookIds,
workspaceId,
Expand Down Expand Up @@ -341,8 +349,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url: android,
webhookIds,
workspaceId,
Expand Down Expand Up @@ -371,8 +381,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url: geo[country],
webhookIds,
workspaceId,
Expand Down Expand Up @@ -401,8 +413,10 @@ export default async function LinkMiddleware(
ev.waitUntil(
recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url,
webhookIds,
workspaceId,
Expand Down
1 change: 0 additions & 1 deletion apps/web/lib/rewardful/import-referrals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ async function createReferral({
url: link.url,
workspaceId: workspace.id,
timestamp: new Date(referral.created_at).toISOString(),
skipRatelimit: true,
});

const clickEvent = clickEventSchemaTB.parse({
Expand Down
17 changes: 10 additions & 7 deletions apps/web/lib/tinybird/record-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,23 @@ import { transformClickEventData } from "../webhook/transform";
**/
export async function recordClick({
req,
linkId,
clickId,
linkId,
domain,
key,
url,
webhookIds,
skipRatelimit,
workspaceId,
timestamp,
referrer,
}: {
req: Request;
linkId: string;
clickId: string;
linkId: string;
domain?: string;
key?: string;
url?: string;
webhookIds?: string[];
skipRatelimit?: boolean;
workspaceId: string | undefined;
timestamp?: string;
referrer?: string;
Expand All @@ -61,10 +63,11 @@ export async function recordClick({

const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP;

const cacheKey = `recordClick:${linkId}:${ip}`;
const cacheKey = `recordClick:${domain}:${key}:${ip}`;

if (!skipRatelimit) {
// by default, we deduplicate clicks from the same IP address + link ID – only record 1 click per hour
// by default, we deduplicate clicks for a domain + key pair from the same IP address – only record 1 click per hour
// we only need to do these if domain + key are defined (only in middleware/link and not /api/track/:path endpoints)
if (domain && key) {
// here, we check if the clickId is cached in Redis within the last hour
const cachedClickId = await redis.get<string>(cacheKey);
if (cachedClickId) {
Expand Down

0 comments on commit 6a98e09

Please sign in to comment.