feat: collect consented analysis usage statistics
Publish GitHub Pages output / publish (push) Successful in 43s

This commit is contained in:
2026-09-06 20:56:08 +09:00
parent 9d2d8963b5
commit b97a4008bb
8 changed files with 169 additions and 5 deletions
@@ -48,3 +48,81 @@ test('Google 공식 형식의 arguments 객체를 dataLayer에 적재한다', ()
});
type GoogleTagWindow = Window & { dataLayer?: IArguments[] };
test('새 동의 이후에만 분석 입력을 전송하고 철회·재동의 및 전송 실패를 처리한다', () => {
const storage = new Map([['holodori:analytics-consent', 'granted']]);
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const scripts: unknown[] = [];
const fakeWindow = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value)
},
location: { origin: 'https://doricalc-holo.live' },
dataLayer: [] as IArguments[],
gtag: undefined as undefined | ((...args: unknown[]) => void)
};
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
cookie: '', title: '홀로도리 계산기',
createElement: () => ({ dataset: {} }),
head: { append: (script: unknown) => scripts.push(script) }
}
});
const usage = {
songId: 'song-1', difficulty: 'expert',
cards: Array.from({ length: 5 }, (_, i) => ({ cardId: `card-${i}`, awakening: i })),
collection: { private: true }, score: 12345
};
try {
const analytics = createGoogleAnalytics('G-TEST1234');
assert.equal(analytics.readConsent(), null);
analytics.trackAnalysisStart(usage);
analytics.trackPageView('/analysis');
assert.equal(scripts.length, 0);
assert.equal(fakeWindow.dataLayer.length, 0);
analytics.grantConsent();
analytics.trackAnalysisStart(usage);
const events = fakeWindow.dataLayer.filter((args) => args[0] === 'event');
assert.equal(events.length, 6);
assert.equal(events[0][1], 'analysis_start');
assert.deepEqual(events[0][2], {
song_id: 'song-1', difficulty: 'expert',
card_1_id: 'card-0', card_1_awakening: 0,
card_2_id: 'card-1', card_2_awakening: 1,
card_3_id: 'card-2', card_3_awakening: 2,
card_4_id: 'card-3', card_4_awakening: 3,
card_5_id: 'card-4', card_5_awakening: 4
});
for (let i = 0; i < 5; i++) {
assert.equal(events[i + 1][1], 'analysis_card_used');
assert.deepEqual(events[i + 1][2], {
song_id: 'song-1', difficulty: 'expert', card_id: `card-${i}`, awakening: i, slot: i + 1
});
}
assert.equal(createGoogleAnalytics('G-TEST1234').readConsent(), 'granted');
const count = fakeWindow.dataLayer.length;
analytics.denyConsent();
analytics.trackAnalysisStart(usage);
analytics.trackPageView('/analysis');
assert.equal(fakeWindow.dataLayer.length, count);
assert.equal(Reflect.get(fakeWindow, 'ga-disable-G-TEST1234'), true);
assert.equal(createGoogleAnalytics('G-TEST1234').readConsent(), 'denied');
analytics.grantConsent();
assert.equal(Reflect.get(fakeWindow, 'ga-disable-G-TEST1234'), false);
analytics.trackAnalysisStart(usage);
assert.equal(fakeWindow.dataLayer.length, count + 6);
assert.equal(scripts.length, 1);
createGoogleAnalytics('').trackAnalysisStart(usage);
assert.equal(fakeWindow.dataLayer.length, count + 6);
fakeWindow.gtag = () => { throw new Error('Analytics unavailable'); };
assert.doesNotThrow(() => analytics.trackAnalysisStart(usage));
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
+35 -1
View File
@@ -1,6 +1,6 @@
import type { AnalyticsClient, AnalyticsConsent } from './types.ts';
const CONSENT_STORAGE_KEY = 'holodori:analytics-consent';
const CONSENT_STORAGE_KEY = 'holodori:analytics-consent:v2';
const MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
type GoogleTagWindow = Window & {
@@ -86,11 +86,19 @@ export function createGoogleAnalytics(rawMeasurementId: string | undefined): Ana
grantConsent() {
sessionConsent = 'granted';
writeStoredConsent('granted');
const currentWindow = browserWindow();
if (available && currentWindow) {
(currentWindow as unknown as Record<string, unknown>)[`ga-disable-${measurementId}`] = false;
}
load();
},
denyConsent() {
sessionConsent = 'denied';
writeStoredConsent('denied');
const currentWindow = browserWindow();
if (available && currentWindow) {
(currentWindow as unknown as Record<string, unknown>)[`ga-disable-${measurementId}`] = true;
}
removeGoogleAnalyticsCookies();
},
trackPageView(path: string) {
@@ -102,6 +110,32 @@ export function createGoogleAnalytics(rawMeasurementId: string | undefined): Ana
page_location: new URL(path, currentWindow.location.origin).href,
page_title: document.title
});
},
trackAnalysisStart(usage) {
const currentWindow = browserWindow();
if (!available || !currentWindow || readConsent() !== 'granted') return;
if (usage.cards.length !== 5) return;
// Whitelist fields: never forward saved user data or calculation results.
try {
load();
const context = { song_id: usage.songId, difficulty: usage.difficulty };
const formation: Record<string, string | number> = { ...context };
usage.cards.forEach((card, index) => {
formation[`card_${index + 1}_id`] = card.cardId;
formation[`card_${index + 1}_awakening`] = card.awakening;
});
currentWindow.gtag?.('event', 'analysis_start', formation);
usage.cards.forEach((card, index) => {
currentWindow.gtag?.('event', 'analysis_card_used', {
...context,
card_id: card.cardId,
awakening: card.awakening,
slot: index + 1
});
});
} catch {
// Analytics must never prevent analysis from running.
}
}
};
}
+7
View File
@@ -1,9 +1,16 @@
export type AnalyticsConsent = 'granted' | 'denied' | null;
export interface AnalysisUsage {
songId: string;
difficulty: string;
cards: { cardId: string; awakening: number }[];
}
export interface AnalyticsClient {
readonly available: boolean;
readConsent(): AnalyticsConsent;
grantConsent(): void;
denyConsent(): void;
trackPageView(path: string): void;
trackAnalysisStart(usage: AnalysisUsage): void;
}
+4 -2
View File
@@ -36,10 +36,12 @@
{#if dialogOpen}
<div class="analytics-consent" role="dialog" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description">
<div class="consent-copy">
<strong id="analytics-consent-title">방문 통계 수집</strong>
<strong id="analytics-consent-title">방문 및 기능 이용 통계 수집</strong>
<p id="analytics-consent-description">
서비스 개선을 위해 페이지 방문 경로와 기기·지역의 대략적인 통계를 Google Analytics로 수집합니다.
보유 카드, 편성, 검색어와 계산값은 전송하지 않습니다.
동의하면 배치 분석 실행 시 선택한 카드 5장과 각 카드의 개화 단계, 악곡·난이도도 전송합니다.
전체 보유 카드 목록, 검색어와 계산 결과는 전송하지 않습니다.
수집 항목이 추가되어 기존 이용자에게도 다시 동의를 요청합니다. 거부해도 모든 기능을 이용할 수 있으며, 분석 설정에서 언제든 변경할 수 있습니다.
</p>
<a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer">Google 개인정보처리방침 ↗</a>
</div>
+10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { page } from '$app/stores';
@@ -706,6 +707,15 @@
});
const noteScore = perfectNoteScore;
const songBonus = boardEffects.directSongScoreBonusPercent;
const analysisSettings = pendingCapture.settings;
analytics.trackAnalysisStart({
songId: analysisSettings.songId,
difficulty: analysisSettings.difficulty,
cards: analysisSettings.cardIds.map((cardId, index) => ({
cardId,
awakening: analysisSettings.awakenings[index]
}))
});
const chart = await loadExactSongChart(selectedChart.path);
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({