/** * SEO Redirect Decommission Report * * Queries PostHog for seo_redirect events over the last 30 days, * cross-references against the redirect array, and outputs: * - Total redirect traffic * - Top 10 most-hit redirects * - Zero-hit decommission candidates * * Usage: * POSTHOG_API_KEY=phx_... npx tsx showcase/scripts/redirect-decommission-report.ts * POSTHOG_API_KEY=phx_... npx tsx showcase/scripts/redirect-decommission-report.ts --slack * * The --slack flag outputs a Slack-formatted message to stdout (for CI). * Without --slack, outputs a human-readable report. * * For deterministic replay (cross-check fixture generation / offline * smoke-test), pass `--events-json=` to skip the PostHog fetch and * read an EventCount[] array from disk instead. The PostHog env-var checks * are also skipped in this mode. Intended for tests and reproducible * diffs — do not use in scheduled runs. * * Formatter logic lives in `./redirect-decommission-core.ts` so showcase-harness * can drive the same body from a probe tick. Any drift between this CLI * and that core is caught by a byte-for-byte cross-check in * `showcase/harness/test/fixtures/redirect-decommission/cli-stdout.txt`. */ import fs from "fs"; // Named-import interop: `../shell/src/lib/seo-redirects` has no "type":"module" // one level up, so tsx under Node 25 surfaces its exports via the CJS `default` // namespace rather than direct named bindings. Use a namespace import and // pick either shape to stay compatible across both toolchains. import * as seoRedirectsModule from "../shell/src/lib/seo-redirects"; import { computeRedirectDecommission, type EventCount, type RedirectEntryLite, } from "./redirect-decommission-core"; const seoRedirects: RedirectEntryLite[] = (( seoRedirectsModule as { seoRedirects?: RedirectEntryLite[] } ).seoRedirects ?? ( seoRedirectsModule as unknown as { default?: { seoRedirects?: RedirectEntryLite[] }; } ).default?.seoRedirects ?? []) as RedirectEntryLite[]; const POSTHOG_HOST = "https://eu.i.posthog.com"; const DAYS = 30; interface ParsedArgs { slackFormat: boolean; eventsJsonPath: string | null; } function parseArgs(): ParsedArgs { const slackFormat = process.argv.includes("--slack"); let eventsJsonPath: string | null = null; for (const arg of process.argv.slice(2)) { if (arg.startsWith("--events-json=")) { eventsJsonPath = arg.slice("--events-json=".length); } } return { slackFormat, eventsJsonPath }; } async function queryPostHog( apiKey: string, projectId: string, ): Promise { const now = new Date(); const from = new Date(now.getTime() - DAYS * 24 * 60 * 60 * 1000); const query = { kind: "HogQLQuery", query: ` SELECT properties.redirect_id AS redirect_id, count() AS cnt FROM events WHERE event = 'seo_redirect' AND timestamp >= toDateTime('${from.toISOString()}') AND timestamp <= toDateTime('${now.toISOString()}') GROUP BY redirect_id ORDER BY cnt DESC `, }; const res = await fetch(`${POSTHOG_HOST}/api/projects/${projectId}/query/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ query }), }); if (!res.ok) { const text = await res.text(); throw new Error(`PostHog API error ${res.status}: ${text}`); } const data = await res.json(); const results: EventCount[] = []; for (const row of data.results || []) { results.push({ redirect_id: row[0], count: row[1] }); } return results; } function loadEventsFromFile(path: string): EventCount[] { const raw = fs.readFileSync(path, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { throw new Error( `--events-json fixture at ${path} is not a JSON array of { redirect_id, count }`, ); } return parsed as EventCount[]; } async function main() { const { slackFormat, eventsJsonPath } = parseArgs(); let events: EventCount[]; if (eventsJsonPath) { // Offline / fixture-driven mode — used by the cross-check test and for // reproducible golden generation. Skips PostHog entirely. events = loadEventsFromFile(eventsJsonPath); } else { const apiKey = process.env.POSTHOG_API_KEY; if (!apiKey) { console.error("POSTHOG_API_KEY env var is required"); process.exit(1); } const projectId = process.env.POSTHOG_PROJECT_ID; if (!projectId) { console.error("POSTHOG_PROJECT_ID env var is required"); process.exit(1); } events = await queryPostHog(apiKey, projectId); } const { body, hasCandidates } = computeRedirectDecommission({ events, redirects: seoRedirects, days: DAYS, slackFormat, }); console.log(body); // Exit code signals CI whether to post to Slack if (slackFormat && !hasCandidates) { process.exit(2); // No candidates — CI should skip Slack post } } main().catch((err) => { console.error("Fatal:", err); process.exit(1); });