feat(analytics): implement Google Analytics (#1533)

This commit is contained in:
Cyrus Gray
2026-05-12 11:50:49 -07:00
committed by GitHub
parent af4d231ced
commit 8e9aef60f6
21 changed files with 426 additions and 16 deletions
+8
View File
@@ -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:".
+5
View File
@@ -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=
+1
View File
@@ -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",
+6
View File
@@ -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) {
+1 -1
View File
@@ -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
View File
@@ -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) {
+2
View File
@@ -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,
+2
View File
@@ -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.
+15 -1
View File
@@ -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;
+189 -1
View File
@@ -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();
});
});
+140 -6
View File
@@ -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);
}
/**
+12
View File
@@ -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);
}
}
}
+3
View File
@@ -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;
+8
View File
@@ -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
+4 -1
View File
@@ -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,