From 93bad388f82489bfa3b004739170914cddc9e321 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 27 May 2026 04:30:07 -0700 Subject: [PATCH] feat(analytics): dual GA4 + OpenPanel, disable Sentry profiling (#2750) --- .github/workflows/build-desktop.yml | 2 +- app/src/services/__tests__/analytics.test.ts | 173 +++++++-------- app/src/services/analytics.ts | 209 ++++++++++++------- app/src/utils/config.ts | 5 +- 4 files changed, 218 insertions(+), 171 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 44e885a52..2b6732b25 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -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 diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index 7235362c6..1fec2baf4 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -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).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); }); }); diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index eef2eba09..e0152b01c 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -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 ): 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); } /** diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index db476ae7c..3a2566682 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -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;