feat: collect consented analysis usage statistics
Publish GitHub Pages output / publish (push) Successful in 43s
Publish GitHub Pages output / publish (push) Successful in 43s
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user