Skip to content
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 검증 응답이 메인 터미널에 출력됨

---

Expand Down
7 changes: 7 additions & 0 deletions ga.md
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
257 changes: 257 additions & 0 deletions src/main/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<string, string | number | boolean> = {};
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<AnalyticsStore | null> => {
try {
const raw = await readFile(getAnalyticsStorePath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<AnalyticsStore>;
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<string> => {
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<string | undefined> => {
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 };
});
};
11 changes: 7 additions & 4 deletions src/main/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -82,6 +82,9 @@ function setupAPIHandlers() {

/* Updater 핸들러 설정 */
setupUpdaterHandlers();

/* Analytics 핸들러 설정 */
setupAnalyticsHandlers();
}
/* 위젯 상태 확인 요청 핸들러 */
ipcMain.handle('widget:isOpen', () => {
Expand Down
Loading