Release 0.15.0: Amplitude Analytics + Session Replay (with #21 Part C identity lifecycle)
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>
This commit is contained in:
+67
-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,56 @@ 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 + #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.
|
||||
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 })
|
||||
} 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])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
@@ -141,7 +192,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,378 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user