Skip to content

Commit b3dc694

Browse files
committed
Web: engagement + friction analytics, consent-gated PostHog, ga-daily CLI
Adds a delegated event surface in src/scripts/analytics.ts driven by data-* attributes on call sites. Sixteen events total: - click: cta_click, nav_click, outbound_click (subdomain-aware), modal_open, modal_close (with via=escape|backdrop|button), external_doc_click - engagement: section_view, section_dwell, scroll_depth, page_active_time, live_chip_hover, network_viz_engage, blog_post_read, install_command_copy - friction: web_vital (LCP / CLS / TTFB), dead_click Tagged the homepage, /for/* heads, sitewide nav, blog article body, and the OSI modal close paths. Outbound treats *.pilotprotocol.network as internal. PostHog (heatmaps + session recordings) lazy-loads via dynamic import only after the user clicks Allow on ConsentBanner.astro. Inert without PUBLIC_POSTHOG_KEY env var. Anonymous mode, all inputs masked. scripts/ga-daily.mjs is a zero-dep terminal digest against the GA4 Data API (auths via the existing service-account key). Surfaces totals, top pages, top events, CTA breakdowns, outbound destinations, scroll-depth histogram, section views, top referrers. Plus --source <name> for cohort filtering and --funnel install for end-to-end conversion math. Taxonomy + author guide in web/ANALYTICS.md.
1 parent 5fb961f commit b3dc694

16 files changed

Lines changed: 1528 additions & 43 deletions

web/ANALYTICS.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Analytics
2+
3+
The website ships GA4 (`G-EEWEKT0GW5`, loaded in `src/components/BaseHead.astro`)
4+
plus a small custom event layer in `src/scripts/analytics.ts` driven by
5+
`data-*` attributes on call sites. There is no per-page glue — to track a
6+
new behavior, add the appropriate attribute and the engine will pick it up.
7+
8+
## When to add an event
9+
10+
Only when the result will be looked at on a dashboard. Every event in the
11+
taxonomy below is a maintenance commitment — it has to stay aligned with
12+
the call site that triggers it and with the dashboard that consumes it.
13+
Twelve events is the budget; we cap at ~15 before we start removing.
14+
15+
If you find yourself adding an event "just in case," stop. The point is
16+
deciding what to ship next based on real signal, not collecting telemetry
17+
for its own sake.
18+
19+
## Event taxonomy
20+
21+
### Click events
22+
23+
| Event | Triggered by | Key params |
24+
| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------- |
25+
| `cta_click` | Click on `[data-track="cta_click"]` | `target`, `location` |
26+
| `nav_click` | Click on `[data-track="nav_click"]` | `target` |
27+
| `outbound_click` | Click on `<a>` to a host outside `*.pilotprotocol.network`| `target` (host), `href` |
28+
| `modal_open` | Click on `[data-track="modal_open"]` | `target` (modal id) |
29+
| `modal_close` | OSI modal closed (any path) | `target`, `via=escape \| backdrop \| button` |
30+
| `external_doc_click` | Click on a `/docs/*` link from a `/blog/*` page | `target` (docs path) |
31+
32+
### Engagement
33+
34+
| Event | Triggered by | Key params |
35+
| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------- |
36+
| `section_view` | `[data-track-section]` ≥ 50% in viewport, once per page | `target` (section name) |
37+
| `section_dwell` | Section exits viewport (or pagehide), > 250 ms | `target`, `duration_ms` |
38+
| `scroll_depth` | Page reaches 25 / 50 / 75 / 100% scroll | `value` |
39+
| `page_active_time` | `pagehide`, accumulated visible time, > 1 s | `duration_ms` |
40+
| `live_chip_hover` | Hover dwell on `[data-track-hover]` chip past threshold | `target` |
41+
| `network_viz_engage` | Hover dwell on the network viz past threshold | (none) |
42+
| `blog_post_read` | Scroll past 75% of `[data-track-read]` article | `slug` |
43+
| `install_command_copy` | User copies text inside `[data-track-copy]` | `command` |
44+
45+
### Friction
46+
47+
| Event | Triggered by | Key params |
48+
| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------- |
49+
| `web_vital` | LCP / CLS / TTFB measured (LCP and CLS report on pagehide)| `name`, `value` (CLS is `× 1000` to keep an integer) |
50+
| `dead_click` | Click that hit no link, no button, no `[data-track]` | `location` (section or pathname), `target` (tag name) |
51+
52+
The bottom three rows are part of the budget but the engine doesn't fire
53+
them yet. Wire them when we have a dashboard slot for the data.
54+
55+
## Adding a tracked behavior
56+
57+
Every behavior is a `data-*` attribute on the call site — no JS edits needed.
58+
59+
### Click events
60+
61+
```html
62+
<a href="/docs/getting-started"
63+
data-track="cta_click"
64+
data-track-target="install"
65+
data-track-location="hero">
66+
Install in one line →
67+
</a>
68+
```
69+
70+
The `data-track` value is the GA4 event name itself (snake_case). `target`
71+
and `location` ride along as event params. Use one of the canonical event
72+
names from the taxonomy table — adding a new event name means adding a row
73+
to the table.
74+
75+
### Outbound link tracking
76+
77+
Nothing to do — any `<a>` whose host is not `pilotprotocol.network` (or the
78+
current host) auto-fires `outbound_click`. Internal anchors are ignored.
79+
`mailto:`, `tel:`, `javascript:` and other non-http URLs are skipped.
80+
81+
### Section visibility
82+
83+
```html
84+
<section id="thesis" data-track-section="thesis">…</section>
85+
```
86+
87+
Fires `section_view` with `target=thesis` the first time the section is
88+
≥ 50% in viewport on a given page load.
89+
90+
### Copy-to-clipboard
91+
92+
```html
93+
<div data-track-copy="install.sh">
94+
$ curl -fsSL https://pilotprotocol.network/install.sh | sh
95+
</div>
96+
```
97+
98+
When the user copies any text whose selection anchor is inside the wrapper,
99+
fires `install_command_copy` with `command="install.sh"`.
100+
101+
### Hover dwell
102+
103+
```html
104+
<div data-track-hover="network_viz_engage" data-track-hover-threshold-ms="1500">
105+
```
106+
107+
Starts a timer on `mouseenter`, fires once after the threshold (default
108+
`1500`). Cancelled by `mouseleave`. Cap is per page-load — won't fire twice.
109+
110+
## Where things live
111+
112+
- `src/components/BaseHead.astro` — GA4 boot + `import '../scripts/analytics.ts'`
113+
- `src/scripts/analytics.ts` — the entire engine (single file, no deps)
114+
- This document — the canonical taxonomy reference
115+
116+
## How to verify locally
117+
118+
1. `npm run dev`
119+
2. Open `http://localhost:4321/` (or whatever port Astro picks)
120+
3. DevTools → Console: paste
121+
```js
122+
window.dataLayer = window.dataLayer || [];
123+
const orig = window.gtag;
124+
window.gtag = (...args) => { console.log('gtag', ...args); orig?.(...args); };
125+
```
126+
4. Click around, scroll, hover, copy. Each tracked behavior should log a
127+
`gtag event <name> { ... }` line. If something doesn't log, the call
128+
site is missing its `data-*` attribute.
129+
130+
## Read access for dashboards
131+
132+
The GA4 data lives in property `530680522`. For programmatic access (Looker
133+
Studio, scripts, CI), use the service-account key at
134+
`~/.config/gcp-keys/ga-reader-key.json`. Don't commit it.
135+
136+
## Privacy & consent
137+
138+
Two layers ship today:
139+
140+
1. **GA4** (loaded sitewide, no banner). IP anonymization on, no PII, no
141+
cross-site identifiers. The custom events above carry only the params
142+
the docs declare — no free-form text, no form contents, no path-with-PII.
143+
2. **PostHog** (opt-in, gated on the consent banner). Session recordings
144+
and heatmaps. Only loads when the user clicked **Allow** on the banner
145+
in `src/components/ConsentBanner.astro`. Loaded with
146+
`person_profiles: 'never'` (anonymous mode), `maskAllInputs: true`, and
147+
any element marked `[data-private]` is masked in recordings.
148+
149+
The banner shows once per device (persisted in `localStorage` as
150+
`pilot-analytics-consent``accepted | declined`). Cleared the
151+
choice? The banner reappears on next visit.
152+
153+
PostHog only initialises when **both** conditions hold:
154+
- `localStorage['pilot-analytics-consent'] === 'accepted'`, AND
155+
- `PUBLIC_POSTHOG_KEY` is defined (set in your `.env` or Cloudflare Pages
156+
environment vars).
157+
158+
If the key is unset, the consent banner is decorative — clicking Allow does
159+
nothing harmful, just no recordings happen. To turn PostHog on for real:
160+
161+
```bash
162+
# Sign up at https://posthog.com/, create a project, copy the API key.
163+
# Then in the Cloudflare Pages project settings:
164+
PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxx
165+
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # or eu.i.posthog.com
166+
```
167+
168+
To turn it off: unset the env var. The banner can stay; without a key,
169+
nothing loads.

0 commit comments

Comments
 (0)