Compare commits

..
1 Commits
Author SHA1 Message Date
westfarn 3a4a463416 Wire Tianji custom event tracking for auth, billing, and chat (#36)
Unit Tests / test (pull_request) Successful in 12s
Add identifyAccount + AnalyticsSession, instrument MVP funnel call sites without PII payloads, and document event names in ANALYTICS.md. Billing portal event reserved until #33.
2026-07-27 09:51:19 -05:00
6 changed files with 24 additions and 55 deletions
-2
View File
@@ -227,8 +227,6 @@ See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operat
- Script: `https://tianji.aimloperations.com/tracker.js` - Script: `https://tianji.aimloperations.com/tracker.js`
- **Page views** always load on prod/beta (no consent gate) - **Page views** always load on prod/beta (no consent gate)
- **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`) - **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`)
- Consent choice stored in `localStorage` key `hesychia_analytics_consent_v1` (`granted` / `denied`)
- Banner links to `/terms_of_service/#analytics` for the always-on vs optional split
- Event catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md) - Event catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md)
## Related repos ## Related repos
@@ -29,23 +29,19 @@ describe('AnalyticsConsentBanner', () => {
renderBanner(); renderBanner();
expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument();
expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument(); expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Analytics and Cookies/i })).toHaveAttribute(
'href',
'/terms_of_service/#analytics',
);
}); });
it('hides after accept and persists granted', () => { it('hides after accept and persists granted', () => {
renderBanner(); renderBanner();
fireEvent.click(screen.getByRole('button', { name: /accept analytics/i })); fireEvent.click(screen.getByRole('button', { name: /accept analytics/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent_v1')).toBe('granted'); expect(localStorage.getItem('hesychia_analytics_consent')).toBe('granted');
}); });
it('hides after decline and persists denied', () => { it('hides after decline and persists denied', () => {
renderBanner(); renderBanner();
fireEvent.click(screen.getByRole('button', { name: /decline/i })); fireEvent.click(screen.getByRole('button', { name: /decline/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent_v1')).toBe('denied'); expect(localStorage.getItem('hesychia_analytics_consent')).toBe('denied');
}); });
}); });
@@ -51,15 +51,11 @@ const AnalyticsConsentBanner = (): JSX.Element | null => {
We always collect anonymous page views and referral data to understand traffic. We always collect anonymous page views and referral data to understand traffic.
With your consent, we also collect how you use features (sign-in, checkout, chat With your consent, we also collect how you use features (sign-in, checkout, chat
actions, and similar). We do not use advertising cookies. When you accept and are actions, and similar). We do not use advertising cookies. When you accept and are
signed in, we may associate that activity with your account. See the{' '} signed in, we may associate that activity with your account. See our{' '}
<Link <Link component={RouterLink} to="/terms_of_service/" underline="hover">
component={RouterLink} Terms of Service
to="/terms_of_service/#analytics"
underline="hover"
>
Analytics and Cookies
</Link>{' '} </Link>{' '}
section of our Terms of Service for details. for details.
</Typography> </Typography>
</Box> </Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}>
+4 -4
View File
@@ -24,7 +24,7 @@ describe('analytics event helpers', () => {
}); });
it('tracks custom events when consent granted', () => { it('tracks custom events when consent granted', () => {
localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); localStorage.setItem('hesychia_analytics_consent', 'granted');
const track = jest.fn(); const track = jest.fn();
window.tianji = { track, identify: jest.fn() }; window.tianji = { track, identify: jest.fn() };
@@ -33,7 +33,7 @@ describe('analytics event helpers', () => {
}); });
it('queues events until tianji is ready then flushes after consent', () => { it('queues events until tianji is ready then flushes after consent', () => {
localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); localStorage.setItem('hesychia_analytics_consent', 'granted');
trackEvent('Checkout Started'); trackEvent('Checkout Started');
const track = jest.fn(); const track = jest.fn();
@@ -50,13 +50,13 @@ describe('analytics event helpers', () => {
identifyUser({ userId: 'u1' }); identifyUser({ userId: 'u1' });
expect(identify).not.toHaveBeenCalled(); expect(identify).not.toHaveBeenCalled();
localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); localStorage.setItem('hesychia_analytics_consent', 'granted');
identifyUser({ userId: 'u1' }); identifyUser({ userId: 'u1' });
expect(identify).toHaveBeenCalledWith({ userId: 'u1' }); expect(identify).toHaveBeenCalledWith({ userId: 'u1' });
}); });
it('identifyAccount sends email as userId without names', () => { it('identifyAccount sends email as userId without names', () => {
localStorage.setItem('hesychia_analytics_consent_v1', 'granted'); localStorage.setItem('hesychia_analytics_consent', 'granted');
const identify = jest.fn(); const identify = jest.fn();
window.tianji = { track: jest.fn(), identify }; window.tianji = { track: jest.fn(), identify };
@@ -5,9 +5,10 @@ import {
isAnalyticsEnvironment, isAnalyticsEnvironment,
setAnalyticsConsent, setAnalyticsConsent,
ANALYTICS_CONSENT_CHANGED_EVENT, ANALYTICS_CONSENT_CHANGED_EVENT,
CONSENT_STORAGE_KEY,
} from './analyticsConsent'; } from './analyticsConsent';
const CONSENT_KEY = 'hesychia_analytics_consent';
describe('isAnalyticsEnvironment', () => { describe('isAnalyticsEnvironment', () => {
const originalEnv = process.env; const originalEnv = process.env;
@@ -63,33 +64,26 @@ describe('analytics consent storage', () => {
expect(hasAnalyticsConsent()).toBe(true); expect(hasAnalyticsConsent()).toBe(true);
}); });
it('reads granted and denied from versioned localStorage key', () => { it('reads granted and denied from localStorage', () => {
localStorage.setItem(CONSENT_STORAGE_KEY, 'granted'); localStorage.setItem(CONSENT_KEY, 'granted');
expect(getAnalyticsConsent()).toBe('granted'); expect(getAnalyticsConsent()).toBe('granted');
expect(isAnalyticsConsentResolved()).toBe(true); expect(isAnalyticsConsentResolved()).toBe(true);
localStorage.setItem(CONSENT_STORAGE_KEY, 'denied'); localStorage.setItem(CONSENT_KEY, 'denied');
expect(getAnalyticsConsent()).toBe('denied'); expect(getAnalyticsConsent()).toBe('denied');
expect(hasAnalyticsConsent()).toBe(false); expect(hasAnalyticsConsent()).toBe(false);
}); });
it('migrates legacy consent key to v1', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted');
expect(getAnalyticsConsent()).toBe('granted');
expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted');
expect(localStorage.getItem('hesychia_analytics_consent')).toBeNull();
});
it('persists consent and dispatches change event', () => { it('persists consent and dispatches change event', () => {
const listener = jest.fn(); const listener = jest.fn();
window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
setAnalyticsConsent('denied'); setAnalyticsConsent('denied');
expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('denied'); expect(localStorage.getItem(CONSENT_KEY)).toBe('denied');
expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenCalled();
setAnalyticsConsent('granted'); setAnalyticsConsent('granted');
expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted'); expect(localStorage.getItem(CONSENT_KEY)).toBe('granted');
window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
}); });
+7 -22
View File
@@ -1,10 +1,6 @@
export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending'; export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending';
/** Versioned so policy/copy changes can force a fresh choice later. */ const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent';
export const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent_v1';
/** Pre-#37 key from beta Tianji baseline (#42); migrated once on read. */
const LEGACY_CONSENT_STORAGE_KEY = 'hesychia_analytics_consent';
export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed'; export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed';
/** Prod + beta builds only (CRA NODE_ENV is production for both). */ /** Prod + beta builds only (CRA NODE_ENV is production for both). */
@@ -18,27 +14,17 @@ export const isAnalyticsEnvironment = (): boolean => {
); );
}; };
const readStoredConsent = (): AnalyticsConsentStatus | null => { export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
try { if (!isAnalyticsEnvironment()) return 'granted';
const current = localStorage.getItem(CONSENT_STORAGE_KEY);
if (current === 'granted' || current === 'denied') return current;
const legacy = localStorage.getItem(LEGACY_CONSENT_STORAGE_KEY); try {
if (legacy === 'granted' || legacy === 'denied') { const value = localStorage.getItem(CONSENT_STORAGE_KEY);
localStorage.setItem(CONSENT_STORAGE_KEY, legacy); if (value === 'granted' || value === 'denied') return value;
localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY);
return legacy;
}
} catch { } catch {
/* localStorage unavailable */ /* localStorage unavailable */
} }
return null; return 'pending';
};
export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
if (!isAnalyticsEnvironment()) return 'granted';
return readStoredConsent() ?? 'pending';
}; };
export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted'; export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted';
@@ -48,7 +34,6 @@ export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() !
export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => { export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => {
try { try {
localStorage.setItem(CONSENT_STORAGE_KEY, status); localStorage.setItem(CONSENT_STORAGE_KEY, status);
localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY);
} catch { } catch {
/* localStorage unavailable */ /* localStorage unavailable */
} }