analytics: make each weekly funnel stage separately measurable (FRE-366)
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import {
|
||||
logVisit,
|
||||
enrichAnalyticsEntry,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
import { resolveClientIp, requestHost } from "~/server/request-client";
|
||||
|
||||
/**
|
||||
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
||||
@@ -15,35 +15,75 @@ import { downloadPath } from "~/lib/download-asset";
|
||||
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
||||
* serves Sparkle updates, and the Nook door.
|
||||
*
|
||||
* The `stage` is what separates the doors within one asset label: "click"
|
||||
* for a door the visitor chose (landing button, tRPC proxy), "served" for
|
||||
* the file route that actually streams the DMG. Until FRE-366 both wrote the
|
||||
* same unlabelled row, so "download click → DMG served" could not be split —
|
||||
* one served click wrote two identical rows. `summariseDownloads()` and the
|
||||
* /analytics downloads block still group on the path alone; the stage is a
|
||||
* separate column precisely so that grouping cannot change.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
event: APIEvent,
|
||||
asset: string,
|
||||
stage: DownloadStage
|
||||
): Promise<void> {
|
||||
await trackRequest(event, {
|
||||
path: downloadPath(asset),
|
||||
method: "GET",
|
||||
stage
|
||||
});
|
||||
}
|
||||
|
||||
export type DownloadStage = "click" | "served";
|
||||
|
||||
export interface TrackedRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
/** Funnel stage label; only download rows carry one today. */
|
||||
stage?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one server-side request event with the shared visitor dimensions:
|
||||
* referrer, campaign tags, edge country, real client address and host.
|
||||
*
|
||||
* This is the one writer every measured door goes through — download doors
|
||||
* via `trackDownload`, the checkout door directly — so the dimensions can
|
||||
* never drift between doors. Same best-effort, flushed-immediately contract
|
||||
* as `trackDownload`.
|
||||
*
|
||||
* `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.
|
||||
* so such an event is not recorded. No server-side caller downloads today.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
export async function trackRequest(
|
||||
event: APIEvent,
|
||||
asset: string
|
||||
tracked: TrackedRequest
|
||||
): 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",
|
||||
path: tracked.path,
|
||||
method: tracked.method,
|
||||
stage: tracked.stage ?? null,
|
||||
referrer,
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress:
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null),
|
||||
// Behind the production proxy the direct peer is loopback; the real
|
||||
// client address comes from the forwarded header the proxy sets —
|
||||
// see `~/server/request-client` for the trust boundary.
|
||||
ipAddress: resolveClientIp(event),
|
||||
host: requestHost(event),
|
||||
// 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),
|
||||
@@ -56,6 +96,6 @@ export async function trackDownload(
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error(`Failed to log download of ${asset}:`, error);
|
||||
console.error(`Failed to log ${tracked.method} ${tracked.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user