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