π View & Click Tracking
How the API tracks unique page views and outbound clicks across tools, blogs, and tutorials β including KV buffering, cron-based D1 sync, real-time count merging, deduplication strategy, and rank score updates.
Coverage Matrix
Which resource types track which signals. KV buffering, click count, and rank score are Tools-only features currently.
| Resource | viewCount | clickCount | KV buffered? | rankScore update | Dedup method |
|---|---|---|---|---|---|
| /api/tools/:id/view | β Yes (client ping) | β | β KV β D1 via cron | β οΈ On cron sync | Per-tool cookie viewed_tool_{id} β 24 h |
| /api/tools/:id/click | β | β Yes (deduped) | β KV β D1 via cron | β οΈ On cron sync | Per-tool cookie clicked_tool_{id} β 1 h |
| /api/tools/slug/:slug | β Yes (cookie-gated) | β Yes β via POST /:id/click | β οΈ KV read for real-time merge only | β After each event | Multi-slug cookie viewed_tools |
| /api/ai-for-lawyers/:slug | β Yes | β οΈ Shared β POST /api/tools/:id/click | β οΈ KV read for real-time merge only | β After view | Per-slug cookie viewed_tool_{slug} |
| /api/ai-for-real-estate/:slug | β Yes | β οΈ Shared β POST /api/tools/:id/click | β οΈ KV read for real-time merge only | β After view | Per-slug cookie viewed_tool_{slug} |
| /api/ai-for-videos/:slug | β No slug route | β οΈ Shared β POST /api/tools/:id/click | β | β No view tracking | β |
| /api/blogs/:slug | β Yes | β Not implemented | β Direct D1 write | β No rank score | Multi-slug cookie viewed_blogs |
| /api/tutorials/:slug | β Yes | β Not implemented | β Direct D1 write | β No rank score | Multi-slug cookie |
KV Buffer Architecture
Tool view and click counts are not written directly to D1 on each request. Instead they are buffered in Cloudflare KV (a fast key-value store) and flushed to D1 by a cron job every 30 minutes. This prevents D1 write contention under high traffic and keeps the response latency low.
v:{toolId} β pending view count for this tool (plain numeric string)c:{toolId} β pending click count for this tool (plain numeric string)Tools with zero buffered counts have no KV entry. The cron job lists all keys with prefix
v: and c:, flushes them to D1, then deletes the keys.
await import() calls at runtime are silently ignored β the block executes but nothing happens. All imports from counter.service.ts must be static top-level imports.
How It Works β Client-Side View Ping
Tool pages are CDN-cached β the backend GET /:slug route is never hit on a cached page load. View tracking therefore uses a client-side POST ping after the page renders. The frontend calls POST /api/tools/:id/view on every page load after checking a local dedup cookie.
viewed_tool_{id}. If found β returns { counted: false } immediately. No KV write.COUNTERS KV namespace is bound, the worker reads the current buffered count from v:{toolId}, adds 1, and writes it back. Response is returned immediately.trackToolView(). This path is awaited before the response β CF Workers kills unawaited promises when the Response is returned.viewed_tool_{id}=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax is set in the response. The counter will not increment again for this browser within 24 hours.v: keys, deletes each key, then increments D1 by the buffered count. If D1 fails, the key is restored so the next cron cycle retries.Deduplication Strategies
Two patterns are used. The main tools route stores all seen slugs in one cookie; the domain-specific routers and the view-ping endpoint use one cookie key per tool.
// Cookie: "viewed_tools" (or "viewed_blogs") // Value: "slug-one,slug-two,slug-three" (comma-separated, max 200 entries) const raw = getCookie(c, "viewed_tools") ?? ""; const viewed = new Set(raw ? raw.split(",") : []); const isNew = !viewed.has(slug); // After fetching: if (isNew) { viewed.add(slug); setCookie(c, "viewed_tools", [...viewed].slice(-200).join(","), { maxAge: 60 * 60 * 24, // 24 hours httpOnly: true, sameSite: "Lax", path: "/", }); }
// Cookie key: "viewed_tool_{slug}" β one cookie per visited tool // β οΈ Must use exact-match parse β .includes() causes false positives // e.g. "viewed_tool_ai" matches inside "viewed_tool_ai-tools" const viewedKey = `viewed_tool_${slug}`; const rawCookie = c.req.header("cookie") ?? ""; const cookieParts = rawCookie.split(";").map((p) => p.trim()); const alreadySeen = cookieParts.some( (p) => p === viewedKey || p.startsWith(`${viewedKey}=`) ); // After fetching: if (!alreadySeen) { c.header("Set-Cookie", `${viewedKey}=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax`); }
// Same exact-match parse as Pattern B above // Uses tool ID (not slug) because cached pages have the ID available client-side const viewedKey = `viewed_tool_${id}`; const rawCookie = c.req.header("cookie") ?? ""; const cookieParts = rawCookie.split(";").map((p) => p.trim()); const alreadySeen = cookieParts.some( (p) => p === viewedKey || p.startsWith(`${viewedKey}=`) ); if (alreadySeen) return c.json({ success: true, counted: false }); // Increment KV then set cookie...
SameSite=Lax. The frontend must call the view/click endpoints through the same domain (e.g. a proxy route). Calling the raw workers.dev URL from a different origin causes the browser to silently drop the dedup cookie β every request will have an empty cookie header and every page load will be counted as a new view.
Real-Time Count Merging
Because views and clicks are buffered in KV and only synced to D1 every 30 minutes, the D1 counts are always slightly behind. When serving a tool detail page the API merges both sources to return a real-time total.
realTimeViews = D1.viewCount + KV buffer (v:{toolId})realTimeClicks = D1.clickCount + KV buffer (c:{toolId})Applied in
getToolBySlug() and getToolStats() β both call getKVCountsForTool(toolId, env.COUNTERS) and add the buffered delta to the D1 values before returning the response.
// After fetching the tool from D1... const base = deserializeTool(tool); if (env.COUNTERS) { const { kvViews, kvClicks } = await getKVCountsForTool(tool.id, env.COUNTERS); if (kvViews > 0) base.viewCount = (tool.viewCount ?? 0) + kvViews; if (kvClicks > 0) base.clickCount = (tool.clickCount ?? 0) + kvClicks; } return base; // counts reflect everything recorded since last cron sync
Cron Sync β KV β D1
The Cloudflare Cron Trigger defined in wrangler.toml fires the scheduled() handler in src/index.ts every 30 minutes. It runs two steps in sequence.
[triggers] crons = ["*/30 * * * *"] # every 30 minutes # change to "0 * * * *" for hourly, "*/15 * * * *" for every 15 min
v: and c: keys in the COUNTERS KV namespace. For each key: reads the buffered count, deletes the key, then runs prisma.tool.update({ viewCount: { increment: count } }). If the D1 update fails, the key is restored so no counts are lost.rankScore for every non-archived tool using the updated D1 values. Runs in batches of 100 to stay within D1 limits.| Cron step | Function | Source file | On failure |
|---|---|---|---|
| Counter flush | syncCountersToDb() | counters/counter.service.ts | KV key restored; next cron retries |
| Ranking recalc | recalculateAllRankings() | tools/tool.service.ts | Logged; rankings remain at last known value |
wrangler dev --test-scheduled then curl "http://localhost:8787/cdn-cgi/handler/scheduled". In production you can trigger it once from the Cloudflare dashboard under Workers > Triggers.
View Count Endpoints
Client-Side View Ping (primary)
βΆThe primary view tracking endpoint. Called by the frontend after a cached tool page loads β the backend GET route is never reached on CDN-cached pages so this ping is the only way to record the view. No request body required. No auth required.
Uses Pattern B cookie dedup (per-tool, exact-match parse). On a new view, increments v:{toolId} in Cloudflare KV. Falls back to a direct D1 write if KV is not bound (local dev).
| Param | Type | Description |
|---|---|---|
| id | string | Tool ID (not slug) |
| Name | Value | TTL |
|---|---|---|
| viewed_tool_{id} | 1 | 24 hours (Max-Age=86400) |
| Storage | Action |
|---|---|
Cloudflare KV β v:{id} | +1 (buffered; flushed to D1 by cron) |
D1 Tool.viewCount | Updated on next cron sync (every 30 min) |
| rankScore | Recalculated on cron sync after D1 update |
{ "success": true, "counted": true }
{ "success": true, "counted": false }
/api/tools/:id/view on the same origin). This ensures SameSite=Lax cookies are correctly sent and saved by the browser. Calling a different domain will cause the dedup cookie to be silently dropped, counting every page load as a new view.
// Call after page mount β fire-and-forget // Must go through the same domain (proxied) β not the raw workers.dev URL fetch(`/api/tools/${toolId}/view`, { method: "POST", });
Tools β View Count (server-side, non-cached only)
βΆ
Fetches a published tool by slug. Uses Pattern A (multi-slug viewed_tools cookie). On a new view, increments viewCount and triggers updateRankScore() β both fire-and-forget so the response is never delayed.
Note: This endpoint is only called when the page is not CDN-cached (e.g., first request after a deploy, or during local development). For production cached pages, the view is tracked by POST /:id/view instead.
The response merges D1 counts with the KV buffer so viewCount and clickCount are always real-time totals.
| Name | Value format | TTL | Max entries |
|---|---|---|---|
| viewed_tools | Comma-separated slugs | 24 hours | 200 slugs (oldest evicted) |
| Field | Action |
|---|---|
| viewCount | +1 directly to D1 (fire-and-forget) |
| rankScore | Recalculated after viewCount update |
Lawyer Tools β View Count
βΆFetches a lawyer tool by slug from the shared Tool table (guards toolType === "lawyer"). Uses Pattern B (per-slug cookie key, exact-match parse). Delegates to the same getToolBySlug() service as the main tools route, so view tracking and rank score update behavior is identical.
| Name | Value | TTL |
|---|---|---|
| viewed_tool_{slug} | 1 | 24 hours (Max-Age=86400) |
| Field | Action |
|---|---|
| viewCount | +1 directly to D1 (fire-and-forget) |
| rankScore | Recalculated after viewCount update |
Real Estate Tools β View Count
βΆIdentical to the Lawyer route in mechanism. Guards toolType === "real-estate". Uses Pattern B (exact-match cookie parse). Delegates to getToolBySlug().
| Name | Value | TTL |
|---|---|---|
| viewed_tool_{slug} | 1 | 24 hours |
| Field | Action |
|---|---|
| viewCount | +1 directly to D1 (fire-and-forget) |
| rankScore | Recalculated after viewCount update |
Blog β View Count
βΆFetches a published blog post. Uses Pattern A (multi-slug viewed_blogs cookie). Increments viewCount directly in D1 (fire-and-forget). No KV buffering. No rank score is computed for blogs β only viewCount, helpfulCount, and notHelpfulCount are tracked.
| Name | Value format | TTL | Max entries |
|---|---|---|---|
| viewed_blogs | Comma-separated slugs | 24 hours | 200 slugs |
| Field | Action |
|---|---|
| viewCount | +1 directly to D1 (fire-and-forget) |
| rankScore | β Not implemented for blogs |
Tutorials β View Count
βΆFetches a published tutorial. Same pattern as blogs β multi-slug cookie, fire-and-forget viewCount increment directly to D1, no KV buffering, no rank score.
| Field | Action |
|---|---|
| viewCount | +1 directly to D1 (fire-and-forget) |
| rankScore | β Not implemented for tutorials |
Click Count Endpoint
Track Tool Click
βΆIncrements clickCount on a Tool when the user clicks through to the tool's external website. No auth required. No request body needed. Should be called from the frontend at the moment of outbound navigation β e.g., on the "Visit Website" button click.
This endpoint covers all tool types β lawyer, real estate, and video tools all use the shared Tool table, so the same endpoint works for all of them. There is no separate /click route on the domain-specific routers.
If the COUNTERS KV namespace is bound, the click is buffered in KV (c:{toolId}) and flushed to D1 by the cron job. Otherwise it writes directly to D1.
| Param | Type | Description |
|---|---|---|
| id | string | Tool ID (not slug) |
Uses the same exact-match per-key cookie pattern as view tracking. One click is counted per browser per tool per hour. The TTL is shorter than views (1 h vs 24 h) β returning to a tool and clicking through again later in the day is legitimate, but spam-clicking in the same session is not.
| Cookie name | Value | TTL |
|---|---|---|
| clicked_tool_{id} | 1 | 1 hour (Max-Age=3600) |
| Storage | Action |
|---|---|
Cloudflare KV β c:{id} | +1 (buffered; flushed to D1 by cron) |
D1 Tool.clickCount | Updated on next cron sync (every 30 min) |
| rankScore | Recalculated on cron sync after D1 update |
{ "success": true, "counted": true }
{ "success": true, "counted": false }
{ "success": false, "message": "Tool not found." }
Rank Score β Formula
Tools only. The rank score is a single float stored on the Tool row and kept in sync after every engagement event. It is the primary sort key for the homepage and listing endpoints.
+ clickCount Γ 2.5 β strong outbound-intent signal
+ viewCount Γ 0.05 β dampened to prevent gaming
+ rating Γ reviewCount Γ 3.0 β quality weighted by credibility
+ 50 β if featured (editorial spotlight)
+ 25 β if verified (quality badge)
+ 30 β if trending (editorial flag)
+ 30 / (daysSincePublished + 1) β recency boost, decays from ~30 β ~0
When Rank Score Updates
For KV-buffered events (view ping, click), the rank score updates on the cron flush rather than immediately. For all other events it recalculates in the background after the action.
| Trigger event | Endpoint | rankScore updated? | When |
|---|---|---|---|
| View ping (client-side) | POST /api/tools/:id/view | β οΈ KV buffered | On cron flush (every 30 min) |
| Page view (GET, non-cached) | GET /api/tools/slug/:slug | β Yes | Fire-and-forget after D1 write |
| Page view (GET, non-cached) | GET /api/ai-for-lawyers/:slug | β Yes | Fire-and-forget after D1 write |
| Page view (GET, non-cached) | GET /api/ai-for-real-estate/:slug | β Yes | Fire-and-forget after D1 write |
| Outbound click | POST /api/tools/:id/click | β οΈ KV buffered | On cron flush (every 30 min) |
| Upvote toggle | POST /api/tools/:id/upvote | β Yes | Awaited |
| Review submitted | POST /api/tools/:id/reviews | β Yes | Awaited |
| Rating submitted | POST /api/tools/:id/rate | β Yes | Awaited |
| Tool created / status changed | POST/PATCH /api/tools | β Yes | Awaited |
| Admin bulk recalculate | POST /api/tools/admin/recalculate-rankings | β Yes β all tools | Awaited (batched) |
| Cron sync | Scheduled handler (every 30 min) | β Yes β all tools | After counter flush |
rankScore field and are sorted purely by publishedAt, viewCount, or helpfulCount at query time.