Skip to content

feat(site): add google analytics 4 to the pages, with a privacy page - #95

Merged
ChelseaKR merged 2 commits into
mainfrom
feat/ga4
Sep 18, 2026
Merged

ChelseaKR merged 2 commits into
mainfrom
feat/ga4

Conversation

@ChelseaKR

@ChelseaKR ChelseaKR commented Sep 18, 2026

Copy link
Copy Markdown
Owner

What this does

Adds Google Analytics 4 to https://chelseakr.github.io/perimeter/, following the owner decision of 2026-09-17: GA4 on every public site, with privacy pages and claims updated to match. The property is G-8PD9WL8LN0. It already has 14-month retention, and Google signals is off in the property.

Where the ID lives

src/perimeter/analytics.py, GA4_MEASUREMENT_ID = "G-8PD9WL8LN0". render.page() puts the loader in every page's <head> and the footer note and opt-out on every page. cli.build_site now also writes privacy.html. Setting the constant to "" removes the loader, the control and every reference to Google. The footer and the privacy page then say the site runs no analytics. A malformed ID fails the build. The committed site/ was rebuilt from data/raw/ with make site, and make site-check confirms it is byte-identical to a fresh build. All five JSON artifacts are unchanged, and none of them carry any analytics.

How it loads

There is one inline script per page:

  • It does nothing unless location.hostname === "chelseakr.github.io" and the path starts with /perimeter/. The file:// pages that both accessibility gates read, local builds and CI therefore never load GA.
  • It returns before creating dataLayer or requesting gtag.js under GPC, under DNT "1"/"yes" (checking navigator, window and msDoNotTrack), or after an opt-out.
  • Consent Mode v2 defaults deny the three ad signals everywhere. analytics_storage is denied through region for the EEA, GB and CH (32 codes), where Google gets cookieless pings, and granted elsewhere.
  • config sets allow_google_signals: false and allow_ad_personalization_signals: false. gtag.js is appended async.
  • The footer has an "Opt out of analytics" / "Opt back in" button. It saves the choice in localStorage under perimeter:analytics-opt-out, a key that names the project because every chelseakr.github.io site shares one origin. It also sets ga-disable-<ID> for the current page.

The footer says "Google Analytics" without the "4" on purpose. tests/test_pages_html.py holds every number in page prose to a count, and a product-name digit in every footer would have needed a blanket exemption for "4".

CSP

No page sets a CSP, and GitHub Pages sends no headers, so no CSP change was needed.

Claims changed

  • New privacy.html, built by the same page() shell (disclaimer, nav, canonical, og tags) and linked from every footer. It covers what GA4 records, the _ga/_ga_… cookies, the EEA/UK/CH cookieless pings, that the ad features are off, Google LLC in the US, 14-month retention, the three ways to opt out, and GitHub as the host.
  • README Observability row: it said "these pages ship no script and this project takes no telemetry; that refusal is a position…". It now says the pages run GA4, which is not configured as a RUM beacon, and that the RUM gap stays open.
  • README Performance row: "Three static pages, no script shipped" now names the async loader.
  • README: a new "Analytics on the pages" section and a Layout row. "Builds the same three pages" and "land on all three pages" were updated to match the four pages.
  • tools/a11y.mjs and tools/a11y_browser/playwright.config.ts: comments that said the pages "ship no script" are updated. No behaviour changed.
  • CHANGELOG entry. The repo description makes no tracking claim, so it is unchanged.

Tests

  • New tests/test_analytics.py, 38 tests. It lifts the inline script out of the committed pages and runs it in Node against a stubbed window/navigator/document/localStorage:
    • The production host and path load exactly one async gtag/js?id=G-8PD9WL8LN0, with consent defaults (full region list), js and config in order.
    • These cases load nothing: localhost, 127.0.0.1, file:, another host, a sibling project's path, the origin root, a /perimeter-fork/ lookalike, GPC, all four DNT forms, and the opt-out flag.
    • These cases still load: a sibling site's opt-out key, GPC false, DNT "0" and blocked storage.
    • The footer button toggles the flag and ga-disable-…, and reports GPC/DNT or blocked storage.
    • Build level: an empty ID gives no script and no Google reference in the page shell or on the privacy page. Malformed IDs raise. Every committed page carries exactly one loader, and no JSON artifact carries the ID.
  • Negative controls: each of the five guards (host, path, GPC, DNT, opt-out) is deleted in turn. The test asserts the guard occurred once before and zero times after, and that the length dropped by exactly the guard's length. It then asserts GA loads in the case that guard exists for. Without Node, the harness skips locally and fails under CI.
  • make verify passes: lock check, ruff lint+format, mypy strict, 1223 tests at 98.83% coverage, pip-audit, html-validate, axe in jsdom (4 pages, 0 violations), npm audit, and the Chromium axe + reflow gate (10 passed), which runs the loader for real on file:// and checks the revealed opt-out button. The determinism gate also passes. make site-check passes against data/raw/.

A second commit replaces the test helper's <script> regex with plain string slicing. The first push's CodeQL run flagged it as py/bad-tag-filter (a tag regex reads as an HTML filter that misses <SCRIPT>). make verify passed again after that change.

Owner step in the property

In the web stream's Enhanced measurement settings, please check which options are on. The privacy page says GA records page views and, by default, scrolling and outbound clicks.

Note: GA's _ga cookie uses path / on the shared chelseakr.github.io origin, so it has the same value on each sibling site's property. That matches the other sites (cookie_path isn't set). The privacy page says the cookies are set on chelseakr.github.io.

Prepared with AI assistance; reviewed before submission.

Owner decision 2026-09-17: GA4 on every public site, with privacy copy
changed to match. src/perimeter/analytics.py holds the measurement ID
and the loader; render.page puts the loader and a footer opt-out on
every page, and the build now also writes privacy.html. An empty ID
removes all of it; the JSON artifacts never carry any.

The loader does nothing unless served from chelseakr.github.io under
/perimeter/ (so the file:// pages both accessibility gates read never
reach Google), under Global Privacy Control or Do Not Track, or after
the footer opt-out (localStorage perimeter:analytics-opt-out). Google
signals and ad personalisation are off; Consent Mode v2 denies the ad
signals everywhere and analytics storage in the EEA, UK and CH.

The README's "these pages ship no script and this project takes no
telemetry" and "no script shipped" now describe the loader.
tests/test_analytics.py executes it in Node and deletes each guard as a
negative control, asserting the deletion landed.
Comment thread tests/test_analytics.py Fixed
CodeQL's py/bad-tag-filter read the <script> regex in the test helper as
an HTML filter that misses upper-case tags. The page is this build's own
output, so plain slicing is exact and gives the scanner nothing to flag.
@ChelseaKR
ChelseaKR merged commit 4d53dec into main Sep 18, 2026
6 checks passed
@ChelseaKR
ChelseaKR deleted the feat/ga4 branch September 18, 2026 06:37
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