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">
|
||||
|
||||
Reference in New Issue
Block a user