Compare commits

..

2 Commits

Author SHA1 Message Date
Ben Stull 6cfbf69e26 v0.15.0 post-correction: @amplitude/unified + session replay + overlay binding
Mid-Session-L correction to the v0.15.0 release that the dispatched
subagent (Session ξ) shipped. ξ was working from a pre-vendor brief
that specified @amplitude/analytics-browser and treated the API key
as a secret via `flotilla secret set`. Operator subsequently
provisioned the Amplitude project, surfaced the vendor's
recommended installation prompt, and confirmed the key value.
Three downstream changes:

- Package: swap @amplitude/analytics-browser → @amplitude/unified
  (analytics + session replay in one install; vendor-recommended).
- Init call: `amplitude.init(KEY, undefined, { defaultTracking: false })`
  becomes `amplitude.initAll(KEY, { analytics: { autocapture: true },
  sessionReplay: { sampleRate: 1 } })`. Vendor's exact installation-
  wizard shape; gates remain on the v0.13.0 consent banner.
- Binding: Amplitude browser keys are bundle-embedded by design
  (same nature as VITE_TURNSTILE_SITE_KEY from v0.12.0), so the key
  is public, not secret. CHANGELOG MUST step rewritten to bind via
  `flotilla overlay set <deployment> VITE_AMPLITUDE_API_KEY=<key>`
  rather than `flotilla secret set`. The roadmap row #13's
  "new secret: AMPLITUDE_API_KEY" wording predated vendor
  consultation; the roadmap will be updated when this ships.

§19.2 candidate captured in CHANGELOG: split the analytics consent
toggle into a separate session-replay category (recording has a
larger privacy footprint than event counters), follow-up release.

Wrapper structural shape (track/identify/anonymize, queue + drain,
consent-flip → setOptOut, lazy import) is unchanged from ξ's work.
Event taxonomy and Login.jsx / App.jsx / Admin.jsx / etc. instrument
sites are unchanged. Frontend build verified green
(VITE_APP_NAME=… npm run build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:44:33 -07:00
Ben Stull 0fd8c52724 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>
2026-05-28 04:40:04 -07:00
3 changed files with 34 additions and 164 deletions
+15 -34
View File
@@ -51,30 +51,16 @@ nothing lands in our DB, no migration.
### Added
- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
surface: `track(name, props)`, `identify({ user_id, properties? })`,
`setUserProperties(properties)`, `anonymize()`, the `EVENTS`
taxonomy constant, and a `__resetForTests` helper. Internally
lazy-imports `@amplitude/unified` and calls
`amplitude.initAll(API_KEY, { analytics: { autocapture: true },
sessionReplay: { sampleRate: 1 } })` only after consent is
granted; queues pre-init calls and drains them on init resolve;
flips `setOptOut(true)` on a granted→denied consent change (stops
both analytics events and session replay). The wrapper subscribes
to `onConsentChange()` so a freshly-banner-clicked "analytics on"
flips the SDK live without a page reload.
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
shipped inline with v0.15.0 instead of waiting for a follow-up).
`identify({ user_id, properties })` accepts a property bag that
applies as an Amplitude `Identify` event with `.set()` semantics
by default; values wrapped as `['__setOnce__', value]` apply with
`.setOnce()` semantics (immutable after first write — for
account-history markers like `first_sign_in_at`). The new
`setUserProperties(properties)` exposes the same property-apply
path for mid-session state changes (role grant/revoke, passcode
set, device trusted) so the Amplitude record stays current without
waiting for the next sign-in. `anonymize()` now clears both the
user_id binding AND the pending-property cache so a subsequent
sign-in as a different user starts with a fully fresh slate.
surface: `track(name, props)`, `identify({ user_id })`,
`anonymize()`, the `EVENTS` taxonomy constant, and a
`__resetForTests` helper. Internally lazy-imports
`@amplitude/unified` and calls `amplitude.initAll(API_KEY,
{ analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } })`
only after consent is granted; queues pre-init calls and drains
them on init resolve; flips `setOptOut(true)` on a granted→denied
consent change (stops both analytics events and session replay).
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
@@ -99,16 +85,11 @@ nothing lands in our DB, no migration.
- `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
revoke action with `action ∈ { 'grant', 'revoke' }` and
`target_user_id` (string).
- **User binding + properties** (`App.jsx`): when `me.authenticated`
lands and a user id is available, the wrapper's
`identify({ user_id, properties })` is called with
`String(viewer.id)` AND a durable property bag — `role`,
`permission_state`, `passcode_set`, `device_trusted` (mutable;
refresh each sign-in), plus `first_sign_in_at` and
`account_created_at` (setOnce — immutable user-history markers).
The sign-out gesture calls `anonymize()` before the nav. No email,
display name, gitea_login, or other PII is passed through the SDK —
Amplitude only sees opaque ids, enums, timestamps, booleans.
- **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/unified`** dependency added to
`frontend/package.json` (analytics + session replay in one
install). Lockfile updated.
+6 -26
View File
@@ -48,43 +48,23 @@ export default function App() {
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
}, [location.pathname, location.search])
// v0.15.0 + #21 Part C — bind the authenticated user id AND
// durable user properties 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.
//
// Property bag passed to identify (set vs setOnce per #21 Part C):
// set: role, permission_state, passcode_set, device_trusted
// (these can change mid-account-life — refresh each sign-in)
// setOnce: first_sign_in_at, account_created_at
// (immutable user-history markers — set on the first
// sign-in that observes them, never overwritten)
//
// PII discipline: NO email, NO display_name, NO gitea_login passed
// through — Amplitude only sees opaque ids + enums + timestamps +
// booleans.
// 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
const viewer = me?.authenticated ? me.user : null
if (uid != null && lastUserIdRef.current !== uid) {
lastUserIdRef.current = uid
const props = {}
if (viewer?.role != null) props.role = viewer.role
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
identify({ user_id: String(uid), properties: props })
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, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
}, [me?.authenticated, me?.user?.id])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
+13 -104
View File
@@ -100,10 +100,7 @@ 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.
let _pendingProperties = null // identify({ properties }) or
// setUserProperties() before init.
const _queue = [] // {kind: 'track'|'identify'|'anonymize'|
// 'setUserProperties', ...}
const _queue = [] // {kind: 'track'|'identify'|'anonymize', ...}
function warnNoKey() {
if (_warnedNoKey) return
@@ -122,26 +119,6 @@ function consentGranted() {
return !!(c && c.recorded_at && c.analytics)
}
// Apply a {key: value} property bag as an Amplitude Identify event.
// Used by both `identify({ properties })` and `setUserProperties`.
function applyProperties(props) {
if (!_initialized || !_amplitude || !props) return
try {
const id = new _amplitude.Identify()
for (const [k, v] of Object.entries(props)) {
if (v === undefined || v === null) continue
if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') {
id.setOnce(k, v[1])
} else {
id.set(k, v)
}
}
_amplitude.identify(id)
} catch (_) {
// SDK errors are non-fatal; analytics is best-effort.
}
}
// Drain the queue. Called once init resolves.
function drainQueue() {
if (!_initialized || !_amplitude) return
@@ -149,10 +126,6 @@ function drainQueue() {
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
_pendingUserId = null
}
if (_pendingProperties != null) {
applyProperties(_pendingProperties)
_pendingProperties = null
}
while (_queue.length > 0) {
const item = _queue.shift()
try {
@@ -160,9 +133,6 @@ function drainQueue() {
_amplitude.track(item.name, item.props || {})
} else if (item.kind === 'identify') {
if (item.user_id != null) _amplitude.setUserId(item.user_id)
if (item.properties != null) applyProperties(item.properties)
} else if (item.kind === 'setUserProperties') {
applyProperties(item.properties)
} else if (item.kind === 'anonymize') {
_amplitude.reset()
}
@@ -267,93 +237,33 @@ export function track(name, props) {
_queue.push({ kind: 'track', name, props })
}
/** Attach an authenticated user id and optional durable properties.
* Pass `{ user_id: '<opaque-id>', properties?: { role, first_sign_in_at, … } }`.
* DO NOT pass email, display name, or other PII as user_id or in
* properties. Idempotent — subsequent calls with the same id are
* cheap; properties are merged into the Amplitude user record.
*
* To mark a property as setOnce (immutable after first write),
* pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`.
* Bare values use Amplitude's `.set()` (mutable).
*
* Pattern (per #21 Part C):
* - On sign-in success in App.jsx: identify with viewer.id + the
* durable property bag (role, permission_state, first_sign_in_at
* setOnce, passcode_set, device_trusted_count, account_created_at
* setOnce).
* - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx:
* identify with the new viewer.id + invitation-derived properties
* (invited_by_admin_id, invited_at setOnce, initial_role, claim_method)
* BEFORE firing any track() — so the Amplitude user record is
* created with the OHM user_id from the first event, not as an
* anonymous device that retroactively links. */
export function identify({ user_id, properties } = {}) {
/** 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 && properties == null) return
if (user_id == null) return
bootstrap()
if (!consentGranted()) {
// Hold for when consent lands; identify-on-sign-in is a common
// race with the consent banner choice.
if (user_id != null) _pendingUserId = user_id
if (properties != null) {
_pendingProperties = { ..._pendingProperties, ...properties }
}
// 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 {
if (user_id != null) _amplitude.setUserId(user_id)
if (properties != null) applyProperties(properties)
} catch (_) {}
try { _amplitude.setUserId(user_id) } catch (_) {}
return
}
if (user_id != null) _pendingUserId = user_id
if (properties != null) {
_pendingProperties = { ..._pendingProperties, ...properties }
}
_queue.push({ kind: 'identify', user_id, properties })
}
/** Update durable user properties on the current Amplitude user
* record mid-session — for state changes that shouldn't wait for the
* next sign-in to surface (role grant/revoke, passcode set, device
* trusted, etc.). Same property shape as `identify({ properties })`.
* setOnce values use the `['__setOnce__', value]` sentinel pattern.
* Has no effect if no identify has happened yet — set the user_id
* via `identify()` first.
*
* Per #21 Part C: call this from any surface where the user's
* Amplitude-relevant state changes mid-session, so the dashboard
* stays current. */
export function setUserProperties(properties) {
if (!API_KEY) { warnNoKey(); return }
if (properties == null) return
bootstrap()
if (!consentGranted()) {
_pendingProperties = { ..._pendingProperties, ...properties }
return
}
if (_initialized && _amplitude) {
applyProperties(properties)
return
}
_pendingProperties = { ..._pendingProperties, ...properties }
_queue.push({ kind: 'setUserProperties', properties })
_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.
*
* Per #21 Part C: clears both the user_id binding AND the pending
* property cache, so a subsequent sign-in as a different user
* starts with a fully fresh slate (no carry-over properties from
* the previous user). */
* no effect when analytics is disabled. */
export function anonymize() {
if (!API_KEY) { warnNoKey(); return }
_pendingUserId = null
_pendingProperties = null
bootstrap()
if (!consentGranted()) return
if (_initialized && _amplitude) {
@@ -373,6 +283,5 @@ export function __resetForTests() {
_initialized = false
_warnedNoKey = false
_pendingUserId = null
_pendingProperties = null
_queue.length = 0
}