# 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 <message>` 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 `<site-tag>` 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.
