This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Phase 1 — Metric Ground-Truth Audit
|
||||
* ───────────────────────────────────────
|
||||
*
|
||||
* Run against a live DB to capture the *actual* counts per metric
|
||||
* table for a single brand. Output is written to stdout AND to
|
||||
* scripts/audit-results.md so the ground truth lives alongside the
|
||||
* code-side audit.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx scripts/audit-phase1.ts
|
||||
* # or pick a different brand
|
||||
* BRAND_NAME="Modern Heart" npx tsx scripts/audit-phase1.ts
|
||||
*
|
||||
* This script is READ-ONLY — it only runs aggregate() / groupBy()
|
||||
* / count() queries. Safe to run in production.
|
||||
*
|
||||
* Scoping note: some metric tables are keyed by brandId directly
|
||||
* (SiteConversion, SiteEvent, RealUserMetric, AiInteraction,
|
||||
* AiUsageLog, PlatformEvent, MetricSnapshot, GscPage, TechnicalAudit)
|
||||
* while TrackedEvent and TrackedSession are scoped via TrackedSite.
|
||||
* The script resolves the brand's TrackedSite once and uses
|
||||
* trackedSiteId for those two tables.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Accumulator for the markdown report. Every log() call appends to
|
||||
// both stdout and this buffer; the buffer is flushed to
|
||||
// scripts/audit-results.md at the end of the run.
|
||||
const report: string[] = [];
|
||||
function log(line: string) {
|
||||
log(line);
|
||||
report.push(line);
|
||||
}
|
||||
|
||||
// JSON.stringify replacer that handles BigInt + Date cleanly.
|
||||
function replacer(_key: string, value: unknown): unknown {
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function j(v: unknown): string {
|
||||
return JSON.stringify(v, replacer);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const brandNeedle = process.env.BRAND_NAME ?? "Modern Heart";
|
||||
|
||||
const brand = await prisma.brand.findFirst({
|
||||
where: { name: { contains: brandNeedle, mode: "insensitive" } },
|
||||
select: { id: true, name: true, domain: true, createdAt: true },
|
||||
});
|
||||
if (!brand) {
|
||||
console.error(`No brand matching "${brandNeedle}" found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const site = await prisma.trackedSite.findUnique({
|
||||
where: { brandId: brand.id },
|
||||
select: { id: true, status: true, firstEventAt: true, lastEventAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
log("=== Brand ===");
|
||||
log(j({ brand }));
|
||||
log("=== TrackedSite ===");
|
||||
log(j({ site }));
|
||||
|
||||
// ─── Direct-brandId tables ──────────────────────────────────
|
||||
//
|
||||
// Each aggregate runs independently so a single failing query
|
||||
// doesn't blackhole the rest of the audit. Empty / errored rows
|
||||
// render as ERROR in the output.
|
||||
const brandTables: Array<{ name: string; run: () => Promise<unknown> }> = [
|
||||
{
|
||||
name: "SiteConversion",
|
||||
run: () => prisma.siteConversion.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SiteEvent",
|
||||
run: () => prisma.siteEvent.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "RealUserMetric",
|
||||
run: () => prisma.realUserMetric.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "AiInteraction",
|
||||
run: () => prisma.aiInteraction.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "AiUsageLog",
|
||||
run: () => prisma.aiUsageLog.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "PlatformEvent",
|
||||
run: () => prisma.platformEvent.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "MetricSnapshot",
|
||||
run: () => prisma.metricSnapshot.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { date: true },
|
||||
_max: { date: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "GscPage",
|
||||
run: () => prisma.gscPage.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { date: true },
|
||||
_max: { date: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "TechnicalAudit",
|
||||
run: () => prisma.technicalAudit.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { createdAt: true },
|
||||
_max: { createdAt: true },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
log("\n=== Brand-scoped tables ===");
|
||||
for (const t of brandTables) {
|
||||
try {
|
||||
const result = await t.run();
|
||||
log(`${t.name}: ${j(result)}`);
|
||||
} catch (err) {
|
||||
log(`${t.name}: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TrackedSite-scoped tables ──────────────────────────────
|
||||
log("\n=== TrackedSite-scoped tables ===");
|
||||
if (!site) {
|
||||
log("TrackedEvent / TrackedSession: SKIPPED (no TrackedSite for brand)");
|
||||
} else {
|
||||
try {
|
||||
const te = await prisma.trackedEvent.aggregate({
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
});
|
||||
log(`TrackedEvent: ${j(te)}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
try {
|
||||
const ts = await prisma.trackedSession.aggregate({
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
_min: { startedAt: true },
|
||||
_max: { startedAt: true },
|
||||
});
|
||||
log(`TrackedSession: ${j(ts)}`);
|
||||
} catch (err) {
|
||||
log(`TrackedSession: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Type breakdowns (the numbers the UI actually renders) ──
|
||||
log("\n=== Distinct event-type counts ===");
|
||||
|
||||
if (site) {
|
||||
try {
|
||||
const trackedTypes = await prisma.trackedEvent.groupBy({
|
||||
by: ["eventType"],
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
});
|
||||
trackedTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
// Top 30 only — longer lists hide the signal.
|
||||
log(`TrackedEvent types (top 30): ${j(trackedTypes.slice(0, 30))}`);
|
||||
log(`TrackedEvent distinct type count: ${trackedTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const siteConvTypes = await prisma.siteConversion.groupBy({
|
||||
by: ["conversionType"],
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
});
|
||||
siteConvTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
// All SiteConversion types — there are rarely more than ~20
|
||||
// distinct values but capped at 30 for safety.
|
||||
log(`SiteConversion types (top 30): ${j(siteConvTypes.slice(0, 30))}`);
|
||||
log(`SiteConversion distinct type count: ${siteConvTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`SiteConversion types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteEventTypes = await prisma.siteEvent.groupBy({
|
||||
by: ["eventType"],
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
});
|
||||
siteEventTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
log(`SiteEvent types (top 20): ${j(siteEventTypes.slice(0, 20))}`);
|
||||
log(`SiteEvent distinct type count: ${siteEventTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`SiteEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// ─── 30-day windowed ground truth ──────────────────────────
|
||||
// The UI's default range is 30 days. Logging both all-time
|
||||
// and 30-day counts side by side so "why does the dashboard
|
||||
// show 528 conversions when the DB has 3000+?" is obvious.
|
||||
log("\n=== 30-day windowed counts ===");
|
||||
const since30d = new Date(Date.now() - 30 * 86_400_000);
|
||||
|
||||
try {
|
||||
const conv30 = await prisma.siteConversion.count({
|
||||
where: { brandId: brand.id, timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`SiteConversion (30d): ${conv30}`);
|
||||
} catch (err) {
|
||||
log(`SiteConversion (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (site) {
|
||||
try {
|
||||
const te30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent (30d): ${te30}`);
|
||||
const sessStart30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, eventType: "session_start", timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent session_start (30d): ${sessStart30}`);
|
||||
const pv30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, eventType: "page_view", timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent page_view (30d): ${pv30}`);
|
||||
const ts30 = await prisma.trackedSession.count({
|
||||
where: { trackedSiteId: site.id, startedAt: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedSession (30d): ${ts30}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent/TrackedSession (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const gsc30 = await prisma.gscPage.aggregate({
|
||||
where: { brandId: brand.id, date: { gte: since30d } },
|
||||
_sum: { clicks: true, impressions: true },
|
||||
_avg: { position: true, ctr: true },
|
||||
});
|
||||
log(`GscPage (30d) aggregate: ${j(gsc30)}`);
|
||||
} catch (err) {
|
||||
log(`GscPage (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ai30 = await prisma.aiUsageLog.aggregate({
|
||||
where: { brandId: brand.id, timestamp: { gte: since30d } },
|
||||
_count: true,
|
||||
_sum: { estimatedCost: true, inputTokens: true, outputTokens: true },
|
||||
});
|
||||
log(`AiUsageLog (30d): ${j(ai30)}`);
|
||||
} catch (err) {
|
||||
log(`AiUsageLog (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
log("\n=== Audit complete ===");
|
||||
|
||||
// Flush the full run to scripts/audit-results.md so the result is
|
||||
// checked in alongside the code-side audit. Wrapping in ``` keeps
|
||||
// the markdown file renderable even for large JSON payloads.
|
||||
const outPath = "scripts/audit-results.md";
|
||||
const now = new Date().toISOString();
|
||||
const body = [
|
||||
"# Phase 1 — Metric Ground-Truth Audit",
|
||||
"",
|
||||
`**Generated:** ${now}`,
|
||||
`**Brand:** ${brand.name} (${brand.id})`,
|
||||
`**Domain:** ${brand.domain}`,
|
||||
"",
|
||||
"This file is produced by `scripts/audit-phase1.ts` and captures",
|
||||
"the raw per-table counts + event-type breakdowns for a single",
|
||||
"brand. These are the **ground-truth numbers** the UI surfaces",
|
||||
"must agree with.",
|
||||
"",
|
||||
"Re-run with `npx tsx scripts/audit-phase1.ts` — the file is",
|
||||
"overwritten in place.",
|
||||
"",
|
||||
"## Full output",
|
||||
"",
|
||||
"```text",
|
||||
...report,
|
||||
"```",
|
||||
"",
|
||||
].join("\n");
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, body, "utf8");
|
||||
console.log(`\n[audit-phase1] wrote ${outPath} (${report.length} lines)`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[audit-phase1] FATAL", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user