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