Files
freno-dev/src/server/track-download.ts
Michael Freno a524f23be2 downloads: track every door and surface them on /analytics
The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
2026-10-22 12:59:48 -04:00

62 lines
2.3 KiB
TypeScript

import type { APIEvent } from "@solidjs/start/server";
import { getRequestIP } from "vinxi/http";
import {
logVisit,
enrichAnalyticsEntry,
flushAnalytics
} from "~/server/analytics";
import { parseUtm } from "~/server/utm";
import { downloadPath } from "~/lib/download-asset";
/**
* Record one download event under the canonical path `/api/downloads/<asset>`.
*
* Every download door funnels through here so the analytics page has a single
* label per asset to group on: the tRPC click proxy, the S3 file route that
* serves Sparkle updates, and the Nook door.
*
* Best-effort by design — a failed analytics write must never block a
* download. Flushed rather than merely buffered: a download is low-volume and
* the 30s batch timer is not guaranteed to survive the response on serverless.
*
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
* Node request, whose headers have no `get` — that throw is swallowed below,
* so such a click is not recorded. No server-side caller downloads today.
*/
export async function trackDownload(
event: APIEvent,
asset: string
): Promise<void> {
const { request } = event;
try {
const referrer =
request.headers.get("referer") ?? request.headers.get("referrer");
const forwarded = request.headers.get("x-forwarded-for");
await logVisit(
enrichAnalyticsEntry({
path: downloadPath(asset),
method: "GET",
referrer,
userAgent: request.headers.get("user-agent"),
ipAddress:
getRequestIP(event.nativeEvent) ??
(forwarded ? forwarded.split(",")[0]!.trim() : null),
// The click carries its own tags when a campaign links straight at the
// door; otherwise the landing page's tags ride along in the referer.
...parseUtm(request.url, referrer),
// Edge header country, overridable with ?c= for manual verification.
country:
new URL(request.url).searchParams.get("c") ??
request.headers.get("x-vercel-ip-country") ??
request.headers.get("cf-ipcountry")
})
);
await flushAnalytics();
} catch (error) {
console.error(`Failed to log download of ${asset}:`, error);
}
}