mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(analytics): dual GA4 + OpenPanel, disable Sentry profiling (#2750)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user