Skip to content

feat(analytics): restore Google Ads gtag, Plausible, and a GA4-ready loader - #171

Merged
dboskovic merged 1 commit into
masterfrom
feat/analytics-restore
Sep 1, 2026
Merged

feat(analytics): restore Google Ads gtag, Plausible, and a GA4-ready loader#171
dboskovic merged 1 commit into
masterfrom
feat/analytics-restore

Conversation

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor

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 master abda3770, application/views/page.php); the old Universal Analytics ID UA-46942708-1 is dead (July 2023 sunset) and must never ship.

What

  • index.html head (ships in dev, build, and the prerendered dist):
    • Google Ads gtag restored verbatim: async gtag.js?id=AW-831825021 + dataLayer bootstrap + gtag('js', new Date()) + gtag('config', 'AW-831825021').
    • Plausible cloud script: <script defer data-domain="csvjson.com" src="https://plausible.io/js/script.js"></script>.
  • GA4-ready loader (app/src/analytics/): a build-time VITE_GA4_MEASUREMENT_ID adds gtag('config', <id>) to the same single gtag.js load; unset ships Ads-only with no GA4 call. The config goes through a G- format guard, so a stale UA- ID can never be configured. GA4's implicit pageview is disabled because the SPA fires pageviews explicitly (no double counting).
  • Pageview plumbing: one pageview on app mount (gtag page_view + plausible('pageview')) and one on legacy /:tool/:id hydration — conversion actions need no frontend code (David configures them in the Ads console).
  • CI (criterion 8, amended): gtag removed from the remnant ban; segment|linkedin|carbonads|chikita|flatfile|typekit|putObject stay banned. verify-seo.sh now positively asserts the Ads loader/config and Plausible tag ship in the prerendered HTML, and fails if UA-46942708-1 ever reappears. Unit tests assert the built dist/index.html and the GA4 guard (set/unset/malformed).
  • FAQ copy: the "no telemetry anywhere" clause is now honest — privacy-respecting analytics that count visits and never see your data; conversion-privacy claims unchanged and still true.
  • Docs: criterion 8 amendment recorded in docs/verification-report.md.

Tradeoff note: the Ads config is duplicated as a literal in index.html and as GOOGLE_ADS_ID in config.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.
  • Intentionally excluded: no conversion event code (Ads console handles actions), no GA4 measurement ID (David creates the new GA4 property; set VITE_GA4_MEASUREMENT_ID at build time when ready).

Verification

  • 107/107 tests, lint + typecheck clean, production build + prerender green, dist committed fresh (dist-freshness gate), verify-seo.sh passing locally including the new analytics assertions and the UA- negative check.

🔗 Obvious Project · 🧵 Obvious Thread

…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.
@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review September 1, 2026 03:59
@dboskovic
dboskovic merged commit 3bafa84 into master Sep 1, 2026
2 of 3 checks passed

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() in main.tsx adds a second identical event on every app mount; permalink visits get a third from the App.tsx hydration 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) Dropping gtag from remnant_re in ci.yml removed the only src-level tripwire for the dead UA-46942708-1 ID — the never-ship invariant is now enforced only on dist/index.html, while app/src and JS bundles are unguarded. One-token fix: add UA-46942708 to remnant_re.
  • Suggestion: 1 (body-only) — the permalink hydration pageview reuses the same event name and page_path as 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.


View full review in Obvious

Comment thread app/src/main.tsx
// 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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src/seo/faqContent.ts
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.",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/workflows/ci.yml
# 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

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.

obvious-autobuild-staging Bot added a commit that referenced this pull request Sep 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants