72f8457933
Wave 5 / Track A. Roadmap item #13. Folds in #21 Part C user-identity- lifecycle work inline rather than as a follow-up release (operator ask: "implement Amplitude best practices from the very get-go"). Wraps @amplitude/unified with: - consent-gated lazy init (v0.13.0 cookie banner; SDK never loads without explicit analytics opt-in) - amplitude.initAll(KEY, { analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } }) — vendor-recommended shape - identify({ user_id, properties? }) with set vs setOnce semantics - setUserProperties(properties) for mid-session state changes - anonymize() clears both user_id binding AND property cache Wired into App.jsx (identify on me.user with role / permission_state / passcode_set / device_trusted as set; first_sign_in_at / account_created_at as setOnce; Page Viewed on route change; Sign-out fires User Signed Out + anonymize), Login.jsx (User Signed In + Beta Access Requested), and the per-feature surfaces (ProposeModal, PRModal, RFCView, RFCDiscussionPanel, PRView, Admin). Binding: VITE_AMPLITUDE_API_KEY via `flotilla overlay set` (bundle-embedded public key like VITE_TURNSTILE_SITE_KEY — not secret). Mid-Session-L correction: original dispatch brief specified secret-set; vendor guidance + bundle visibility settled it as overlay. PII discipline: no email, no display_name, no gitea_login, no free-text fields ever sent. Properties are opaque ids + enums + timestamps + booleans only. Subagent ξ shipped the wrapper structure + nine-event taxonomy on feature/v0.15.0-amplitude (0fd8c52+6cfbf69post-correction). Driver-side integration extended the wrapper with setUserProperties + identify properties for the #21 Part C identity-lifecycle work. Frontend build verified green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
// 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 <deployment> VITE_AMPLITUDE_API_KEY=<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: '<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 } = {}) {
|
|
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
|
|
}
|