feat(web): add privacy-friendly Umami custom events
Instrument the play flow with 9 content-free custom events (game_start, art_style_select, style_image_upload, scene_reached, choice_select, vision_click, tts_toggle, fullscreen_toggle, play_heartbeat) to measure retention, engagement depth and session duration. Privacy is enforced by construction, not convention: - lib/analytics.ts types each event with a discriminated union, so a payload has no slot for free text — prompts, world guides, uploaded images and vision output can never reach analytics (compile-time guarantee, not a comment). - track() no-ops without window.umami and never throws into the app. - coarse 30s heartbeat fires only while the tab is visible. - script stays gated on NEXT_PUBLIC_UMAMI_* env (blank → no script), honours Do-Not-Track, and locks to an exact data-domains allowlist. - one-line on-site disclosure with a link, shown only when tracking is on. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Privacy-first analytics. Sends only content-free, categorical events to
|
||||
// Umami, and only when the tracker script is actually present (gated by the
|
||||
// NEXT_PUBLIC_UMAMI_* env vars in components/Analytics.tsx). With no script
|
||||
// loaded — local dev, forks, a non-matching data-domains host, or a visitor
|
||||
// with Do Not Track — `window.umami` is undefined and every call here is a
|
||||
// silent no-op: zero runtime impact, no errors.
|
||||
//
|
||||
// RULE: never pass free text (player prompts, custom world/style guides,
|
||||
// uploaded images, vision output) or any per-user identifier. Only enums,
|
||||
// indices, counts and booleans — that is what keeps these events as
|
||||
// privacy-friendly as the cookieless page-view baseline.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
||||
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
||||
// world/style guide or vision string, so free text can't be attached by mistake
|
||||
// (a bare `Record<string, string>` would happily accept it). Keep every field an
|
||||
// enum, index, count or boolean. `never` marks events that carry no payload.
|
||||
type AnalyticsEventData = {
|
||||
game_start:
|
||||
| {
|
||||
source: "prompt";
|
||||
gender: string;
|
||||
art_style: string;
|
||||
plot_style: string;
|
||||
pacing: string;
|
||||
tts: boolean;
|
||||
has_prompt: boolean;
|
||||
has_style_ref: boolean;
|
||||
}
|
||||
| { source: "curated"; gender: string; tts: boolean; card: string }
|
||||
| { source: "custom" };
|
||||
art_style_select: { style: string };
|
||||
style_image_upload: { ok: boolean };
|
||||
scene_reached: { scene_index: number };
|
||||
choice_select: {
|
||||
scene_index: number;
|
||||
choice_index: number;
|
||||
kind: "advance-beat" | "change-scene";
|
||||
};
|
||||
vision_click: { result: "insert-beat" | "change-scene" };
|
||||
tts_toggle: { muted: boolean };
|
||||
fullscreen_toggle: { on: boolean };
|
||||
play_heartbeat: never;
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = keyof AnalyticsEventData;
|
||||
|
||||
// Payload is required for events that define one and forbidden for those typed
|
||||
// `never` (the conditional rest tuple collapses to `[]`), so `track("game_start")`
|
||||
// without data and `track("play_heartbeat", {...})` with data are both errors.
|
||||
export function track<E extends AnalyticsEvent>(
|
||||
event: E,
|
||||
...[data]: AnalyticsEventData[E] extends never ? [] : [AnalyticsEventData[E]]
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.umami?.track(event, data as Record<string, unknown> | undefined);
|
||||
} catch {
|
||||
// Analytics must never throw into the app.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user