diff --git a/README.md b/README.md index edd3841..ca7b5a1 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operat - **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 diff --git a/llm-fe/ANALYTICS.md b/llm-fe/ANALYTICS.md new file mode 100644 index 0000000..786b2b6 --- /dev/null +++ b/llm-fe/ANALYTICS.md @@ -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. diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index afe35a4..50a46d8 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -22,6 +22,7 @@ import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThe import Tracker from './llm-fe/components/Tracker/Tracker'; import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext'; import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; +import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession'; const ProtectedRoutes = () => { const { authenticated, loading } = useContext(AuthContext); @@ -45,6 +46,7 @@ class App extends Component { +
diff --git a/llm-fe/src/llm-fe/components/AnalyticsSession/AnalyticsSession.tsx b/llm-fe/src/llm-fe/components/AnalyticsSession/AnalyticsSession.tsx new file mode 100644 index 0000000..359e391 --- /dev/null +++ b/llm-fe/src/llm-fe/components/AnalyticsSession/AnalyticsSession.tsx @@ -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(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; diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.tsx index 7ae4a44..de83c48 100644 --- a/llm-fe/src/llm-fe/contexts/MessageContext.tsx +++ b/llm-fe/src/llm-fe/contexts/MessageContext.tsx @@ -5,6 +5,7 @@ import { ConversationContext } from "./ConversationContext"; import { ConversationPrompt, ConversationPromptType } from "../data"; import { axiosInstance } from "../../axiosApi"; import { AxiosResponse } from "axios"; +import { AnalyticsEvents, trackEvent } from "../utils/analytics"; type MessageProviderProps ={ children? : ReactNode; @@ -177,7 +178,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => { if (messageResponsePart.current === 1){ // this has to do with the conversation id if(!selectedConversation){ - setSelectedConversation(Number(message)) + const conversationId = Number(message); + setSelectedConversation(conversationId) + trackEvent(AnalyticsEvents.CONVERSATION_CREATED, { + conversationId, + }); } } else if (messageResponsePart.current === 2){ diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index b40b414..6aa4fb1 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -16,6 +16,7 @@ import { MessageContext } from "../../contexts/MessageContext"; import ParticleBackground from "../../components/ParticleBackground/ParticleBackground"; import Header2 from "../../components/Header2/Header2"; +import { AnalyticsEvents, trackEvent } from "../../utils/analytics"; // Styled Components const PageContainer = styled.div` @@ -335,6 +336,10 @@ const AsyncDashboardInner = (): JSX.Element => { conversationRef.current = tempConversations; setConversationDetails(tempConversations); sendMessage(prompt, selectedConversation, file, fileType, modelName); + trackEvent(AnalyticsEvents.MESSAGE_SENT, { + hasConversation: Boolean(selectedConversation), + hasAttachment: Boolean(file), + }); resetForm({ values: { prompt: "", diff --git a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx index 22e942f..c25a1b7 100644 --- a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx +++ b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx @@ -9,6 +9,7 @@ import { AuthContext } from '../../contexts/AuthContext'; import { AccountContext } from '../../contexts/AccountContext'; import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; +import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; const PageContainer = styled.div` position: relative; @@ -102,6 +103,7 @@ const AuthCallback = (): JSX.Element => { const run = async () => { const error = searchParams.get('error'); if (error) { + trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', error }); setErrorMessage( oauthErrorMessage(error, searchParams.get('error_description')) ); @@ -112,8 +114,10 @@ const AuthCallback = (): JSX.Element => { const access = searchParams.get('access'); const refresh = searchParams.get('refresh'); const needsCheckout = searchParams.get('needs_checkout') === '1'; + const isNewUser = searchParams.get('created') === '1'; if (!access || !refresh) { + trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso', reason: 'missing_tokens' }); setErrorMessage('Missing sign-in tokens. Please try again.'); setStatusText('Sign-in failed'); return; @@ -148,9 +152,17 @@ const AuthCallback = (): JSX.Element => { setAuthentication(true); 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) { setStatusText('Starting checkout…'); const { success_url, cancel_url } = checkoutReturnUrls(); + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' }); const checkoutResponse = await axiosInstance.post('/finance/checkout/', { success_url, cancel_url, @@ -175,6 +187,7 @@ const AuthCallback = (): JSX.Element => { } catch (err) { console.log(err); if (!cancelled) { + trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'sso' }); setErrorMessage('Could not finish sign-in. Try again.'); setStatusText('Sign-in failed'); } diff --git a/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx b/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx index 2d51f2d..e4b31f7 100644 --- a/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx +++ b/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; const PageContainer = styled.div` position: relative; @@ -81,6 +82,10 @@ const NavLink = styled(Link)` const BillingCancel = (): JSX.Element => { const navigate = useNavigate(); + useEffect(() => { + trackEvent(AnalyticsEvents.PAYMENT_CANCELED); + }, []); + return ( diff --git a/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx b/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx index b07ea20..310a62a 100644 --- a/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx +++ b/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import styled from 'styled-components'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import { AuthContext } from '../../contexts/AuthContext'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; const PageContainer = styled.div` position: relative; @@ -85,6 +86,12 @@ const BillingSuccess = (): JSX.Element => { const [searchParams] = useSearchParams(); const [sessionId] = useState(() => searchParams.get('session_id') || ''); + useEffect(() => { + trackEvent(AnalyticsEvents.PAYMENT_SUCCESS, { + hasSessionId: Boolean(sessionId), + }); + }, [sessionId]); + useEffect(() => { if (!loading && authenticated) { const timer = window.setTimeout(() => navigate('/'), 2500); diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index e3d76e3..f307971 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios'; import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; +import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import styled from 'styled-components'; import * as Yup from 'yup'; @@ -214,7 +215,8 @@ const SignIn = (): JSX.Element => { setAccount(account); setAuthentication(true) 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) { navigate('/'); } else { @@ -223,6 +225,7 @@ const SignIn = (): JSX.Element => { } catch (error) { console.log(error) + trackEvent(AnalyticsEvents.LOGIN_FAILED, { method: 'password' }); setErrorMessage('Error retrieving account. Try again'); } } diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx index bf84e78..fcfc566 100644 --- a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx @@ -9,6 +9,7 @@ import { AxiosResponse } from 'axios'; import { Account, AccountType } from '../../data'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; +import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import styled from 'styled-components'; import * as Yup from 'yup'; @@ -224,6 +225,7 @@ const SignUp = (): JSX.Element => { const handleSignUp = async (values: SignUpValues): Promise => { setErrorMessage(''); + trackEvent(AnalyticsEvents.SIGNUP_STARTED, { method: 'password' }); try { const registerResponse = await axiosInstance.post('/user/create/', { email: values.email, @@ -235,9 +237,12 @@ const SignUp = (): JSX.Element => { await setTokens(registerResponse.data.access, registerResponse.data.refresh); applyAccessToken(registerResponse.data.access); - await loadAccount(); + const account = await loadAccount(); + trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' }); + identifyAccount(account); const { success_url, cancel_url } = checkoutReturnUrls(); + trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' }); const checkoutResponse = await axiosInstance.post('/finance/checkout/', { success_url, cancel_url, @@ -252,6 +257,7 @@ const SignUp = (): JSX.Element => { window.location.assign(checkoutUrl); } catch (error: unknown) { console.log(error); + trackEvent(AnalyticsEvents.SIGNUP_FAILED, { method: 'password' }); const axiosError = error as { response?: { status?: number; data?: { detail?: string; email?: string[] } }; }; diff --git a/llm-fe/src/llm-fe/pages/TermsOfService/TermsOfService.tsx b/llm-fe/src/llm-fe/pages/TermsOfService/TermsOfService.tsx index b82579b..37b0909 100644 --- a/llm-fe/src/llm-fe/pages/TermsOfService/TermsOfService.tsx +++ b/llm-fe/src/llm-fe/pages/TermsOfService/TermsOfService.tsx @@ -7,6 +7,7 @@ import { AccountContext } from '../../contexts/AccountContext'; import { Account } from '../../data'; import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument'; import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout'; +import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; type TermsOfServiceProps = { /** Marketing / legal alias: always read-only chrome (no app shell). */ @@ -30,6 +31,7 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen setSubmitting(true); try { await axiosInstance.post('/user/acknowledge_tos/'); + trackEvent(AnalyticsEvents.TOS_ACKNOWLEDGED); if (account) { setAccount(new Account({ ...account, has_signed_tos: true })); } diff --git a/llm-fe/src/llm-fe/utils/analytics.test.ts b/llm-fe/src/llm-fe/utils/analytics.test.ts index 921c99c..dc5d87e 100644 --- a/llm-fe/src/llm-fe/utils/analytics.test.ts +++ b/llm-fe/src/llm-fe/utils/analytics.test.ts @@ -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', () => { const originalEnv = process.env; @@ -53,4 +54,27 @@ describe('analytics event helpers', () => { identifyUser({ 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/); + }); }); diff --git a/llm-fe/src/llm-fe/utils/analytics.ts b/llm-fe/src/llm-fe/utils/analytics.ts index f25876c..dacfe81 100644 --- a/llm-fe/src/llm-fe/utils/analytics.ts +++ b/llm-fe/src/llm-fe/utils/analytics.ts @@ -1,4 +1,5 @@ import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent'; +import type { Account } from '../data'; type QueuedCall = | { type: 'track'; eventName: string; data?: Record } @@ -6,7 +7,21 @@ type 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 = { LOGIN_SUCCESS: 'Login Success', LOGIN_FAILED: 'Login Failed', @@ -75,3 +90,17 @@ export const identifyUser = (userInfo: Record): void => { 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 = { + userId: account.email, + }; + if (account.company?.id != null) { + traits.companyId = account.company.id; + } + if (account.is_company_manager) { + traits.isCompanyManager = true; + } + identifyUser(traits); +};