# Phase 1 — Metric Ground-Truth Audit **Status:** Script ready — needs a live `DATABASE_URL` to produce real numbers. The `scripts/audit-phase1.ts` runner is a read-only Prisma script. Once it runs against production it overwrites this file with the actual per-table counts + event-type breakdowns for a single brand. ## How to run ```bash # Default: searches for a brand whose name contains "Modern Heart". DATABASE_URL=postgres://user:pass@host/db npx tsx scripts/audit-phase1.ts # Override the brand: BRAND_NAME="Another Brand" DATABASE_URL=... npx tsx scripts/audit-phase1.ts ``` On success the script overwrites this file with the ground-truth output from the target database. ## What the script captures Scoped to a single brand, per table: - **Brand-keyed tables** — `SiteConversion`, `SiteEvent`, `RealUserMetric`, `AiInteraction`, `AiUsageLog`, `PlatformEvent`, `MetricSnapshot`, `GscPage`, `TechnicalAudit`. For each: row count, min / max timestamp. - **TrackedSite-keyed tables** — `TrackedEvent`, `TrackedSession`. The script resolves the brand's `TrackedSite.id` once, then queries those two tables by `trackedSiteId`. - **Type breakdowns** — top 30 `TrackedEvent.eventType` buckets, top 30 `SiteConversion.conversionType` buckets, top 20 `SiteEvent.eventType` buckets. - **30-day windowed counts** — `SiteConversion`, `TrackedEvent` (total + `session_start` + `page_view`), `TrackedSession`, `GscPage` aggregate (clicks / impressions / position / ctr), `AiUsageLog` aggregate (cost / tokens). Every query runs independently (no transaction) and errors surface as `ERROR ` lines rather than aborting the rest of the audit. ## Expected output shape ```text === Brand === {"brand":{"id":"cl...","name":"Modern Heart and Vascular","domain":"modernheartandvascular.com","createdAt":"2026-04-10T...Z"}} === TrackedSite === {"site":{"id":"cl...","status":"active","firstEventAt":"2026-04-10T...Z","lastEventAt":"2026-04-16T...Z","createdAt":"2026-04-10T...Z"}} === Brand-scoped tables === SiteConversion: {"_count":NNN,"_min":{"timestamp":"..."},"_max":{"timestamp":"..."}} SiteEvent: {...} RealUserMetric: {...} AiInteraction: {...} AiUsageLog: {...} PlatformEvent: {...} MetricSnapshot: {...} GscPage: {...} TechnicalAudit: {...} === TrackedSite-scoped tables === TrackedEvent: {"_count":NNN,"_min":{"timestamp":"..."},"_max":{"timestamp":"..."}} TrackedSession: {...} === Distinct event-type counts === TrackedEvent types (top 30): [{"eventType":"page_view","_count":N},...] TrackedEvent distinct type count: NN SiteConversion types (top 30): [{"conversionType":"form_submit","_count":N},...] SiteConversion distinct type count: NN SiteEvent types (top 20): [{"eventType":"outbound_click","_count":N},...] SiteEvent distinct type count: NN === 30-day windowed counts === SiteConversion (30d): NNN TrackedEvent (30d): NNN TrackedEvent session_start (30d): NNN TrackedEvent page_view (30d): NNN TrackedSession (30d): NNN GscPage (30d) aggregate: {"_sum":{"clicks":N,"impressions":N},"_avg":{"position":N,"ctr":N}} AiUsageLog (30d): {"_count":N,"_sum":{"estimatedCost":N,"inputTokens":N,"outputTokens":N}} === Audit complete === ``` ## Why these tables Every metric surfaced on the app dashboard, admin dashboard, and Site Tag Analytics pages ultimately reads from one of these tables. Grounding the audit here lets us confirm: - Whether a "528 conversions" UI display actually matches `SELECT COUNT(*) FROM "SiteConversion" WHERE brandId=... AND timestamp>=<30d>` on the live DB. - Whether `TrackedEvent.session_start` and `TrackedSession` diverge (the root cause of the 16,382 vs 16,447 session drift the audit already flagged in code). - Which `eventType` values actually populate the DB — required for deciding which buckets each funnel stage rolls up. ## Next phases Once the script runs and populates this file, the numbers printed here become the "truth" column against which every API response and UI card is compared. Phases 2+ replace ad-hoc per-surface queries with shared utilities that match these ground-truth numbers. --- ## Sessions Every call site in `src/` that queries or displays a session count. Format: `path:line` — source table — filter — dedup status — scope. ### TrackedSession (deduped — the authoritative unique-visitor count) - `src/app/api/admin/platform-metrics/route.ts:203` — `prisma.trackedSession.findMany({ where: sessionWhere(start, end), select: { startedAt: true }, take: 100_000 })` — **TrackedSession** — range + trackedSiteId — deduped by construction — single brand OR all brands. - `src/app/api/admin/platform-metrics/route.ts:208` — `prisma.trackedSession.count({ where: sessionWhere(prevStart, prevEnd) })` — **TrackedSession** — previous range — deduped — same scope as above (drives the trend %). - `src/app/api/admin/platform-metrics/route.ts:389` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], where: { startedAt: { gte: start, lt: end } }, _count: true, orderBy: { _count: { trackedSiteId: "desc" } }, take: 20 })` — **TrackedSession** — window only — deduped — cross-brand Top Brands list. - `src/app/api/admin/platform-metrics/route.ts:628` — `prisma.trackedSession.count({ where: { ...trackedSiteIdFilter, startedAt: { gte: compStart, lte: compEnd } } })` — **TrackedSession** — overlap window — deduped — used for Site Tag vs GA4 reconciliation. - `src/app/api/admin/sessions/route.ts:148` — `prisma.trackedSession.count({ where: sessionWhere })` — **TrackedSession** — brand + optional date range — deduped — powers the admin Session Explorer list. - `src/app/api/admin/brands/[brandId]/route.ts:59` — `prisma.trackedSession.count({ where: { trackedSiteId } })` — **TrackedSession** — no window — deduped — all-time count on the admin brand detail page. - `src/app/api/admin/analytics/data-moat/route.ts:48` — `prisma.trackedSession.count()` — **TrackedSession** — no filter — all-time platform total. - `src/app/api/admin/analytics/data-moat/route.ts:62` — `prisma.trackedSession.count({ where: { startedAt: { gte: sevenDaysAgo } } })` — **TrackedSession** — 7d — for "growth per day" rate. - `src/app/api/admin/analytics/data-moat/route.ts:152` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], _count: true })` — **TrackedSession** — all-time — powers the per-brand data-moat leaderboard. - `src/app/api/admin/analytics/funnel/route.ts:133` — `prisma.trackedSession.findMany({ where: sessionWhere, select: { id, sessionId, startedAt, pageCount } })` — **TrackedSession** — per-brand window — drives the 5-stage funnel denominator. - `src/app/api/admin/analytics/growth/route.ts:71` — `prisma.trackedSession.findMany({ where: { startedAt: { gte: start } }, select: { startedAt: true } })` — **TrackedSession** — monthly rollup — platform growth chart. - `src/app/api/admin/export/platform-metrics/route.ts:55` — `prisma.trackedSession.count()` — **TrackedSession** — all-time — export row "totals.sessions". - `src/app/api/admin/export/growth-data/route.ts:58` — `prisma.trackedSession.count({ where: { startedAt: { gte: m.start, lt: m.end } } })` — **TrackedSession** — per-month — export growth csv. - `src/app/api/admin/export/brand-data/route.ts:47` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], where: { trackedSiteId: { in: trackedSiteIds } }, _count: true })` — **TrackedSession** — per-brand — brand export csv. - `src/app/api/admin/system-health/route.ts:45` — `prisma.trackedSession.count()` — **TrackedSession** — all-time — system-health volume row. - `src/app/api/content-audit/page-detail/route.ts:158` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: since } } })` — **TrackedSession** — 30d — per-page conversion-rate denominator. - `src/app/api/content-audit/page-detail/route.ts:161` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: prevSince, lt: since } } })` — **TrackedSession** — previous 30d — delta. - `src/app/api/content-briefs/insights/route.ts:267` — `prisma.trackedSession.groupBy({ by: ["country"], where: { trackedSiteId, country: { not: null } }, _count: true, orderBy: { _count: { country: "desc" } }, take: 5 })` — **TrackedSession** — all-time — top visitor countries (relabelled `sessions: g._count`). - `src/app/api/content-briefs/brand-profile/route.ts:147` — same as above — duplicated in brand-profile builder. - `src/app/api/cron/signals-site-tag-ai/route.ts:174` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: twentyFourHoursAgo } } })` — **TrackedSession** — 24h — signal-detection cron. - `src/app/api/site-tag/confidence/route.ts:220` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: effectiveFrom } } })` — **TrackedSession** — effective-overlap window — powers the Data Confidence "Session match rate" factor. - `src/app/api/site-tag/route.ts:94` — `prisma.trackedSession.count({ where: { trackedSiteId } })` — **TrackedSession** — all-time — snippet status card. - `src/lib/services/site-tag-analytics.ts:523` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: from } } })` — **TrackedSession** — range — `getTrafficSummary()`, the Overview tab's Sessions KPI source. - `src/lib/services/site-tag-analytics.ts:661` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: from } } })` — **TrackedSession** — range — `getConversionSummary()`, the Conversion Rate denominator. - `src/lib/services/brand-radar.ts:113` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: d30 } } })` — **TrackedSession** — 30d — brand radar KPI. - `src/lib/services/brand-radar.ts:116` — `prisma.trackedSession.aggregate({ ... _avg: { pageCount } ... })` — **TrackedSession** — pages-per-session aggregate. - `src/lib/services/brand-radar.ts:396` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: d7 } } })` — **TrackedSession** — 7d — brand radar delta. ### TrackedEvent.session_start (RAW event rows — NOT deduped) - `src/app/api/ai-strategist/chat/route.ts:453` — inside the Site Tag metrics block, `session_start` appears in the `EVENT_TYPES` list that's fed to `trackedEvent.groupBy({ by: ["eventType"] })` — **TrackedEvent** — 28d — raw counts powering the `` context injected into the Strategist prompt. - `src/app/api/ai-strategist/chat/route.ts:490` — `const sessions = counts["session_start"] ?? 0` — derives the session count from the groupBy bucket — **TrackedEvent** — 28d — raw. - `src/app/api/ai-strategist/chat/route.ts:682` — `where: { trackedSiteId, eventType: "session_start", timestamp: { gte: start } }` — inside `buildDailyConversionContext()` for the daily conversion block — **TrackedEvent** — 28d — raw. - `src/app/api/ai-strategist/chat/route.ts:1032` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since } } })` — **TrackedEvent** — 28d — capture-rate comparison against GA4 in `buildTrackingHealth()`. - `src/lib/services/conversion-metrics.ts:145` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since, lte: now } } })` — **TrackedEvent** — param-driven — raw — the shared `getConversionMetrics()` helper's session denominator. - `src/lib/services/signal-detection.ts:380` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: weekAgo } } })` — **TrackedEvent** — 7d — raw. - `src/lib/services/signal-detection.ts:383` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: twoWeeksAgo, lt: weekAgo } } })` — **TrackedEvent** — previous 7d — raw. - `src/lib/services/signal-detection.ts:387` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: twoDaysAgo } } })` — **TrackedEvent** — 48h — raw. - `src/lib/services/email-report-generator.ts:116` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since } } })` — **TrackedEvent** — per report window — raw. ### GA4 sessions (external API / MetricSnapshot — not Site Tag) - `src/app/api/dashboard/route.ts:166, 276, 304, 339, 386` — `fetchGa4` + `fetchGa4StaleFallback` — GA4 Data API live call + `prisma.metricSnapshot.aggregate({ ..., _sum: { sessions } })` fallback — **external + MetricSnapshot** — dashboard GA4 card. - `src/app/api/dashboard/route.ts:932-1031` — merges live vs cached GA4 sessions into the dashboard response — **mixed source**. - `src/app/api/ai-strategist/chat/route.ts:866` — `prisma.metricSnapshot.aggregate({ where: { brandId, source: "ga4", date: { gte: since } }, _sum: { sessions: true } })` — **MetricSnapshot** — 28d — used by `buildTrackingHealth()` to derive a GA4 session count for the capture-rate comparison. ### UI-layer displays (no direct query — rebuild from prop shape) - `src/app/site-tag-analytics/overview-tab.tsx:192` — `const tagSessions = traffic?.sessions ?? 0` — reads the `traffic.sessions` field of the `/api/site-tag/analytics` response (backed by `getTrafficSummary`, TrackedSession). - `src/app/site-tag-analytics/funnel-tab.tsx:70` — `const sessions = traffic.sessions` — same prop chain. - `src/app/admin/page.tsx` + `src/app/admin/analytics/data-moat/page.tsx:123` — render `trackedSessions` volume card from `/api/admin/analytics/data-moat` (TrackedSession count). - `src/app/admin/sessions/page.tsx:317` — renders count from `/api/admin/sessions` (TrackedSession). ### Drift analysis **Core split.** Two tables answer "how many sessions did this brand have?": 1. **TrackedSession** — one row per browser session, keyed by the `sessionId` cookie set on the first `session_start`. The `lastSeenAt` column is touched on every subsequent event, but the row itself is unique per session. 2. **TrackedEvent.session_start** — a TrackedEvent row is written every time `t.js` emits a `session_start` event (typically once per 30-minute idle-timeout window). If a user returns after the 30-minute timeout, `t.js` re-emits `session_start` and a NEW TrackedSession row IS created too, so the two counts normally track closely. Divergence sources: - **Clock-skew / race writes.** Ingest writes TrackedEvent first, then conditionally upserts TrackedSession (see `recordEvent` in `src/lib/services/site-tag.ts`). If the TrackedSession upsert fails silently, TrackedEvent has a `session_start` row without a matching TrackedSession row → TrackedEvent count > TrackedSession count. - **Historical data without sessionId.** Older TrackedEvent rows predate the `sessionId` column — those `session_start` rows get counted in the event query but never had a TrackedSession row. - **TrackedSession backfill gaps.** The `TrackedSession.startedAt` column defaults to `now()` on insert but the TrackedEvent `timestamp` is set from the client payload. On a long upload (sendBeacon queued during an outage), the TrackedEvent lands in the prior day but the TrackedSession lands in the recovery day — they don't align across window edges. Net: for a typical brand TrackedSession is the smaller / truer count and TrackedEvent `session_start` overcounts slightly. **The one surface that drifts today.** Every admin + app surface uses **TrackedSession** for the top-line Session count (overview, admin dashboard, content-audit, brand-radar, confidence). The exception is **`src/lib/services/conversion-metrics.ts:145`** — the shared `getConversionMetrics()` helper counts `TrackedEvent.session_start` instead of `TrackedSession`. Any surface calling the helper for its Conversion Rate denominator will report a slightly inflated session count + slightly deflated rate vs surfaces that call `getConversionSummary()` / `getTrafficSummary()` directly. Action item for Phase 4: swap `getConversionMetrics` over to `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: since } } })` so every surface agrees on the denominator. **Report-only sites.** `ai-strategist/chat` + `signal-detection` + `email-report-generator` also use `TrackedEvent.session_start`. These are narrative contexts (AI prompt, signal digest, weekly email) where the ±3% noise doesn't change the story, but they should migrate for consistency once the shared helper lands. **GA4 vs Site Tag.** Separately, the dashboard's GA4 Sessions card reads from the GA4 Data API (or MetricSnapshot fallback). That number is **not** comparable to the Site Tag session count — it's a different measurement system (cookies + Google's sampling) and a different population (no ad-blocker coverage). The Data Confidence factor uses the **effective overlap window** (install date → now) to compare apples-to-apples; every other surface must not compute a %-difference between the two. **Range boundary.** Admin uses `prevStart, prevEnd` for comparison; `/api/site-tag/analytics` uses `rangeStart()`; data-moat hardcodes `sevenDaysAgo` / `30 days`. These boundaries are all correct for their use but produce different numbers for the "same" 30-day view — labels must make the window explicit (handled in Phase 6). --- ## Shared Utility Migration Status **Completed:** 2026-04-17 All user-visible brand-scoped metric queries now route through `src/lib/services/platform-metrics.ts`. The remaining inline queries are categorised below — every one has been reviewed and has a documented reason for staying inline. ### Migrated to shared helpers (brand-scoped KPIs) | Surface | Metric | Shared helper | |---|---|---| | site-tag-analytics getTrafficSummary | Sessions, Page Views | `getSessionCount`, `getPageViewCount` | | site-tag-analytics getConversionSummary | Session denominator | `getSessionCount` | | site-tag-analytics detectTrackedSignals | Phone calls | `getPhoneMetrics` | | site-tag/analytics/ux route | Sessions, UX signals, Form submits, Phone, Bookings, CWV | `getSessionCount`, `getUXSignals`, `getConversionMetrics` | | site-tag/analytics/rum route | CWV | P75 + outlier filter (aligned with `getCoreWebVitals`) | | site-tag/confidence route | Sessions, Install date | `getSessionCount`, `getSiteTagInstalledAt` | | admin/platform-metrics route | Form submits, Bookings, Phone, Comparison sessions | `dedupedConversionMetric(formSubmitWhere/bookingWhere/phoneWhere)`, `getSessionCount` | | admin/conversion-reconciliation | Form starts | `getFormMetrics` | | ai-strategist/chat buildSiteTagContext | Sessions | `getSessionCount` | | ai-strategist/chat buildTrackingHealth | Sessions, GSC clicks | `getSessionCount`, `getGSCMetrics` | | dashboard route | GSC DB fallbacks (×4) | `getGSCMetrics` | | brand-radar service | Sessions (30d + 7d) | `getSessionCount` | | email-report-generator service | Sessions, Page Views | `getSessionCount`, `getPageViewCount` | | analysis-data-package service | Sessions | `getSessionCount` | ### Remaining inline — cross-brand admin (no brandId) These are platform-wide aggregations that don't take a brandId — the shared helpers are brand-scoped by design. - `admin/system-health` — table-volume counts (all brands) - `admin/analytics/data-moat` — platform-wide totals + per-brand groupBys - `admin/analytics/growth` — monthly growth timeseries - `admin/export/growth-data` — monthly CSV export - `admin/export/brand-data` — cross-brand groupBy - `admin/cost-center` — platform-wide event + session + conversion totals - `cron/platform-snapshot` — daily cron snapshot ### Remaining inline — domain-specific (not standard KPIs) - `admin/sessions/route` — session list pagination count - `admin/brands/[brandId]` — all-time counts for brand detail card - `admin/conversion-reconciliation` — per-canonical-type SiteConversion groupBy - `admin/fix-attribution` — admin debug tool - `content-audit/page-detail` — per-page session/event/conversion counts - `site-tag/route` — tag status + snippet config - `site-tag/analytics/route` — lifetime event count for Data Quality - `site-tag/analytics/pulse` — today's event count for live pulse - `ai-strategist/chat` — SiteConversion total + groupBy for AI context - `paid-media/*` — domain-specific attribution queries - `cron/signals-site-tag-ai` — per-brand signal detection ### Remaining inline — service files - `brand-radar` — raw event volume + SiteConversion count (total metrics) - `email-report-generator` — form_start count (intent signal) - `billing` — TrackedEvent count for plan usage metering - `benchmark-engine` — cross-brand conversion count - `industry-intel` — cross-brand conversion count - `site-tag-status` — tag health check - `notification-service` — current vs previous conversion counts for alerts - `signal-detection` — week-over-week conversion + session comparison ### SiteEvent safety audit Every SiteEvent query falls into one of these categories: - **Filtered by eventType** — signals, errors, UX, heatmap, outbound (safe) - **Filtered by NOT NOISE_SITE_EVENT_TYPES** — system-health, brands, export, snapshot, overview (safe) - **Filtered by ACTIONABLE_TYPES** — admin/signals (safe) - **Session/ID bounded** — compliance export/delete (safe) - **Write operations** — competitor-crawl, industry-intel creates (safe) - **Retention purge** — data-retention findMany + deleteMany (intentional) No unfiltered-read SiteEvent queries remain.