feat(analytics): dual GA4 + OpenPanel, disable Sentry profiling (#2750)

This commit is contained in:
Steven Enamakel
2026-05-27 17:00:07 +05:30
committed by GitHub
parent cc4665b09c
commit 93bad388f8
4 changed files with 218 additions and 171 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ jobs:
sudo apt-get install -y \
libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
patchelf cmake libasound2-dev libxdo-dev libxtst-dev libx11-dev libxi-dev \
libevdev-dev libssl-dev libclang-dev \
libevdev-dev libssl-dev libclang-dev desktop-file-utils \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev
+79 -94
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
// Hoisted mocks so tests can swap return values per case.
const hoisted = vi.hoisted(() => ({
@@ -16,15 +16,9 @@ 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,
}));
@@ -42,16 +36,6 @@ 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.
@@ -73,9 +57,7 @@ vi.mock('../../utils/config', () => ({
get IS_DEV() {
return hoisted.isDev;
},
get GA_MEASUREMENT_ID() {
return hoisted.gaMeasurementId;
},
GA_MEASUREMENT_ID: 'G-TEST12345',
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
SENTRY_RELEASE: 'openhuman@test+abc',
SENTRY_SMOKE_TEST: false,
@@ -300,16 +282,13 @@ describe('initSentry beforeSend manual-staging bypass', () => {
const opts = hoisted.init.mock.calls[0][0] as {
release: string;
tracesSampler: () => number;
tracesSampleRate: number;
replaysSessionSampleRate: number;
replaysOnErrorSampleRate: number;
integrations: Array<{ name?: string }>;
};
expect(opts.release).toBe('openhuman@test+abc');
hoisted.analyticsEnabled = true;
expect(opts.tracesSampler()).toBe(0.1);
hoisted.analyticsEnabled = false;
expect(opts.tracesSampler()).toBe(0);
expect(opts.tracesSampleRate).toBe(0);
expect(opts.replaysSessionSampleRate).toBe(0);
expect(opts.replaysOnErrorSampleRate).toBe(0);
const names = opts.integrations.map(i => i.name).filter(Boolean);
@@ -381,111 +360,119 @@ describe('initSentry beforeSend manual-staging bypass', () => {
// the Sentry test pattern above (dynamic `import('../analytics')` per-test).
// ---------------------------------------------------------------------------
/** Reset all GA stubs and config state, then return a fresh analytics module. */
/** Stub for `document.createElement('script')` — captures the injected src. */
let createdScripts: Array<{ async: boolean; defer: boolean; src: string }> = [];
const originalCreateElement = document.createElement.bind(document);
/** Reset window.op and module state, return a fresh analytics module. */
async function freshAnalytics() {
vi.resetModules();
hoisted.gaInitialize.mockReset();
hoisted.gaSet.mockReset();
hoisted.gaSend.mockReset();
hoisted.gaEvent.mockReset();
delete (window as unknown as Record<string, unknown>).op;
createdScripts = [];
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'script') {
const fake = { async: false, defer: false, src: '' } as unknown as HTMLScriptElement;
createdScripts.push(fake as unknown as { async: boolean; defer: boolean; src: string });
return fake;
}
return originalCreateElement(tag);
});
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => node);
return import('../analytics');
}
describe('initGA', () => {
describe('initGA (OpenPanel)', () => {
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();
afterEach(() => {
vi.restoreAllMocks();
});
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 () => {
test('injects both gtag.js and op1.js scripts', 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 });
expect(createdScripts).toHaveLength(2);
expect(createdScripts[0].src).toBe('https://www.googletagmanager.com/gtag/js?id=G-TEST12345');
expect(createdScripts[1].src).toBe('https://openpanel.dev/op1.js');
expect(window.gtag).toBeDefined();
expect(window.op).toBeDefined();
});
test('is idempotent — second call does not inject additional scripts', async () => {
hoisted.analyticsEnabled = true;
const { initGA } = await freshAnalytics();
initGA();
initGA();
expect(createdScripts).toHaveLength(2);
});
});
describe('trackPageView', () => {
describe('trackPageView (OpenPanel)', () => {
beforeEach(() => {
hoisted.analyticsEnabled = true;
hoisted.gaMeasurementId = 'G-TEST12345';
hoisted.isDev = false;
});
test('sends a pageview when consent is on and GA is initialized', async () => {
afterEach(() => {
vi.restoreAllMocks();
});
test('sends a screen_view event when consent is on', async () => {
const { initGA, trackPageView } = await freshAnalytics();
initGA();
const opSpy = vi.spyOn(window, 'op');
trackPageView('/home');
expect(hoisted.gaSend).toHaveBeenCalledWith({ hitType: 'pageview', page: '/home' });
expect(opSpy).toHaveBeenCalledWith('track', 'screen_view', { page: '/home' });
});
test('is a no-op when consent is off', async () => {
const { initGA, syncAnalyticsConsent, trackPageView } = await freshAnalytics();
initGA();
const opSpy = vi.spyOn(window, 'op');
syncAnalyticsConsent(false);
trackPageView('/home');
expect(hoisted.gaSend).not.toHaveBeenCalled();
expect(opSpy).not.toHaveBeenCalled();
});
test('is a no-op when GA was never initialized', async () => {
// No initGA() call — gaInitialized stays false inside the fresh module.
test('is a no-op when OpenPanel was never initialized', async () => {
const { trackPageView } = await freshAnalytics();
trackPageView('/home');
expect(hoisted.gaSend).not.toHaveBeenCalled();
expect(window.op).toBeUndefined();
});
});
describe('trackEvent', () => {
describe('trackEvent (OpenPanel)', () => {
beforeEach(() => {
hoisted.analyticsEnabled = true;
hoisted.gaMeasurementId = 'G-TEST12345';
hoisted.isDev = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
test('sends allowed events with correct params', async () => {
const { initGA, trackEvent } = await freshAnalytics();
initGA();
const opSpy = vi.spyOn(window, 'op');
trackEvent('app_open', { version: '1.0.0' });
expect(hoisted.gaEvent).toHaveBeenCalledWith('app_open', { version: '1.0.0' });
expect(opSpy).toHaveBeenCalledWith('track', '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();
const opSpy = vi.spyOn(window, 'op');
trackEvent('internal_debug_event');
expect(hoisted.gaEvent).not.toHaveBeenCalled();
const trackCalls = opSpy.mock.calls.filter(
c => c[0] === 'track' && c[1] === 'internal_debug_event'
);
expect(trackCalls).toHaveLength(0);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('internal_debug_event'));
warnSpy.mockRestore();
});
@@ -493,45 +480,43 @@ describe('trackEvent', () => {
test('is a no-op when consent is off', async () => {
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
initGA();
const opSpy = vi.spyOn(window, 'op');
syncAnalyticsConsent(false);
trackEvent('app_open');
expect(hoisted.gaEvent).not.toHaveBeenCalled();
expect(opSpy).not.toHaveBeenCalled();
});
});
describe('syncAnalyticsConsent GA integration', () => {
describe('syncAnalyticsConsent OpenPanel 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();
afterEach(() => {
vi.restoreAllMocks();
});
test('syncAnalyticsConsent(true) re-enables GA events after disable', async () => {
test('syncAnalyticsConsent(false) prevents subsequent events', async () => {
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
initGA();
const opSpy = vi.spyOn(window, 'op');
syncAnalyticsConsent(false);
trackEvent('app_open');
const trackCalls = opSpy.mock.calls.filter(c => c[0] === 'track');
expect(trackCalls).toHaveLength(0);
});
test('syncAnalyticsConsent(true) re-enables events after disable', async () => {
const { initGA, syncAnalyticsConsent, trackEvent } = await freshAnalytics();
initGA();
syncAnalyticsConsent(false);
syncAnalyticsConsent(true);
const opSpy = vi.spyOn(window, 'op');
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();
expect(opSpy).toHaveBeenCalledWith('track', 'app_open', undefined);
});
});
+134 -75
View File
@@ -1,8 +1,8 @@
/**
* Analytics & Sentry service
*
* Initializes Sentry for error reporting and Google Analytics 4 for anonymous
* usage tracking. Both are gated on user analytics consent and skipped in dev.
* Initializes Sentry for error reporting and OpenPanel for anonymous
* usage tracking. Both are gated on user analytics consent.
*
* Sentry privacy guarantees enforced in `beforeSend`:
* - No breadcrumbs, requests, extras, or arbitrary contexts (only OS /
@@ -12,14 +12,11 @@
* - `sendDefaultPii: false` (no IP, no cookies)
* - All breadcrumb-producing integrations disabled
*
* GA4 privacy guarantees:
* OpenPanel 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 {
@@ -33,28 +30,57 @@ import {
import { CoreRpcError } from './coreRpcClient';
// ---------------------------------------------------------------------------
// GA4 — module-level state
// Google Analytics 4 typings — raw gtag.js API
// ---------------------------------------------------------------------------
type GtagCommand = 'config' | 'event' | 'set' | 'js';
interface GtagFn {
(...args: [GtagCommand, ...unknown[]]): void;
}
// ---------------------------------------------------------------------------
// OpenPanel typings — raw script injection API
// ---------------------------------------------------------------------------
type OpMethod = 'init' | 'track' | 'identify' | 'increment' | 'decrement' | 'clear' | 'alias';
interface OpFn {
(...args: [OpMethod, ...unknown[]]): void;
q?: unknown[];
}
declare global {
interface Window {
dataLayer: unknown[];
gtag: GtagFn;
op: OpFn;
}
}
const OPENPANEL_CLIENT_ID = 'e9c996d5-497f-4eec-9bde-630019ad525b';
const OPENPANEL_API_URL = 'https://panel.tinyhumans.ai/api';
// ---------------------------------------------------------------------------
// Module-level state
// ---------------------------------------------------------------------------
/** Set to `true` after `ReactGA.initialize()` succeeds. */
let gaInitialized = false;
let opInitialized = 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).
* Shadow of the user's analytics consent state. Kept in sync by
* `syncAnalyticsConsent`. Default: `false` (deny until explicitly allowed).
*/
let gaEnabled = false;
let analyticsEnabled = false;
/**
* Allowlist of event names that may be sent to GA4.
* Allowlist of event names that may be sent to OpenPanel.
*
* 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([
export const ALLOWED_EVENTS = new Set([
'app_open',
'onboarding_start',
'onboarding_step_complete',
@@ -101,7 +127,7 @@ export function initSentry(): void {
// Privacy: disable EVERYTHING that could leak sensitive state.
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
tracesSampler: () => (isAnalyticsEnabled() ? 0.1 : 0),
tracesSampleRate: 0,
defaultIntegrations: false,
integrations: [
Sentry.functionToStringIntegration(),
@@ -223,96 +249,129 @@ export function syncAnalyticsConsent(enabled: boolean): void {
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}`);
analyticsEnabled = enabled;
if (gaInitialized || opInitialized) {
console.debug(`[analytics] consent updated: enabled=${enabled}`);
}
}
// ---------------------------------------------------------------------------
// GA4 — public API
// Analytics — public API (GA4 + OpenPanel, both fire on every call)
// ---------------------------------------------------------------------------
/**
* 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;
}
function initGoogleAnalytics(): void {
if (gaInitialized || !GA_MEASUREMENT_ID) return;
try {
ReactGA.initialize(GA_MEASUREMENT_ID, {
gaOptions: {
// Disable automatic page view so we send them manually from AppShell.
send_page_view: false,
},
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(...args: [GtagCommand, ...unknown[]]) {
window.dataLayer.push(args);
};
window.gtag('js', new Date());
window.gtag('config', GA_MEASUREMENT_ID, {
send_page_view: false,
allow_ad_personalization_signals: false,
});
// Disable ad personalization signals unconditionally — this is a privacy
// tool, not an advertising platform.
ReactGA.set({ allow_ad_personalization_signals: false });
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
document.head.appendChild(script);
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 });
console.debug('[analytics] GA initialized (gtag.js)', { 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 });
function initOpenPanel(): void {
if (opInitialized) return;
try {
window.op =
window.op ||
(function (this: void) {
const n: unknown[] = [];
return new Proxy(
function (this: void) {
if (arguments.length) n.push([].slice.call(arguments));
} as unknown as OpFn,
{
get(_t: unknown, r: string) {
if (r === 'q') return n;
return function () {
n.push([r].concat([].slice.call(arguments)));
};
},
has(_t: unknown, r: string) {
return r === 'q';
},
}
);
})();
window.op('init', {
apiUrl: OPENPANEL_API_URL,
clientId: OPENPANEL_CLIENT_ID,
trackScreenViews: false,
trackOutgoingLinks: false,
trackAttributes: false,
});
const script = document.createElement('script');
script.defer = true;
script.async = true;
script.src = 'https://openpanel.dev/op1.js';
document.head.appendChild(script);
opInitialized = true;
console.debug('[analytics] OpenPanel initialized', { clientId: OPENPANEL_CLIENT_ID });
} catch (err) {
console.warn('[analytics] OpenPanel initialization failed:', err);
}
}
/**
* Send an anonymous feature-engagement event if analytics consent is on.
* Initialize all analytics providers (GA4 + OpenPanel).
* Idempotent — each provider initializes at most once.
*/
export function initGA(): void {
initGoogleAnalytics();
initOpenPanel();
analyticsEnabled = isAnalyticsEnabled();
}
/**
* Send an anonymous page view to all initialized providers.
*/
export function trackPageView(path: string): void {
if ((!gaInitialized && !opInitialized) || !analyticsEnabled) return;
console.debug('[analytics] trackPageView', { path });
if (gaInitialized) window.gtag('event', 'page_view', { page_path: path });
if (opInitialized) window.op('track', 'screen_view', { page: path });
}
/**
* Send an anonymous feature-engagement event to all initialized providers.
*
* 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.
* Event names must appear in `ALLOWED_EVENTS`. Calls with unlisted names
* are dropped and a console warning is emitted.
*/
export function trackEvent(
eventName: string,
params?: Record<string, string | number | boolean>
): void {
if (!gaInitialized || !gaEnabled) return;
if ((!gaInitialized && !opInitialized) || !analyticsEnabled) return;
if (!GA_ALLOWED_EVENTS.has(eventName)) {
if (!ALLOWED_EVENTS.has(eventName)) {
console.warn(
`[analytics] trackEvent dropped — '${eventName}' is not in GA_ALLOWED_EVENTS allowlist`
`[analytics] trackEvent dropped — '${eventName}' is not in ALLOWED_EVENTS allowlist`
);
return;
}
console.debug('[analytics] trackEvent', { eventName, params });
ReactGA.event(eventName, params);
if (gaInitialized) window.gtag('event', eventName, params);
if (opInitialized) window.op('track', eventName, params);
}
/**
+4 -1
View File
@@ -93,9 +93,12 @@ 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. */
/** Google Analytics 4 Measurement ID. Leave blank to disable GA. */
export const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
/** When true, allow GA in dev builds (for local debugging). Set `VITE_GA_FORCE_DEV=true` in `.env.local`. */
export const GA_FORCE_DEV = import.meta.env.VITE_GA_FORCE_DEV === 'true';
/** Sentry DSN for error reporting. Leave blank to disable. */
export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined;