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
+5
View File
@@ -19,6 +19,8 @@ import AnalyticsPage from './llm-fe/pages/Analytics/Analytics';
import PasswordResetConfirmation from './llm-fe/pages/PasswordResetConfirmation/PasswordReset';
import GlobalThemeWrapper from './llm-fe/components/GlobalThemeWrapper/GlobalThemeWrapper';
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 { authenticated, loading } = useContext(AuthContext);
@@ -40,7 +42,9 @@ class App extends Component {
render() {
return (
<GlobalThemeWrapper>
<AnalyticsConsentProvider>
<Tracker />
<AnalyticsConsentBanner />
<div className='site'>
<main>
<div className="main-container">
@@ -76,6 +80,7 @@ class App extends Component {
</main>
</div>
</AnalyticsConsentProvider>
</GlobalThemeWrapper>
-1
View File
@@ -21,7 +21,6 @@ const root = ReactDOM.createRoot(
root.render(
<React.StrictMode>
<AppRouter>
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
<AuthProvider>
<AccountProvider>
<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 { 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 = () => {
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
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');
useEffect(() => {
if (!isAnalyticsEnvironment()) return;
document.body.appendChild(script);
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
return () => {
document.body.removeChild(script);
};
}
}, []);
const script = document.createElement('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;
@@ -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 }),
);
};