// analytics.js — v0.15.0 / roadmap item #13. // // Wrapper around `@amplitude/unified` (Amplitude Analytics + // Session Replay) 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.initAll(API_KEY, { analytics: { autocapture: true }, // sessionReplay: { sampleRate: 1 } })`. // If consent.analytics is false (or undecided), the SDK is // not loaded — no network request, no cookies, no session // replay recording. 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 and session replay stops // recording (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) // // Session replay scope: this release ships session replay at // `sampleRate: 1` (100% of sessions are recorded for full-DOM // playback). That is the vendor-recommended default for new // Amplitude deployments. The v0.13.0 consent banner's single // "analytics" toggle gates both events and session replay together — // a separate consent category for session-replay specifically is a // §19.2 follow-up. // // 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 // `overlay set` verb (see CHANGELOG for the operator gesture): // Amplitude browser keys are bundle-embedded by design (visible // to anyone with dev tools, same nature as the v0.12.0 // `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not // secret. // // 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. let _pendingProperties = null // identify({ properties }) or // setUserProperties() before init. const _queue = [] // {kind: 'track'|'identify'|'anonymize'| // 'setUserProperties', ...} function warnNoKey() { if (_warnedNoKey) return _warnedNoKey = true // eslint-disable-next-line no-console console.warn( '[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' + 'and session replay will not be sent. This is expected in dev; ' + 'in production it means the operator has not yet run ' + '`flotilla overlay set VITE_AMPLITUDE_API_KEY=`.', ) } function consentGranted() { const c = getConsent() 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 if (_pendingUserId != null) { try { _amplitude.setUserId(_pendingUserId) } catch (_) {} _pendingUserId = null } if (_pendingProperties != null) { applyProperties(_pendingProperties) _pendingProperties = 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) if (item.properties != null) applyProperties(item.properties) } else if (item.kind === 'setUserProperties') { applyProperties(item.properties) } 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/unified') // The unified package exposes `initAll`, `track`, // `setUserId`, `reset`, `setOptOut` as named functions. // We hold the module so the queue drainer can call them // by name. _amplitude = mod // initAll wires up both Analytics and Session Replay in one // call. Vendor-recommended init shape from the Amplitude // installation wizard: // - analytics.autocapture: true — auto-instruments page // views, session start/end, clicks, and form interactions. // Our explicit `track('Page Viewed', …)` etc. layer on top // for app-specific names that survive renames. // - sessionReplay.sampleRate: 1 — record 100% of sessions // for full-DOM playback. Gated by the v0.13.0 consent // banner just like the rest of the SDK; never starts // recording without explicit analytics opt-in. const ret = mod.initAll(API_KEY, { analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 }, }) // initAll returns an AmplitudeReturn with a `.promise` accessor // (consistent with the legacy `init`). Some unified builds // resolve synchronously; await defensively. if (ret && ret.promise) await ret.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 and optional durable properties. * Pass `{ user_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 } = {}) { if (!API_KEY) { warnNoKey(); return } if (user_id == null && properties == 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 } } return } if (_initialized && _amplitude) { try { if (user_id != null) _amplitude.setUserId(user_id) if (properties != null) applyProperties(properties) } 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 }) } /** 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). */ export function anonymize() { if (!API_KEY) { warnNoKey(); return } _pendingUserId = null _pendingProperties = 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 _pendingProperties = null _queue.length = 0 }