Compare commits

...
3 Commits
Author SHA1 Message Date
westfarn d6f6698a15 Wire Tianji custom event tracking for auth, billing, and chat (#36)
Unit Tests / test (pull_request) Successful in 13s
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 10:37:05 -05:00
westfarn 1c9e04cf86 Merge pull request 'Add cookie / analytics consent banner polish (#37)' (#44) from feat/37-analytics-consent-banner into master
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / deploy-beta (push) Successful in 9s
Unit Tests / test (push) Successful in 12s
Reviewed-on: #44
2026-07-27 08:22:39 -07:00
westfarn 9c814dcc60 Harden analytics consent banner storage and ToS deep-link (#37)
Unit Tests / test (pull_request) Successful in 13s
Version consent in localStorage (v1 + legacy migrate), point banner at Terms analytics section, and document the always-on vs optional policy. Banner/provider shipped in #42.
2026-07-27 09:50:40 -05:00
18 changed files with 230 additions and 29 deletions
+3
View File
@@ -227,6 +227,9 @@ 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)
## Related repos ## Related repos
+32
View File
@@ -0,0 +1,32 @@
# Tianji product events (Hesychia)
Helpers: `llm-fe/src/llm-fe/utils/analytics.ts`
Consent gate: custom events + identify only when `hesychia_analytics_consent*` is `granted` (see #37).
Page views: always via `Tracker` / `tracker.js` on prod + beta.
## Event catalog
| Event name | Constant | Call site(s) | Payload (non-PII) |
|------------|----------|--------------|-------------------|
| Login Success | `LOGIN_SUCCESS` | SignIn, AuthCallback | `{ method: 'password' \| 'sso' }` |
| Login Failed | `LOGIN_FAILED` | SignIn, AuthCallback | `{ method }` |
| Sign Up Started | `SIGNUP_STARTED` | SignUp | — |
| Sign Up Success | `SIGNUP_SUCCESS` | SignUp, AuthCallback (`created`) | `{ method? }` |
| Sign Up Failed | `SIGNUP_FAILED` | SignUp, AuthCallback | `{ method? }` |
| Logout | `LOGOUT` | AnalyticsSession (auth → logged out) | — |
| Checkout Started | `CHECKOUT_STARTED` | SignUp, AuthCallback | `{ source }` |
| Payment Success | `PAYMENT_SUCCESS` | BillingSuccess | `{ hasSessionId }` |
| Payment Canceled | `PAYMENT_CANCELED` | BillingCancel | — |
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | *(pending #33 Account portal CTA)* | — |
## Identify
`identifyAccount(account)` sends `{ userId: email, companyId?, isCompanyManager? }`.
No message text, document contents, or names.
## Environments
Only prod/beta (`REACT_APP_DEPLOY_ENV` or production build with Tianji website id). Local dev skips unless env set.
+2
View File
@@ -22,6 +22,7 @@ import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThe
import Tracker from './llm-fe/components/Tracker/Tracker'; import Tracker from './llm-fe/components/Tracker/Tracker';
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext'; import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
const ProtectedRoutes = () => { const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext); const { authenticated, loading } = useContext(AuthContext);
@@ -45,6 +46,7 @@ class App extends Component {
<GlobalThemeWrapper> <GlobalThemeWrapper>
<AnalyticsConsentProvider> <AnalyticsConsentProvider>
<Tracker /> <Tracker />
<AnalyticsSession />
<AnalyticsConsentBanner /> <AnalyticsConsentBanner />
<div className='site'> <div className='site'>
<main> <main>
@@ -29,19 +29,23 @@ 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')).toBe('granted'); expect(localStorage.getItem('hesychia_analytics_consent_v1')).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')).toBe('denied'); expect(localStorage.getItem('hesychia_analytics_consent_v1')).toBe('denied');
}); });
}); });
@@ -51,11 +51,15 @@ 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 our{' '} signed in, we may associate that activity with your account. See the{' '}
<Link component={RouterLink} to="/terms_of_service/" underline="hover"> <Link
Terms of Service component={RouterLink}
to="/terms_of_service/#analytics"
underline="hover"
>
Analytics and Cookies
</Link>{' '} </Link>{' '}
for details. section of our Terms of Service 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 }}>
@@ -0,0 +1,36 @@
import { useContext, useEffect, useRef } from 'react';
import { AccountContext } from '../contexts/AccountContext';
import { AuthContext } from '../contexts/AuthContext';
import { useAnalyticsConsent } from '../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../utils/analytics';
/**
* Identify on authenticated session; track Logout when session ends.
* Mount inside AnalyticsConsentProvider.
*/
const AnalyticsSession = (): null => {
const { account } = useContext(AccountContext);
const { authenticated, loading } = useContext(AuthContext);
const { hasConsent } = useAnalyticsConsent();
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (!hasConsent || loading) return;
if (account) {
identifyAccount(account);
wasAuthenticatedRef.current = true;
return;
}
if (wasAuthenticatedRef.current && !authenticated) {
trackEvent(AnalyticsEvents.LOGOUT);
}
wasAuthenticatedRef.current = authenticated;
}, [account, authenticated, hasConsent, loading]);
return null;
};
export default AnalyticsSession;
@@ -5,6 +5,7 @@ import { ConversationContext } from "./ConversationContext";
import { ConversationPrompt, ConversationPromptType } from "../data"; import { ConversationPrompt, ConversationPromptType } from "../data";
import { axiosInstance } from "../../axiosApi"; import { axiosInstance } from "../../axiosApi";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
type MessageProviderProps ={ type MessageProviderProps ={
children? : ReactNode; children? : ReactNode;
@@ -177,7 +178,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
if (messageResponsePart.current === 1){ if (messageResponsePart.current === 1){
// this has to do with the conversation id // this has to do with the conversation id
if(!selectedConversation){ if(!selectedConversation){
setSelectedConversation(Number(message)) const conversationId = Number(message);
setSelectedConversation(conversationId)
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
conversationId,
});
} }
} }
else if (messageResponsePart.current === 2){ else if (messageResponsePart.current === 2){
@@ -16,6 +16,7 @@ import { MessageContext } from "../../contexts/MessageContext";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"; import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import Header2 from "../../components/Header2/Header2"; import Header2 from "../../components/Header2/Header2";
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
// Styled Components // Styled Components
const PageContainer = styled.div` const PageContainer = styled.div`
@@ -335,6 +336,10 @@ const AsyncDashboardInner = (): JSX.Element => {
conversationRef.current = tempConversations; conversationRef.current = tempConversations;
setConversationDetails(tempConversations); setConversationDetails(tempConversations);
sendMessage(prompt, selectedConversation, file, fileType, modelName); sendMessage(prompt, selectedConversation, file, fileType, modelName);
trackEvent(AnalyticsEvents.MESSAGE_SENT, {
hasConversation: Boolean(selectedConversation),
hasAttachment: Boolean(file),
});
resetForm({ resetForm({
values: { values: {
prompt: "", prompt: "",
@@ -9,6 +9,7 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext'; import { AccountContext } from '../../contexts/AccountContext';
import { Account, AccountType } from '../../data'; import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
@@ -102,6 +103,7 @@ const AuthCallback = (): JSX.Element => {
const run = async () => { const run = async () => {
const error = searchParams.get('error'); const error = searchParams.get('error');
if (error) { if (error) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', error });
setErrorMessage( setErrorMessage(
oauthErrorMessage(error, searchParams.get('error_description')) oauthErrorMessage(error, searchParams.get('error_description'))
); );
@@ -112,8 +114,10 @@ const AuthCallback = (): JSX.Element => {
const access = searchParams.get('access'); const access = searchParams.get('access');
const refresh = searchParams.get('refresh'); const refresh = searchParams.get('refresh');
const needsCheckout = searchParams.get('needs_checkout') === '1'; const needsCheckout = searchParams.get('needs_checkout') === '1';
const isNewUser = searchParams.get('created') === '1';
if (!access || !refresh) { if (!access || !refresh) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', reason: 'missing_tokens' });
setErrorMessage('Missing sign-in tokens. Please try again.'); setErrorMessage('Missing sign-in tokens. Please try again.');
setStatusText('Sign-in failed'); setStatusText('Sign-in failed');
return; return;
@@ -148,9 +152,17 @@ const AuthCallback = (): JSX.Element => {
setAuthentication(true); setAuthentication(true);
setNeedsNewPassword(get_user_response.data.has_usable_password); setNeedsNewPassword(get_user_response.data.has_usable_password);
if (isNewUser) {
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'sso' });
} else {
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
}
identifyAccount(account);
if (needsCheckout) { if (needsCheckout) {
setStatusText('Starting checkout…'); setStatusText('Starting checkout…');
const { success_url, cancel_url } = checkoutReturnUrls(); const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', { const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url, success_url,
cancel_url, cancel_url,
@@ -175,6 +187,7 @@ const AuthCallback = (): JSX.Element => {
} catch (err) { } catch (err) {
console.log(err); console.log(err);
if (!cancelled) { if (!cancelled) {
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso' });
setErrorMessage('Could not finish sign-in. Try again.'); setErrorMessage('Could not finish sign-in. Try again.');
setStatusText('Sign-in failed'); setStatusText('Sign-in failed');
} }
@@ -1,7 +1,8 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
@@ -81,6 +82,10 @@ const NavLink = styled(Link)`
const BillingCancel = (): JSX.Element => { const BillingCancel = (): JSX.Element => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
trackEvent(AnalyticsEvents.PAYMENT_CANCELED);
}, []);
return ( return (
<PageContainer> <PageContainer>
<ParticleBackground /> <ParticleBackground />
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import { AuthContext } from '../../contexts/AuthContext'; import { AuthContext } from '../../contexts/AuthContext';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
const PageContainer = styled.div` const PageContainer = styled.div`
position: relative; position: relative;
@@ -85,6 +86,12 @@ const BillingSuccess = (): JSX.Element => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [sessionId] = useState(() => searchParams.get('session_id') || ''); const [sessionId] = useState(() => searchParams.get('session_id') || '');
useEffect(() => {
trackEvent(AnalyticsEvents.PAYMENT_SUCCESS, {
hasSessionId: Boolean(sessionId),
});
}, [sessionId]);
useEffect(() => { useEffect(() => {
if (!loading && authenticated) { if (!loading && authenticated) {
const timer = window.setTimeout(() => navigate('/'), 2500); const timer = window.setTimeout(() => navigate('/'), 2500);
+4 -1
View File
@@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data'; import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import styled from 'styled-components'; import styled from 'styled-components';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -214,7 +215,8 @@ const SignIn = (): JSX.Element => {
setAccount(account); setAccount(account);
setAuthentication(true) setAuthentication(true)
setNeedsNewPassword(get_user_response.data.has_usable_password) setNeedsNewPassword(get_user_response.data.has_usable_password)
// TODO: terms of service trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
identifyAccount(account);
if (account.has_signed_tos) { if (account.has_signed_tos) {
navigate('/'); navigate('/');
} else { } else {
@@ -223,6 +225,7 @@ const SignIn = (): JSX.Element => {
} catch (error) { } catch (error) {
console.log(error) console.log(error)
trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'password' });
setErrorMessage('Error retrieving account. Try again'); setErrorMessage('Error retrieving account. Try again');
} }
} }
+7 -1
View File
@@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data'; import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import styled from 'styled-components'; import styled from 'styled-components';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -224,6 +225,7 @@ const SignUp = (): JSX.Element => {
const handleSignUp = async (values: SignUpValues): Promise<void> => { const handleSignUp = async (values: SignUpValues): Promise<void> => {
setErrorMessage(''); setErrorMessage('');
trackEvent(AnalyticsEvents.SIGNUP_STARTED, { method: 'password' });
try { try {
const registerResponse = await axiosInstance.post('/user/create/', { const registerResponse = await axiosInstance.post('/user/create/', {
email: values.email, email: values.email,
@@ -235,9 +237,12 @@ const SignUp = (): JSX.Element => {
await setTokens(registerResponse.data.access, registerResponse.data.refresh); await setTokens(registerResponse.data.access, registerResponse.data.refresh);
applyAccessToken(registerResponse.data.access); applyAccessToken(registerResponse.data.access);
await loadAccount(); const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account);
const { success_url, cancel_url } = checkoutReturnUrls(); const { success_url, cancel_url } = checkoutReturnUrls();
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
const checkoutResponse = await axiosInstance.post('/finance/checkout/', { const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url, success_url,
cancel_url, cancel_url,
@@ -252,6 +257,7 @@ const SignUp = (): JSX.Element => {
window.location.assign(checkoutUrl); window.location.assign(checkoutUrl);
} catch (error: unknown) { } catch (error: unknown) {
console.log(error); console.log(error);
trackEvent(AnalyticsEvents.SIGNUP_FAILED, { method: 'password' });
const axiosError = error as { const axiosError = error as {
response?: { status?: number; data?: { detail?: string; email?: string[] } }; response?: { status?: number; data?: { detail?: string; email?: string[] } };
}; };
@@ -7,6 +7,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data'; import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument'; import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout'; import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
type TermsOfServiceProps = { type TermsOfServiceProps = {
/** Marketing / legal alias: always read-only chrome (no app shell). */ /** Marketing / legal alias: always read-only chrome (no app shell). */
@@ -30,6 +31,7 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
setSubmitting(true); setSubmitting(true);
try { try {
await axiosInstance.post('/user/acknowledge_tos/'); await axiosInstance.post('/user/acknowledge_tos/');
trackEvent(AnalyticsEvents.TOS_ACKNOWLEDGED);
if (account) { if (account) {
setAccount(new Account({ ...account, has_signed_tos: true })); setAccount(new Account({ ...account, has_signed_tos: true }));
} }
+28 -4
View File
@@ -1,4 +1,5 @@
import { flushAnalyticsQueue, identifyUser, trackEvent } from './analytics'; import { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data';
describe('analytics event helpers', () => { describe('analytics event helpers', () => {
const originalEnv = process.env; const originalEnv = process.env;
@@ -23,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', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
const track = jest.fn(); const track = jest.fn();
window.tianji = { track, identify: jest.fn() }; window.tianji = { track, identify: jest.fn() };
@@ -32,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', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
trackEvent('Checkout Started'); trackEvent('Checkout Started');
const track = jest.fn(); const track = jest.fn();
@@ -49,8 +50,31 @@ describe('analytics event helpers', () => {
identifyUser({ userId: 'u1' }); identifyUser({ userId: 'u1' });
expect(identify).not.toHaveBeenCalled(); expect(identify).not.toHaveBeenCalled();
localStorage.setItem('hesychia_analytics_consent', 'granted'); localStorage.setItem('hesychia_analytics_consent_v1', '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', () => {
localStorage.setItem('hesychia_analytics_consent_v1', 'granted');
const identify = jest.fn();
window.tianji = { track: jest.fn(), identify };
identifyAccount(
new Account({
email: 'user@example.com',
first_name: 'Secret',
last_name: 'Name',
is_company_manager: true,
company: { id: 42, name: 'Acme' },
}),
);
expect(identify).toHaveBeenCalledWith({
userId: 'user@example.com',
companyId: 42,
isCompanyManager: true,
});
expect(JSON.stringify(identify.mock.calls[0][0])).not.toMatch(/Secret|Name|Acme/);
});
}); });
+30 -1
View File
@@ -1,4 +1,5 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent'; import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
type QueuedCall = type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> } | { type: 'track'; eventName: string; data?: Record<string, unknown> }
@@ -6,7 +7,21 @@ type QueuedCall =
const queue: QueuedCall[] = []; const queue: QueuedCall[] = [];
/** Named events for product analytics. Page views stay on automatic tracker.js. */ /**
* Named product events for Tianji (consent-gated). Page views stay on tracker.js.
*
* | Event | When |
* |-------|------|
* | Login Success / Failed | Password or SSO sign-in |
* | Sign Up Started / Success / Failed | Self-serve registration |
* | Logout | Sign out |
* | Checkout Started | Stripe Checkout session created |
* | Payment Success / Canceled | Billing return URLs |
* | Conversation Created | New chat id assigned over WS |
* | Message Sent | User submits prompt (no content) |
* | ToS Acknowledged | POST acknowledge_tos succeeds |
* | Billing Portal Opened | When #33 portal CTA ships |
*/
export const AnalyticsEvents = { export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success', LOGIN_SUCCESS: 'Login Success',
LOGIN_FAILED: 'Login Failed', LOGIN_FAILED: 'Login Failed',
@@ -75,3 +90,17 @@ export const identifyUser = (userInfo: Record<string, unknown>): void => {
queue.push({ type: 'identify', userInfo: { ...userInfo } }); queue.push({ type: 'identify', userInfo: { ...userInfo } });
}; };
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
const traits: Record<string, unknown> = {
userId: account.email,
};
if (account.company?.id != null) {
traits.companyId = account.company.id;
}
if (account.is_company_manager) {
traits.isCompanyManager = true;
}
identifyUser(traits);
};
@@ -5,10 +5,9 @@ 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;
@@ -64,26 +63,33 @@ describe('analytics consent storage', () => {
expect(hasAnalyticsConsent()).toBe(true); expect(hasAnalyticsConsent()).toBe(true);
}); });
it('reads granted and denied from localStorage', () => { it('reads granted and denied from versioned localStorage key', () => {
localStorage.setItem(CONSENT_KEY, 'granted'); localStorage.setItem(CONSENT_STORAGE_KEY, 'granted');
expect(getAnalyticsConsent()).toBe('granted'); expect(getAnalyticsConsent()).toBe('granted');
expect(isAnalyticsConsentResolved()).toBe(true); expect(isAnalyticsConsentResolved()).toBe(true);
localStorage.setItem(CONSENT_KEY, 'denied'); localStorage.setItem(CONSENT_STORAGE_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_KEY)).toBe('denied'); expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('denied');
expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenCalled();
setAnalyticsConsent('granted'); setAnalyticsConsent('granted');
expect(localStorage.getItem(CONSENT_KEY)).toBe('granted'); expect(localStorage.getItem(CONSENT_STORAGE_KEY)).toBe('granted');
window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener); window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
}); });
+22 -7
View File
@@ -1,6 +1,10 @@
export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending'; export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending';
const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent'; /** Versioned so policy/copy changes can force a fresh choice later. */
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). */
@@ -14,17 +18,27 @@ export const isAnalyticsEnvironment = (): boolean => {
); );
}; };
export const getAnalyticsConsent = (): AnalyticsConsentStatus => { const readStoredConsent = (): AnalyticsConsentStatus | null => {
if (!isAnalyticsEnvironment()) return 'granted';
try { try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY); const current = localStorage.getItem(CONSENT_STORAGE_KEY);
if (value === 'granted' || value === 'denied') return value; if (current === 'granted' || current === 'denied') return current;
const legacy = localStorage.getItem(LEGACY_CONSENT_STORAGE_KEY);
if (legacy === 'granted' || legacy === 'denied') {
localStorage.setItem(CONSENT_STORAGE_KEY, legacy);
localStorage.removeItem(LEGACY_CONSENT_STORAGE_KEY);
return legacy;
}
} catch { } catch {
/* localStorage unavailable */ /* localStorage unavailable */
} }
return 'pending'; return null;
};
export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
if (!isAnalyticsEnvironment()) return 'granted';
return readStoredConsent() ?? 'pending';
}; };
export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted'; export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted';
@@ -34,6 +48,7 @@ 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 */
} }