From 5859d3a073b7d517cd16385c1919c6e88e5f0e20 Mon Sep 17 00:00:00 2001
From: aliferous3
Date: Wed, 30 Sep 2026 02:13:40 +0400
Subject: [PATCH] feat: GA4 acquisition analytics (gtag.js, env-gated,
host-gated, manual pageviews)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds Google Analytics 4 as the acquisition/traffic-source layer alongside
PostHog (which remains the product-behavior system, untouched).
- src/analytics/ga4Model.mjs: pure validation/sanitization — measurement-ID
format check, devledger.site production gate + localhost debug gate,
canonical pathname normalization, page_location/page_title params, strict
event allowlist (page_view, login_start, login, select_content), and
utm_*→campaign_* field mapping with slug-charset sanitization.
- src/analytics/ga4.ts: typed facade — lazy init only when
VITE_GA_MEASUREMENT_ID is set and the host is devledger.site (localhost
runs debug_mode), send_page_view:false, async gtag.js from
googletagmanager.com only, pathname-deduped manual page_view, never
throws. No User-ID, no identify, no Signals/ads features.
- Wiring: boot init+pageview+popstate in main.tsx; navigate/popstate
pageviews in pages.ts; login_start on the GitHub button and login on
the post-OAuth successful boot — method:"github" only, zero identity.
- CSP: adds www.googletagmanager.com (script-src, img-src) and
www.google-analytics.com + region1.google-analytics.com
(connect-src, img-src) — the minimum for direct gtag.js collection.
- Env: VITE_GA_MEASUREMENT_ID joins the public-env allowlist;
.env.example documents the name with an empty placeholder. The real ID
lives only in Vercel env config.
- Legal: Privacy/Security/Terms disclose GA4 — sanitized pageviews,
campaign fields, login funnel, _ga cookies, disabled features.
- Tests: 24-case suite covering ID hygiene, host gating, sanitization,
event allowlist, UTM rules, wiring, and PostHog separation.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.env.example | 8 +
src/analytics/ga4.ts | 143 ++++++++++++++
src/analytics/ga4Model.mjs | 168 ++++++++++++++++
src/ledger/shared.tsx | 2 +
src/legal/PrivacyPolicyPage.tsx | 8 +-
src/legal/TermsOfServicePage.tsx | 3 +-
src/main.tsx | 9 +
src/pages.ts | 3 +
src/security/SecurityPrivacyPage.tsx | 1 +
test/ga4-analytics.test.mjs | 277 +++++++++++++++++++++++++++
test/supply-chain.test.mjs | 12 +-
vercel.json | 2 +-
12 files changed, 626 insertions(+), 10 deletions(-)
create mode 100644 src/analytics/ga4.ts
create mode 100644 src/analytics/ga4Model.mjs
create mode 100644 test/ga4-analytics.test.mjs
diff --git a/.env.example b/.env.example
index 8b18b52..0d09d25 100644
--- a/.env.example
+++ b/.env.example
@@ -48,3 +48,11 @@ CRON_SECRET=
# IPs, or delivery ids. Dedicated key — do NOT reuse SESSION_SECRET,
# CRON_SECRET, or the webhook secret. Optional: unset → hashes are null.
SECURITY_EVENT_HASH_KEY=
+
+# Public client analytics configuration (optional — silent no-ops unset).
+# VITE_POSTHOG_* power the PostHog product analytics integration;
+# VITE_GA_MEASUREMENT_ID enables GA4 acquisition analytics. Both are
+# public, publishable identifiers — never secrets.
+VITE_POSTHOG_PROJECT_TOKEN=
+VITE_POSTHOG_HOST=
+VITE_GA_MEASUREMENT_ID=
diff --git a/src/analytics/ga4.ts b/src/analytics/ga4.ts
new file mode 100644
index 0000000..a106482
--- /dev/null
+++ b/src/analytics/ga4.ts
@@ -0,0 +1,143 @@
+/* Google Analytics 4 acquisition-analytics facade — the typed, app-facing
+ half of the GA4 integration. All validation/sanitization lives in
+ ga4Model.mjs so it can be unit-tested directly; this module only owns the
+ gtag.js lifecycle and the typed capture entry points.
+
+ Contract (enforced by the model + this file):
+ - page_view, login_start, login and select_content only — nothing else;
+ - send_page_view stays false: every page_view is manual and sanitized
+ (origin + normalized pathname — never location.href, never query/hash);
+ - the only query data ever read is the five utm_* campaign fields;
+ - official telemetry is emitted only on devledger.site (localhost runs in
+ debug mode); a fork on another hostname emits nothing;
+ - no measurement ID, blocked gtag.js, or any failure → silent no-op;
+ - no User-ID, no identify-equivalent, no Google Signals/ads features. */
+
+import {
+ ga4CampaignFields,
+ ga4PagePath,
+ ga4PageViewParams,
+ isGA4DebugHost,
+ isGA4ProductionHost,
+ isValidMeasurementId,
+ validateGA4Event,
+} from './ga4Model.mjs';
+
+export {
+ GA4_EVENT_PROPERTIES,
+ GA4_UTM_TO_CAMPAIGN_FIELD,
+ ga4CampaignFields,
+ ga4PageLocation,
+ ga4PagePath,
+ ga4PageTitle,
+ ga4PageViewParams,
+ isGA4DebugHost,
+ isGA4ProductionHost,
+ isValidMeasurementId,
+ sanitizeCampaignValue,
+ validateGA4Event,
+} from './ga4Model.mjs';
+
+export type GA4EventName = 'login_start' | 'login' | 'select_content';
+
+export interface GA4EventProps {
+ login_start: { method: 'github' };
+ login: { method: 'github' };
+ select_content: { content_type: 'product_cta'; item_id: string };
+}
+
+type Gtag = (...args: unknown[]) => void;
+
+declare global {
+ interface Window {
+ dataLayer?: unknown[];
+ gtag?: Gtag;
+ }
+}
+
+const GTAG_SCRIPT_ORIGIN = 'https://www.googletagmanager.com';
+
+let initStarted = false;
+let enabled = false;
+let lastPagePath: string | null = null;
+
+function measurementId(): string | null {
+ const id = import.meta.env.VITE_GA_MEASUREMENT_ID;
+ return isValidMeasurementId(id) ? id : null;
+}
+
+function gtag(): Gtag | null {
+ return typeof window !== 'undefined' && typeof window.gtag === 'function'
+ ? window.gtag
+ : null;
+}
+
+// Initializes gtag.js once: defines dataLayer/gtag (events queue safely even
+// before the script arrives), configures the stream with automatic
+// page_view measurement OFF, then injects the async tag. Never throws —
+// analytics must never break the app. main.tsx calls this once at boot.
+export function initGoogleAnalytics(): void {
+ if (initStarted || typeof window === 'undefined') return;
+ initStarted = true;
+ const id = measurementId();
+ const host = window.location.host;
+ if (!id || (!isGA4ProductionHost(host) && !isGA4DebugHost(host))) return;
+ try {
+ window.dataLayer = window.dataLayer || [];
+ window.gtag = function gtagShim(...args: unknown[]) {
+ window.dataLayer!.push(args);
+ };
+ window.gtag('js', new Date());
+ window.gtag('config', id, {
+ send_page_view: false,
+ debug_mode: isGA4DebugHost(host),
+ // Ads/identity features stay off — acquisition reporting only.
+ allow_google_signals: false,
+ allow_ad_personalization_signals: false,
+ });
+ const script = document.createElement('script');
+ script.async = true;
+ script.src = `${GTAG_SCRIPT_ORIGIN}/gtag/js?id=${encodeURIComponent(id)}`;
+ document.head.appendChild(script);
+ enabled = true;
+ } catch {
+ enabled = false;
+ }
+}
+
+// The only sanctioned way to emit a GA4 event. Validation runs first; an
+// invalid name or property bag is dropped silently.
+export function captureGoogleEvent(
+ name: E,
+ props: GA4EventProps[E],
+): void {
+ const clean = validateGA4Event(name, props as Record);
+ const fn = gtag();
+ if (!clean || !enabled || !fn) return;
+ try {
+ fn('event', name, clean);
+ } catch {
+ /* analytics is best-effort — never surface to the app */
+ }
+}
+
+// Manual sanitized page_view — the caller never supplies URL data. The
+// pathname is normalized (trailing slashes collapse, query/hash discarded)
+// and deduped so StrictMode remounts and hash/query-only history entries
+// cannot double-count. Recognized utm_* fields are lifted into GA4's
+// documented campaign_* dimensions via gtag('set'); page_location itself
+// stays clean.
+export function captureGooglePageview(): void {
+ const fn = gtag();
+ if (!enabled || !fn || typeof window === 'undefined') return;
+ const pathname = ga4PagePath(window.location.pathname);
+ if (pathname === lastPagePath) return;
+ lastPagePath = pathname;
+ try {
+ const campaign = ga4CampaignFields(window.location.search);
+ if (campaign) fn('set', campaign);
+ fn('event', 'page_view', ga4PageViewParams(window.location, document.title));
+ } catch {
+ /* analytics is best-effort — never surface to the app */
+ }
+}
diff --git a/src/analytics/ga4Model.mjs b/src/analytics/ga4Model.mjs
new file mode 100644
index 0000000..62492c5
--- /dev/null
+++ b/src/analytics/ga4Model.mjs
@@ -0,0 +1,168 @@
+/* Google Analytics 4 acquisition-analytics model — the pure, testable core.
+ The typed facade lives in ga4.ts.
+
+ GA4 is the acquisition/marketing layer only (traffic sources, landing
+ pages, campaigns, login funnel). Product behavior stays in PostHog; the
+ two integrations are deliberately independent — this module shares the
+ PostHog model's philosophy but none of its code.
+
+ Hard rules enforced here, in one place:
+
+ 1. Only the event names in GA4_EVENT_PROPERTIES may be emitted, each with
+ ONLY the properties its schema lists.
+ 2. page_view is never caller-constructed — the facade derives a sanitized
+ page_location itself (origin + normalized pathname; query and fragment
+ are always discarded).
+ 3. The only query parameters ever read are the five documented utm_*
+ campaign fields, mapped to GA4's campaign_* dimensions after strict
+ value sanitization. Arbitrary query params (token, code, state, ...)
+ can never become event or campaign data.
+ 4. Official telemetry is emitted only on the canonical production host —
+ a fork carrying a configured measurement ID to another hostname emits
+ nothing. Localhost runs in GA4 debug mode only.
+ 5. No measurement ID configured → every entry point is a silent no-op. */
+
+// ── Event allowlist ───────────────────────────────────────────────────────
+
+// Keep GA4 tiny: page_view (manual) + the acquisition funnel
+// (CTA → login start → login). Nothing from the PostHog product taxonomy.
+export const GA4_EVENT_PROPERTIES = {
+ // Google recommended `login` event — method only, never any identity.
+ login: { method: ['github'] },
+ // Funnel step: the GitHub OAuth button was activated.
+ login_start: { method: ['github'] },
+ // Google recommended `select_content` — used for the blog/product CTA.
+ // item_id carries a slug-like identifier only (validated below).
+ select_content: { content_type: ['product_cta'], item_id: null },
+};
+
+// Slug-shaped item identifiers (article slug, cta id). No free text.
+const ITEM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,99}$/;
+
+// Returns the event's sanitized property object, or null when the event or
+// any property is outside the allowlist. Exact key-set match.
+export function validateGA4Event(name, props) {
+ const schema = GA4_EVENT_PROPERTIES[name];
+ if (!schema || !props || typeof props !== 'object') return null;
+ const allowed = Object.keys(schema);
+ const keys = Object.keys(props);
+ if (keys.length !== allowed.length || !keys.every((k) => allowed.includes(k))) {
+ return null;
+ }
+ const clean = {};
+ for (const [key, spec] of Object.entries(schema)) {
+ const value = props[key];
+ if (typeof value !== 'string') return null;
+ if (spec === null) {
+ if (!ITEM_ID_PATTERN.test(value)) return null;
+ } else if (!spec.includes(value)) {
+ return null;
+ }
+ clean[key] = value;
+ }
+ return clean;
+}
+
+// ── Host gating ───────────────────────────────────────────────────────────
+
+// Official GA4 telemetry exists only on the canonical public origin. Local
+// dev hosts may initialize for QA but run with debug_mode so they never
+// pollute production reports.
+export function isGA4ProductionHost(host) {
+ return host === 'devledger.site';
+}
+
+export function isGA4DebugHost(host) {
+ return (
+ host === 'localhost' ||
+ host === '127.0.0.1' ||
+ host === '::1' ||
+ host.startsWith('localhost:') ||
+ host.startsWith('127.0.0.1:')
+ );
+}
+
+// A measurement ID must look like one before it is ever used — guards a
+// malformed or secret-bearing value from being injected into the tag URL.
+export function isValidMeasurementId(id) {
+ return typeof id === 'string' && /^G-[A-Z0-9]{4,20}$/.test(id);
+}
+
+// ── Sanitized page identity ───────────────────────────────────────────────
+
+// Canonical page path: strips trailing slashes to the non-slash canonical
+// form, rejects anything that is not a plain path (query/hash/encoding or
+// exotic characters collapse to '/') — same canonical convention the
+// PostHog integration uses, implemented independently.
+export function ga4PagePath(pathname) {
+ if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
+ if (/[?#]/.test(pathname)) return '/';
+ const clean = pathname.replace(/\/+$/, '') || '/';
+ if (!/^\/[A-Za-z0-9\-._~/]*$/.test(clean)) return '/';
+ return clean;
+}
+
+// page_location is rebuilt from origin + sanitized pathname — never
+// location.href, so query strings and fragments cannot survive.
+export function ga4PageLocation(locationLike) {
+ const origin =
+ typeof locationLike?.origin === 'string' ? locationLike.origin : '';
+ return origin + ga4PagePath(locationLike?.pathname);
+}
+
+// Document titles are static editorial strings; sanitized defensively
+// (control characters stripped, capped) before they may ride an event.
+export function ga4PageTitle(title) {
+ if (typeof title !== 'string') return '';
+ return title.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
+}
+
+// The complete manual page_view parameter set.
+export function ga4PageViewParams(locationLike, title) {
+ return {
+ page_location: ga4PageLocation(locationLike),
+ page_title: ga4PageTitle(title),
+ };
+}
+
+// ── Campaign (UTM) extraction ─────────────────────────────────────────────
+
+// The only recognized inbound parameters, mapped to GA4's documented
+// campaign_* fields. Everything else in the query string is ignored.
+export const GA4_UTM_TO_CAMPAIGN_FIELD = {
+ utm_source: 'campaign_source',
+ utm_medium: 'campaign_medium',
+ utm_campaign: 'campaign_name',
+ utm_term: 'campaign_term',
+ utm_content: 'campaign_content',
+};
+
+// Conservative campaign value: slug-ish text only — letters, digits,
+// underscore, hyphen, dot and space. Anything else (URL delimiters,
+// markup, encodings, scheme-like values, control characters) is rejected.
+export function sanitizeCampaignValue(value) {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > 100) return null;
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.\- ]*$/.test(trimmed)) return null;
+ return trimmed;
+}
+
+// Parse the five utm_* parameters out of a query string into GA4 campaign
+// fields. Returns null when nothing valid is present. Unrelated params
+// (token, code, state, ...) are never touched.
+export function ga4CampaignFields(search) {
+ if (typeof search !== 'string' || !search.startsWith('?')) return null;
+ let params;
+ try {
+ params = new URLSearchParams(search);
+ } catch {
+ return null;
+ }
+ const fields = {};
+ for (const [utm, field] of Object.entries(GA4_UTM_TO_CAMPAIGN_FIELD)) {
+ const value = sanitizeCampaignValue(params.get(utm));
+ if (value) fields[field] = value;
+ }
+ return Object.keys(fields).length ? fields : null;
+}
diff --git a/src/ledger/shared.tsx b/src/ledger/shared.tsx
index 3ce3f7c..c0d1c64 100644
--- a/src/ledger/shared.tsx
+++ b/src/ledger/shared.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { GRID, INTENSITY_BG, YEARS, COLS, type GridCell } from './data';
import { captureEvent } from '../analytics/posthog';
+import { captureGoogleEvent } from '../analytics/ga4';
import { markLoginPending } from '../analytics/loginFunnel';
/* ---------- scroll reveal hook ---------- */
@@ -42,6 +43,7 @@ export function GithubButton({ large = false, onLogin, remember = false }: { lar
// pending flag lets the post-callback boot emit login_succeeded once.
markLoginPending();
captureEvent('github_login_started', {});
+ captureGoogleEvent('login_start', { method: 'github' });
window.location.assign(remember ? '/api/auth/login?remember=1' : '/api/auth/login');
};
return (
diff --git a/src/legal/PrivacyPolicyPage.tsx b/src/legal/PrivacyPolicyPage.tsx
index 6427dc8..3bde31a 100644
--- a/src/legal/PrivacyPolicyPage.tsx
+++ b/src/legal/PrivacyPolicyPage.tsx
@@ -34,8 +34,8 @@ export function PrivacyPolicyPage() {
This policy applies to Dev Ledger’s website, GitHub App, account and sync services, dashboard,
- feedback tools, and product analytics. It does not govern GitHub, Vercel, Supabase, PostHog, or any
- other third-party service under their own terms and privacy notices.
+ feedback tools, and product analytics. It does not govern GitHub, Vercel, Supabase, PostHog, Google,
+ or any other third-party service under their own terms and privacy notices.
@@ -48,7 +48,8 @@ export function PrivacyPolicyPage() {
▸ Pull-request metadata: pull-request id and number, author identifiers, state, and created/closed/merged timestamps.
▸ Account operations: server-side session records, sync state, coverage windows, and security-event records.
▸ Feedback: the feedback type, title, optional description, optional verified screenshot, and automatic page/range/build context that you choose to submit.
- ▸ Limited product analytics: selected dashboard range, sync lifecycle category, share-card opens/downloads, feedback type, and delete-data completion.
+ ▸ Limited product analytics: selected dashboard range, sync lifecycle category, share-card opens/downloads, feedback type, delete-data completion, and sanitized page views.
+ ▸ Acquisition analytics: sanitized page views, coarse campaign-attribution fields when present in the URL (utm_source, utm_medium, utm_campaign, utm_term, utm_content), and login funnel steps measured by Google Analytics 4 — no query strings, URL fragments, account identifiers, or repository data are sent.
@@ -109,6 +110,7 @@ export function PrivacyPolicyPage() {
▸ Vercel — application hosting, delivery, operational logs, and cookieless Web Analytics;
▸ Supabase — application database infrastructure;
▸ PostHog — limited product analytics in the EU region, with autocapture, session replay, heatmaps, surveys, console capture, and performance capture disabled in the current configuration.
+ ▸ Google Analytics 4 — acquisition and traffic-source measurement: sanitized page views, campaign attribution fields, and login funnel events. GA4’s own client/session identifier cookies (_ga, _ga_*) are used for this measurement; Google Signals, advertising personalization, remarketing, and User-ID are not enabled. Google processes this data under its own privacy terms.
We may also disclose information when reasonably necessary to comply with law, enforce legal rights, or
diff --git a/src/legal/TermsOfServicePage.tsx b/src/legal/TermsOfServicePage.tsx
index c34f097..77c7c2e 100644
--- a/src/legal/TermsOfServicePage.tsx
+++ b/src/legal/TermsOfServicePage.tsx
@@ -128,7 +128,8 @@ export function TermsOfServicePage() {
- Dev Ledger depends on third-party services including GitHub, Vercel, Supabase, and PostHog. Their services
+ Dev Ledger depends on third-party services including GitHub, Vercel, Supabase, PostHog, and Google
+ Analytics. Their services
may be unavailable, changed, rate-limited, or discontinued independently of Dev Ledger, and your use of
those services is also subject to their own terms and policies.
diff --git a/src/main.tsx b/src/main.tsx
index 1608dc6..1c57667 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -17,6 +17,7 @@ import "@fontsource-variable/newsreader/opsz-italic.css";
import "./index.css";
import App from "./App";
import { initAnalytics, captureEvent, capturePageview } from "./analytics/posthog";
+import { initGoogleAnalytics, captureGoogleEvent, captureGooglePageview } from "./analytics/ga4";
import { consumeLoginPending } from "./analytics/loginFunnel";
import { SecurityPrivacyPage } from "./security/SecurityPrivacyPage";
import { PrivacyPolicyPage } from "./legal/PrivacyPolicyPage";
@@ -73,6 +74,7 @@ function Gate() {
// Auth state itself is untouched — consume only reads a flag.
if (ok && consumeLoginPending()) {
captureEvent("github_login_succeeded", {});
+ captureGoogleEvent("login", { method: "github" });
}
})
.catch(() => live && setAuth("out"));
@@ -182,6 +184,13 @@ void initAnalytics()
})
.catch(() => {});
+// GA4 acquisition analytics — separate system, same discipline: silent
+// no-op without VITE_GA_MEASUREMENT_ID, emits only on devledger.site
+// (localhost debug mode), manual sanitized page_view only.
+initGoogleAnalytics();
+captureGooglePageview();
+window.addEventListener('popstate', () => captureGooglePageview());
+
createRoot(document.getElementById("root")!).render(
diff --git a/src/pages.ts b/src/pages.ts
index c4f938f..81f642e 100644
--- a/src/pages.ts
+++ b/src/pages.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { capturePageview } from './analytics/posthog.ts';
+import { captureGooglePageview } from './analytics/ga4.ts';
/* Canonical three-page registry — the entire authenticated app is exactly
OVERVIEW / ACTIVITY / CODE. Header nav, routing and the right rail all
@@ -37,6 +38,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } {
const onPop = () => {
const next = pageFromPath(window.location.pathname);
capturePageview();
+ captureGooglePageview();
setPage(next);
// Section-rail hash jumps land on their anchor; everything else tops.
const el = window.location.hash
@@ -60,6 +62,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } {
window.scrollTo(0, 0);
setPage(next);
capturePageview();
+ captureGooglePageview();
},
[page],
);
diff --git a/src/security/SecurityPrivacyPage.tsx b/src/security/SecurityPrivacyPage.tsx
index d138f42..db20f59 100644
--- a/src/security/SecurityPrivacyPage.tsx
+++ b/src/security/SecurityPrivacyPage.tsx
@@ -283,6 +283,7 @@ export function SecurityPrivacyPage() {
['RETENTION', 'Core analytics are kept while your account or a repository\u2019s history is retained. Persisted security events target a 30-day retention window via scheduled cleanup. Webhook replay-deduplication rows are pruned once older than 30 days when subsequent valid webhook traffic arrives, so actual deletion can occur later during quiet periods. Sessions expire and revoke independently. Backups carry their own recovery retention.'],
['PLATFORM ANALYTICS', 'This site is hosted on Vercel and loads Vercel Web Analytics — the platform\u2019s built-in, cookieless traffic measurement. Per Vercel\u2019s documentation its data points can include the page URL and route, referrer, browser and version, operating system and version, device type, and geolocation (country, region, and city). Visitors are determined using a hash derived from the incoming request that resets daily — cookies are not used for visitor identification, and visitors cannot be tracked between different days or different websites. The default analytics data is aggregated and designed not to identify or re-identify end users. The integration was verified to work under this site\u2019s existing Content Security Policy without weakening it; Vercel may adjust how its intake endpoints are served over time. This platform measurement is separate from Dev Ledger\u2019s own records — it is not stored in the Dev Ledger database and it does not read your GitHub or dashboard data. Dev Ledger\u2019s DELETE MY DATA action does not delete Vercel Web Analytics records because those records are held by the hosting analytics platform, not in Dev Ledger\u2019s application database.'],
['PRODUCT ANALYTICS', 'Dev Ledger also uses PostHog (EU region) for a deliberately limited set of product interaction events: sync start/completion/failure category, the selected dashboard range, share-card opens and downloads, feedback submission type, and delete-data completion. IP capture is disabled in the PostHog project configuration, and session replay, autocapture, heatmaps, surveys, console capture and performance/network capture are all disabled — only the allowlisted custom events are sent, through a first-party proxy under this site\u2019s existing Content Security Policy. No GitHub repository content, source code, commit messages, email address, feedback text or screenshots are intentionally sent to PostHog. Events are currently not linked to your Dev Ledger account or GitHub identity — this implementation never calls PostHog\u2019s identify() API and stores no analytics cookies or persistent analytics identifiers. PostHog event records are held by PostHog, separate from the Dev Ledger application database. Dev Ledger\u2019s DELETE MY DATA action deletes your Dev Ledger database record but does not automatically delete PostHog event records that carry no link back to the deleted account.'],
+ ['ACQUISITION ANALYTICS', 'Dev Ledger additionally uses Google Analytics 4 for acquisition and traffic-source measurement on the public website. The integration sends a deliberately small event set — sanitized page views (the path only; query strings and URL fragments are stripped before measurement), the five standard utm_* campaign fields when present in a link you followed, a content-selection signal when a blog call-to-action is used, and login funnel steps (sign-in start and completion, method only). GA4\u2019s automatic page-view measurement is disabled in favor of these manual sanitized events. GA4 uses its own client/session identifier cookies (_ga, _ga_*) — unlike the PostHog integration, this is not a cookieless measurement. Google Signals, advertising personalization, remarketing, user-provided data and User-ID are not enabled, and no GitHub username, repository data, email, session identifier or OAuth material is sent. Because page views are measured without their query strings, secrets or OAuth parameters present in a URL cannot reach Google Analytics through this integration. GA4 records are held by Google under its own terms, separate from the Dev Ledger application database, and DELETE MY DATA does not remove them.'],
]} />
diff --git a/test/ga4-analytics.test.mjs b/test/ga4-analytics.test.mjs
new file mode 100644
index 0000000..2e7f510
--- /dev/null
+++ b/test/ga4-analytics.test.mjs
@@ -0,0 +1,277 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync, existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import {
+ GA4_EVENT_PROPERTIES,
+ GA4_UTM_TO_CAMPAIGN_FIELD,
+ ga4CampaignFields,
+ ga4PageLocation,
+ ga4PagePath,
+ ga4PageTitle,
+ ga4PageViewParams,
+ isGA4DebugHost,
+ isGA4ProductionHost,
+ isValidMeasurementId,
+ sanitizeCampaignValue,
+ validateGA4Event,
+} from '../src/analytics/ga4Model.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const src = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
+// Comments legitimately mention forbidden identifiers — negative assertions
+// run against code only.
+const codeOnly = (rel) =>
+ src(rel).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
+
+function allSourceFiles(dir = path.join(ROOT, 'src'), acc = []) {
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) allSourceFiles(p, acc);
+ else if (/\.(ts|tsx|js|jsx|mjs)$/.test(e.name)) acc.push(p);
+ }
+ return acc;
+}
+
+/* ── 1. Measurement ID is env-configured, never hard-coded ── */
+
+test('no real GA4 measurement ID is hard-coded anywhere in the repo', () => {
+ // G-XXXXXXXXXX pattern — docs examples are fine, a real ID is not.
+ const scan = (dir) =>
+ allSourceFiles(dir).concat(
+ ['index.html', 'security.html', 'privacy.html', 'terms.html', 'vercel.json',
+ 'package.json', '.env.example', 'README.md']
+ .map((f) => path.join(ROOT, f))
+ .filter(existsSync),
+ );
+ for (const f of scan(path.join(ROOT, 'src'))) {
+ const rel = path.relative(ROOT, f);
+ const content = readFileSync(f, 'utf8');
+ for (const m of content.matchAll(/\bG-[A-Z0-9]{6,}\b/g)) {
+ assert.fail(`${rel} hard-codes a GA4 measurement ID (${m[0]}) — use VITE_GA_MEASUREMENT_ID`);
+ }
+ }
+});
+
+test('measurement ID format is validated before use', () => {
+ // synthetic valid IDs only — the real ID must never enter this repo
+ assert.equal(isValidMeasurementId('G-EXAMPLE01'), true);
+ assert.equal(isValidMeasurementId('G-ABC12345'), true);
+ assert.equal(isValidMeasurementId(''), false);
+ assert.equal(isValidMeasurementId(undefined), false);
+ assert.equal(isValidMeasurementId('UA-12345-1'), false);
+ assert.equal(isValidMeasurementId('g-lower'), false);
+ assert.equal(isValidMeasurementId('SECRET_KEY=abc'), false);
+ assert.equal(isValidMeasurementId('G-TOO;rm -rf'), false);
+});
+
+test('facade reads the measurement ID only from VITE_GA_MEASUREMENT_ID', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /import\.meta\.env\.VITE_GA_MEASUREMENT_ID/);
+ assert.doesNotMatch(facade, /G-[A-Z0-9]{6,}/, 'facade must not embed a real ID');
+});
+
+/* ── 2. Host gating — official telemetry only on the canonical origin ── */
+
+test('GA4 emits only on devledger.site; localhost is debug-only', () => {
+ assert.equal(isGA4ProductionHost('devledger.site'), true);
+ assert.equal(isGA4ProductionHost('www.devledger.site'), false);
+ assert.equal(isGA4ProductionHost('dev-ledger.vercel.app'), false);
+ assert.equal(isGA4ProductionHost('dev-ledger-site.vercel.app'), false);
+ assert.equal(isGA4ProductionHost('fork-example.com'), false);
+ assert.equal(isGA4ProductionHost('localhost:5173'), false);
+ assert.equal(isGA4DebugHost('localhost:5173'), true);
+ assert.equal(isGA4DebugHost('127.0.0.1:4173'), true);
+});
+
+test('facade refuses to initialize without an ID or off the canonical host', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /initStarted/, 'single-initialization guard missing');
+ assert.match(facade, /isGA4ProductionHost\(host\) && !isGA4DebugHost\(host\)/, 'host gate missing');
+ assert.match(facade, /isGA4ProductionHost|isGA4DebugHost/);
+});
+
+/* ── 3. Initialization shape ── */
+
+test('config disables automatic page_view and ads/identity features', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /send_page_view:\s*false/, 'send_page_view must be false');
+ assert.match(facade, /allow_google_signals:\s*false/);
+ assert.match(facade, /allow_ad_personalization_signals:\s*false/);
+ assert.match(facade, /debug_mode: isGA4DebugHost/, 'localhost must run debug_mode');
+});
+
+test('gtag script loads async from googletagmanager.com only', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /script\.async\s*=\s*true/);
+ assert.match(facade, /googletagmanager\.com/);
+ assert.match(facade, /\/gtag\/js\?id=/);
+ assert.doesNotMatch(facade, /gtm\.js/, 'GTM must not be used');
+});
+
+/* ── 4. Page-view sanitization ── */
+
+test('ga4PagePath normalizes to the canonical non-slash form', () => {
+ assert.equal(ga4PagePath('/'), '/');
+ assert.equal(ga4PagePath('/blog'), '/blog');
+ assert.equal(ga4PagePath('/blog/'), '/blog');
+ assert.equal(ga4PagePath('/blog/what-is-code-churn'), '/blog/what-is-code-churn');
+ assert.equal(ga4PagePath('/blog/what-is-code-churn/'), '/blog/what-is-code-churn');
+ assert.equal(ga4PagePath('/blog/what-is-code-churn///'), '/blog/what-is-code-churn');
+ // hostile/malformed inputs degrade to '/'
+ assert.equal(ga4PagePath('/blog/x?token=SECRET'), '/');
+ assert.equal(ga4PagePath('/blog/x#private'), '/');
+ assert.equal(ga4PagePath('/blog/%2e%2e'), '/');
+ assert.equal(ga4PagePath('blog/foo'), '/');
+ assert.equal(ga4PagePath(''), '/');
+ assert.equal(ga4PagePath(null), '/');
+});
+
+test('page_location never carries query or fragment', () => {
+ const loc = { origin: 'https://devledger.site', pathname: '/blog/foo' };
+ assert.equal(ga4PageLocation(loc), 'https://devledger.site/blog/foo');
+ // pathname containing query-like content is rejected before it can leak
+ const dirty = { origin: 'https://devledger.site', pathname: '/blog/foo?token=SECRET#private' };
+ assert.equal(ga4PageLocation(dirty), 'https://devledger.site/');
+});
+
+test('page_view params are exactly page_location + sanitized page_title', () => {
+ const params = ga4PageViewParams(
+ { origin: 'https://devledger.site', pathname: '/security' },
+ 'Security — Dev Ledger',
+ );
+ assert.deepEqual(Object.keys(params).sort(), ['page_location', 'page_title']);
+ assert.equal(params.page_location, 'https://devledger.site/security');
+ assert.equal(params.page_title, 'Security — Dev Ledger');
+});
+
+test('facade never passes window.location.href to GA4', () => {
+ const facade = codeOnly('src/analytics/ga4.ts');
+ assert.doesNotMatch(facade, /location\.href/);
+});
+
+test('pageview dedupe is pathname-based (StrictMode/hash/query safe)', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /pathname === lastPagePath/, 'pathname dedupe missing');
+});
+
+/* ── 5. Event allowlist ── */
+
+test('GA4 event set is exactly the acquisition funnel', () => {
+ assert.deepEqual(Object.keys(GA4_EVENT_PROPERTIES).sort(), [
+ 'login',
+ 'login_start',
+ 'select_content',
+ ]);
+});
+
+test('login events carry method:github only — never identity', () => {
+ assert.deepEqual(validateGA4Event('login', { method: 'github' }), { method: 'github' });
+ assert.deepEqual(validateGA4Event('login_start', { method: 'github' }), { method: 'github' });
+ assert.equal(validateGA4Event('login', { method: 'github', user_id: 'x' }), null);
+ assert.equal(validateGA4Event('login', { method: 'github', email: 'a@b.c' }), null);
+ assert.equal(validateGA4Event('login', { method: 'google' }), null);
+});
+
+test('select_content carries schema-limited params only', () => {
+ assert.deepEqual(
+ validateGA4Event('select_content', { content_type: 'product_cta', item_id: 'what-is-code-churn' }),
+ { content_type: 'product_cta', item_id: 'what-is-code-churn' },
+ );
+ assert.equal(validateGA4Event('select_content', { content_type: 'product_cta', item_id: 'x?y=1' }), null);
+ assert.equal(validateGA4Event('select_content', { content_type: 'banner' }), null);
+});
+
+test('arbitrary event names and props are rejected', () => {
+ assert.equal(validateGA4Event('sync_started', { trigger: 'manual' }), null);
+ assert.equal(validateGA4Event('page_view', {}), null, 'page_view is facade-built only');
+ assert.equal(validateGA4Event('anything', {}), null);
+});
+
+/* ── 6. UTM campaign extraction ── */
+
+test('recognized utm_* fields map to campaign_* fields', () => {
+ assert.deepEqual(Object.keys(GA4_UTM_TO_CAMPAIGN_FIELD).sort(), [
+ 'utm_campaign',
+ 'utm_content',
+ 'utm_medium',
+ 'utm_source',
+ 'utm_term',
+ ]);
+ const fields = ga4CampaignFields(
+ '?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_term=dev&utm_content=video',
+ );
+ assert.deepEqual(fields, {
+ campaign_source: 'linkedin',
+ campaign_medium: 'social',
+ campaign_name: 'launch',
+ campaign_term: 'dev',
+ campaign_content: 'video',
+ });
+});
+
+test('unrelated and sensitive query params never become campaign fields', () => {
+ const fields = ga4CampaignFields('?token=DO_NOT_SEND&code=AUTH&state=OAUTH&utm_source=x');
+ assert.deepEqual(fields, { campaign_source: 'x' });
+ assert.equal(ga4CampaignFields('?token=DO_NOT_SEND&code=AUTH&state=OAUTH'), null);
+ assert.equal(ga4CampaignFields(''), null);
+ assert.equal(ga4CampaignFields('not-a-query'), null);
+});
+
+test('malformed/token-like campaign values are rejected', () => {
+ assert.equal(sanitizeCampaignValue('linkedin'), 'linkedin');
+ assert.equal(sanitizeCampaignValue('x'.repeat(101)), null);
+ assert.equal(sanitizeCampaignValue('https://evil.example/x'), null);
+ assert.equal(sanitizeCampaignValue('javascript:alert(1)'), null);
+ assert.equal(sanitizeCampaignValue('a?b'), null);
+ assert.equal(sanitizeCampaignValue('a#b'), null);
+ assert.equal(sanitizeCampaignValue('a&b'), null);
+ assert.equal(sanitizeCampaignValue('a=b'), null);
+ assert.equal(sanitizeCampaignValue('a/b'), null);
+ assert.equal(sanitizeCampaignValue('a%20b'), null);
+ assert.equal(sanitizeCampaignValue('line\nbreak'), null);
+ assert.equal(sanitizeCampaignValue(''), null);
+ assert.equal(sanitizeCampaignValue(42), null);
+ // encoded smuggling through the query string is neutralized by charset rules
+ const fields = ga4CampaignFields('?utm_source=%22%3E%3Cscript%3E');
+ assert.equal(fields, null);
+});
+
+test('facade applies campaign fields via gtag set, not page_location', () => {
+ const facade = src('src/analytics/ga4.ts');
+ assert.match(facade, /fn\('set', campaign\)/);
+ // page_location is built by ga4PageViewParams only
+ assert.match(facade, /ga4PageViewParams\(window\.location, document\.title\)/);
+});
+
+/* ── 7. Wiring ── */
+
+test('GA4 wiring: boot init + pageview, popstate, navigate, login events', () => {
+ const main = src('src/main.tsx');
+ assert.match(main, /initGoogleAnalytics\(\)/);
+ assert.match(main, /captureGooglePageview\(\)/);
+ assert.match(main, /addEventListener\('popstate',[\s\S]*?captureGooglePageview/);
+ assert.match(main, /captureGoogleEvent\("login", \{ method: "github" \}\)/);
+ const pages = src('src/pages.ts');
+ assert.ok((pages.match(/captureGooglePageview\(\)/g) || []).length >= 2,
+ 'navigate + popstate must each emit a GA4 pageview');
+ const shared = src('src/ledger/shared.tsx');
+ assert.match(shared, /captureGoogleEvent\('login_start', \{ method: 'github' \}\)/);
+});
+
+test('GA4 and PostHog are separate modules — no cross-import', () => {
+ assert.doesNotMatch(codeOnly('src/analytics/ga4.ts'), /from ['"].*posthog/,
+ 'ga4 must not import PostHog code');
+ assert.doesNotMatch(codeOnly('src/analytics/ga4Model.mjs'), /posthog/,
+ 'ga4Model must not reference PostHog');
+ assert.doesNotMatch(codeOnly('src/analytics/posthog.ts'), /from ['"].*ga4/,
+ 'posthog must not import GA4 code');
+});
+
+test('no user_id/identify usage in GA4 code', () => {
+ const facade = codeOnly('src/analytics/ga4.ts');
+ assert.doesNotMatch(facade, /user_id/);
+ assert.doesNotMatch(facade, /identify/);
+});
diff --git a/test/supply-chain.test.mjs b/test/supply-chain.test.mjs
index 98f8edc..14d275c 100644
--- a/test/supply-chain.test.mjs
+++ b/test/supply-chain.test.mjs
@@ -17,13 +17,15 @@ const load = (rel) => JSON.parse(src(rel));
// Deny-by-default: Dev Ledger ships no custom VITE_* variables.
const VITE_BUILTINS = new Set(['DEV', 'PROD', 'MODE', 'SSR', 'BASE_URL']);
-// Explicit security decision: the PostHog product-analytics integration
-// ships exactly two PUBLIC client configuration values — the PostHog
-// project token (a publishable client key by design, not a secret) and
-// the same-origin proxy host. Anything else stays denied.
+// Explicit security decision: analytics integrations ship exactly three
+// PUBLIC client configuration values — the PostHog project token (a
+// publishable client key by design, not a secret), the same-origin proxy
+// host, and the GA4 measurement ID (a public identifier, not a secret).
+// Anything else stays denied.
const ALLOWED_PUBLIC_VITE_VARS = new Set([
'VITE_POSTHOG_PROJECT_TOKEN',
'VITE_POSTHOG_HOST',
+ 'VITE_GA_MEASUREMENT_ID',
]);
function allFiles(dir, acc = []) {
@@ -99,7 +101,7 @@ test('import.meta.env usage is limited to Vite built-ins + allowlisted public va
}
// The two allowlisted public names are the only VITE_* strings
// permitted in client source at all.
- const stripped = content.replace(/\bVITE_POSTHOG_PROJECT_TOKEN\b|\bVITE_POSTHOG_HOST\b/g, '');
+ const stripped = content.replace(/\bVITE_POSTHOG_PROJECT_TOKEN\b|\bVITE_POSTHOG_HOST\b|\bVITE_GA_MEASUREMENT_ID\b/g, '');
assert.doesNotMatch(stripped, /\bVITE_[A-Z]/, `${path.relative(ROOT, f)} references a VITE_* name`);
}
});
diff --git a/vercel.json b/vercel.json
index 3a381e4..486448d 100644
--- a/vercel.json
+++ b/vercel.json
@@ -143,7 +143,7 @@
},
{
"key": "Content-Security-Policy",
- "value": "default-src 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-elem 'self' 'sha256-HR6/MuuYfB8aijiNP5MPm3YOR8WqVmL7UkE3Q8OslTs='; style-src-attr 'none'; font-src 'self'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
+ "value": "default-src 'self'; script-src 'self' https://www.googletagmanager.com; script-src-attr 'none'; style-src 'self'; style-src-elem 'self' 'sha256-HR6/MuuYfB8aijiNP5MPm3YOR8WqVmL7UkE3Q8OslTs='; style-src-attr 'none'; font-src 'self'; img-src 'self' data: https://avatars.githubusercontent.com https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
},
{
"key": "Link",