Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cfbf69e26 | |||
| 0fd8c52724 |
+15
-34
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user