Files
copilotkit--copilotkit/showcase/scripts/redirect-decommission-report.ts
2026-07-13 12:58:18 +08:00

168 lines
5.1 KiB
TypeScript

/**
* 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=<path>` 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<EventCount[]> {
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);
});