Skip to content

feat(analytics): replace Plausible with Google Analytics behind a consent bar - #36

Merged
datlechin merged 1 commit into
mainfrom
feat/google-analytics
Sep 23, 2026
Merged

datlechin merged 1 commit into
mainfrom
feat/google-analytics

Conversation

@datlechin

Copy link
Copy Markdown
Member

Replaces self-hosted Plausible with Google Analytics 4 (G-FG4YV1QXYN) on the marketing site. The account portal makes the same switch in TableProApp/license#85, and the two must ship together.

Consent

GA4 sets _ga and _ga_<ID>, which need permission first in the EEA and UK, so the tag loads in Consent Mode. The head script's order is the contract, and AnalyticsConsentTest pins it by position:

  1. gtag('consent', 'default', …) with every storage type denied.
  2. If localStorage['tablepro:analytics-consent'] === 'granted', analytics_storage is granted.
  3. Only then gtag('config'), so a returning reader's first page view carries its cookies.

Until the reader allows it, GA gets one cookieless ping per page and nothing is stored on the device. ad_storage, ad_user_data and ad_personalization are never granted.

The bar

  • Allow and Decline are the same button at the same weight. A filled Allow next to an outlined Decline is the nudge regulators single out.
  • It renders only after hydration, because SSR can't see localStorage. It also renders only when gtag exists, so dev and staging never show it.
  • Bottom-left, clear of Crisp. Crisp's launcher measured 54px square, 14px from the corner on a phone, so on phones the bar leaves 5.5rem on the right.
  • Consent can be withdrawn. "Cookie settings" in the footer and a button in /privacy#cookies reopen the bar. Declining after allowing deletes the _ga cookies. consent.test.ts runs this against a fake browser and checks the exact cookie writes.

The account portal carries a copy of the tag, the bar and consent.ts under the same key, so one answer covers both apps. docs/architecture.md has a new "Analytics and consent" section on that contract.

Events and privacy

download_click, checkout_started and newsletter_signup_clicked keep their names and parameters, now sent through gtag.

/privacy no longer calls analytics cookie-less. It now:

  • names Google Analytics as a processor
  • lists _ga / _ga_<ID> with consent as the lawful basis
  • lists the consent key and the portal's tablepro-session / XSRF-TOKEN cookies
  • covers the US transfer under the Data Privacy Framework and a retention of at most 14 months

Before merging

  • Add GOOGLE_ANALYTICS_ID=G-FG4YV1QXYN to /var/www/tablepro.app/.env. PLAUSIBLE_* can go. The deploy rebuilds the config cache because config/ changed, so the value must already be there.
  • Merge together with TableProApp/license#85.

After merging (GA admin)

  • Register location, platform, tier, cycle and source as event-scoped custom dimensions, or the event parameters won't show in reports.
  • Set data retention to 14 months, to match /privacy.
  • Data redaction: add the query parameters signature, expires, token, email, order as a second safeguard behind the portal's own redaction.
  • Keep enhanced measurement's "page changes based on browser history events" on. It counts Inertia navigations.

Not in this PR

  • Crisp is live and sets its own cookies, which /privacy doesn't list.
  • tablepro:attribution is still written without consent.

Testing

  • php artisan test --compact: 241 passed, including the 9 new tests in AnalyticsConsentTest.
  • npm run test:js: 26 passed, including 7 new in consent.test.ts.
  • tsc --noEmit clean.
  • In headless Chrome, with a fake ID:
    • no cookies before consent
    • _ga and _ga_* after Allow
    • on reload, consent:update sits before config in the dataLayer
    • Cookie settings then Decline removes both cookies
    • no hydration errors

…sent bar

Self-hosted Plausible is gone. Google Analytics 4 takes its place, loaded in
Consent Mode: every storage type starts denied, so the tag sets no cookie and
sends only a cookieless ping per page until the reader clicks Allow. The
advertising signals stay denied for everyone.

The head script's order is the contract, and AnalyticsConsentTest pins it by
position: consent default, then a stored "granted" applied from
localStorage, then config, so a returning reader's first page view carries
its cookies.

ConsentBar asks once, after hydration (SSR cannot see localStorage), and only
when gtag is on the page, so dev and staging never show it. Allow and Decline
are the same variant at the same size. It sits bottom-left and on a phone
leaves 5.5rem for Crisp's launcher, measured on the live site at 54px, 14px
from the corner. "Cookie settings" in the footer and a button in
/privacy#cookies reopen it; declining deletes any _ga cookie already set.

The account portal is a separate app on this origin carrying a copy of the
tag, the bar and consent.ts under the same storage key, so one answer covers
both. docs/architecture.md now has an "Analytics and consent" section on that
contract.

download_click, checkout_started and newsletter_signup_clicked keep their
names and parameters, now sent through gtag.

/privacy no longer calls analytics cookie-less. It names Google Analytics as
a processor, lists _ga and _ga_<ID> with consent as the lawful basis, the
consent key, and the portal's session cookies, and covers the US transfer
and a 14-month retention.

GOOGLE_ANALYTICS_ID replaces PLAUSIBLE_DOMAIN and PLAUSIBLE_SCRIPT_URL and
must be set in the server .env before this deploys.
@datlechin
datlechin merged commit bb01c27 into main Sep 23, 2026
5 checks passed
@datlechin
datlechin deleted the feat/google-analytics branch September 23, 2026 07:52
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.

1 participant