21 KiB
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
# 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'sTrackedSite.idonce, then queries those two tables bytrackedSiteId. - Type breakdowns — top 30
TrackedEvent.eventTypebuckets, top 30SiteConversion.conversionTypebuckets, top 20SiteEvent.eventTypebuckets. - 30-day windowed counts —
SiteConversion,TrackedEvent(total +session_start+page_view),TrackedSession,GscPageaggregate (clicks / impressions / position / ctr),AiUsageLogaggregate (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
=== 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_startandTrackedSessiondiverge (the root cause of the 16,382 vs 16,447 session drift the audit already flagged in code). - Which
eventTypevalues 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 (relabelledsessions: 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_startappears in theEVENT_TYPESlist that's fed totrackedEvent.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 } }— insidebuildDailyConversionContext()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 inbuildTrackingHealth().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 sharedgetConversionMetrics()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 bybuildTrackingHealth()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 thetraffic.sessionsfield of the/api/site-tag/analyticsresponse (backed bygetTrafficSummary, 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— rendertrackedSessionsvolume 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?":
-
TrackedSession — one row per browser session, keyed by the
sessionIdcookie set on the firstsession_start. ThelastSeenAtcolumn is touched on every subsequent event, but the row itself is unique per session. -
TrackedEvent.session_start — a TrackedEvent row is written every time
t.jsemits asession_startevent (typically once per 30-minute idle-timeout window). If a user returns after the 30-minute timeout,t.jsre-emitssession_startand 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
recordEventinsrc/lib/services/site-tag.ts). If the TrackedSession upsert fails silently, TrackedEvent has asession_startrow without a matching TrackedSession row → TrackedEvent count > TrackedSession count. - Historical data without sessionId. Older TrackedEvent rows predate the
sessionIdcolumn — thosesession_startrows get counted in the event query but never had a TrackedSession row. - TrackedSession backfill gaps. The
TrackedSession.startedAtcolumn defaults tonow()on insert but the TrackedEventtimestampis 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_startovercounts slightly. - Clock-skew / race writes. Ingest writes TrackedEvent first, then conditionally upserts TrackedSession (see
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 groupBysadmin/analytics/growth— monthly growth timeseriesadmin/export/growth-data— monthly CSV exportadmin/export/brand-data— cross-brand groupByadmin/cost-center— platform-wide event + session + conversion totalscron/platform-snapshot— daily cron snapshot
Remaining inline — domain-specific (not standard KPIs)
admin/sessions/route— session list pagination countadmin/brands/[brandId]— all-time counts for brand detail cardadmin/conversion-reconciliation— per-canonical-type SiteConversion groupByadmin/fix-attribution— admin debug toolcontent-audit/page-detail— per-page session/event/conversion countssite-tag/route— tag status + snippet configsite-tag/analytics/route— lifetime event count for Data Qualitysite-tag/analytics/pulse— today's event count for live pulseai-strategist/chat— SiteConversion total + groupBy for AI contextpaid-media/*— domain-specific attribution queriescron/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 meteringbenchmark-engine— cross-brand conversion countindustry-intel— cross-brand conversion countsite-tag-status— tag health checknotification-service— current vs previous conversion counts for alertssignal-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.