feat(analytics): implement Google Analytics#1533
Conversation
…tracking Add GA4 via react-ga4 with triple-gate (IS_DEV, GA_MEASUREMENT_ID, analyticsEnabled consent). Track route changes via AppShell useLocation effect, plus events for app_open, onboarding lifecycle, account connect, chat send, and skill install/uninstall. All events validated against an allowlist — no PII, message content, or credentials leave the app. Update privacy disclosure text and capability catalog to reflect the new analytics scope. Closes tinyhumansai#1479
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR implements Google Analytics 4 tracking across the OpenHuman app with privacy gating, consent management, and event allowlisting. Configuration is sourced from environment variables, initialization is wired into app startup, route changes and feature actions emit anonymous events, and user-facing documentation is updated to reflect the new tracking behavior. ChangesGoogle Analytics 4 Integration with Privacy Gating
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes The PR is substantial, spanning service core, tests, configuration, startup, and distributed feature tracking across multiple UI layers. The analytics service introduces new consent/initialization state and event allowlisting logic requiring close review. Comprehensive test coverage validates GA behavior, but the distributed event tracking across many components increases cognitive load and surface-area verification. Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
app/src/services/analytics.ts (1)
198-210: 💤 Low valueConsider removing the redundant
ReactGA.setcall insyncAnalyticsConsent.Lines 206-208 call
ReactGA.set({ allow_ad_personalization_signals: false })every time consent is toggled, but this value is already set tofalseininitGA()(line 244) and should never change. Since the actual consent enforcement happens via thegaEnabledflag that gatestrackPageViewandtrackEvent, this re-set is redundant.♻️ Proposed simplification
export function syncAnalyticsConsent(enabled: boolean): void { const client = Sentry.getClient(); if (client && !enabled) { void Sentry.flush(2000); } // Update the GA consent shadow and toggle ad-personalization signals. gaEnabled = enabled; if (gaInitialized) { - ReactGA.set({ allow_ad_personalization_signals: false }); console.debug(`[analytics] GA consent updated: enabled=${enabled}`); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/services/analytics.ts` around lines 198 - 210, The syncAnalyticsConsent function contains a redundant call to ReactGA.set({ allow_ad_personalization_signals: false }) which is already established in initGA and never changes; remove that ReactGA.set call from syncAnalyticsConsent, keep updating the gaEnabled shadow and the console.debug line (adjust the debug message if desired) and rely on initGA to set allow_ad_personalization_signals once; references: function syncAnalyticsConsent, initGA, gaEnabled, gaInitialized, and ReactGA.set.app/src/services/__tests__/analytics.test.ts (1)
409-417: ⚡ Quick winEnsure
console.warnspy is always restoredLine 410 creates a global spy, but restore on Line 416 won’t run if an assertion throws first. Wrap the body in
try/finallyto prevent cross-test leakage.Suggested patch
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(); + try { + const { initGA, trackEvent } = await freshAnalytics(); + initGA(); + trackEvent('internal_debug_event'); + expect(hoisted.gaEvent).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('internal_debug_event')); + } finally { + warnSpy.mockRestore(); + } });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/services/__tests__/analytics.test.ts` around lines 409 - 417, The test creates a global console.warn spy (warnSpy) but only restores it after assertions, risking leaks if an assertion throws; wrap the body of the test that calls freshAnalytics(), initGA(), trackEvent('internal_debug_event'), and the assertions in a try/finally so warnSpy.mockRestore() is always executed in the finally block to guarantee restoration even on failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/main.tsx`:
- Around line 54-57: The trackEvent('app_open', { version: APP_VERSION }) call
is firing for overlay/mascot windows as well; wrap it so it only runs for the
main/non-standalone window. Add or call a single predicate (e.g. isMainWindow(),
isMainAppWindow(), or check window.type/window.name/IPC-provided flag) before
invoking trackEvent in main.tsx, leaving initSentry() and initGA()
unchanged—only call trackEvent when that predicate returns true so
overlays/mascots do not emit app_open.
In `@app/src/pages/onboarding/pages/ContextPage.tsx`:
- Around line 16-19: The onNext handler currently calls completeAndExit()
without handling rejection; update the onNext callback that wraps trackEvent and
completeAndExit to await or chain a .catch on completeAndExit (the function name
completeAndExit in this component) and handle errors explicitly—e.g., log the
error via the existing logger or trackEvent an error event and surface user
feedback (toast/modal) as appropriate—so any failure in completeAndExit is not
an unhandled rejection and the failure path is observable.
In `@app/src/services/webviewAccountService.ts`:
- Around line 312-329: The code currently fires
trackEvent('account_connect_success', connectSuccessParams) unconditionally,
which counts warm re-opens (payload.state === 'reused') as new connects; update
the logic in the webview reveal path and the else branch so that trackEvent is
only called when payload.state !== 'reused' (i.e., a real new connect).
Specifically, inside the invoke(...).finally() and the fallback else, check
payload.state and only call trackEvent('account_connect_success',
connectSuccessParams) when payload.state !== 'reused' while still always
dispatching setAccountStatus({ accountId, status: 'open' }) as before.
---
Nitpick comments:
In `@app/src/services/__tests__/analytics.test.ts`:
- Around line 409-417: The test creates a global console.warn spy (warnSpy) but
only restores it after assertions, risking leaks if an assertion throws; wrap
the body of the test that calls freshAnalytics(), initGA(),
trackEvent('internal_debug_event'), and the assertions in a try/finally so
warnSpy.mockRestore() is always executed in the finally block to guarantee
restoration even on failure.
In `@app/src/services/analytics.ts`:
- Around line 198-210: The syncAnalyticsConsent function contains a redundant
call to ReactGA.set({ allow_ad_personalization_signals: false }) which is
already established in initGA and never changes; remove that ReactGA.set call
from syncAnalyticsConsent, keep updating the gaEnabled shadow and the
console.debug line (adjust the debug message if desired) and rely on initGA to
set allow_ad_personalization_signals once; references: function
syncAnalyticsConsent, initGA, gaEnabled, gaInitialized, and ReactGA.set.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b180a648-aa89-4a26-89df-5fd57aca37b7
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (21)
.claude/memory.md.gitignoreapp/.env.exampleapp/package.jsonapp/src/App.tsxapp/src/components/settings/panels/PrivacyPanel.tsxapp/src/components/skills/InstallSkillDialog.tsxapp/src/components/skills/UninstallSkillConfirmDialog.tsxapp/src/features/privacy/whatLeavesItems.tsapp/src/main.tsxapp/src/pages/Accounts.tsxapp/src/pages/Conversations.tsxapp/src/pages/onboarding/OnboardingLayout.tsxapp/src/pages/onboarding/pages/ContextPage.tsxapp/src/pages/onboarding/pages/SkillsPage.tsxapp/src/pages/onboarding/pages/WelcomePage.tsxapp/src/services/__tests__/analytics.test.tsapp/src/services/analytics.tsapp/src/services/webviewAccountService.tsapp/src/utils/config.tssrc/openhuman/about_app/catalog.rs
| // Initialize Sentry and GA early (before React renders) | ||
| initSentry(); | ||
| initGA(); | ||
| trackEvent('app_open', { version: APP_VERSION }); |
There was a problem hiding this comment.
Emit app_open only for the main window.
Line 57 currently fires for overlay/mascot windows too, which can overcount app-open analytics. Gate this event to non-standalone/main context.
Suggested patch
initSentry();
initGA();
-trackEvent('app_open', { version: APP_VERSION });
+if (!isStandaloneWindow) {
+ trackEvent('app_open', { version: APP_VERSION });
+}
document.documentElement.dataset.window = currentWindowLabel;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Initialize Sentry and GA early (before React renders) | |
| initSentry(); | |
| initGA(); | |
| trackEvent('app_open', { version: APP_VERSION }); | |
| // Initialize Sentry and GA early (before React renders) | |
| initSentry(); | |
| initGA(); | |
| if (!isStandaloneWindow) { | |
| trackEvent('app_open', { version: APP_VERSION }); | |
| } | |
| document.documentElement.dataset.window = currentWindowLabel; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main.tsx` around lines 54 - 57, The trackEvent('app_open', { version:
APP_VERSION }) call is firing for overlay/mascot windows as well; wrap it so it
only runs for the main/non-standalone window. Add or call a single predicate
(e.g. isMainWindow(), isMainAppWindow(), or check
window.type/window.name/IPC-provided flag) before invoking trackEvent in
main.tsx, leaving initSentry() and initGA() unchanged—only call trackEvent when
that predicate returns true so overlays/mascots do not emit app_open.
There was a problem hiding this comment.
Fixed in d98ec80 — gated app_open with if (!isStandaloneWindow) so overlay/mascot windows don't fire it.
There was a problem hiding this comment.
@graycyrus, confirmed — the current code in app/src/main.tsx already reflects the fix with if (!isStandaloneWindow) guarding the trackEvent('app_open', ...) call. Overlay and mascot windows will no longer inflate app-open counts. Thanks for the quick turnaround!
🧠 Learnings used
Learnt from: graycyrus
Repo: tinyhumansai/openhuman PR: 167
File: app/src/components/OnboardingOverlay.tsx:30-39
Timestamp: 2026-04-01T13:52:17.936Z
Learning: In React components, do not perform synchronous `setState` (or other state-updating calls) directly inside `useEffect` bodies. The lint rule `react-hooks/set-state-in-effect` (enforced in this codebase) disallows patterns such as resetting state flags at the top of an effect before returning. Refactor to avoid calling state setters in the effect; prefer deriving state from props/render, using refs, or restructuring logic so state updates occur outside `useEffect`.
| onNext={() => { | ||
| trackEvent('onboarding_step_complete', { step_name: 'context' }); | ||
| void completeAndExit(); | ||
| }} |
There was a problem hiding this comment.
Handle completeAndExit() rejection explicitly
Line 18 intentionally drops the returned promise. If onboarding completion fails, this can become an unhandled rejection and the failure path is silent.
Suggested patch
onNext={() => {
trackEvent('onboarding_step_complete', { step_name: 'context' });
- void completeAndExit();
+ void completeAndExit().catch(error => {
+ console.error('[onboarding:context-page] completeAndExit failed', error);
+ });
}}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| onNext={() => { | |
| trackEvent('onboarding_step_complete', { step_name: 'context' }); | |
| void completeAndExit(); | |
| }} | |
| onNext={() => { | |
| trackEvent('onboarding_step_complete', { step_name: 'context' }); | |
| void completeAndExit().catch(error => { | |
| console.error('[onboarding:context-page] completeAndExit failed', error); | |
| }); | |
| }} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/pages/onboarding/pages/ContextPage.tsx` around lines 16 - 19, The
onNext handler currently calls completeAndExit() without handling rejection;
update the onNext callback that wraps trackEvent and completeAndExit to await or
chain a .catch on completeAndExit (the function name completeAndExit in this
component) and handle errors explicitly—e.g., log the error via the existing
logger or trackEvent an error event and surface user feedback (toast/modal) as
appropriate—so any failure in completeAndExit is not an unhandled rejection and
the failure path is observable.
There was a problem hiding this comment.
Fixed in d98ec80 — added .catch() to completeAndExit() with a diagnostic log.
There was a problem hiding this comment.
@graycyrus Thanks for the quick fix! The updated file confirms the .catch() chain is in place with the diagnostic console.error log — that exactly matches the suggested patch and ensures any completeAndExit() rejection is now observable rather than silently swallowed. ✅
🧠 Learnings used
Learnt from: graycyrus
Repo: tinyhumansai/openhuman PR: 167
File: app/src/components/OnboardingOverlay.tsx:30-39
Timestamp: 2026-04-01T13:52:17.936Z
Learning: In React components, do not perform synchronous `setState` (or other state-updating calls) directly inside `useEffect` bodies. The lint rule `react-hooks/set-state-in-effect` (enforced in this codebase) disallows patterns such as resetting state flags at the top of an effect before returning. Refactor to avoid calling state setters in the effect; prefer deriving state from props/render, using refs, or restructuring logic so state updates occur outside `useEffect`.
| const trigger = payload.trigger === 'watchdog' ? 'watchdog' : 'load'; | ||
|
|
||
| const provider = store.getState().accounts.accounts[accountId]?.provider; | ||
| const connectSuccessParams = provider ? { provider } : undefined; | ||
|
|
||
| if (bounds) { | ||
| invoke('webview_account_reveal', { args: { account_id: accountId, bounds, trigger } }) | ||
| .catch(err => { | ||
| errLog('webview_account_reveal failed account=%s: %o', accountId, err); | ||
| }) | ||
| .finally(() => { | ||
| store.dispatch(setAccountStatus({ accountId, status: 'open' })); | ||
| trackEvent('account_connect_success', connectSuccessParams); | ||
| }); | ||
| } else { | ||
| store.dispatch(setAccountStatus({ accountId, status: 'open' })); | ||
| trackEvent('account_connect_success', connectSuccessParams); | ||
| } |
There was a problem hiding this comment.
Avoid counting warm re-opens as account_connect_success.
This event is emitted even when payload.state === 'reused', which represents reopening an already-loaded account, not a new successful connect.
Suggested patch
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 } })
@@
.finally(() => {
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
- trackEvent('account_connect_success', connectSuccessParams);
+ if (shouldTrackConnectSuccess) {
+ trackEvent('account_connect_success', connectSuccessParams);
+ }
});
} else {
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
- trackEvent('account_connect_success', connectSuccessParams);
+ if (shouldTrackConnectSuccess) {
+ trackEvent('account_connect_success', connectSuccessParams);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const trigger = payload.trigger === 'watchdog' ? 'watchdog' : 'load'; | |
| const provider = store.getState().accounts.accounts[accountId]?.provider; | |
| const connectSuccessParams = provider ? { provider } : undefined; | |
| if (bounds) { | |
| invoke('webview_account_reveal', { args: { account_id: accountId, bounds, trigger } }) | |
| .catch(err => { | |
| errLog('webview_account_reveal failed account=%s: %o', accountId, err); | |
| }) | |
| .finally(() => { | |
| store.dispatch(setAccountStatus({ accountId, status: 'open' })); | |
| trackEvent('account_connect_success', connectSuccessParams); | |
| }); | |
| } else { | |
| store.dispatch(setAccountStatus({ accountId, status: 'open' })); | |
| trackEvent('account_connect_success', connectSuccessParams); | |
| } | |
| 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 => { | |
| errLog('webview_account_reveal failed account=%s: %o', accountId, err); | |
| }) | |
| .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); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/services/webviewAccountService.ts` around lines 312 - 329, The code
currently fires trackEvent('account_connect_success', connectSuccessParams)
unconditionally, which counts warm re-opens (payload.state === 'reused') as new
connects; update the logic in the webview reveal path and the else branch so
that trackEvent is only called when payload.state !== 'reused' (i.e., a real new
connect). Specifically, inside the invoke(...).finally() and the fallback else,
check payload.state and only call trackEvent('account_connect_success',
connectSuccessParams) when payload.state !== 'reused' while still always
dispatching setAccountStatus({ accountId, status: 'open' }) as before.
There was a problem hiding this comment.
Fixed in d98ec80 — added shouldTrackConnectSuccess = payload.state !== 'reused' guard so warm re-opens don't count as new connections.
There was a problem hiding this comment.
@graycyrus, confirmed — the fix is exactly in line with the suggestion. shouldTrackConnectSuccess = payload.state !== 'reused' is correctly guarding both the .finally() path and the else branch, so warm re-opens no longer inflate account_connect_success counts. Thanks for the quick turnaround!
…ection, skip reused accounts - Gate app_open event to main window only (skip overlay/mascot windows) - Add .catch() to completeAndExit() in ContextPage to prevent unhandled rejection - Skip account_connect_success for reused/warm-reopened accounts
Summary
react-ga4with triple-gate:IS_DEV,GA_MEASUREMENT_IDenv var, andanalyticsEnableduser consent toggleAppShelluseLocation()effectapp_open, onboarding lifecycle (onboarding_start,onboarding_step_complete,onboarding_complete),account_connect_start/success,chat_message_sent,skill_install/skill_uninstallabout_app/catalog.rsConfiguration
Set
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXXinapp/.env.localto enable. Leave blank or omit to disable. GA is always disabled in dev mode (IS_DEV=true).Privacy
Test plan
pnpm typecheck— cleanpnpm lint— 0 errors (39 pre-existing warnings)pnpm format:check— cleanpnpm build— successpnpm test:unit— 2075 passed, 0 failedcargo check(core + Tauri shell) — cleanCloses #1479
Summary by CodeRabbit
Release Notes
New Features
Documentation