feat(analytics): restore Google Ads gtag, Plausible, and a GA4-ready loader - #171
Conversation
…loader
Analytics are restored for launch per David (2026-09-01), superseding spec
criterion 8's zero-telemetry decision for analytics only.
- app/index.html carries the exact legacy Google Ads tag (gtag.js loader +
dataLayer bootstrap + gtag('js') + gtag('config', 'AW-831825021')) and the
Plausible cloud script (data-domain=csvjson.com) in the head; the build and
prerender carry both into dist/index.html.
- app/src/analytics adds the GA4 config to the same single gtag.js load only
when VITE_GA4_MEASUREMENT_ID is set at build time; unset ships Ads-only.
A G- format guard means a dead UA- ID can never be configured.
- Pageview plumbing: one pageview on app mount (main.tsx) and one on legacy
/:tool/:id hydration (App.tsx), to gtag and Plausible.
- CI: gtag removed from the criterion-8 remnant ban (still banned: segment,
linkedin, carbonads, chikita, flatfile, typekit, putObject); verify-seo.sh
now asserts the Ads gtag and Plausible ship and the dead UA- ID never does.
- FAQ privacy copy updated: conversion claims unchanged, analytics described
as privacy-respecting and blind to user data.
- docs/verification-report.md documents the criterion 8 amendment.
There was a problem hiding this comment.
Obvious Code Review
Verdict: COMMENT
Note: this PR was merged before the review completed — the findings below describe code now live on master. The High finding in particular warrants a fast follow-up: Plausible is recording ~2 pageviews per visit on production.
- Blocker: 0
- High: 1 — Plausible double-counts every visit (script.js auto-tracks a pageview on load;
trackPageview()inmain.tsxadds a second identical event on every app mount; permalink visits get a third from theApp.tsxhydration effect). GA4 got the correct treatment (send_page_view: false) — the same suppression was not applied to Plausible. Every Plausible metric will read ~2x from launch day. - Medium: 2 — (1) FAQ privacy copy lumps the Google Ads gtag into "privacy-respecting: they count only that a visit happened" — that describes Plausible, not a standard Google measurement tag that sends page URL/referrer/client ID and typically sets
_gcl_*first-party cookies; "never see your data" holds, "count only that a visit happened" does not. (2) Droppinggtagfromremnant_rein ci.yml removed the only src-level tripwire for the deadUA-46942708-1ID — the never-ship invariant is now enforced only ondist/index.html, whileapp/srcand JS bundles are unguarded. One-token fix: addUA-46942708toremnant_re. - Suggestion: 1 (body-only) — the permalink hydration pageview reuses the same event name and
page_pathas the mount pageview, so the "a /:tool/:id link was actually used" signal it is meant to mark is not measurable as a distinct metric; if it matters, send a distinct event (e.g.gtag("event", "permalink_view")/plausible("Permalink View")) at the hydration site instead of a duplicate pageview.
The restoration itself is well-executed: verbatim legacy Ads tag, single-loader design, clean GA4 env-var guard that structurally excludes UA- IDs, and decision provenance documented at every touch point. The gap is that the two analytics surfaces got different duplicate-pageview treatment — GA4's was anticipated, Plausible's was not.
| // to the gtag.js load bootstrapped in index.html, then one pageview per app | ||
| // load. Legacy /:tool/:id loads fire another on hydration (App.tsx). | ||
| configureAnalytics(); | ||
| trackPageview(); |
There was a problem hiding this comment.
🟠 High (reliability) — Plausible double-counts every visit: auto pageview on script load plus this manual call.
trackPageview() here calls window.plausible?.("pageview") on every app load, but Plausible's standard script.js (loaded defer in the app/index.html head) already sends a pageview automatically when it executes. Deferred head scripts run before the body module bundle (same in-order queue), so window.plausible is always defined by the time the bundle runs — the manual call reliably lands as a second, identical pageview on every visit. Permalink visits get a third from the App.tsx hydration effect.
GA4 got the correct treatment — gtag("config", ..., { send_page_view: false }) — but no equivalent suppression exists for Plausible. All Plausible metrics will read ~2x from launch day, permanently once collected. The unit test stubs window.plausible, so it cannot catch this interaction.
Fix: Plausible already covers the initial load; only call it when the URL changes. Split the surfaces — e.g. trackPageview({ plausible: false }) at mount, full trackPageview() only in the hydration effect. Verify in the Plausible dashboard that one fresh visit shows 1 pageview, not 2.
There was a problem hiding this comment.
Fixed in #172 (fix/analytics-followup): Plausible now runs in manual mode — the head script is script.manual.js, so it fires nothing automatically, and the apps trackPageview() at mount is its single pageview source. We went with manual mode rather than the split suggested here because it keeps the invariant crisp ("the app owns the pageview, exactly once per mount") and keeps gtag and Plausible symmetric: the hydration effect now fires a distinct gtag(event,permalink_view) + plausible(Permalink View) instead of a duplicate pageview, so permalink visits carry one pageview + one event. verify-seo.sh now fails the build if auto-tracking script.js ever ships, and unit tests pin manual mode in the built dist.
| question: "Does my data ever leave the browser?", | ||
| answer: | ||
| "No. The conversion runs entirely in your browser — nothing is uploaded, nothing is stored, nothing is logged. Files you open are read locally in the page, and there is no account, server processing, or telemetry anywhere in the flow.", | ||
| "No. The conversion runs entirely in your browser — nothing is uploaded, nothing is stored, nothing is logged. Files you open are read locally in the page, there is no account and no server processing, and the site's analytics are privacy-respecting: they count only that a visit happened and never see your data.", |
There was a problem hiding this comment.
🟡 Medium (security) — Privacy copy lumps the Google Ads tag into "privacy-respecting: they count only that a visit happened."
That sentence accurately describes Plausible (cookieless, no cross-site identifiers), but it covers the restored Google Ads gtag too — and AW-831825021 is a standard Google measurement tag: gtag.js transmits the page URL, referrer, and a client identifier to googletagmanager.com, and with conversion linking (default for Ads conversion tags) sets first-party cookies (_gcl_au/_gcl_aw). "Never see your data" holds — conversion data stays in the browser — but "count only that a visit happened" does not describe the Ads tag. This copy ships in the prerendered FAQ page that crawlers index.
Fix: Distinguish the surfaces, e.g. "…no server processing. Visit analytics never see your data: Plausible counts visits cookieless; a standard Google Ads conversion tag measures ad performance. Your files and converted data never leave the browser." The same claim appears in app/index.html's inline comment and the criterion-8 section of docs/verification-report.md.
There was a problem hiding this comment.
Fixed in #172. The FAQ now reads: "The sites analytics never see your data: Plausible counts visits cookieless, and the Google tag is a standard conversion-measurement tag that uses the page URL and referrer to measure ad performance. Your files and converted output never leave the browser." The same claim was also corrected in the app/index.html comment and the criterion-8 record in docs/verification-report.md.
| # zero-tolerance gate, and a bare non-empty check would false- | ||
| # positive on lines whose only hit was the sanctioned URL. | ||
| remnant_re='segment|gtag|linkedin|carbonads|chikita|flatfile|typekit|putObject' | ||
| remnant_re='segment|linkedin|carbonads|chikita|flatfile|typekit|putObject' # gtag removed 2026-09-01: sanctioned analytics tag |
There was a problem hiding this comment.
🟡 Medium (reliability) — Dropping gtag from remnant_re removed the only src-level tripwire for the dead UA-46942708-1 ID.
On master, the gtag ban incidentally caught any reintroduction of the dead Universal Analytics ID — a gtag("config", "UA-46942708-1") anywhere in the scanned targets (app/src, app/index.html, app/public, app/dist, shim files) failed the gate. After this change, the never-ship invariant (stated in app/index.html, config.ts, and the verification report) is enforced only on the built dist/index.html (verify-seo.sh grep + the vitest assertion) — a UA- literal hardcoded into app/src (bundled into JS, never in index.html) passes every gate. The runtime isGa4Configured() guard covers only the env-var GA4 path, not a direct hardcoded call.
Fix: Add UA-46942708 to remnant_re — the literal is never legitimate anywhere in the shipped tree. One-token change restores the invariant at pre-PR coverage strength.
There was a problem hiding this comment.
Fixed in #172: UA-46942708 is re-added to remnant_re, so the never-ship invariant is back at source-level strength (anywhere in the scanned tree, not just built HTML). To keep the gate false-positive-free, the verbatim literal was removed from the config.ts comment and is assembled from fragments inside the test that asserts the negative (with comments explaining why).
|
All three findings are fixed in #172 (fix/analytics-followup, targeting master): Plausible switched to manual mode with exactly one pageview per mount and a distinct permalink_view event on hydration (the High finding), honest per-tag privacy copy in the FAQ, index.html comment, and verification report (Medium 1), and the dead UA-46942708 ID re-added to the CI remnant ban with the gate kept false-positive-free (Medium 2). CI was pending on #172 as of the last snapshot; monitoring continues there. |
…st copy (#172) Follow-up to the merged analytics restore (#171), from Obvious code review. - HIGH: Plausible auto-tracked a pageview on load while the app fired its own on mount (plus a third on permalink hydration). Switch to manual mode (script.manual.js): Plausible fires nothing on its own; the app sends exactly one pageview per mount, and the /:tool/:id hydration path now fires a distinct gtag('event','permalink_view') + window.plausible('Permalink View') instead of a duplicate pageview. verify-seo.sh fails if the auto-tracking script.js ever ships. - MEDIUM: FAQ privacy copy now characterizes each tag honestly — Plausible counts visits cookieless; the Google tag is a standard conversion- measurement tag using page URL and referrer. "Never see your data" kept (still true for both). - MEDIUM: dead UA-46942708 re-added to the CI remnant ban so the never-ship invariant is enforced at source level, not only in built HTML; config.ts and test literals rephrased/assembled so the gate stays false-positive-free. - GA4 send_page_view:false behavior (already correct) unchanged. Co-authored-by: Obvious <obvious@obvious.ai>
Why
CSVJSON launched with a zero-telemetry posture (spec criterion 8), but launch needs its analytics back: David restored Google Ads conversion tracking and asked for Plausible on top (2026-09-01 — recorded as a scope change to criterion 8 in
docs/verification-report.md). The legacy site's exact Ads tag (AW-831825021) was recovered from git history (pre-rebuild masterabda3770,application/views/page.php); the old Universal Analytics IDUA-46942708-1is dead (July 2023 sunset) and must never ship.What
gtag.js?id=AW-831825021+ dataLayer bootstrap +gtag('js', new Date())+gtag('config', 'AW-831825021').<script defer data-domain="csvjson.com" src="https://plausible.io/js/script.js"></script>.app/src/analytics/): a build-timeVITE_GA4_MEASUREMENT_IDaddsgtag('config', <id>)to the same single gtag.js load; unset ships Ads-only with no GA4 call. The config goes through aG-format guard, so a staleUA-ID can never be configured. GA4's implicit pageview is disabled because the SPA fires pageviews explicitly (no double counting).page_view+plausible('pageview')) and one on legacy/:tool/:idhydration — conversion actions need no frontend code (David configures them in the Ads console).gtagremoved from the remnant ban;segment|linkedin|carbonads|chikita|flatfile|typekit|putObjectstay banned.verify-seo.shnow positively asserts the Ads loader/config and Plausible tag ship in the prerendered HTML, and fails ifUA-46942708-1ever reappears. Unit tests assert the builtdist/index.htmland the GA4 guard (set/unset/malformed).docs/verification-report.md.Tradeoff note: the Ads config is duplicated as a literal in
index.htmland asGOOGLE_ADS_IDinconfig.ts(HTML templates can't import TS); a unit test pins both to the same literal so they can't drift.How to Review
app/index.html— the tags (head) and the shipped comment.app/src/analytics/— config guard + helpers + tests (incl. built-dist assertions)..github/workflows/ci.yml,.github/scripts/verify-seo.sh— gate changes.VITE_GA4_MEASUREMENT_IDat build time when ready).Verification
verify-seo.shpassing locally including the new analytics assertions and the UA- negative check.🔗 Obvious Project · 🧵 Obvious Thread