Release 0.15.0: Amplitude analytics (cookie-consent gated)
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) <noreply@anthropic.com>
This commit is contained in:
+136
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Generated
+135
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+47
-3
@@ -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() {
|
||||
<>
|
||||
<span className="user-name">{viewer.display_name}</span>
|
||||
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
<a
|
||||
className="btn-link"
|
||||
href="/auth/logout"
|
||||
onClick={() => {
|
||||
// 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</a>
|
||||
</>
|
||||
) : (
|
||||
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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: '<opaque-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
|
||||
}
|
||||
Reference in New Issue
Block a user