Gap
The site sells Core Web Vitals. The product does not have them.
/features/web-vitals is live and says, present tense, no hedge:
Real-user LCP, INP, and CLS collected from the same script, shown next to your traffic.
It is also in the nav mega-menu ("Real-user LCP, INP, and CLS beside traffic"), the footer, the homepage, the features index, and use-cases/publishers.
There is no implementation. Grepping LCP|CLS|INP|TTFB|FCP across every .ts/.stx/.sql outside node_modules, storage/, dist/ and config/pantry returns five files, all of them marketing. No column, no endpoint, no collection in public/script.js, no dashboard panel.
Of the eight features the nav advertises, seven are built. This is the eighth, and it is the only one with neither code nor an issue.
Why it matters
This is the one place where the marketing site describes a capability the product lacks, which makes it a trust problem before it is a feature problem. It is also cheap: the reference implementation exists in ts-analytics (src/Analytics.ts:3140-3205 for collection, src/handlers/performance.ts for the read side).
And it is privacy-clean, which is not true of most things we could add. PerformanceObserver reports timings the browser has already computed for its own rendering; no identifier is involved and nothing new is learned about a person. Guardrail #28 does not touch this.
Proposed
Port it, with four deliberate departures from the ts-analytics version rather than a straight copy:
1. Drop FID. ts-analytics collects it. Google retired FID in favour of INP in March 2024, and our own marketing only ever claims LCP/INP/CLS. Collect LCP, CLS, INP, FCP, TTFB.
2. One beacon per page, not five. ts-analytics fires a separate request per metric as each finalizes. Five metrics on every pageview is a 5x increase in ingest volume for a product whose pitch includes being light. LCP, CLS and INP are only final at visibilitychange → hidden anyway, so buffer and send one combined beacon there (sendBeacon, falling back to fetch(keepalive)), with pagehide as the Safari fallback.
3. Store CLS as a real number. ts-analytics multiplies CLS by 1000 on the wire because its beacon rounds every value to an integer, then divides back at read time in two separate places — which was already the subject of a bug (ts-analytics#133). A numeric column and an unrounded value removes the class entirely.
4. Report p75 only above a minimum sample count. The p75 of one sample is not a percentile, it is that one person's phone. This is the same reasoning as the k-anonymity floor in #40, and it should reuse privacy.minSegmentSize rather than invent a second threshold.
Shape
- migration:
web_vitals(site_id, session_id, path, metric, value, timestamp), FK tositeswithON DELETE CASCADE, indexed(site_id, metric, timestamp) app/Models/WebVital.tspublic/script.js: collection, opt-out viadata-vitals="false"alongside the existingdata-respect-dnt="false"/collect: accept the vitals beaconapp/Analytics/vitals.ts: thresholds, good/needs-improvement/poor rating, p75GET /api/sites/{siteId}/vitalsand.../vitals-trends- a dashboard panel beside the pages report, since that adjacency is precisely what the marketing page promises
- retention purge, per-visitor erasure (#5) and full-site data deletion must all cover the new table
Priority: P1 — we are already advertising it.