diff --git a/README.md b/README.md index a5dcfe9..d1031ec 100644 --- a/README.md +++ b/README.md @@ -90,20 +90,26 @@ ## πŸ“ˆ Google Analytics (GA4) -Renderer(μ›Ή)μ—μ„œ GA4λ₯Ό μ‚¬μš©ν•˜λ €λ©΄ `.env`에 μ•„λž˜ 값을 μΆ”κ°€ν•˜μ„Έμš”. +ν˜„μž¬ μ•±μ˜ 뢄석 μ΄λ²€νŠΈλŠ” **Rendererμ—μ„œ GA SDKλ₯Ό 직접 ν˜ΈμΆœν•˜μ§€ μ•Šκ³ **, μ•„λž˜ 경둜둜 μ „μ†‘λ©λ‹ˆλ‹€. -```bash -VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX -``` +`renderer logEvent -> preload contextBridge -> ipc -> main -> GA4 Measurement Protocol` -- 기본적으둜 ν”„λ‘œλ•μ…˜ λΉŒλ“œ(`import.meta.env.PROD`)μ—μ„œλ§Œ μ΄ˆκΈ°ν™”λ©λ‹ˆλ‹€. -- λ‘œμ»¬μ—μ„œ 확인이 ν•„μš”ν•˜λ©΄ 개발 ν™˜κ²½μ—μ„œλ§Œ μ•„λž˜ 값을 μΆ”κ°€ν•΄ ν™œμ„±ν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€. +### ν•„μˆ˜ ν™˜κ²½λ³€μˆ˜ (루트 `.env`) ```bash -VITE_GA_ENABLE_IN_DEV=true +GA4_MEASUREMENT_ID=G-XXXXXXXXXX +GA4_API_SECRET=YOUR_API_SECRET +GA4_DEBUG_MP=true ``` -- React Router 경둜 λ³€κ²½λ§ˆλ‹€ `page_view` 이벀트λ₯Ό μ „μ†‘ν•©λ‹ˆλ‹€. +- `GA4_API_SECRET`은 Main ν”„λ‘œμ„ΈμŠ€μ—μ„œλ§Œ μ‚¬μš©λ˜λ©° Renderer λ²ˆλ“€μ— ν¬ν•¨λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. +- μžλ™ `page_view`/URL 기반 좔적은 μ‚¬μš©ν•˜μ§€ μ•Šκ³ , 퍼널/핡심 행동 이벀트만 μ „μ†‘ν•©λ‹ˆλ‹€. + +### DebugView 확인 + +- `GA4_DEBUG_MP=true`일 λ•Œ: + - `mp/collect` 전솑 μ‹œ `debug_mode`κ°€ ν¬ν•¨λ˜μ–΄ DebugView 확인 κ°€λŠ₯ + - `debug/mp/collect` 검증 응닡이 메인 터미널에 좜λ ₯됨 --- diff --git a/ga.md b/ga.md index a4aa4f0..533cdf0 100644 --- a/ga.md +++ b/ga.md @@ -1,3 +1,10 @@ +## Analytics Runtime Notes + +- Rendererμ—μ„œλŠ” `logEvent(name, params)`만 ν˜ΈμΆœν•©λ‹ˆλ‹€. +- μ‹€μ œ 전솑은 `preload -> ipc -> main` 경유둜 GA4 Measurement Protocol(`mp/collect`)μ—μ„œ μ²˜λ¦¬ν•©λ‹ˆλ‹€. +- `GA4_API_SECRET`은 루트 `.env` + Main ν”„λ‘œμ„ΈμŠ€μ—μ„œλ§Œ μ½μŠ΅λ‹ˆλ‹€. +- μžλ™ `page_view`λŠ” μ‚¬μš©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + name affectsfunnel affectretention category description ga등둝여뢀 metricpurpose notes parameters qachecked requiredparams screen sessionidrequried status trigger useridrequired userscope download_click Yes No 퍼널 μ„€μΉ˜ νŽ˜μ΄μ§€μ—μ„œ μ‚¬μš©μžκ°€ λ‹€μš΄λ‘œλ“œ λ²„νŠΌμ„ ν΄λ¦­ν•œ 행동을 μΆ”μ ν•˜λŠ” 이벀트 No μœ μž… 채널 및 OS별 λ‹€μš΄λ‘œλ“œ μ „ν™˜μœ¨μ„ λΆ„μ„ν•˜μ—¬ λ§ˆμΌ€νŒ… 효율과 초기 μ„œλΉ„μŠ€ 관심도λ₯Ό μΈ‘μ • platform: string (mac | windows), diff --git a/src/main/src/analytics.ts b/src/main/src/analytics.ts new file mode 100644 index 0000000..3417ea8 --- /dev/null +++ b/src/main/src/analytics.ts @@ -0,0 +1,257 @@ +import { app, ipcMain } from 'electron'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +type AnalyticsParams = Record; + +const ANALYTICS_STORE_FILENAME = 'analytics.json'; +const GA_COLLECT_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; +const GA_DEBUG_ENDPOINT = 'https://www.google-analytics.com/debug/mp/collect'; + +const PII_KEY_PATTERNS = [ + /email/i, + /e-mail/i, + /phone/i, + /mobile/i, + /tel/i, + /contact/i, +]; + +const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; +const PHONE_PATTERN = /\+?\d[\d\s\-().]{7,}\d/; + +type AnalyticsStore = { + clientId: string; + userId?: string; +}; + +const runtimeSessionId = Math.floor(Date.now() / 1000); +const runtimeAppSessionId = randomUUID(); +let cachedStore: AnalyticsStore | null = null; +let analyticsConfigWarned = false; + +const getAnalyticsConfig = () => { + const measurementId = + process.env.GA4_MEASUREMENT_ID ?? + process.env.VITE_GA_MEASUREMENT_ID ?? + import.meta.env.VITE_GA_MEASUREMENT_ID; + const apiSecret = process.env.GA4_API_SECRET; + const debugEnabled = process.env.GA4_DEBUG_MP === 'true'; + const enabled = Boolean(measurementId && apiSecret); + + if (!enabled && !analyticsConfigWarned) { + analyticsConfigWarned = true; + console.warn( + '[analytics] disabled: missing GA4_MEASUREMENT_ID or GA4_API_SECRET', + ); + } + + return { measurementId, apiSecret, debugEnabled, enabled }; +}; + +const getAnalyticsStorePath = () => + join(app.getPath('userData'), ANALYTICS_STORE_FILENAME); + +const isPIIKey = (key: string) => + PII_KEY_PATTERNS.some((pattern) => pattern.test(key)); + +const maskPotentialPII = (value: string): string => { + if (EMAIL_PATTERN.test(value)) return '[redacted_email]'; + if (PHONE_PATTERN.test(value)) return '[redacted_phone]'; + return value; +}; + +const sanitizeValue = (value: unknown): string | number | boolean | undefined => { + if (typeof value === 'string') return maskPotentialPII(value); + if (typeof value === 'number' || typeof value === 'boolean') return value; + return undefined; +}; + +const sanitizeParams = (params?: AnalyticsParams) => { + if (!params) return {}; + + const sanitized: Record = {}; + Object.entries(params).forEach(([key, value]) => { + if (isPIIKey(key)) return; + const safeValue = sanitizeValue(value); + if (safeValue !== undefined) { + sanitized[key] = safeValue; + } + }); + return sanitized; +}; + +const readStore = async (): Promise => { + try { + const raw = await readFile(getAnalyticsStorePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if (!parsed.clientId || typeof parsed.clientId !== 'string') { + return null; + } + return { + clientId: parsed.clientId, + userId: typeof parsed.userId === 'string' ? parsed.userId : undefined, + }; + } catch { + return null; + } +}; + +const writeStore = async (store: AnalyticsStore) => { + const storePath = getAnalyticsStorePath(); + await mkdir(app.getPath('userData'), { recursive: true }); + await writeFile(storePath, JSON.stringify(store, null, 2), 'utf-8'); +}; + +export const getOrCreateClientId = async (): Promise => { + if (cachedStore?.clientId) return cachedStore.clientId; + + const existing = await readStore(); + if (existing) { + cachedStore = existing; + return existing.clientId; + } + + const created: AnalyticsStore = { clientId: randomUUID() }; + cachedStore = created; + await writeStore(created); + return created.clientId; +}; + +const getStoredUserId = async (): Promise => { + if (cachedStore?.userId) return cachedStore.userId; + const existing = cachedStore ?? (await readStore()); + if (!existing) return undefined; + cachedStore = existing; + return existing.userId; +}; + +const getReleaseChannel = () => { + const fromEnv = process.env.RELEASE_CHANNEL ?? process.env.APP_CHANNEL; + if (fromEnv) return fromEnv; + return app.isPackaged ? 'stable' : 'development'; +}; + +const sendRequest = async (url: string, payload: object) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`GA4 request failed: ${response.status} ${response.statusText}`); + } + return response; +}; + +const postToGa4 = async ( + eventName: string, + eventParams?: AnalyticsParams, + sourceScreen?: string, +) => { + const { measurementId, apiSecret, debugEnabled, enabled } = getAnalyticsConfig(); + if (!enabled || !measurementId || !apiSecret) return; + + const clientId = await getOrCreateClientId(); + const userId = await getStoredUserId(); + const sanitizedEventParams = sanitizeParams(eventParams); + + const commonParams = { + app_version: app.getVersion(), + platform: process.platform, + release_channel: getReleaseChannel(), + session_id: runtimeSessionId, + app_session_id: runtimeAppSessionId, + engagement_time_msec: 1, + screen: + typeof sourceScreen === 'string' && sourceScreen.length > 0 + ? sourceScreen + : 'unknown', + }; + + const payload = { + client_id: clientId, + ...(userId ? { user_id: userId } : {}), + events: [ + { + name: eventName, + params: { + ...commonParams, + ...sanitizedEventParams, + }, + }, + ], + }; + + const collectUrl = `${GA_COLLECT_ENDPOINT}?measurement_id=${encodeURIComponent( + measurementId, + )}&api_secret=${encodeURIComponent(apiSecret)}`; + const debugUrl = `${GA_DEBUG_ENDPOINT}?measurement_id=${encodeURIComponent( + measurementId, + )}&api_secret=${encodeURIComponent(apiSecret)}`; + + try { + // DebugView λ…ΈμΆœμ„ μœ„ν•΄ debug_modeλ₯Ό ν¬ν•¨ν•œ collect 전솑을 μœ μ§€ + const collectPayload = debugEnabled + ? { + ...payload, + events: payload.events.map((event) => ({ + ...event, + params: { + ...event.params, + debug_mode: 1, + }, + })), + } + : payload; + await sendRequest(collectUrl, collectPayload); + + // debug/mp/collect 응닡 검증은 디버그 ν† κΈ€ μ‹œ μΆ”κ°€ μˆ˜ν–‰ + if (debugEnabled) { + const debugResponse = await sendRequest(debugUrl, payload); + const body = await debugResponse.text(); + if (body) { + console.log('[analytics] debug/mp/collect response:', body); + } + } + } catch (error) { + console.warn('[analytics] Failed to send event:', error); + } +}; + +const normalizeScreen = (screen: unknown, fallbackUrl?: string) => { + if (typeof screen === 'string' && screen.length > 0) return screen; + if (fallbackUrl) return fallbackUrl; + return 'unknown'; +}; + +export const setAnalyticsUserId = async (userId: string) => { + const trimmed = userId.trim(); + if (!trimmed) return; + + const clientId = await getOrCreateClientId(); + const nextStore: AnalyticsStore = { clientId, userId: trimmed }; + cachedStore = nextStore; + await writeStore(nextStore); +}; + +export const setupAnalyticsHandlers = () => { + void getOrCreateClientId().catch((error) => { + console.warn('[analytics] Failed to initialize client_id store:', error); + }); + + ipcMain.handle('analytics:logEvent', async (event, name: string, params?: AnalyticsParams) => { + await postToGa4( + name, + params, + normalizeScreen(params?.screen, event.senderFrame?.url), + ); + return { success: true }; + }); + + ipcMain.handle('analytics:setUserId', async (_event, userId: string) => { + await setAnalyticsUserId(userId); + return { success: true }; + }); +}; diff --git a/src/main/src/index.ts b/src/main/src/index.ts index 0776c10..97fc9fe 100644 --- a/src/main/src/index.ts +++ b/src/main/src/index.ts @@ -1,5 +1,6 @@ import { app, ipcMain, nativeTheme } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { config as loadDotenv } from 'dotenv'; import { appendFile, mkdir } from 'fs/promises'; import { join } from 'path'; import './security-restrictions'; @@ -14,6 +15,9 @@ import { setupUpdaterHandlers, initializeUpdater, } from '/@/updaterHandlers'; +import { setupAnalyticsHandlers } from '/@/analytics'; + +loadDotenv({ path: join(process.cwd(), '.env') }); /** * Setup IPC handlers for Electron-specific features @@ -37,10 +41,6 @@ function setupAPIHandlers() { await appendFile(logPath, logLine, 'utf-8'); - if (import.meta.env.DEV) { - console.log(`πŸ“ Log written to: ${logPath}`); - } - return { success: true, path: logPath }; } catch (error) { console.error('Failed to write log:', error); @@ -82,6 +82,9 @@ function setupAPIHandlers() { /* Updater ν•Έλ“€λŸ¬ μ„€μ • */ setupUpdaterHandlers(); + + /* Analytics ν•Έλ“€λŸ¬ μ„€μ • */ + setupAnalyticsHandlers(); } /* μœ„μ ― μƒνƒœ 확인 μš”μ²­ ν•Έλ“€λŸ¬ */ ipcMain.handle('widget:isOpen', () => { diff --git a/src/main/src/widgetWindow.ts b/src/main/src/widgetWindow.ts index 82aadc4..eca4425 100644 --- a/src/main/src/widgetWindow.ts +++ b/src/main/src/widgetWindow.ts @@ -5,6 +5,29 @@ import { WIDGET_CONFIG } from './widgetConfig'; /*μœ„μ ― 관리 λ³€μˆ˜*/ let widgetWindow: BrowserWindow | null = null; +const PROD_WIDGET_URL = 'https://app.bugi.co.kr/widget'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const loadUrlWithRetry = async ( + win: BrowserWindow, + url: string, + options: { retries: number; delayMs: number }, +) => { + let lastError: unknown; + for (let attempt = 0; attempt <= options.retries; attempt += 1) { + try { + await win.loadURL(url); + return; + } catch (error) { + lastError = error; + if (attempt === options.retries) break; + await sleep(options.delayMs); + } + } + throw lastError; +}; + /* Create a widget window (μœ„μ ― μ°½ 생성)*/ async function createWidgetWindow() { // 이미 μœ„μ ― 창이 있으면 포컀슀만 μ£Όκ³  λ°˜ν™˜(쀑볡 λ°©μ§€) @@ -55,12 +78,28 @@ async function createWidgetWindow() { }); // μœ„μ ― μ „μš© URL - const baseUrl = - import.meta.env.DEV && process.env.VITE_DEV_SERVER_URL !== undefined - ? `${process.env.VITE_DEV_SERVER_URL}widget` - : 'https://app.bugi.co.kr/widget'; + const devServerUrl = process.env.VITE_DEV_SERVER_URL; + const widgetUrl = + import.meta.env.DEV && devServerUrl + ? new URL('widget', devServerUrl).toString() + : PROD_WIDGET_URL; - await widgetWindow.loadURL(baseUrl); + try { + await loadUrlWithRetry(widgetWindow, widgetUrl, { + retries: import.meta.env.DEV ? 5 : 1, + delayMs: 400, + }); + } catch (error) { + if (import.meta.env.DEV) { + console.warn( + `[widget] Failed to load dev widget URL (${widgetUrl}). Fallback to production URL.`, + error, + ); + await widgetWindow.loadURL(PROD_WIDGET_URL); + return; + } + throw error; + } } export async function openWidgetWindow() { diff --git a/src/preload/src/index.ts b/src/preload/src/index.ts index 6f14da9..c98ce64 100644 --- a/src/preload/src/index.ts +++ b/src/preload/src/index.ts @@ -52,6 +52,23 @@ type UpdaterEventChannel = | 'updater:download-progress' | 'updater:update-downloaded'; +type AnalyticsEventName = + | 'download_click' + | 'sign_up_complete' + | 'onboarding_enter' + | 'measure_page_enter' + | 'first_measure_start' + | 'measure_start' + | 'measure_end' + | 'bad_posture_enter' + | 'posture_recovered' + | 'widget_toggle' + | 'widget_visibility_end' + | 'notification_toggle' + | 'meaningful_use'; + +type AnalyticsParams = Record; + // window.bugi νƒ€μž… type BugiAPI = { version: number; @@ -119,6 +136,14 @@ interface ElectronAPI { callback: (data?: unknown) => void, ) => void; }; + + analytics: { + logEvent: ( + name: AnalyticsEventName, + params?: AnalyticsParams, + ) => Promise<{ success: boolean }>; + setUserId: (userId: string) => Promise<{ success: boolean }>; + }; } // Expose version number to renderer @@ -257,5 +282,16 @@ const electronAPI: ElectronAPI = { ipcRenderer.removeListener(channel, (_event, data) => callback(data)); }, }, + + analytics: { + logEvent: (name: AnalyticsEventName, params?: AnalyticsParams) => + ipcRenderer.invoke('analytics:logEvent', name, params) as ReturnType< + ElectronAPI['analytics']['logEvent'] + >, + setUserId: (userId: string) => + ipcRenderer.invoke('analytics:setUserId', userId) as ReturnType< + ElectronAPI['analytics']['setUserId'] + >, + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/src/app/providers/App.tsx b/src/renderer/src/app/providers/App.tsx index a0ddc0a..ec92b9f 100644 --- a/src/renderer/src/app/providers/App.tsx +++ b/src/renderer/src/app/providers/App.tsx @@ -1,48 +1,11 @@ import { router } from '@shared/config/router'; -import { initGA4, setGAUserId, trackPageView } from '@shared/lib/analytics/ga4'; import { LoadingSpinner } from '@shared/ui/loading'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Suspense, useEffect, useRef } from 'react'; +import { Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; function App() { const queryClient = new QueryClient(); - const lastPathRef = useRef(null); - - useEffect(() => { - const measurementId = import.meta.env.VITE_GA_MEASUREMENT_ID; - if (!measurementId) return; - - const enabled = - import.meta.env.PROD || import.meta.env.VITE_GA_ENABLE_IN_DEV === 'true'; - if (!enabled) return; - - initGA4(measurementId, { debug_mode: !import.meta.env.PROD }); - const storedUserId = localStorage.getItem('userId'); - if (storedUserId) setGAUserId(storedUserId); - - const toPath = (loc: { pathname?: string; search?: string; hash?: string }) => - `${loc.pathname ?? ''}${loc.search ?? ''}${loc.hash ?? ''}` || '/'; - - const send = (loc: { pathname: string; search: string; hash: string }) => { - const path = toPath(loc); - if (lastPathRef.current === path) return; - lastPathRef.current = path; - - trackPageView({ - page_path: path, - page_title: document.title, - }); - }; - - // initial - send(router.state.location); - - // navigation changes - return router.subscribe((state) => { - send(state.location); - }); - }, []); return ( diff --git a/src/renderer/src/entities/user/api/use-login-mutation.ts b/src/renderer/src/entities/user/api/use-login-mutation.ts index 169512a..d96284e 100644 --- a/src/renderer/src/entities/user/api/use-login-mutation.ts +++ b/src/renderer/src/entities/user/api/use-login-mutation.ts @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import api from '@shared/api'; import { LoginInput, LoginResponse } from '../types'; import axios from 'axios'; -import { setGAUserId } from '@shared/lib/analytics/ga4'; +import { setAnalyticsUserId } from '@shared/lib/analytics'; /*둜그인 api */ const login = async (data: LoginInput): Promise => { @@ -52,7 +52,7 @@ export const useLoginMutation = () => { const userId = payload.data?.userId ?? payload.data?.id; if (userId) { localStorage.setItem('userId', userId); - setGAUserId(userId); + setAnalyticsUserId(userId); } } catch (error) { console.error('μ‚¬μš©μž 정보 쑰회 μ‹€νŒ¨:', error); diff --git a/src/renderer/src/shared/lib/analytics/client.ts b/src/renderer/src/shared/lib/analytics/client.ts new file mode 100644 index 0000000..d098322 --- /dev/null +++ b/src/renderer/src/shared/lib/analytics/client.ts @@ -0,0 +1,27 @@ +import { AnalyticsEventName, AnalyticsEventParamsMap } from './schema'; + +type EventParams = Record; + +const toEventParams = ( + params?: AnalyticsEventParamsMap[T], +): EventParams | undefined => { + if (!params) return undefined; + const compact = Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined), + ) as EventParams; + return Object.keys(compact).length > 0 ? compact : undefined; +}; + +export const logEvent = async ( + name: T, + params?: AnalyticsEventParamsMap[T], +) => { + if (!window.electronAPI?.analytics) return; + await window.electronAPI.analytics.logEvent(name, toEventParams(params)); +}; + +export const setAnalyticsUserId = async (userId: string) => { + if (!window.electronAPI?.analytics) return; + if (!userId) return; + await window.electronAPI.analytics.setUserId(userId); +}; diff --git a/src/renderer/src/shared/lib/analytics/events.ts b/src/renderer/src/shared/lib/analytics/events.ts index 13ee00e..26b5797 100644 --- a/src/renderer/src/shared/lib/analytics/events.ts +++ b/src/renderer/src/shared/lib/analytics/events.ts @@ -1,45 +1,45 @@ -import { trackEvent } from './ga4'; +import { logEvent } from './client'; export const AnalyticsEvents = { downloadClick: (params: { platform: 'mac' | 'windows'; source: string }) => - trackEvent('download_click', params), + logEvent('download_click', params), signUpComplete: (params: { user_id?: string }) => - trackEvent('sign_up_complete', params.user_id ? params : undefined), + logEvent('sign_up_complete', params.user_id ? params : undefined), onboardingEnter: (params: { step: string }) => - trackEvent('onboarding_enter', params), + logEvent('onboarding_enter', params), measurePageEnter: (params: { session_id: string }) => - trackEvent('measure_page_enter', params), + logEvent('measure_page_enter', params), firstMeasureStart: (params: { seconds_from_signup: number }) => - trackEvent('first_measure_start', params), + logEvent('first_measure_start', params), measureStart: (params: { session_id: string }) => - trackEvent('measure_start', params), + logEvent('measure_start', params), measureEnd: (params: { session_id: string; duration_sec: number }) => - trackEvent('measure_end', params), + logEvent('measure_end', params), badPostureEnter: (params: { session_id: string; posture_level: number }) => - trackEvent('bad_posture_enter', params), + logEvent('bad_posture_enter', params), postureRecovered: (params: { session_id: string; posture_level: number; recovery_time_sec: number; - }) => trackEvent('posture_recovered', params), + }) => logEvent('posture_recovered', params), widgetToggle: (params: { enabled: boolean }) => - trackEvent('widget_toggle', params), + logEvent('widget_toggle', params), widgetVisibilityEnd: (params: { duration_sec: number; session_id?: string }) => - trackEvent('widget_visibility_end', params), + logEvent('widget_visibility_end', params), notificationToggle: (params: { enabled: boolean }) => - trackEvent('notification_toggle', params), + logEvent('notification_toggle', params), meaningfulUse: (params: { type: string }) => - trackEvent('meaningful_use', params), + logEvent('meaningful_use', params), } as const; diff --git a/src/renderer/src/shared/lib/analytics/ga4.ts b/src/renderer/src/shared/lib/analytics/ga4.ts deleted file mode 100644 index 9e7dcdc..0000000 --- a/src/renderer/src/shared/lib/analytics/ga4.ts +++ /dev/null @@ -1,126 +0,0 @@ -type Ga4Config = { - send_page_view?: boolean; - debug_mode?: boolean; - user_id?: string; - [key: string]: unknown; -}; - -type Ga4PageViewParams = { - page_path?: string; - page_location?: string; - page_title?: string; - [key: string]: unknown; -}; - -type GtagFn = { - (command: 'js', date: Date): void; - (command: 'config', measurementId: string, config?: Ga4Config): void; - (command: 'set', params: Record): void; - (command: 'event', eventName: string, params?: Record): void; -}; - -declare global { - interface Window { - dataLayer?: unknown[]; - gtag?: GtagFn; - } -} - -let activeMeasurementId: string | null = null; -let initialized = false; - -function ensureGtagStub() { - if (typeof window === 'undefined') return; - window.dataLayer = window.dataLayer ?? []; - if (window.gtag) return; - - // Use the canonical gtag stub shape so gtag.js can seamlessly take over. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gtag = function (..._args: any[]) { - // eslint-disable-next-line prefer-rest-params - window.dataLayer?.push(arguments); - }; - - window.gtag = gtag as unknown as GtagFn; -} - -function injectGtagScript(measurementId: string) { - if (typeof document === 'undefined') return; - const existing = document.querySelector( - `script[data-ga4="gtag"][data-measurement-id="${measurementId}"]`, - ); - if (existing) return; - - const script = document.createElement('script'); - script.async = true; - script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent( - measurementId, - )}`; - script.dataset.ga4 = 'gtag'; - script.dataset.measurementId = measurementId; - document.head.appendChild(script); -} - -export function initGA4(measurementId: string, config?: Ga4Config) { - if (!measurementId) return; - if (initialized && activeMeasurementId === measurementId) return; - - activeMeasurementId = measurementId; - initialized = true; - - ensureGtagStub(); - injectGtagScript(measurementId); - - window.gtag?.('js', new Date()); - window.gtag?.('config', measurementId, { - send_page_view: false, - ...config, - }); -} - -export function setGAUserId(userId: string) { - if (!activeMeasurementId) return; - if (!userId) return; - ensureGtagStub(); - - // Apply to subsequent events/config - window.gtag?.('set', { user_id: userId }); - window.gtag?.('config', activeMeasurementId, { user_id: userId }); -} - -export function trackPageView(params?: Ga4PageViewParams) { - if (!activeMeasurementId) return; - ensureGtagStub(); - - const page_location = - params?.page_location ?? - (typeof window !== 'undefined' ? window.location.href : undefined); - - window.gtag?.('event', 'page_view', { - page_location, - ...params, - }); -} - -export function trackEvent( - eventName: string, - params?: Record, -) { - if (!activeMeasurementId) return; - if (!eventName) return; - ensureGtagStub(); - - const sanitizedParams = params - ? Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined), - ) - : undefined; - - window.gtag?.( - 'event', - eventName, - sanitizedParams && Object.keys(sanitizedParams).length > 0 - ? sanitizedParams - : undefined, - ); -} diff --git a/src/renderer/src/shared/lib/analytics/index.ts b/src/renderer/src/shared/lib/analytics/index.ts index c3fe1ed..8dd564d 100644 --- a/src/renderer/src/shared/lib/analytics/index.ts +++ b/src/renderer/src/shared/lib/analytics/index.ts @@ -1,3 +1,3 @@ -export * from './ga4'; +export * from './client'; export * from './events'; - +export * from './schema'; diff --git a/src/renderer/src/shared/lib/analytics/schema.ts b/src/renderer/src/shared/lib/analytics/schema.ts new file mode 100644 index 0000000..e6011ba --- /dev/null +++ b/src/renderer/src/shared/lib/analytics/schema.ts @@ -0,0 +1,21 @@ +export type AnalyticsEventParamsMap = { + download_click: { platform: 'mac' | 'windows'; source: string }; + sign_up_complete: { user_id?: string }; + onboarding_enter: { step: string }; + measure_page_enter: { session_id: string }; + first_measure_start: { seconds_from_signup: number }; + measure_start: { session_id: string }; + measure_end: { session_id: string; duration_sec: number }; + bad_posture_enter: { session_id: string; posture_level: number }; + posture_recovered: { + session_id: string; + posture_level: number; + recovery_time_sec: number; + }; + widget_toggle: { enabled: boolean }; + widget_visibility_end: { duration_sec: number; session_id?: string }; + notification_toggle: { enabled: boolean }; + meaningful_use: { type: string }; +}; + +export type AnalyticsEventName = keyof AnalyticsEventParamsMap; diff --git a/src/renderer/src/shared/types/vite-env.d.ts b/src/renderer/src/shared/types/vite-env.d.ts index 151a99d..7c1da9b 100644 --- a/src/renderer/src/shared/types/vite-env.d.ts +++ b/src/renderer/src/shared/types/vite-env.d.ts @@ -3,6 +3,6 @@ /// interface ImportMetaEnv { - readonly VITE_GA_MEASUREMENT_ID?: string; - readonly VITE_GA_ENABLE_IN_DEV?: string; + readonly VITE_BASE_URL?: string; + readonly VITE_APP_VERSION?: string; }