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.
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
import { createClient } from "@libsql/client/web";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import {
|
|
ensureAnalyticsSchema,
|
|
getWeeklyReport,
|
|
renderWeeklyMarkdown,
|
|
type Connections
|
|
} from "~/server/weekly-metrics";
|
|
|
|
/**
|
|
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
|
*
|
|
* bun --env-file=.env scripts/weekly-metrics.ts
|
|
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
|
*
|
|
* Required environment (all four; the analytics token also opens the licence
|
|
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
|
* or Apple secrets):
|
|
*
|
|
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
|
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
|
*
|
|
* This script deliberately does not import `~/env/server`: that schema demands
|
|
* every production secret, and the metrics job needs two databases and nothing
|
|
* else.
|
|
*
|
|
* The output path is anchored at the repo root, so running it from any cwd
|
|
* writes the same file.
|
|
*/
|
|
|
|
interface Options {
|
|
days: number;
|
|
now: Date;
|
|
out: string;
|
|
stdout: boolean;
|
|
}
|
|
|
|
function usage(): string {
|
|
return [
|
|
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
|
"",
|
|
" --days N window length in days (default 7)",
|
|
" --now ISO end of the window (default: now)",
|
|
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
|
" --stdout print the report instead of writing a file"
|
|
].join("\n");
|
|
}
|
|
|
|
function parseArgs(argv: string[]): Options {
|
|
const repoRoot = resolve(import.meta.dir, "..");
|
|
const options: Options = {
|
|
days: 7,
|
|
now: new Date(),
|
|
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
|
stdout: false
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i]!;
|
|
const value = () => {
|
|
const next = argv[i + 1];
|
|
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
|
i += 1;
|
|
return next;
|
|
};
|
|
|
|
switch (arg) {
|
|
case "--days": {
|
|
const days = Number(value());
|
|
if (!Number.isFinite(days) || days <= 0) {
|
|
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
|
}
|
|
options.days = days;
|
|
break;
|
|
}
|
|
case "--now": {
|
|
const now = new Date(value());
|
|
if (Number.isNaN(now.getTime())) {
|
|
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
|
}
|
|
options.now = now;
|
|
break;
|
|
}
|
|
case "--out":
|
|
options.out = resolve(value());
|
|
break;
|
|
case "--stdout":
|
|
options.stdout = true;
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
console.log(usage());
|
|
process.exit(0);
|
|
default:
|
|
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function requiredEnv(name: string): string {
|
|
const value = process.env[name];
|
|
if (!value || value.trim() === "") {
|
|
throw new Error(
|
|
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
|
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
|
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function connect(url: string, token: string) {
|
|
return createClient({ url, authToken: token });
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
|
|
const connections: Connections = {
|
|
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
|
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
|
};
|
|
|
|
// The app migrates the table on boot, but this job must not depend on a
|
|
// deploy having happened: add any missing post-hoc columns (stage, host)
|
|
// through the same idempotent bootstrap path first.
|
|
await ensureAnalyticsSchema(connections.analytics);
|
|
|
|
const report = await getWeeklyReport(connections, {
|
|
days: options.days,
|
|
now: options.now
|
|
});
|
|
const markdown = renderWeeklyMarkdown(report);
|
|
|
|
if (options.stdout) {
|
|
process.stdout.write(markdown);
|
|
return;
|
|
}
|
|
|
|
await mkdir(dirname(options.out), { recursive: true });
|
|
await writeFile(options.out, markdown, "utf8");
|
|
|
|
const f = report.counts;
|
|
console.log(
|
|
`wrote ${options.out}\n` +
|
|
`window ${report.window.start} → ${report.window.end}\n` +
|
|
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
|
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
|
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
|
);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
});
|