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
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-09-06T18:13:00+09:00 updatedAt: 2026-09-06T20:54:00+09:00
--- ---
## 2026년 9월 6일 ## 2026년 9월 6일
### 배치 분석 이용 통계 안내
`IMPROVED` 통계 수집에 동의하면 배치 분석 실행 시 선택한 카드·개화 단계·악곡·난이도를 서비스 개선을 위해 수집합니다. 수집 범위 변경에 따라 기존 이용자에게도 다시 동의를 요청하며, 거부해도 모든 기능을 이용할 수 있습니다.
### 연결 카드 최적화의 동점 배치 유지 ### 연결 카드 최적화의 동점 배치 유지
`FIXED` 일부 보드에서 파란 영역을 반대쪽으로 판정하던 오류를 수정했습니다. 액티브 발동률 UP 합계와 예상 점수가 모두 같으면 기존 연결 카드 배치를 우선 유지해 불필요한 교환을 줄입니다. `FIXED` 일부 보드에서 파란 영역을 반대쪽으로 판정하던 오류를 수정했습니다. 액티브 발동률 UP 합계와 예상 점수가 모두 같으면 기존 연결 카드 배치를 우선 유지해 불필요한 교환을 줄입니다.
+28
View File
@@ -0,0 +1,28 @@
# 이용 통계 운영
온라인 앱은 기존 GA4 측정 ID를 사용한다. 서버 빌드는 `PUBLIC_GA_MEASUREMENT_ID`, Pages 빌드는 `VITE_GA_MEASUREMENT_ID`를 설정한다. 유효한 ID가 없거나 이용자가 동의하지 않으면 앱의 통계 이벤트를 전송하지 않는다.
## 동의 범위
동의 키는 `holodori:analytics-consent:v2`다. 이전 키의 동의는 새 범위에 적용하지 않으며, 기존 이용자에게도 안내를 다시 표시한다. 분석 설정에서 거부하면 이후 전송을 중단한다. 전체 보유 목록, 검색어, 성장 데이터, 계산 결과는 이벤트에 포함하지 않는다.
## 배치 분석 이벤트
사용자가 최적 배치 분석을 실행하고 입력 검증을 통과하면, 비동기 악곡 로딩 전에 실행 당시 입력을 전송한다. 이후 로딩이나 계산이 실패해도 실행 횟수에는 포함된다. 카드 선택 변경, 기록 열기, 성장 비교, 연결 카드 최적화는 이 이벤트를 발생시키지 않는다.
| 이벤트 | 실행당 횟수 | 매개변수 |
| --- | --- | --- |
| `analysis_start` | 1 | `song_id`, `difficulty`, `card_1_id`~`card_5_id`, `card_1_awakening`~`card_5_awakening` |
| `analysis_card_used` | 5 | `song_id`, `difficulty`, `card_id`, `awakening`, `slot` |
카드와 악곡은 앱 데이터의 ID로 기록한다. slot은 실행 당시 입력 슬롯(1~5)이며 최적화 결과 순서가 아니다. 카드 이벤트를 분리해 어느 슬롯에 사용했든 같은 카드로 집계할 수 있다.
## GA4 보고서 설정
GA4 관리의 맞춤 정의에서 `song_id`, `difficulty`, `card_id`, `awakening`, `slot`을 이벤트 범위 맞춤 측정기준으로 등록한다. 편성 단위 비교가 필요하면 `card_1_id`~`card_5_id`와 각 개화 매개변수도 등록한다.
탐색에서 이벤트 이름을 `analysis_card_used`로 필터링하고 행을 `card_id`, 값을 이벤트 수로 설정하면 카드 사용 빈도를 볼 수 있다. 악곡과 난이도를 추가해 곡별 사용 현황을 확인한다. 실행 횟수는 `analysis_start`의 이벤트 수를 사용한다. 두 이벤트를 합친 수는 실행 횟수가 아니다. 반복 실행도 각각 집계하며, 동의 거부·차단 도구 등으로 전체 이용량과 차이가 날 수 있다.
배포 후 동의한 브라우저에서 분석을 한 번 실행하고 GA4 실시간 보고서에서 이벤트 도착을 확인한다. 맞춤 보고서 반영에는 시간이 걸릴 수 있다. 코드 검증은 실제 GA4 수신 확인을 대신하지 않는다.
참고: [GA4 이벤트 설정](https://developers.google.com/analytics/devguides/collection/ga4/events), [이벤트 매개변수](https://developers.google.com/analytics/devguides/collection/ga4/event-parameters).
+2 -1
View File
@@ -5,5 +5,6 @@ export const analytics: AnalyticsClient = {
readConsent: () => null, readConsent: () => null,
grantConsent: () => {}, grantConsent: () => {},
denyConsent: () => {}, denyConsent: () => {},
trackPageView: () => {} trackPageView: () => {},
trackAnalysisStart: () => {}
}; };
@@ -48,3 +48,81 @@ test('Google 공식 형식의 arguments 객체를 dataLayer에 적재한다', ()
}); });
type GoogleTagWindow = Window & { dataLayer?: IArguments[] }; 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'; 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; const MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
type GoogleTagWindow = Window & { type GoogleTagWindow = Window & {
@@ -86,11 +86,19 @@ export function createGoogleAnalytics(rawMeasurementId: string | undefined): Ana
grantConsent() { grantConsent() {
sessionConsent = 'granted'; sessionConsent = 'granted';
writeStoredConsent('granted'); writeStoredConsent('granted');
const currentWindow = browserWindow();
if (available && currentWindow) {
(currentWindow as unknown as Record<string, unknown>)[`ga-disable-${measurementId}`] = false;
}
load(); load();
}, },
denyConsent() { denyConsent() {
sessionConsent = 'denied'; sessionConsent = 'denied';
writeStoredConsent('denied'); writeStoredConsent('denied');
const currentWindow = browserWindow();
if (available && currentWindow) {
(currentWindow as unknown as Record<string, unknown>)[`ga-disable-${measurementId}`] = true;
}
removeGoogleAnalyticsCookies(); removeGoogleAnalyticsCookies();
}, },
trackPageView(path: string) { trackPageView(path: string) {
@@ -102,6 +110,32 @@ export function createGoogleAnalytics(rawMeasurementId: string | undefined): Ana
page_location: new URL(path, currentWindow.location.origin).href, page_location: new URL(path, currentWindow.location.origin).href,
page_title: document.title 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 type AnalyticsConsent = 'granted' | 'denied' | null;
export interface AnalysisUsage {
songId: string;
difficulty: string;
cards: { cardId: string; awakening: number }[];
}
export interface AnalyticsClient { export interface AnalyticsClient {
readonly available: boolean; readonly available: boolean;
readConsent(): AnalyticsConsent; readConsent(): AnalyticsConsent;
grantConsent(): void; grantConsent(): void;
denyConsent(): void; denyConsent(): void;
trackPageView(path: string): void; trackPageView(path: string): void;
trackAnalysisStart(usage: AnalysisUsage): void;
} }
+4 -2
View File
@@ -36,10 +36,12 @@
{#if dialogOpen} {#if dialogOpen}
<div class="analytics-consent" role="dialog" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description"> <div class="analytics-consent" role="dialog" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description">
<div class="consent-copy"> <div class="consent-copy">
<strong id="analytics-consent-title">방문 통계 수집</strong> <strong id="analytics-consent-title">방문 및 기능 이용 통계 수집</strong>
<p id="analytics-consent-description"> <p id="analytics-consent-description">
서비스 개선을 위해 페이지 방문 경로와 기기·지역의 대략적인 통계를 Google Analytics로 수집합니다. 서비스 개선을 위해 페이지 방문 경로와 기기·지역의 대략적인 통계를 Google Analytics로 수집합니다.
보유 카드, 편성, 검색어와 계산값은 전송하지 않습니다. 동의하면 배치 분석 실행 시 선택한 카드 5장과 각 카드의 개화 단계, 악곡·난이도도 전송합니다.
전체 보유 카드 목록, 검색어와 계산 결과는 전송하지 않습니다.
수집 항목이 추가되어 기존 이용자에게도 다시 동의를 요청합니다. 거부해도 모든 기능을 이용할 수 있으며, 분석 설정에서 언제든 변경할 수 있습니다.
</p> </p>
<a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer">Google 개인정보처리방침 ↗</a> <a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer">Google 개인정보처리방침 ↗</a>
</div> </div>
+10
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte'; import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts'; import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -706,6 +707,15 @@
}); });
const noteScore = perfectNoteScore; const noteScore = perfectNoteScore;
const songBonus = boardEffects.directSongScoreBonusPercent; 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); const chart = await loadExactSongChart(selectedChart.path);
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) }; progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({ worker.postMessage({