Enable beta auto-deploy + Tianji beta ID / consent-gated events (#35) (#42)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 9s

## Summary

Closes #35.

- **Flip deploy workflows** (mirror `dta_webapp`): push/merge to `master` → auto-deploy **beta**; **Deploy Prod** is `workflow_dispatch` only
- **Tianji**: `REACT_APP_TIANJI_WEBSITE_ID` + `REACT_APP_DEPLOY_ENV` in `.env.production` / `.env.beta`; Tracker loads page views always on prod/beta
- **Consent policy**: page views always live; custom events / identify gated via `analyticsConsent` + `AnalyticsConsentBanner` + `trackEvent`/`identifyUser` helpers (call sites remain #36)
- Remove invalid Tianji `<script>` from `index.tsx` (Tracker component owns injection)
- README documents beta auto / prod button + Tianji policy

## Ops note — beta Tianji website ID

Prod ID unchanged: `cm7x7m52m03kbddswbswrt17y`.

`.env.beta` has `REACT_APP_TIANJI_WEBSITE_ID=` empty until a **Hesychia Beta** website is created in Tianji (workspace `cm7w8087y020lddswyhamadj2`). Paste the new id into `.env.beta` before beta page views show under a distinct site.

## Test plan

- [x] `npm run test:ci` (106 tests)
- [ ] Create Hesychia Beta Tianji website; set `REACT_APP_TIANJI_WEBSITE_ID` in `.env.beta`
- [ ] Merge to master → Deploy Beta runs; prod does **not** auto-deploy
- [ ] Manual Run workflow on Deploy Prod still works
- [ ] Beta build hits `beta.chatbackend` REST/WS; static root `/var/www/beta.chat.aimloperations/html` (8083)
- [ ] Prod/beta: tracker.js loads without accepting consent; banner shows until Accept/Decline; `trackEvent` only fires after AcceptReviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-07-27 06:54:19 -07:00
parent 28a805404a
commit 966ad7aba4
17 changed files with 639 additions and 30 deletions
+4 -1
View File
@@ -1,7 +1,10 @@
name: Deploy Beta name: Deploy Beta
# Auto-deploy beta after push to master (mirrors dta_webapp).
on: on:
workflow_dispatch: {} push:
branches:
- master
jobs: jobs:
unit-tests: unit-tests:
+24 -8
View File
@@ -1,15 +1,31 @@
name: Deploy Prod name: Deploy Prod
# Runs after Unit Tests completes on master. Direct pushes only (not PRs). # Manual prod deploy only (mirrors dta_webapp). Push to master deploys beta.
on: on:
workflow_run: workflow_dispatch: {}
workflows: [Unit Tests]
types: [completed]
branches: [master]
jobs: jobs:
deploy: unit-tests:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' runs-on: self-hosted
defaults:
run:
working-directory: llm-fe
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:ci
deploy-prod:
needs: unit-tests
runs-on: self-hosted runs-on: self-hosted
env: env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
@@ -19,4 +35,4 @@ jobs:
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \ "$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app chat_web_app \ --app chat_web_app \
--env prod \ --env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}" --ref "${{ gitea.sha }}"
+22 -6
View File
@@ -92,10 +92,12 @@ to be visible in the browser bundle.
| File | Used when | Backend (current) | | File | Used when | Backend (current) |
|------|-----------|-------------------| |------|-----------|-------------------|
| `.env.development` | `npm start` | `http://127.0.0.1:8001` | | `.env.development` | `npm start` | `http://127.0.0.1:8001` |
| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` | | `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` + prod Tianji ID |
| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` | | `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` + beta Tianji ID |
| `.env.mobile` | `npm run build:mobile` | prod by default (override for beta shell) | | `.env.mobile` | `npm run build:mobile` | prod by default (override for beta shell) |
Each committed env sets `REACT_APP_DEPLOY_ENV` (`production` / `beta`) and `REACT_APP_TIANJI_WEBSITE_ID`.
Required keys (already set in the committed env files): Required keys (already set in the committed env files):
```env ```env
@@ -184,20 +186,22 @@ npm run test:ci # one-shot (matches Gitea Unit Tests)
## CI / deployment ## CI / deployment
Gitea Actions workflows in `.gitea/workflows/`: Gitea Actions workflows in `.gitea/workflows/` (same pattern as `dta_webapp`):
| Workflow | Trigger | Behavior | | Workflow | Trigger | Behavior |
|----------|---------|----------| |----------|---------|----------|
| `unit-tests.yml` | push/PR to `master` | `npm ci` + `npm run test:ci` in `llm-fe/` | | `unit-tests.yml` | push/PR to `master` | `npm ci` + `npm run test:ci` in `llm-fe/` |
| `deploy-prod.yml` | after green **Unit Tests** on `master` **push** | `server-infra/scripts/deploy.sh --app chat_web_app --env prod --ref <sha>` | | `deploy-beta.yml` | **push** to `master` (after unit tests in-workflow) | `server-infra/scripts/deploy.sh --app chat_web_app --env beta --ref <sha>` |
| `deploy-beta.yml` | `workflow_dispatch` | tests, then deploy `--env beta` | | `deploy-prod.yml` | **manual** `workflow_dispatch` only | tests, then deploy `--env prod` |
Push/merge to `master` auto-deploys **beta** only. Prod requires the Gitea **Run workflow** button on Deploy Prod.
Deploy flow (on each webserver listed in `host_apps`): Deploy flow (on each webserver listed in `host_apps`):
1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_<env>` 1. Git checkout at the pinned ref under `/opt/apps/src/chat_web_app_<env>`
2. `npm ci` in `llm-fe/` 2. `npm ci` in `llm-fe/`
3. `npm run build:<env>` → writes the static tree under `/var/www/...` 3. `npm run build:<env>` → writes the static tree under `/var/www/...`
4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083** if enabled) 4. **web-static** nginx serves that root on the host port (prod **8082**, beta **8083**)
Manual deploy from the control node: Manual deploy from the control node:
@@ -209,9 +213,21 @@ Manual deploy from the control node:
NPM (not managed by Ansible) should balance public Hesychia domains to: NPM (not managed by Ansible) should balance public Hesychia domains to:
- `adama:8082` + `roslin:8082` + `ai-server-4080:8082` (prod) - `adama:8082` + `roslin:8082` + `ai-server-4080:8082` (prod)
- beta hosts on **8083** / `beta.chat.aimloperations.com` (see server-infra)
See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operations/server-infra). See [server-infra IMPLEMENTATION.md](https://git.aimloperations.com/ai_ml_operations/server-infra).
### Tianji analytics
| Build | Env file | Website ID |
|-------|----------|------------|
| prod | `.env.production` | `REACT_APP_TIANJI_WEBSITE_ID` (prod site) |
| beta | `.env.beta` | distinct beta site ID (set after creating website in Tianji) |
- Script: `https://tianji.aimloperations.com/tracker.js`
- **Page views** always load on prod/beta (no consent gate)
- **Custom events / identify** require analytics consent (`AnalyticsConsentBanner` + `trackEvent` / `identifyUser`)
## Related repos ## Related repos
- **Backend:** [chat_backend](https://git.aimloperations.com/ai_ml_operations/chat_backend) — Django API + WebSocket - **Backend:** [chat_backend](https://git.aimloperations.com/ai_ml_operations/chat_backend) — Django API + WebSocket
+4
View File
@@ -1,4 +1,8 @@
# Beta API hosts (current). Target public product domain: hesychia.ai # Beta API hosts (current). Target public product domain: hesychia.ai
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over. # Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
REACT_APP_DEPLOY_ENV=beta
REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
# Tianji website id for Hesychia beta (distinct from prod so analytics stay separate).
# Create "Hesychia Beta" in Tianji UI (workspace cm7w8087y020lddswyhamadj2), then paste id here.
REACT_APP_TIANJI_WEBSITE_ID=cms38bw671mf9n5jjw3xp1j3q
+3
View File
@@ -1,4 +1,7 @@
# Production API hosts (current). Target public product domain: hesychia.ai # Production API hosts (current). Target public product domain: hesychia.ai
# Flip these to hesychia.ai API hosts when server-infra / DNS cut over. # Flip these to hesychia.ai API hosts when server-infra / DNS cut over.
REACT_APP_DEPLOY_ENV=production
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/ REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/ REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
# Tianji website id (prod). Page views always on; custom events need consent.
REACT_APP_TIANJI_WEBSITE_ID=cm7x7m52m03kbddswbswrt17y
+5
View File
@@ -19,6 +19,8 @@ import AnalyticsPage from './llm-fe/pages/Analytics/Analytics';
import PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset'; import PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset';
import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper'; import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper';
import Tracker from './llm-fe/components/Tracker/Tracker'; import Tracker from './llm-fe/components/Tracker/Tracker';
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
const ProtectedRoutes = () => { const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext); const { authenticated, loading } = useContext(AuthContext);
@@ -40,7 +42,9 @@ class App extends Component {
render() { render() {
return ( return (
<GlobalThemeWrapper> <GlobalThemeWrapper>
<AnalyticsConsentProvider>
<Tracker /> <Tracker />
<AnalyticsConsentBanner />
<div className='site'> <div className='site'>
<main> <main>
<div className="main-container"> <div className="main-container">
@@ -76,6 +80,7 @@ class App extends Component {
</main> </main>
</div> </div>
</AnalyticsConsentProvider>
</GlobalThemeWrapper> </GlobalThemeWrapper>
-1
View File
@@ -21,7 +21,6 @@ const root = ReactDOM.createRoot(
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<AppRouter> <AppRouter>
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
<AuthProvider> <AuthProvider>
<AccountProvider> <AccountProvider>
<WebSocketProvider> <WebSocketProvider>
@@ -0,0 +1,47 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { AnalyticsConsentProvider } from '../../contexts/AnalyticsConsentContext';
import AnalyticsConsentBanner from './AnalyticsConsentBanner';
const renderBanner = () =>
render(
<MemoryRouter>
<AnalyticsConsentProvider>
<AnalyticsConsentBanner />
</AnalyticsConsentProvider>
</MemoryRouter>,
);
describe('AnalyticsConsentBanner', () => {
const originalEnv = process.env;
beforeEach(() => {
localStorage.clear();
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('shows until the user accepts or declines', () => {
renderBanner();
expect(screen.getByRole('dialog', { name: /analytics consent/i })).toBeInTheDocument();
expect(screen.getByText(/Help us improve Hesychia/i)).toBeInTheDocument();
});
it('hides after accept and persists granted', () => {
renderBanner();
fireEvent.click(screen.getByRole('button', { name: /accept analytics/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent')).toBe('granted');
});
it('hides after decline and persists denied', () => {
renderBanner();
fireEvent.click(screen.getByRole('button', { name: /decline/i }));
expect(screen.queryByRole('dialog', { name: /analytics consent/i })).not.toBeInTheDocument();
expect(localStorage.getItem('hesychia_analytics_consent')).toBe('denied');
});
});
@@ -0,0 +1,75 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Box, Button, Link, Paper, Stack, Typography } from '@mui/material';
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
/**
* Opt-in for custom Tianji events / identify. Page views already load via Tracker.
*/
const AnalyticsConsentBanner = (): JSX.Element | null => {
const { showBanner, acceptAnalytics, declineAnalytics } = useAnalyticsConsent();
if (!showBanner) return null;
return (
<Box
role="dialog"
aria-label="Analytics consent"
aria-modal="false"
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1400,
px: { xs: 2, sm: 3 },
pb: { xs: 2, sm: 3 },
pointerEvents: 'none',
}}
>
<Paper
elevation={8}
sx={{
pointerEvents: 'auto',
maxWidth: 960,
mx: 'auto',
p: { xs: 2, sm: 2.5 },
borderRadius: 2,
}}
>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent="space-between"
>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Help us improve Hesychia
</Typography>
<Typography variant="body2" color="text.secondary">
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
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{' '}
<Link component={RouterLink} to="/terms_of_service/" underline="hover">
Terms of Service
</Link>{' '}
for details.
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ flexShrink: 0 }}>
<Button variant="outlined" color="inherit" onClick={declineAnalytics}>
Decline
</Button>
<Button variant="contained" onClick={acceptAnalytics}>
Accept analytics
</Button>
</Stack>
</Stack>
</Paper>
</Box>
);
};
export default AnalyticsConsentBanner;
@@ -0,0 +1,58 @@
import React from 'react';
import { render } from '@testing-library/react';
import Tracker from './Tracker';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
describe('Tracker', () => {
const originalEnv = process.env;
beforeEach(() => {
document.body.innerHTML = '';
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'production',
REACT_APP_TIANJI_WEBSITE_ID: 'cm7x7m52m03kbddswbswrt17y',
};
});
afterEach(() => {
process.env = { ...originalEnv };
document.body.innerHTML = '';
});
it('loads Tianji tracker without analytics consent (page views always on)', () => {
render(<Tracker />);
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script).not.toBeNull();
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y');
});
it('loads distinct website id for beta builds', () => {
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'beta',
REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test',
};
render(<Tracker />);
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test');
});
it('does not load Tianji tracker outside analytics environments', () => {
process.env = {
...originalEnv,
NODE_ENV: 'development',
REACT_APP_DEPLOY_ENV: undefined,
REACT_APP_TIANJI_WEBSITE_ID: undefined,
};
render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
});
});
@@ -1,23 +1,42 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { flushAnalyticsQueue } from '../../utils/analytics';
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
/**
* Loads Tianji tracker.js for automatic page views on prod/beta builds.
* Page views always run (no consent gate). Custom events use analytics.ts + consent.
*/
const Tracker: React.FC = () => { const Tracker: React.FC = () => {
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV === 'production') { if (!isAnalyticsEnvironment()) return;
const script = document.createElement('script');
script.src = "https://tianji.aimloperations.com/tracker.js";
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', 'cm7x7m52m03kbddswbswrt17y');
document.body.appendChild(script); const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
return () => { const script = document.createElement('script');
document.body.removeChild(script); script.id = TRACKER_SCRIPT_ID;
}; script.src = 'https://tianji.aimloperations.com/tracker.js';
} script.async = true;
}, []); script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
return null; document.body.appendChild(script);
return () => {
const loaded = document.getElementById(TRACKER_SCRIPT_ID);
if (loaded?.parentNode) {
loaded.parentNode.removeChild(loaded);
}
};
}, []);
return null;
}; };
export default Tracker; export default Tracker;
@@ -0,0 +1,87 @@
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
ANALYTICS_CONSENT_CHANGED_EVENT,
AnalyticsConsentStatus,
getAnalyticsConsent,
isAnalyticsEnvironment,
setAnalyticsConsent,
} from '../utils/analyticsConsent';
import { flushAnalyticsQueue } from '../utils/analytics';
type AnalyticsConsentContextValue = {
consent: AnalyticsConsentStatus;
hasConsent: boolean;
isResolved: boolean;
showBanner: boolean;
acceptAnalytics: () => void;
declineAnalytics: () => void;
};
const AnalyticsConsentContext = createContext<AnalyticsConsentContextValue | undefined>(
undefined,
);
type AnalyticsConsentProviderProps = {
children?: ReactNode;
};
export const AnalyticsConsentProvider = ({ children }: AnalyticsConsentProviderProps) => {
const [consent, setConsent] = useState<AnalyticsConsentStatus>(() => getAnalyticsConsent());
useEffect(() => {
const onConsentChanged = (event: Event) => {
const next = (event as CustomEvent<AnalyticsConsentStatus>).detail;
if (next === 'granted' || next === 'denied') {
setConsent(next);
} else {
setConsent(getAnalyticsConsent());
}
};
window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged);
return () => window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, onConsentChanged);
}, []);
const acceptAnalytics = useCallback(() => {
setAnalyticsConsent('granted');
setConsent('granted');
flushAnalyticsQueue();
}, []);
const declineAnalytics = useCallback(() => {
setAnalyticsConsent('denied');
setConsent('denied');
}, []);
const value = useMemo<AnalyticsConsentContextValue>(
() => ({
consent,
hasConsent: consent === 'granted',
isResolved: consent !== 'pending',
showBanner: isAnalyticsEnvironment() && consent === 'pending',
acceptAnalytics,
declineAnalytics,
}),
[acceptAnalytics, consent, declineAnalytics],
);
return (
<AnalyticsConsentContext.Provider value={value}>{children}</AnalyticsConsentContext.Provider>
);
};
export const useAnalyticsConsent = (): AnalyticsConsentContextValue => {
const context = useContext(AnalyticsConsentContext);
if (!context) {
throw new Error('useAnalyticsConsent must be used within AnalyticsConsentProvider');
}
return context;
};
+10
View File
@@ -0,0 +1,10 @@
export {};
declare global {
interface Window {
tianji?: {
track: (eventName: string, data?: Record<string, unknown>) => void;
identify: (userInfo: Record<string, unknown>) => void;
};
}
}
+56
View File
@@ -0,0 +1,56 @@
import { flushAnalyticsQueue, identifyUser, trackEvent } from './analytics';
describe('analytics event helpers', () => {
const originalEnv = process.env;
beforeEach(() => {
localStorage.clear();
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
delete (window as { tianji?: unknown }).tianji;
});
afterEach(() => {
process.env = { ...originalEnv };
delete (window as { tianji?: unknown }).tianji;
});
it('does not track custom events without consent', () => {
const track = jest.fn();
window.tianji = { track, identify: jest.fn() };
trackEvent('Login Success');
expect(track).not.toHaveBeenCalled();
});
it('tracks custom events when consent granted', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted');
const track = jest.fn();
window.tianji = { track, identify: jest.fn() };
trackEvent('Login Success', { method: 'password' });
expect(track).toHaveBeenCalledWith('Login Success', { method: 'password' });
});
it('queues events until tianji is ready then flushes after consent', () => {
localStorage.setItem('hesychia_analytics_consent', 'granted');
trackEvent('Checkout Started');
const track = jest.fn();
window.tianji = { track, identify: jest.fn() };
flushAnalyticsQueue();
expect(track).toHaveBeenCalledWith('Checkout Started', undefined);
});
it('identify requires consent', () => {
const identify = jest.fn();
window.tianji = { track: jest.fn(), identify };
identifyUser({ userId: 'u1' });
expect(identify).not.toHaveBeenCalled();
localStorage.setItem('hesychia_analytics_consent', 'granted');
identifyUser({ userId: 'u1' });
expect(identify).toHaveBeenCalledWith({ userId: 'u1' });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
| { type: 'identify'; userInfo: Record<string, unknown> };
const queue: QueuedCall[] = [];
/** Named events for product analytics. Page views stay on automatic tracker.js. */
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
LOGIN_FAILED: 'Login Failed',
SIGNUP_STARTED: 'Sign Up Started',
SIGNUP_SUCCESS: 'Sign Up Success',
SIGNUP_FAILED: 'Sign Up Failed',
LOGOUT: 'Logout',
CHECKOUT_STARTED: 'Checkout Started',
PAYMENT_SUCCESS: 'Payment Success',
PAYMENT_CANCELED: 'Payment Canceled',
CONVERSATION_CREATED: 'Conversation Created',
MESSAGE_SENT: 'Message Sent',
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
/** Custom events + identify require consent. Automatic page views do not. */
const isEventTrackingEnabled = (): boolean =>
isAnalyticsEnvironment() && hasAnalyticsConsent();
const runQueuedCall = (call: QueuedCall): void => {
if (!window.tianji) return;
if (call.type === 'track') {
window.tianji.track(call.eventName, call.data);
return;
}
window.tianji.identify(call.userInfo);
};
export const flushAnalyticsQueue = (): void => {
if (!isEventTrackingEnabled() || !window.tianji) return;
while (queue.length > 0) {
const call = queue.shift();
if (call) runQueuedCall(call);
}
};
export const trackEvent = (
eventName: AnalyticsEventName | string,
data?: Record<string, unknown>,
): void => {
if (!isEventTrackingEnabled()) return;
const payload = data ? { ...data } : undefined;
if (window.tianji?.track) {
window.tianji.track(eventName, payload);
return;
}
queue.push({ type: 'track', eventName, data: payload });
};
export const identifyUser = (userInfo: Record<string, unknown>): void => {
if (!isEventTrackingEnabled()) return;
if (window.tianji?.identify) {
window.tianji.identify(userInfo);
return;
}
queue.push({ type: 'identify', userInfo: { ...userInfo } });
};
@@ -0,0 +1,90 @@
import {
getAnalyticsConsent,
hasAnalyticsConsent,
isAnalyticsConsentResolved,
isAnalyticsEnvironment,
setAnalyticsConsent,
ANALYTICS_CONSENT_CHANGED_EVENT,
} from './analyticsConsent';
const CONSENT_KEY = 'hesychia_analytics_consent';
describe('isAnalyticsEnvironment', () => {
const originalEnv = process.env;
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns true for production and beta deploy env', () => {
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
expect(isAnalyticsEnvironment()).toBe(true);
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'beta' };
expect(isAnalyticsEnvironment()).toBe(true);
});
it('returns false in development without deploy env', () => {
process.env = {
...originalEnv,
NODE_ENV: 'development',
REACT_APP_DEPLOY_ENV: undefined,
REACT_APP_TIANJI_WEBSITE_ID: undefined,
};
expect(isAnalyticsEnvironment()).toBe(false);
});
});
describe('analytics consent storage', () => {
const originalEnv = process.env;
beforeEach(() => {
localStorage.clear();
process.env = { ...originalEnv, REACT_APP_DEPLOY_ENV: 'production' };
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('defaults to pending in production when unset', () => {
expect(getAnalyticsConsent()).toBe('pending');
expect(hasAnalyticsConsent()).toBe(false);
expect(isAnalyticsConsentResolved()).toBe(false);
});
it('auto-grants consent outside analytics environments', () => {
process.env = {
...originalEnv,
NODE_ENV: 'development',
REACT_APP_DEPLOY_ENV: undefined,
REACT_APP_TIANJI_WEBSITE_ID: undefined,
};
expect(getAnalyticsConsent()).toBe('granted');
expect(hasAnalyticsConsent()).toBe(true);
});
it('reads granted and denied from localStorage', () => {
localStorage.setItem(CONSENT_KEY, 'granted');
expect(getAnalyticsConsent()).toBe('granted');
expect(isAnalyticsConsentResolved()).toBe(true);
localStorage.setItem(CONSENT_KEY, 'denied');
expect(getAnalyticsConsent()).toBe('denied');
expect(hasAnalyticsConsent()).toBe(false);
});
it('persists consent and dispatches change event', () => {
const listener = jest.fn();
window.addEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
setAnalyticsConsent('denied');
expect(localStorage.getItem(CONSENT_KEY)).toBe('denied');
expect(listener).toHaveBeenCalled();
setAnalyticsConsent('granted');
expect(localStorage.getItem(CONSENT_KEY)).toBe('granted');
window.removeEventListener(ANALYTICS_CONSENT_CHANGED_EVENT, listener);
});
});
@@ -0,0 +1,44 @@
export type AnalyticsConsentStatus = 'granted' | 'denied' | 'pending';
const CONSENT_STORAGE_KEY = 'hesychia_analytics_consent';
export const ANALYTICS_CONSENT_CHANGED_EVENT = 'hesychia-analytics-consent-changed';
/** Prod + beta builds only (CRA NODE_ENV is production for both). */
export const isAnalyticsEnvironment = (): boolean => {
const deployEnv = process.env.REACT_APP_DEPLOY_ENV;
if (deployEnv === 'production' || deployEnv === 'beta') return true;
// Fallback: production builds that omit DEPLOY_ENV still load tracker when a website id is set.
return (
process.env.NODE_ENV === 'production' &&
Boolean(process.env.REACT_APP_TIANJI_WEBSITE_ID)
);
};
export const getAnalyticsConsent = (): AnalyticsConsentStatus => {
if (!isAnalyticsEnvironment()) return 'granted';
try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
if (value === 'granted' || value === 'denied') return value;
} catch {
/* localStorage unavailable */
}
return 'pending';
};
export const hasAnalyticsConsent = (): boolean => getAnalyticsConsent() === 'granted';
export const isAnalyticsConsentResolved = (): boolean => getAnalyticsConsent() !== 'pending';
export const setAnalyticsConsent = (status: 'granted' | 'denied'): void => {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, status);
} catch {
/* localStorage unavailable */
}
window.dispatchEvent(
new CustomEvent(ANALYTICS_CONSENT_CHANGED_EVENT, { detail: status }),
);
};