Compare commits
1
Commits
3a4a463416
...
a0a034bbf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a034bbf8 |
@@ -227,6 +227,7 @@ 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`)
|
||||||
|
- Event catalog: [`llm-fe/ANALYTICS.md`](llm-fe/ANALYTICS.md)
|
||||||
|
|
||||||
## Related repos
|
## Related repos
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -21,6 +21,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);
|
||||||
@@ -44,6 +45,7 @@ class App extends Component {
|
|||||||
<GlobalThemeWrapper>
|
<GlobalThemeWrapper>
|
||||||
<AnalyticsConsentProvider>
|
<AnalyticsConsentProvider>
|
||||||
<Tracker />
|
<Tracker />
|
||||||
|
<AnalyticsSession />
|
||||||
<AnalyticsConsentBanner />
|
<AnalyticsConsentBanner />
|
||||||
<div className='site'>
|
<div className='site'>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 MDTypography from '../../ui-kit/components/MDTypography';
|
|||||||
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
|
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
|
||||||
import MDBox from '../../ui-kit/components/MDBox';
|
import MDBox from '../../ui-kit/components/MDBox';
|
||||||
import MDButton from '../../ui-kit/components/MDButton';
|
import MDButton from '../../ui-kit/components/MDButton';
|
||||||
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
|
|
||||||
const TermsOfService = (): JSX.Element => {
|
const TermsOfService = (): JSX.Element => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,6 +16,7 @@ const TermsOfService = (): JSX.Element => {
|
|||||||
|
|
||||||
try{
|
try{
|
||||||
await axiosInstance.post('/user/acknowledge_tos/')
|
await axiosInstance.post('/user/acknowledge_tos/')
|
||||||
|
trackEvent(AnalyticsEvents.TOS_ACKNOWLEDGED);
|
||||||
navigate('/')
|
navigate('/')
|
||||||
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,4 +54,27 @@ describe('analytics event helpers', () => {
|
|||||||
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', '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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user