mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
feat(analytics): implement Google Analytics (#1533)
This commit is contained in:
@@ -155,6 +155,14 @@ Quick reference for anyone starting with Claude on this project. Updated by the
|
||||
- **FrameProvider loops — sleep animation resets** — `FrameProvider` uses `frame % durationInFrames` so animations loop. Default `DURATION_FRAMES = FPS * 6` (6s). Sleep animation completes at 4s, then eyes re-open at 6s when frame resets to 0. Fix: use a much longer `durationInFrames` for sleep face (e.g. `FPS * 600`) so the loop never triggers while sleeping.
|
||||
- **Hover detection needs circular hitbox** — The mascot panel is 79x79 but the character is visually circular. Using the full AABB (`cursor_in_panel`) for hover triggers false positives when cursor is in a panel corner. Use distance-from-center check instead. Also suppress hover events for ~1s after panel shows to let the webview load.
|
||||
|
||||
## Google Analytics (Issue #1479)
|
||||
|
||||
- **`react-ga4` injects a `<script>` tag at runtime** — It appends a `gtag.js` `<script>` to `<head>` dynamically. This works because `tauri.conf.json` CSP has `https:` in `default-src` and `connect-src`. If CEF ever tightens `script-src` separately, switch to GA4 Measurement Protocol (pure HTTP POST, no script injection).
|
||||
- **Analytics module pattern** — `app/src/services/analytics.ts` is the single owner of `initGA`, `trackPageView`, `trackEvent`, plus an `ALLOWED_EVENTS` allowlist. Never call `ReactGA` directly from components; go through this module.
|
||||
- **Triple gate before any GA call** — `isAnalyticsEnabled()` (user consent) AND `GA_MEASUREMENT_ID` env var present AND `!IS_DEV`. All three must pass or tracking is silently skipped.
|
||||
- **Route tracking location** — `useLocation()` effect wired in AppShell (not individual pages). All page views emit from one place.
|
||||
- **Capability catalog must stay in sync** — `src/openhuman/about_app/catalog.rs` needs an entry when a new user-visible feature ships. GA was added there as part of issue #1479.
|
||||
|
||||
## PR Checklist CI
|
||||
|
||||
- **N/A items need a checked checkbox** — `scripts/check-pr-checklist.mjs` requires `- [x] N/A: <reason>`. Using `- [ ] N/A:` (unchecked) fails the check even though the text starts with "N/A:".
|
||||
|
||||
@@ -31,6 +31,11 @@ VITE_TELEGRAM_BOT_USERNAME=openhuman_bot
|
||||
# [optional] Skills GitHub repository slug (default: tinyhumansai/openhuman-skills)
|
||||
VITE_SKILLS_GITHUB_REPO=tinyhumansai/openhuman-skills
|
||||
|
||||
# [optional] Google Analytics 4 Measurement ID (e.g. G-XXXXXXXXXX).
|
||||
# Leave blank to disable GA entirely. Analytics is also skipped in dev builds.
|
||||
# Only anonymous page views and feature-engagement events are sent — no PII.
|
||||
VITE_GA_MEASUREMENT_ID=
|
||||
|
||||
# [optional] Sentry DSN for error reporting (leave blank to disable)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"process": "^0.11.10",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-ga4": "^3.0.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-joyride": "^3.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { startWebviewNotificationsService } from './lib/webviewNotifications';
|
||||
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
|
||||
import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider';
|
||||
import SocketProvider from './providers/SocketProvider';
|
||||
import { trackPageView } from './services/analytics';
|
||||
import { startWebviewAccountService } from './services/webviewAccountService';
|
||||
import { persistor, store } from './store';
|
||||
// [#1123] useAppDispatch commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
@@ -116,6 +117,11 @@ function AppShell() {
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Track route changes as anonymous page views.
|
||||
useEffect(() => {
|
||||
trackPageView(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// After the welcome agent calls `complete_onboarding` and
|
||||
// `chat_onboarding_completed` flips false→true, discard the transient
|
||||
|
||||
@@ -229,9 +229,10 @@ const PrivacyPanel = () => {
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
All analytics and bug reports are fully anonymized. When enabled, we collect only
|
||||
crash information, device type, and the file location of errors. We never access
|
||||
your messages, session data, wallet keys, API keys, or any personally identifiable
|
||||
All analytics and bug reports are fully anonymized. When enabled, we collect crash
|
||||
information and device type (via Sentry), plus anonymous usage analytics such as
|
||||
page views and feature engagement (via Google Analytics). We never access your
|
||||
messages, session data, wallet keys, API keys, or any personally identifiable
|
||||
information. You can change this setting at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type InstallSkillFromUrlResult,
|
||||
type SkillSummary,
|
||||
} from '../../services/api/skillsApi';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
|
||||
const log = debug('skills:install-dialog');
|
||||
|
||||
@@ -202,6 +203,9 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
|
||||
installed.stdout.length,
|
||||
installed.stderr.length
|
||||
);
|
||||
for (const skillId of installed.newSkills) {
|
||||
trackEvent('skill_install', { skill_id: skillId });
|
||||
}
|
||||
setResult(installed);
|
||||
onInstalled(installed);
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type SkillSummary,
|
||||
type UninstallSkillResult,
|
||||
} from '../../services/api/skillsApi';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
|
||||
const log = debug('skills:uninstall-dialog');
|
||||
|
||||
@@ -81,6 +82,7 @@ export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstal
|
||||
// slug — the backend resolves by slug, so pass `id`.
|
||||
const result = await skillsApi.uninstallSkill(skill.id);
|
||||
log('confirm: done removedPath=%s', result.removedPath);
|
||||
trackEvent('skill_uninstall', { skill_id: skill.id });
|
||||
onUninstalled(result);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const WHAT_LEAVES_ITEMS: PrivacyLeaveItem[] = [
|
||||
{
|
||||
id: 'sentry',
|
||||
title: 'Crash Reports & Usage Data (opt-out)',
|
||||
body: 'Anonymous crash reports help us fix bugs. Usage data helps us improve the product. Toggle anytime in Settings → Privacy & Security.',
|
||||
body: 'Anonymous crash reports (via Sentry) and anonymous usage analytics — page views and feature engagement (via Google Analytics) — help us fix bugs and improve the product. No personal data, messages, or credentials are ever included. Toggle anytime in Settings → Privacy & Security.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+7
-2
@@ -10,9 +10,10 @@ import { getCoreStateSnapshot } from './lib/coreState/store';
|
||||
import MascotWindowApp from './mascot/MascotWindowApp';
|
||||
import OverlayApp from './overlay/OverlayApp';
|
||||
import './polyfills';
|
||||
import { initSentry } from './services/analytics';
|
||||
import { initGA, initSentry, trackEvent } from './services/analytics';
|
||||
import { setStoreForApiClient } from './services/apiClient';
|
||||
import { primeActiveUserId } from './store/userScopedStorage';
|
||||
import { APP_VERSION } from './utils/config';
|
||||
import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener';
|
||||
import { getActiveUserIdFromCore } from './utils/tauriCommands';
|
||||
|
||||
@@ -50,8 +51,12 @@ const ensureDefaultHashRoute = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Sentry early (before React renders)
|
||||
// Initialize Sentry and GA early (before React renders)
|
||||
initSentry();
|
||||
initGA();
|
||||
if (!isStandaloneWindow) {
|
||||
trackEvent('app_open', { version: APP_VERSION });
|
||||
}
|
||||
document.documentElement.dataset.window = currentWindowLabel;
|
||||
|
||||
if (!isStandaloneWindow) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import WebviewHost from '../components/accounts/WebviewHost';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
|
||||
import { trackEvent } from '../services/analytics';
|
||||
import {
|
||||
hideWebviewAccount,
|
||||
purgeWebviewAccount,
|
||||
@@ -181,6 +182,7 @@ const Accounts = () => {
|
||||
|
||||
const handlePickProvider = (p: ProviderDescriptor) => {
|
||||
setAddOpen(false);
|
||||
trackEvent('account_connect_start', { provider: p.id });
|
||||
const id = makeAccountId();
|
||||
const acct: Account = {
|
||||
id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import MicCloudComposer from '../features/human/MicCloudComposer';
|
||||
// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat';
|
||||
import { useStickToBottom } from '../hooks/useStickToBottom';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import { trackEvent } from '../services/analytics';
|
||||
// [#1123] getCoreStateSnapshot and isWelcomeLocked commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
@@ -603,6 +604,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
// (auto-react, autocomplete, etc.) — never as a primary chat path.
|
||||
try {
|
||||
await chatSend({ threadId: sendingThreadId, message: trimmed, model: CHAT_MODEL_ID });
|
||||
trackEvent('chat_message_sent');
|
||||
|
||||
// Active-thread reset happens in the global ChatRuntimeProvider events.
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setWalkthroughPending } from '../../components/walkthrough/AppWalkthrou
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../constants/onboardingChat';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import { userApi } from '../../services/api/userApi';
|
||||
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
|
||||
import BetaBanner from './components/BetaBanner';
|
||||
@@ -129,6 +130,9 @@ const OnboardingLayout = () => {
|
||||
// }
|
||||
// }
|
||||
|
||||
// Fire onboarding_complete analytics event before navigation.
|
||||
trackEvent('onboarding_complete');
|
||||
|
||||
// Flag the Joyride walkthrough as pending so it auto-starts on /home.
|
||||
// Best-effort: localStorage failures must not block navigation.
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import ContextGatheringStep from '../steps/ContextGatheringStep';
|
||||
|
||||
@@ -12,7 +13,12 @@ const ContextPage = () => {
|
||||
connectedSources={draft.connectedSources}
|
||||
// Chat-provider step is disabled for now, so context-gathering is
|
||||
// the final step when it runs — finish onboarding directly.
|
||||
onNext={() => completeAndExit()}
|
||||
onNext={() => {
|
||||
trackEvent('onboarding_step_complete', { step_name: 'context' });
|
||||
void completeAndExit().catch(error => {
|
||||
console.error('[onboarding:context-page] completeAndExit failed', error);
|
||||
});
|
||||
}}
|
||||
onBack={() => navigate('/onboarding/skills')}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import SkillsStep, { type SkillsConnections } from '../steps/SkillsStep';
|
||||
|
||||
@@ -10,6 +11,7 @@ const SkillsPage = () => {
|
||||
const handleNext = async ({ sources }: SkillsConnections) => {
|
||||
console.debug('[onboarding:skills-page] next', { sources });
|
||||
setDraft(prev => ({ ...prev, connectedSources: sources }));
|
||||
trackEvent('onboarding_step_complete', { step_name: 'skills' });
|
||||
|
||||
// Route to ContextGatheringStep when there's a Composio source the
|
||||
// pipeline can drive. Otherwise jump straight to onboarding completion.
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import WelcomeStep from '../steps/WelcomeStep';
|
||||
|
||||
const WelcomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
return <WelcomeStep onNext={() => navigate('/onboarding/skills')} />;
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('onboarding_start');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WelcomeStep
|
||||
onNext={() => {
|
||||
trackEvent('onboarding_step_complete', { step_name: 'welcome' });
|
||||
navigate('/onboarding/skills');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomePage;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Hoisted mocks so tests can swap return values per case.
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
// Sentry stubs
|
||||
getClient: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
@@ -15,8 +16,16 @@ const hoisted = vi.hoisted(() => ({
|
||||
browserApiErrorsIntegration: vi.fn(() => ({ name: 'BrowserApiErrors' })),
|
||||
globalHandlersIntegration: vi.fn(() => ({ name: 'GlobalHandlers' })),
|
||||
httpContextIntegration: vi.fn(() => ({ name: 'HttpContext' })),
|
||||
// GA stubs
|
||||
gaInitialize: vi.fn(),
|
||||
gaSet: vi.fn(),
|
||||
gaSend: vi.fn(),
|
||||
gaEvent: vi.fn(),
|
||||
// Config state
|
||||
analyticsEnabled: false,
|
||||
appEnvironment: 'staging' as 'staging' | 'production' | 'development',
|
||||
gaMeasurementId: 'G-TEST12345' as string | undefined,
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('@sentry/react', () => ({
|
||||
@@ -33,6 +42,16 @@ vi.mock('@sentry/react', () => ({
|
||||
httpContextIntegration: hoisted.httpContextIntegration,
|
||||
}));
|
||||
|
||||
// Mock react-ga4 with hoisted stubs so tests can assert on GA calls.
|
||||
vi.mock('react-ga4', () => ({
|
||||
default: {
|
||||
initialize: (...args: unknown[]) => hoisted.gaInitialize(...args),
|
||||
set: (...args: unknown[]) => hoisted.gaSet(...args),
|
||||
send: (...args: unknown[]) => hoisted.gaSend(...args),
|
||||
event: (...args: unknown[]) => hoisted.gaEvent(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// `initSentry()` reads `getCoreStateSnapshot().snapshot.analyticsEnabled` to
|
||||
// decide whether non-test events get dropped. Mock it so each test can flip
|
||||
// consent without instantiating the real Redux/persistence stack.
|
||||
@@ -46,11 +65,17 @@ vi.mock('../../lib/coreState/store', () => ({
|
||||
// false. Mock the whole config module so we control both gates. Use a
|
||||
// getter for APP_ENVIRONMENT so tests can flip staging/production per-case
|
||||
// to exercise the defense-in-depth gates added for the consent bypass.
|
||||
// Getters for GA_MEASUREMENT_ID and IS_DEV allow per-test overrides.
|
||||
vi.mock('../../utils/config', () => ({
|
||||
get APP_ENVIRONMENT() {
|
||||
return hoisted.appEnvironment;
|
||||
},
|
||||
IS_DEV: false,
|
||||
get IS_DEV() {
|
||||
return hoisted.isDev;
|
||||
},
|
||||
get GA_MEASUREMENT_ID() {
|
||||
return hoisted.gaMeasurementId;
|
||||
},
|
||||
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
|
||||
SENTRY_RELEASE: 'openhuman@test+abc',
|
||||
SENTRY_SMOKE_TEST: false,
|
||||
@@ -273,3 +298,166 @@ describe('initSentry beforeSend manual-staging bypass', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA4 tests
|
||||
//
|
||||
// Each test calls `vi.resetModules()` and re-imports `analytics` so that the
|
||||
// module-level `gaInitialized` / `gaEnabled` flags start fresh. This mirrors
|
||||
// the Sentry test pattern above (dynamic `import('../analytics')` per-test).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reset all GA stubs and config state, then return a fresh analytics module. */
|
||||
async function freshAnalytics() {
|
||||
vi.resetModules();
|
||||
hoisted.gaInitialize.mockReset();
|
||||
hoisted.gaSet.mockReset();
|
||||
hoisted.gaSend.mockReset();
|
||||
hoisted.gaEvent.mockReset();
|
||||
return import('../analytics');
|
||||
}
|
||||
|
||||
describe('initGA', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.analyticsEnabled = false;
|
||||
hoisted.gaMeasurementId = 'G-TEST12345';
|
||||
hoisted.isDev = false;
|
||||
});
|
||||
|
||||
test('does nothing when GA_MEASUREMENT_ID is empty', async () => {
|
||||
hoisted.gaMeasurementId = '';
|
||||
const { initGA } = await freshAnalytics();
|
||||
initGA();
|
||||
expect(hoisted.gaInitialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does nothing when GA_MEASUREMENT_ID is undefined', async () => {
|
||||
hoisted.gaMeasurementId = undefined;
|
||||
const { initGA } = await freshAnalytics();
|
||||
initGA();
|
||||
expect(hoisted.gaInitialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does nothing when IS_DEV is true', async () => {
|
||||
hoisted.isDev = true;
|
||||
const { initGA } = await freshAnalytics();
|
||||
initGA();
|
||||
expect(hoisted.gaInitialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calls ReactGA.initialize with correct measurement ID and disables auto send_page_view', async () => {
|
||||
hoisted.analyticsEnabled = true;
|
||||
const { initGA } = await freshAnalytics();
|
||||
initGA();
|
||||
expect(hoisted.gaInitialize).toHaveBeenCalledTimes(1);
|
||||
const [measurementId, opts] = hoisted.gaInitialize.mock.calls[0] as [
|
||||
string,
|
||||
{ gaOptions: { send_page_view: boolean } },
|
||||
];
|
||||
expect(measurementId).toBe('G-TEST12345');
|
||||
// Automatic send_page_view must be disabled — we send page views manually.
|
||||
expect(opts.gaOptions.send_page_view).toBe(false);
|
||||
// Ad personalization signals must be disabled unconditionally.
|
||||
expect(hoisted.gaSet).toHaveBeenCalledWith({ allow_ad_personalization_signals: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackPageView', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.analyticsEnabled = true;
|
||||
hoisted.gaMeasurementId = 'G-TEST12345';
|
||||
hoisted.isDev = false;
|
||||
});
|
||||
|
||||
test('sends a pageview when consent is on and GA is initialized', async () => {
|
||||
const { initGA, trackPageView } = await freshAnalytics();
|
||||
initGA();
|
||||
trackPageView('/home');
|
||||
expect(hoisted.gaSend).toHaveBeenCalledWith({ hitType: 'pageview', page: '/home' });
|
||||
});
|
||||
|
||||
test('is a no-op when consent is off', async () => {
|
||||
const { initGA, syncAnalyticsConsent, trackPageView } = await freshAnalytics();
|
||||
initGA();
|
||||
syncAnalyticsConsent(false);
|
||||
trackPageView('/home');
|
||||
expect(hoisted.gaSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('is a no-op when GA was never initialized', async () => {
|
||||
// No initGA() call — gaInitialized stays false inside the fresh module.
|
||||
const { trackPageView } = await freshAnalytics();
|
||||
trackPageView('/home');
|
||||
expect(hoisted.gaSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackEvent', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.analyticsEnabled = true;
|
||||
hoisted.gaMeasurementId = 'G-TEST12345';
|
||||
hoisted.isDev = false;
|
||||
});
|
||||
|
||||
test('sends allowed events with correct params', async () => {
|
||||
const { initGA, trackEvent } = await freshAnalytics();
|
||||
initGA();
|
||||
trackEvent('app_open', { version: '1.0.0' });
|
||||
expect(hoisted.gaEvent).toHaveBeenCalledWith('app_open', { version: '1.0.0' });
|
||||
});
|
||||
|
||||
test('drops events not in the allowlist and logs a warning', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const { initGA, trackEvent } = await freshAnalytics();
|
||||
initGA();
|
||||
trackEvent('internal_debug_event');
|
||||
expect(hoisted.gaEvent).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('internal_debug_event'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('is a no-op when consent is off', async () => {
|
||||
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
|
||||
initGA();
|
||||
syncAnalyticsConsent(false);
|
||||
trackEvent('app_open');
|
||||
expect(hoisted.gaEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncAnalyticsConsent GA integration', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.getClient.mockReset();
|
||||
hoisted.flush.mockReset();
|
||||
hoisted.flush.mockReturnValue(Promise.resolve(true));
|
||||
hoisted.analyticsEnabled = true;
|
||||
hoisted.gaMeasurementId = 'G-TEST12345';
|
||||
hoisted.isDev = false;
|
||||
});
|
||||
|
||||
test('syncAnalyticsConsent(false) prevents subsequent GA events', async () => {
|
||||
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
|
||||
initGA();
|
||||
syncAnalyticsConsent(false);
|
||||
trackEvent('app_open');
|
||||
expect(hoisted.gaEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncAnalyticsConsent(true) re-enables GA events after disable', async () => {
|
||||
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
|
||||
initGA();
|
||||
syncAnalyticsConsent(false);
|
||||
syncAnalyticsConsent(true);
|
||||
trackEvent('app_open');
|
||||
expect(hoisted.gaEvent).toHaveBeenCalledWith('app_open', undefined);
|
||||
});
|
||||
|
||||
test('syncAnalyticsConsent does not redundantly call ReactGA.set (ad personalization already disabled in initGA)', async () => {
|
||||
const { initGA, syncAnalyticsConsent } = await freshAnalytics();
|
||||
initGA();
|
||||
hoisted.gaSet.mockReset();
|
||||
syncAnalyticsConsent(true);
|
||||
// allow_ad_personalization_signals is set once in initGA, not on every consent toggle
|
||||
expect(hoisted.gaSet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,70 @@
|
||||
/**
|
||||
* Analytics & Sentry service
|
||||
*
|
||||
* Initializes Sentry for the React frontend with auto-send semantics:
|
||||
* captured errors are sanitized in `beforeSend` and forwarded to Sentry,
|
||||
* gated only by user analytics consent.
|
||||
* Initializes Sentry for error reporting and Google Analytics 4 for anonymous
|
||||
* usage tracking. Both are gated on user analytics consent and skipped in dev.
|
||||
*
|
||||
* Privacy guarantees enforced in `beforeSend`:
|
||||
* Sentry privacy guarantees enforced in `beforeSend`:
|
||||
* - No breadcrumbs, requests, extras, or arbitrary contexts (only OS /
|
||||
* browser / device metadata kept)
|
||||
* - No frame-level locals or source-context snippets
|
||||
* - No PII — `user` is reduced to a stable anonymous id (or omitted)
|
||||
* - `sendDefaultPii: false` (no IP, no cookies)
|
||||
* - All breadcrumb-producing integrations disabled
|
||||
*
|
||||
* GA4 privacy guarantees:
|
||||
* - Only page views and feature-engagement events from the allowlist are sent
|
||||
* - No user content, messages, credentials, or PII is ever included
|
||||
* - Ad personalization signals are disabled
|
||||
* - Skipped when `IS_DEV` is true or `GA_MEASUREMENT_ID` is not set
|
||||
*/
|
||||
import * as Sentry from '@sentry/react';
|
||||
import ReactGA from 'react-ga4';
|
||||
|
||||
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import {
|
||||
APP_ENVIRONMENT,
|
||||
GA_MEASUREMENT_ID,
|
||||
IS_DEV,
|
||||
SENTRY_DSN,
|
||||
SENTRY_RELEASE,
|
||||
SENTRY_SMOKE_TEST,
|
||||
} from '../utils/config';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA4 — module-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Set to `true` after `ReactGA.initialize()` succeeds. */
|
||||
let gaInitialized = false;
|
||||
|
||||
/**
|
||||
* Shadow of the user's analytics consent state for GA operations that need to
|
||||
* check it without async reads. Kept in sync by `syncAnalyticsConsent`.
|
||||
* Default: `false` (deny until explicitly allowed).
|
||||
*/
|
||||
let gaEnabled = false;
|
||||
|
||||
/**
|
||||
* Allowlist of event names that may be sent to GA4.
|
||||
*
|
||||
* Keeping an explicit allowlist prevents accidentally forwarding internal
|
||||
* debug names or future ad-hoc calls that could carry sensitive information.
|
||||
* Any `trackEvent` call with a name not in this set is dropped and a warning
|
||||
* is logged.
|
||||
*/
|
||||
export const GA_ALLOWED_EVENTS = new Set([
|
||||
'app_open',
|
||||
'onboarding_start',
|
||||
'onboarding_step_complete',
|
||||
'onboarding_complete',
|
||||
'account_connect_start',
|
||||
'account_connect_success',
|
||||
'chat_message_sent',
|
||||
'skill_install',
|
||||
'skill_uninstall',
|
||||
]);
|
||||
|
||||
/** Check if the current user has opted into analytics. */
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return getCoreStateSnapshot().snapshot.analyticsEnabled;
|
||||
@@ -150,13 +191,106 @@ export function initSentry(): void {
|
||||
* `beforeSend` reads `isAnalyticsEnabled()` on every event, so toggling
|
||||
* consent takes effect immediately for new errors. Flush pending events
|
||||
* on opt-out so anything already in flight respects the previous state.
|
||||
*
|
||||
* Also updates the module-level `gaEnabled` flag so `trackPageView` and
|
||||
* `trackEvent` respect the new consent state without reinitializing GA.
|
||||
*/
|
||||
export function syncAnalyticsConsent(enabled: boolean): void {
|
||||
const client = Sentry.getClient();
|
||||
if (!client) return;
|
||||
if (!enabled) {
|
||||
if (client && !enabled) {
|
||||
void Sentry.flush(2000);
|
||||
}
|
||||
|
||||
// Update the GA consent shadow. Ad-personalization is already disabled
|
||||
// unconditionally in initGA() — no need to re-set it on every toggle.
|
||||
gaEnabled = enabled;
|
||||
if (gaInitialized) {
|
||||
console.debug(`[analytics] GA consent updated: enabled=${enabled}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA4 — public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize Google Analytics 4.
|
||||
*
|
||||
* No-ops when:
|
||||
* - `GA_MEASUREMENT_ID` is empty/unset
|
||||
* - `IS_DEV` is true (dev builds never send analytics)
|
||||
* - Already initialized (idempotent)
|
||||
*/
|
||||
export function initGA(): void {
|
||||
if (gaInitialized) return;
|
||||
if (IS_DEV) {
|
||||
console.debug('[analytics] GA skipped in dev build');
|
||||
return;
|
||||
}
|
||||
if (!GA_MEASUREMENT_ID) {
|
||||
console.debug('[analytics] GA skipped — VITE_GA_MEASUREMENT_ID not set');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ReactGA.initialize(GA_MEASUREMENT_ID, {
|
||||
gaOptions: {
|
||||
// Disable automatic page view so we send them manually from AppShell.
|
||||
send_page_view: false,
|
||||
},
|
||||
});
|
||||
// Disable ad personalization signals unconditionally — this is a privacy
|
||||
// tool, not an advertising platform.
|
||||
ReactGA.set({ allow_ad_personalization_signals: false });
|
||||
gaInitialized = true;
|
||||
// Sync enabled state from the current consent snapshot now that GA is up.
|
||||
gaEnabled = isAnalyticsEnabled();
|
||||
console.debug('[analytics] GA initialized', { measurementId: GA_MEASUREMENT_ID });
|
||||
} catch (err) {
|
||||
console.warn('[analytics] GA initialization failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an anonymous page view if analytics consent is on and GA is initialized.
|
||||
*
|
||||
* @param path - The route pathname (e.g. `/home`, `/settings`). Never include
|
||||
* query strings or hash fragments that could contain user content.
|
||||
*/
|
||||
export function trackPageView(path: string): void {
|
||||
if (!gaInitialized || !gaEnabled) return;
|
||||
console.debug('[analytics] trackPageView', { path });
|
||||
ReactGA.send({ hitType: 'pageview', page: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an anonymous feature-engagement event if analytics consent is on.
|
||||
*
|
||||
* Event names must appear in `GA_ALLOWED_EVENTS`. Calls with unlisted names
|
||||
* are dropped and a console warning is emitted — this prevents accidental
|
||||
* exfiltration of internal or sensitive event names.
|
||||
*
|
||||
* Params must contain only non-sensitive metadata (strings, numbers, booleans).
|
||||
* Never pass user content, credentials, message text, or PII.
|
||||
*
|
||||
* @param eventName - An allowlisted event name.
|
||||
* @param params - Optional key/value metadata attached to the event.
|
||||
*/
|
||||
export function trackEvent(
|
||||
eventName: string,
|
||||
params?: Record<string, string | number | boolean>
|
||||
): void {
|
||||
if (!gaInitialized || !gaEnabled) return;
|
||||
|
||||
if (!GA_ALLOWED_EVENTS.has(eventName)) {
|
||||
console.warn(
|
||||
`[analytics] trackEvent dropped — '${eventName}' is not in GA_ALLOWED_EVENTS allowlist`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[analytics] trackEvent', { eventName, params });
|
||||
ReactGA.event(eventName, params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import { addIntegrationNotification } from '../store/notificationSlice';
|
||||
import { fetchRespondQueue } from '../store/providerSurfaceSlice';
|
||||
import type { AccountProvider, IngestedMessage } from '../types/accounts';
|
||||
import { openhumanGetMeetSettings } from '../utils/tauriCommands/config';
|
||||
import { trackEvent } from './analytics';
|
||||
import { threadApi } from './api/threadApi';
|
||||
import { chatSend } from './chatService';
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
@@ -309,6 +310,11 @@ function handleWebviewAccountLoad(payload: WebviewAccountLoadPayload) {
|
||||
const bounds = lastBoundsByAccount.get(accountId);
|
||||
log('load finished account=%s state=%s reveal=%s', accountId, payload.state, Boolean(bounds));
|
||||
const trigger = payload.trigger === 'watchdog' ? 'watchdog' : 'load';
|
||||
|
||||
const provider = store.getState().accounts.accounts[accountId]?.provider;
|
||||
const connectSuccessParams = provider ? { provider } : undefined;
|
||||
const shouldTrackConnectSuccess = payload.state !== 'reused';
|
||||
|
||||
if (bounds) {
|
||||
invoke('webview_account_reveal', { args: { account_id: accountId, bounds, trigger } })
|
||||
.catch(err => {
|
||||
@@ -316,9 +322,15 @@ function handleWebviewAccountLoad(payload: WebviewAccountLoadPayload) {
|
||||
})
|
||||
.finally(() => {
|
||||
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
|
||||
if (shouldTrackConnectSuccess) {
|
||||
trackEvent('account_connect_success', connectSuccessParams);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
|
||||
if (shouldTrackConnectSuccess) {
|
||||
trackEvent('account_connect_success', connectSuccessParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ export const CONSUMER_FIRST_SESSION_ENABLED =
|
||||
export const SKILLS_GITHUB_REPO =
|
||||
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
|
||||
|
||||
/** Google Analytics 4 Measurement ID. Leave blank to disable GA. Skipped in dev builds. */
|
||||
export const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
|
||||
|
||||
/** Sentry DSN for error reporting. Leave blank to disable. */
|
||||
export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||
|
||||
|
||||
Generated
+8
@@ -96,6 +96,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-ga4:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
react-icons:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(react@19.2.5)
|
||||
@@ -4538,6 +4541,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-ga4@3.0.1:
|
||||
resolution: {integrity: sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA==}
|
||||
|
||||
react-icons@5.6.0:
|
||||
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
|
||||
peerDependencies:
|
||||
@@ -10359,6 +10365,8 @@ snapshots:
|
||||
react: 19.2.5
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-ga4@3.0.1: {}
|
||||
|
||||
react-icons@5.6.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@@ -747,7 +747,10 @@ const CAPABILITIES: &[Capability] = &[
|
||||
name: "Manage Privacy and Analytics",
|
||||
domain: "settings",
|
||||
category: CapabilityCategory::Settings,
|
||||
description: "Control privacy, analytics, and related data handling preferences.",
|
||||
description: "Control privacy, analytics, and related data handling preferences. \
|
||||
When enabled, anonymous crash reports are sent to Sentry and anonymous usage \
|
||||
analytics (page views, feature engagement) are sent to Google Analytics. \
|
||||
No personal data, messages, or credentials are ever included.",
|
||||
how_to: "Settings > Privacy (direct route)",
|
||||
status: CapabilityStatus::Stable,
|
||||
privacy: DIAGNOSTICS_TO_BACKEND,
|
||||
|
||||
Reference in New Issue
Block a user