From 0fd8c5272444bb58b4d105cb55080f8043a538e9 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 04:40:04 -0700 Subject: [PATCH] Release 0.15.0: Amplitude analytics (cookie-consent gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap item #13. Ships the frontend Amplitude SDK behind the v0.13.0 cookie/privacy consent gate. The analytics wrapper lives at `frontend/src/lib/analytics.js` and exposes `track`, `identify`, and `anonymize` over a stable nine-event taxonomy (Page Viewed, RFC Viewed, User Signed In / Signed Out, RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin Permission Decision). The wrapper reads consent via `getConsent()` / `onConsentChange()` from `frontend/src/lib/consent.js` (v0.13.0); the SDK module is dynamically `import()`-ed only after `consent.analytics === true`, and a later granted→denied flip calls `setOptOut(true)` so events stop without a page reload. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY` at build time; when unset the wrapper logs one console warning and no-ops so dev environments keep working. Wired into App.jsx (route-change Page Viewed + sign-in identify + sign-out anonymize), Login.jsx (User Signed In with method = otc/passcode/trust-device, Beta Access Requested on capture-profile submit), ProposeModal.jsx (RFC Proposed), RFCView.jsx (RFC Viewed), PRModal.jsx (PR Opened), RFCDiscussionPanel.jsx (Comment Posted with surface=discussion), PRView.jsx (Comment Posted with surface=pr), Admin.jsx (Admin Permission Decision with action=grant/revoke). Event bodies carry only ids and enums — no titles, no comment bodies, no names, no emails. The user binding passes only `String(viewer.id)`. Secret-vs-overlay binding caveat: Amplitude browser API keys are visible in the shipped bundle via dev tools. Per the roadmap, the key is still bound through `flotilla secret set` (rather than `flotilla overlay set`) to keep all-keys-in-Secret-Manager regularity for the OHM deployment; the CHANGELOG documents the choice. Operator pre-deploy gesture (in the Upgrade steps block): `pbpaste | ... ohm-rfc-app-flotilla secret set ohm-rfc-app AMPLITUDE_API_KEY` — the wave-paused step before this release can deploy. No backend events ship in this release (Amplitude SaaS holds the events); no schema migration; backend is unchanged. Migration slot 015 remains unused and available for the next minor that needs a schema bump. New dependency: `@amplitude/analytics-browser`. `VITE_AMPLITUDE_API_KEY` documented in `frontend/.env.example` with the binding-choice caveat. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 136 +++++++++ VERSION | 2 +- frontend/.env.example | 18 ++ frontend/package-lock.json | 140 ++++++++- frontend/package.json | 3 +- frontend/src/App.jsx | 50 +++- frontend/src/components/Admin.jsx | 7 + frontend/src/components/Login.jsx | 22 +- frontend/src/components/PRModal.jsx | 4 + frontend/src/components/PRView.jsx | 5 + frontend/src/components/ProposeModal.jsx | 5 + .../src/components/RFCDiscussionPanel.jsx | 5 + frontend/src/components/RFCView.jsx | 11 +- frontend/src/lib/analytics.js | 267 ++++++++++++++++++ 14 files changed, 663 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/analytics.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd9d9..335afe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,142 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.15.0 — 2026-05-28 + +**Minor — no schema migration; one new build-time env var bound via +`flotilla secret set`.** This release ships Amplitude analytics +instrumentation (roadmap item #13). The frontend gains a small +wrapper around `@amplitude/analytics-browser` that gates SDK +initialization on the v0.13.0 cookie/privacy consent — the SDK is +never loaded for visitors who have not granted analytics consent, and +a later consent flip to `denied` calls `setOptOut(true)` so events +stop firing immediately. The wrapper exposes a stable taxonomy of +nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out, +RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin +Permission Decision) wired into the existing routes, the Login flow, +the propose / open-PR / discussion / PR-review surfaces, and the +admin grant/revoke action. Event bodies carry only ids and enums; no +free-text fields (titles, comment bodies, names, emails) are ever +sent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY` at +build time; when unset, the wrapper logs one console warning and +no-ops so dev environments without analytics keep working. No backend +events ship in this release — Amplitude SaaS holds the events, +nothing lands in our DB, no migration. + +### Added + +- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public + surface: `track(name, props)`, `identify({ user_id })`, + `anonymize()`, the `EVENTS` taxonomy constant, and a + `__resetForTests` helper. Internally lazy-imports + `@amplitude/analytics-browser` and calls `amplitude.init(API_KEY, + undefined, { defaultTracking: false })` only after consent is + granted; queues pre-init calls and drains them on init resolve; + flips `setOptOut(true)` on a granted→denied consent change. The + wrapper subscribes to `onConsentChange()` so a freshly-banner-clicked + "analytics on" flips the SDK live without a page reload. +- **Event taxonomy** wired into the app: + - `Page Viewed` — fires from `App.jsx` on every route change with + `path` (`location.pathname`); the location hook owns the firing + and dedupes by path. + - `RFC Viewed` — fires from `RFCView.jsx` once per slug load with + `rfc_slug` and `rfc_id`. + - `User Signed In` — fires from `Login.jsx` with + `method ∈ { 'otc', 'passcode', 'trust-device' }` matching the + three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0. + - `User Signed Out` — fires from `App.jsx`'s "Sign out" click, + followed by `anonymize()` to clear the SDK's user binding before + the hard nav to `/auth/logout`. + - `RFC Proposed` — fires from `ProposeModal.jsx` on submit success + with `rfc_slug`. + - `PR Opened` — fires from `PRModal.jsx` on submit success with + `rfc_slug` and `pr_number`. + - `Comment Posted` — fires from `RFCDiscussionPanel.jsx` + (`surface: 'discussion'`) and from `PRView.jsx` + (`surface: 'pr'`, with `pr_number`) on each post-success. + - `Beta Access Requested` — fires from `Login.jsx` capture-profile + submit success. No PII in the event. + - `Admin Permission Decision` — fires from `Admin.jsx`'s grant / + revoke action with `action ∈ { 'grant', 'revoke' }` and + `target_user_id` (string). +- **User binding** (`App.jsx`): when `me.authenticated` lands and a + user id is available, the wrapper's `identify({ user_id })` is + called with `String(viewer.id)`. The sign-out gesture calls + `anonymize()` before the nav. No email, display name, or other PII + is passed through the SDK. +- **`@amplitude/analytics-browser`** dependency added to + `frontend/package.json`. Lockfile updated. +- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example` + with the secret-vs-overlay binding caveat (see below). + +### Changed + +- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change + Page Viewed firing, a `lastUserIdRef` memo to call + `identify` once per signed-in viewer, and an `onClick` handler on + the "Sign out" link that fires `User Signed Out` + `anonymize()` + before the hard nav. +- **`frontend/src/components/Login.jsx`** — fires `User Signed In` + with the appropriate `method` at each of the three sign-in points + (trust-device cookie path, passcode verify success, OTC verify + success), and fires `Beta Access Requested` on capture-profile + submit success. +- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed` + with `rfc_slug` on submit success. +- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed` + inside the `getRFC` resolution so the event is keyed on the slug + param and includes the loaded `rfc_id`. +- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with + `rfc_slug` and `pr_number` on submit success. +- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires + `Comment Posted` with `surface: 'discussion'` on send-success. +- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted` + with `surface: 'pr'` and `pr_number` on review-comment success. +- **`frontend/src/components/Admin.jsx`** — fires + `Admin Permission Decision` on grant/revoke success. + +### Migration + +- **No schema migration.** Amplitude SaaS holds the events; the + framework's DB is unchanged. Migration slot **015** is unused by + this release and remains available for the next minor that needs a + schema bump. + +### Caveat — secret-vs-overlay binding for `AMPLITUDE_API_KEY` + +Amplitude browser API keys are embedded in the frontend bundle at +build time and visible to anyone with browser dev tools. They are +conventionally treated as semi-sensitive (not truly secret) and would +in principle fit `flotilla overlay set` rather than `flotilla secret +set`. The roadmap calls for `secret set` to keep +all-keys-in-Secret-Manager regularity for the deployment — that's the +choice this release follows. The matching CloudFlare Turnstile pair +(public site key via overlay, secret key via Secret Manager) is the +contrast; Amplitude only has one key so the "is it public?" question +has no separating answer at provisioning time, and the operator runs +the secret-set gesture rather than the overlay-set one. + +### Upgrade steps (from 0.14.0) + +- You **MUST** install the new frontend dependency before building: + `cd frontend && npm install` picks up `@amplitude/analytics-browser` + from the updated `frontend/package.json` and the refreshed + `package-lock.json`. The lockfile change is committed. +- You **MUST** rebuild the frontend after upgrading so the analytics + wrapper and its consent gate ship to viewers. `frontend/package.json#version` + and `VERSION` both move to `0.15.0`. No schema migration; the + backend is unchanged for this release. +- **MUST**: before deploying, the operator runs `pbpaste | /Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla secret set ohm-rfc-app AMPLITUDE_API_KEY` (with the Amplitude project's API key in the clipboard) to bind the new `AMPLITUDE_API_KEY` secret. The deploy MUST NOT proceed before this binding exists. If the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events are sent. +- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments + — the wrapper detects the empty value and no-ops with a single + console warning. The app, the consent banner, and every other + surface keep working unchanged. +- You **SHOULD** verify after deploy that the Amplitude dashboard + receives events when a consenting browser exercises one of the + taxonomy events (the easiest probe: open the deployed site in an + Incognito window, accept analytics on the consent banner, navigate + to an RFC, and watch the project's live event stream). + ## 0.14.0 — 2026-05-28 **Minor — no operator action required; new optional env var.** This diff --git a/VERSION b/VERSION index ac454c6..a551051 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.0 +0.15.0 diff --git a/frontend/.env.example b/frontend/.env.example index 147b48d..82627a2 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -62,3 +62,21 @@ VITE_COOKIES_POLICY_URL= # Examples: # VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA... VITE_TURNSTILE_SITE_KEY= + +# v0.15.0 / roadmap item #13: Amplitude project API key. Embedded in +# the frontend bundle at build time and used by the analytics wrapper +# (`frontend/src/lib/analytics.js`) when the user has granted analytics +# consent (v0.13.0 cookie banner). Provision an Amplitude project at +# app.amplitude.com → Projects → New, copy the API key. +# +# Caveat — secret-vs-overlay binding choice: Amplitude browser API +# keys are visible to anyone with browser dev tools (they ride in the +# shipped bundle). They are conventionally treated as semi-sensitive, +# not truly secret. The roadmap binds the value through flotilla's +# `secret set` verb anyway, to keep all-keys-in-Secret-Manager +# regularity for the OHM deployment. Leave unset in dev; the wrapper +# logs one console warning and no-ops (the app continues to work). +# +# Examples: +# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd +VITE_AMPLITUDE_API_KEY= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2fd0758..705172a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "rfc-app-frontend", - "version": "0.12.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.12.0", + "version": "0.15.0", "dependencies": { + "@amplitude/analytics-browser": "^2.42.4", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", @@ -30,6 +31,113 @@ "vite": "^8.0.12" } }, + "node_modules/@amplitude/analytics-browser": { + "version": "2.42.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz", + "integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "@amplitude/plugin-autocapture-browser": "1.27.2", + "@amplitude/plugin-custom-enrichment-browser": "0.1.9", + "@amplitude/plugin-event-property-attribution-browser": "0.2.1", + "@amplitude/plugin-network-capture-browser": "1.10.1", + "@amplitude/plugin-page-url-enrichment-browser": "0.7.11", + "@amplitude/plugin-page-view-tracking-browser": "2.11.1", + "@amplitude/plugin-web-vitals-browser": "1.1.33", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==", + "license": "MIT" + }, + "node_modules/@amplitude/analytics-core": { + "version": "2.48.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz", + "integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, + "node_modules/@amplitude/plugin-autocapture-browser": { + "version": "1.27.2", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz", + "integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-custom-enrichment-browser": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz", + "integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-event-property-attribution-browser": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz", + "integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-network-capture-browser": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz", + "integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-url-enrichment-browser": { + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz", + "integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-view-tracking-browser": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz", + "integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-web-vitals-browser": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz", + "integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1", + "web-vitals": "5.1.0" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -1399,6 +1507,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", + "license": "MIT" + }, "node_modules/@upsetjs/venn.js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", @@ -2828,6 +2942,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2907,9 +3027,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/use-sync-external-store": { "version": "1.6.0", @@ -3016,6 +3134,18 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, + "node_modules/zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==", + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 69fda27..49bdf33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.12.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite", @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@amplitude/analytics-browser": "^2.42.4", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 247f955..f34926a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react' -import { Routes, Route, Link, useNavigate } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' +import { anonymize, EVENTS, identify, track } from './lib/analytics' import Catalog from './components/Catalog.jsx' import Inbox from './components/Inbox.jsx' import RFCView from './components/RFCView.jsx' @@ -34,6 +35,36 @@ export default function App() { // event that bumps this. const [consentReopenTick, setConsentReopenTick] = useState(0) const navigate = useNavigate() + const location = useLocation() + // v0.15.0 — Page Viewed event taxonomy. We fire on every + // route change; the analytics wrapper itself decides whether + // anything ships out (consent + key check). The first fire is + // also covered because `location` is set on mount. + const lastPathRef = useRef(null) + useEffect(() => { + const path = location.pathname + (location.search || '') + if (lastPathRef.current === path) return + lastPathRef.current = path + track(EVENTS.PAGE_VIEWED, { path: location.pathname }) + }, [location.pathname, location.search]) + + // v0.15.0 — bind the authenticated user id to the analytics + // session when sign-in lands; reset on sign-out (viewer flips to + // null). The wrapper queues these calls until consent + init + // resolve, so the order is safe even on a cold load. + const lastUserIdRef = useRef(null) + useEffect(() => { + const uid = me?.authenticated ? me.user?.id : null + if (uid != null && lastUserIdRef.current !== uid) { + lastUserIdRef.current = uid + identify({ user_id: String(uid) }) + } else if (uid == null && lastUserIdRef.current != null) { + // Sign-out edge — App-level reset is handled separately by the + // sign-out gesture that fires User Signed Out. Clear our local + // memo so a fresh sign-in re-fires identify. + lastUserIdRef.current = null + } + }, [me?.authenticated, me?.user?.id]) useEffect(() => { const handler = () => setConsentReopenTick(t => t + 1) @@ -141,7 +172,20 @@ export default function App() { <> {viewer.display_name} {viewer.role} - Sign out + { + // v0.15.0 — fire the sign-out event before the + // hard nav. The wrapper's track() is sync-enqueue; + // the underlying SDK flush is best-effort across + // navigation. anonymize() clears the user binding + // so any post-nav anonymous events on the next + // page aren't attributed to the prior user. + track(EVENTS.USER_SIGNED_OUT) + anonymize() + }} + >Sign out ) : ( diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx index 0f85c13..69efcc0 100644 --- a/frontend/src/components/Admin.jsx +++ b/frontend/src/components/Admin.jsx @@ -24,6 +24,7 @@ import { addAllowlistEmail, removeAllowlistEmail, } from '../api.js' +import { EVENTS, track } from '../lib/analytics.js' const TABS = [ { path: 'users', label: 'Users' }, @@ -133,6 +134,12 @@ function UsersTab() { setError(null) try { await setUserPermission(userId, state) + // v0.15.0 — analytics: fire on a successful §6.1 grant/revoke. + // action collapses the {pending → granted, revoked → granted} + // edges onto `grant`, and `granted → revoked` onto `revoke`, + // matching the roadmap's two-arm taxonomy. + const action = state === 'granted' ? 'grant' : 'revoke' + track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) }) // Refresh the full row so permission_decided_{at,by_*} update too. await refresh() } catch (e) { diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index 7440f00..be5cabc 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -85,6 +85,7 @@ import { startDeviceTrust, } from '../api' import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget' +import { EVENTS, track } from '../lib/analytics' export default function Login() { // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after @@ -150,7 +151,12 @@ export default function Login() { ;(async () => { try { await startDeviceTrust() - if (!cancelled) window.location.assign('/') + if (!cancelled) { + // v0.15.0 — analytics: device-trust cookie path is one of + // three sign-in methods the taxonomy distinguishes. + track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' }) + window.location.assign('/') + } } catch (_) { // No trusted device — fall through to the email step. } @@ -206,6 +212,10 @@ export default function Login() { setStatus('') try { await verifyPasscode(email.trim(), passcode.trim(), { trustDevice }) + // v0.15.0 — analytics: passcode is the second of three + // sign-in methods. trust-device gets credited separately when + // the cookie-driven path fires above. + track(EVENTS.USER_SIGNED_IN, { method: 'passcode' }) // Reload so App.jsx's getMe() picks up the fresh session. A // returning passcode user is by definition already past the // §6.1 capture step (they couldn't have set a passcode while @@ -264,6 +274,11 @@ export default function Login() { setStatus('') try { await verifyOtc(email.trim(), code.trim(), { trustDevice }) + // v0.15.0 — analytics: OTC is the third sign-in method. + // We fire it here regardless of whether the user then lands + // in capture-profile or offer-passcode — sign-in has happened + // server-side either way. + track(EVENTS.USER_SIGNED_IN, { method: 'otc' }) // OTC verified — the server has signed in the user. Fetch the // canonical /api/auth/me to decide where to land: // * needs_profile → §6.1 capture (then /beta-pending). @@ -320,6 +335,11 @@ export default function Login() { last_name: ln, beta_request_reason: why, }) + // v0.15.0 — analytics: a successful capture-profile submit is + // the moment a beta-access request lands. No PII in the event + // body (no name, no reason text); the count + timestamp is + // what the funnel needs. + track(EVENTS.BETA_ACCESS_REQUESTED) // Hard-load so App.jsx re-fetches /api/auth/me and picks up // the captured fields. The user stays permission_state='pending' // until an admin grants access — the next thing they should diff --git a/frontend/src/components/PRModal.jsx b/frontend/src/components/PRModal.jsx index a1fd4de..23ba78c 100644 --- a/frontend/src/components/PRModal.jsx +++ b/frontend/src/components/PRModal.jsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react' import { draftPRText, openPR } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) { const [title, setTitle] = useState('') @@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen setError(null) try { const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() }) + // v0.15.0 — analytics: fire on §10.2 PR-open success. slug + // and pr_number are the join keys; title/description stay out. + track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number }) onOpened?.(pr_number) } catch (e) { setError(e.message) diff --git a/frontend/src/components/PRView.jsx b/frontend/src/components/PRView.jsx index 3c11457..813ed8c 100644 --- a/frontend/src/components/PRView.jsx +++ b/frontend/src/components/PRView.jsx @@ -22,6 +22,7 @@ import { startResolutionBranch, withdrawPR, } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function PRView({ viewer }) { const { slug, prNumber: prNumberParam } = useParams() @@ -135,6 +136,10 @@ export default function PRView({ viewer }) { anchorPayload: reviewDraft?.anchorPayload || {}, quote: reviewDraft?.quote || null, }) + // v0.15.0 — analytics: fire on §10.4 review-comment success. + // surface=pr distinguishes this from RFC discussion comments. + // No body text or quote material in the event. + track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' }) setReviewText('') setReviewDraft(null) await refresh() diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index 3e54f1b..c42b8ab 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react' import { proposeRFC } from '../api' +import { EVENTS, track } from '../lib/analytics' function slugify(title) { return title @@ -52,6 +53,10 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) { pitch: pitch.trim(), tags, }) + // v0.15.0 — analytics: fire on the §9.1 propose-RFC submit. + // Slug is a stable, low-cardinality identifier (kebab-case + // ascii); title and pitch stay out of the event body. + track(EVENTS.RFC_PROPOSED, { rfc_slug: slug }) onSubmitted?.(result) } catch (err) { setError(err.message || 'Submission failed.') diff --git a/frontend/src/components/RFCDiscussionPanel.jsx b/frontend/src/components/RFCDiscussionPanel.jsx index 9d812c8..197f293 100644 --- a/frontend/src/components/RFCDiscussionPanel.jsx +++ b/frontend/src/components/RFCDiscussionPanel.jsx @@ -18,6 +18,7 @@ import { postDiscussionMessage, resolveDiscussionThread, } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function RFCDiscussionPanel({ slug, viewer }) { const [threads, setThreads] = useState([]) @@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) { void message_id } setComposer('') + // v0.15.0 — analytics: fire on a successful discussion post. + // surface=discussion distinguishes this from PR review comments + // which fire from PRView with surface=pr. No body text. + track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' }) } catch (err) { setError(err.message) } finally { diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 79936d1..5c7f405 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -44,6 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx' import PRModal from './PRModal.jsx' import GraduateDialog from './GraduateDialog.jsx' import { claimOwnership } from '../api' +import { EVENTS, track } from '../lib/analytics' const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_DEBOUNCE_MS = 800 @@ -121,7 +122,15 @@ export default function RFCView({ viewer }) { const [drawerOpen, setDrawerOpen] = useState(false) useEffect(() => { - getRFC(slug).then(setEntry).catch(err => setError(err.message)) + getRFC(slug).then(entry => { + setEntry(entry) + // v0.15.0 — analytics: fire RFC Viewed once per slug load. + // We key on the slug param rather than the loaded entry so a + // re-render doesn't double-fire; the slug is the stable + // identifier. id is included for join-friendliness in the + // Amplitude dashboard. + track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id }) + }).catch(err => setError(err.message)) listModels(slug) .then(({ models, default: def }) => { setModels(models || []) diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js new file mode 100644 index 0000000..586f5b8 --- /dev/null +++ b/frontend/src/lib/analytics.js @@ -0,0 +1,267 @@ +// analytics.js — v0.15.0 / roadmap item #13. +// +// Wrapper around `@amplitude/analytics-browser` that gates SDK +// initialization on the user's cookie/privacy consent (v0.13.0, +// `frontend/src/lib/consent.js`, SPEC §14.5). The wrapper presents +// a stable surface to the rest of the app: +// +// import { track, identify, anonymize } from './lib/analytics' +// +// track('RFC Viewed', { rfc_slug: 'open-human-model' }) +// identify({ user_id: 'u_123' }) +// anonymize() // call on sign-out +// +// At first import the wrapper: +// 1. Calls `bootstrap()` once, which reads `getConsent()` and +// subscribes to `onConsentChange()`. If consent.analytics is +// true, it lazily imports the Amplitude SDK and calls +// `amplitude.init(API_KEY, { defaultTracking: false })`. +// If consent.analytics is false (or undecided), the SDK is +// not loaded — no network request, no cookies. A later +// consent change to `true` triggers init at that moment. +// 2. The wrapper queues `track()` and `identify()` calls made +// before init finishes (lazy import + consent grant), and +// drains the queue when init completes. +// 3. If the user later flips consent from granted → denied, the +// wrapper calls `amplitude.setOptOut(true)` so subsequent +// events are dropped client-side (the SDK is still loaded — +// we cannot unload a script — but it stops firing). +// +// Consent precedence ladder: +// +// consent.analytics === true → init + track +// consent.analytics === false → no init; or if already init, +// setOptOut(true) +// consent.recorded_at === null → treat as denied (banner is up; +// the user has not yet chosen) +// +// API key resolution: +// +// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the +// Amplitude project's API key. When it is unset/empty, the +// wrapper logs one console warning and no-ops — every public +// function becomes a deterministic no-op so dev environments +// (and deployments that intentionally don't ship analytics) +// keep working. The deploy gesture wires the key via flotilla's +// `secret set` verb (see CHANGELOG for the operator gesture). +// +// Note on bundle visibility: Amplitude browser API keys are embedded +// in the frontend bundle and visible via dev tools — they are +// conventionally treated as semi-sensitive, not truly secret. The +// roadmap binds the value through `flotilla secret set` rather than +// `flotilla overlay set` to keep all-keys-in-Secret-Manager +// regularity for the deployment. See CHANGELOG 0.15.0 for the +// caveat write-up. +// +// PII discipline: +// +// `identify({ user_id })` SHOULD pass only the opaque server- +// side user id (the `viewer.id` integer or string). DO NOT pass +// email, display name, IP, or any other PII through the SDK. +// Event properties SHOULD likewise stay limited to ids and +// enums; free-text fields (titles, comment bodies) MUST NOT be +// sent. +// +// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use +// one of these names rather than firing arbitrary strings — that +// keeps the Amplitude dashboard coherent over time. + +import { getConsent, onConsentChange } from './consent.js' + +const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || '' + +// Public taxonomy. Keep this short and stable — new entries should +// land via a release, not ad-hoc. The strings match the Amplitude +// dashboard names exactly (Title Case, spaces, no punctuation). +export const EVENTS = Object.freeze({ + PAGE_VIEWED: 'Page Viewed', + RFC_VIEWED: 'RFC Viewed', + USER_SIGNED_IN: 'User Signed In', + USER_SIGNED_OUT: 'User Signed Out', + RFC_PROPOSED: 'RFC Proposed', + PR_OPENED: 'PR Opened', + COMMENT_POSTED: 'Comment Posted', + BETA_ACCESS_REQUESTED: 'Beta Access Requested', + ADMIN_PERMISSION_DECISION: 'Admin Permission Decision', +}) + +// Internal state. +let _bootstrapped = false +let _amplitude = null // The dynamically imported SDK module. +let _initPromise = null // Pending init (lazy import + sdk.init). +let _initialized = false // True after sdk.init has resolved. +let _warnedNoKey = false +let _pendingUserId = null // identify() called before init resolves. +const _queue = [] // {kind: 'track'|'identify'|'anonymize', ...} + +function warnNoKey() { + if (_warnedNoKey) return + _warnedNoKey = true + // eslint-disable-next-line no-console + console.warn( + '[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' + + 'will not be sent. This is expected in dev; in production it ' + + 'means the operator has not yet run `flotilla secret set ' + + 'ohm-rfc-app AMPLITUDE_API_KEY`.', + ) +} + +function consentGranted() { + const c = getConsent() + return !!(c && c.recorded_at && c.analytics) +} + +// Drain the queue. Called once init resolves. +function drainQueue() { + if (!_initialized || !_amplitude) return + if (_pendingUserId != null) { + try { _amplitude.setUserId(_pendingUserId) } catch (_) {} + _pendingUserId = null + } + while (_queue.length > 0) { + const item = _queue.shift() + try { + if (item.kind === 'track') { + _amplitude.track(item.name, item.props || {}) + } else if (item.kind === 'identify') { + if (item.user_id != null) _amplitude.setUserId(item.user_id) + } else if (item.kind === 'anonymize') { + _amplitude.reset() + } + } catch (_) { + // SDK errors are non-fatal; analytics is best-effort. + } + } +} + +// Lazy import + init. Resolves once the SDK is ready to take events. +// Idempotent: subsequent calls return the same promise. +async function initSdk() { + if (_initPromise) return _initPromise + if (!API_KEY) { + warnNoKey() + // Resolve immediately with a no-op shape; the wrapper's public + // functions check API_KEY and short-circuit, so this never + // actually runs SDK code. + _initPromise = Promise.resolve(null) + return _initPromise + } + _initPromise = (async () => { + try { + const mod = await import('@amplitude/analytics-browser') + // The SDK exports `init`, `track`, `setUserId`, `reset`, + // `setOptOut` as named functions. We hold the module so the + // queue drainer can call them by name. + _amplitude = mod + // defaultTracking: false — we choose what to send, and we + // already gate on consent here. The SDK's own "default + // tracking" would otherwise capture page-views, sessions, + // and form interactions automatically; we want explicit + // `track('Page Viewed', …)` calls from the app instead. + await mod.init(API_KEY, undefined, { + defaultTracking: false, + }).promise + _initialized = true + drainQueue() + } catch (err) { + // Init failure is non-fatal; keep the wrapper alive so future + // calls no-op. Log once for the operator. + // eslint-disable-next-line no-console + console.warn('[analytics] Amplitude init failed:', err) + _initialized = false + } + return _amplitude + })() + return _initPromise +} + +// Bootstrap is called lazily on first track/identify. It wires the +// consent subscription so a later flip from denied→granted triggers +// init at that moment, and granted→denied flips the opt-out. +function bootstrap() { + if (_bootstrapped) return + _bootstrapped = true + if (consentGranted()) { + // Fire-and-forget; the queue catches any events that arrive + // before init resolves. + initSdk() + } + onConsentChange(snapshot => { + const allowed = !!(snapshot && snapshot.recorded_at && snapshot.analytics) + if (allowed && !_initPromise) { + initSdk() + } else if (allowed && _initialized && _amplitude) { + // Re-enable in case we previously opted out. + try { _amplitude.setOptOut(false) } catch (_) {} + } else if (!allowed && _initialized && _amplitude) { + // Granted → denied. Stop firing. We cannot unload the script + // tag; setOptOut is the SDK's contract for "drop subsequent + // events client-side". + try { _amplitude.setOptOut(true) } catch (_) {} + } + }) +} + +/** Fire a track event. Safe to call before consent / init resolve; + * the call is queued and drained once both are true. Drops the + * event silently if API_KEY is empty (with a one-shot warn) or + * consent.analytics is false. */ +export function track(name, props) { + if (!API_KEY) { warnNoKey(); return } + bootstrap() + if (!consentGranted()) return + if (_initialized && _amplitude) { + try { _amplitude.track(name, props || {}) } catch (_) {} + return + } + _queue.push({ kind: 'track', name, props }) +} + +/** Attach an authenticated user id. Pass `{ user_id: '' }`. + * DO NOT pass email or display name. Idempotent — subsequent calls + * with the same id are cheap. */ +export function identify({ user_id } = {}) { + if (!API_KEY) { warnNoKey(); return } + if (user_id == null) return + bootstrap() + if (!consentGranted()) { + // Hold the id for when consent lands; identify-on-sign-in is a + // common race with the consent banner choice. + _pendingUserId = user_id + return + } + if (_initialized && _amplitude) { + try { _amplitude.setUserId(user_id) } catch (_) {} + return + } + _pendingUserId = user_id + _queue.push({ kind: 'identify', user_id }) +} + +/** Reset the user binding. Call this on sign-out so the next page + * navigations are attributed to a fresh anonymous device id. Has + * no effect when analytics is disabled. */ +export function anonymize() { + if (!API_KEY) { warnNoKey(); return } + _pendingUserId = null + bootstrap() + if (!consentGranted()) return + if (_initialized && _amplitude) { + try { _amplitude.reset() } catch (_) {} + return + } + _queue.push({ kind: 'anonymize' }) +} + +/** Test helper — exposed for unit tests, not for app code. + * Resets module-level state so a fresh bootstrap cycle can be + * exercised. */ +export function __resetForTests() { + _bootstrapped = false + _amplitude = null + _initPromise = null + _initialized = false + _warnedNoKey = false + _pendingUserId = null + _queue.length = 0 +}