From 4bf05f6784292a021405b5555b3761bd9c4dbf47 Mon Sep 17 00:00:00 2001
From: yuanzonghao
Date: Thu, 4 Jun 2026 10:14:08 +0800
Subject: [PATCH] feat(web): add privacy-friendly Umami custom events
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 15 +++++++--
app/page.tsx | 45 +++++++++++++++++++++++--
app/play/page.tsx | 28 ++++++++++++++++
components/Analytics.tsx | 11 +++++--
components/CustomForm.tsx | 2 ++
lib/analytics.ts | 69 +++++++++++++++++++++++++++++++++++++++
6 files changed, 163 insertions(+), 7 deletions(-)
create mode 100644 lib/analytics.ts
diff --git a/.env.example b/.env.example
index f0af896..727f97e 100644
--- a/.env.example
+++ b/.env.example
@@ -73,7 +73,18 @@ NEXT_PUBLIC_IMAGE_PROXY_URL=
# NEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js
# Self-host later: point SRC at your own instance — the integration is identical
# (no code change), e.g. NEXT_PUBLIC_UMAMI_SRC=https://stats.example.com/script.js
-# Both blank → no script is injected (zero tracking). NEXT_PUBLIC_ vars are
-# inlined at BUILD time, so set them in the build env (Vercel project settings).
+# Both blank → no script is injected (zero tracking; every track() call no-ops).
+# Beyond page views the app emits content-free custom events (game start, scene
+# reached, choice picked, ...) — only enums/counts/booleans, never your prompts,
+# uploaded images or any per-user ID. The visitor's Do-Not-Track is honoured.
+# NEXT_PUBLIC_ vars are inlined at BUILD time, so set them in the build env
+# (Vercel project settings).
NEXT_PUBLIC_UMAMI_SRC=
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
+
+# Optional hostname allowlist — defense-in-depth on top of the blank-to-disable
+# gate above. The tracker fires only when window.location.hostname EXACTLY
+# matches an entry, so a fork that copied these vars stays silent on its own
+# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards.
+# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
+NEXT_PUBLIC_UMAMI_DOMAINS=
diff --git a/app/page.tsx b/app/page.tsx
index 24ca848..1398894 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
+import { track } from "@/lib/analytics";
/* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -1008,9 +1009,11 @@ function StyleModal({
// 用户事后还可以手动改 draft(仍是 textarea)。
setDraft(data.stylePrompt);
setCustomStyleRefImage(resized);
+ track("style_image_upload", { ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : "解析失败";
setParseError(msg);
+ track("style_image_upload", { ok: false });
} finally {
setParsing(false);
}
@@ -1524,6 +1527,17 @@ export default function HomePage() {
const styleReferenceImage =
artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined;
+ track("game_start", {
+ source: "prompt",
+ gender,
+ art_style: artStyle,
+ plot_style: plotStyle,
+ pacing: pace,
+ tts: audioEnabled,
+ has_prompt: prompt.trim().length > 0,
+ has_style_ref: Boolean(styleReferenceImage),
+ });
+
sessionStorage.setItem(
"infiplot:custom",
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }),
@@ -1533,6 +1547,9 @@ export default function HomePage() {
const stories = STORIES[galleryGender];
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
+ const analyticsOn = Boolean(
+ process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
+ );
// 点卡片 = 直接开始这张卡的故事,零等待:跳 /play?card=m0/f0... 由 /play
// 页面从 /home/firstact/{name}.json 静态文件加载预烘焙好的首幕(含 scene /
@@ -1547,6 +1564,12 @@ export default function HomePage() {
"infiplot:custom",
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }),
);
+ track("game_start", {
+ source: "curated",
+ gender: galleryGender,
+ tts: audioEnabled,
+ card: `${imgPrefix}${idx}`,
+ });
router.push(`/play?card=${imgPrefix}${idx}`);
};
@@ -1778,8 +1801,21 @@ export default function HomePage() {
目前,内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
AI 生成的内容不代表本团队立场。
-
- 本站使用开源的 Umami 进行隐私友好的匿名访问统计:不使用 Cookie、不收集个人信息、不做跨站追踪。
+ {analyticsOn && (
+ <>
+
+ 本站使用开源的{" "}
+
+ Umami
+ {" "}
+ 进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。
+ >
+ )}
@@ -1794,7 +1830,10 @@ export default function HomePage() {
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
+ onPick={(i) => {
+ track("art_style_select", { style: OPTS[styleRow]!.items[i] ?? String(i) });
+ setSel((s) => s.map((v, j) => (j === styleRow ? i : v)));
+ }}
onClose={() => setStyleOpen(false)}
customStyleGuide={customStyleGuide}
setCustomStyleGuide={setCustomStyleGuide}
diff --git a/app/play/page.tsx b/app/play/page.tsx
index 4cb4867..765b1da 100644
--- a/app/play/page.tsx
+++ b/app/play/page.tsx
@@ -26,6 +26,7 @@ import type {
StartResponse,
VisionResponse,
} from "@infiplot/types";
+import { track } from "@/lib/analytics";
const MUTED_STORAGE_KEY = "infiplot:muted";
@@ -373,6 +374,17 @@ function PlayInner() {
mutedRef.current = muted;
}, [muted]);
+ // Coarse liveness ping for active-time analytics. /play is a single SPA
+ // route, so page views alone read as ~0 duration; a 30s heartbeat (only
+ // while the tab is visible) gives Umami the timestamps to derive real
+ // engaged time. Content-free — no payload, and a no-op without the tracker.
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ if (document.visibilityState === "visible") track("play_heartbeat");
+ }, 30_000);
+ return () => window.clearInterval(id);
+ }, []);
+
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
useEffect(() => {
if (!currentBeatId) return;
@@ -472,6 +484,7 @@ function PlayInner() {
// ── Mute persistence (read is via the useState lazy initializer above) ─
const toggleMuted = useCallback(() => {
+ track("tts_toggle", { muted: !mutedRef.current });
setMuted((prev) => {
const next = !prev;
try {
@@ -507,6 +520,7 @@ function PlayInner() {
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;
+ track("fullscreen_toggle", { on: entering });
if (entering) {
try {
if (!document.fullscreenElement) {
@@ -671,6 +685,7 @@ function PlayInner() {
// beatAudioMap is populated lazily by the per-beat fetch effect once
// currentScene becomes non-null (see fetchBeatAudio).
setPhase("ready");
+ track("scene_reached", { scene_index: initial.history.length });
})
.catch((e) => setError(String(e)));
}, [params, router]);
@@ -782,6 +797,7 @@ function PlayInner() {
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
setLastExitLabel(exitLabel);
setPhase("ready");
+ track("scene_reached", { scene_index: newSession.history.length });
} catch (e) {
if ((e as { name?: string }).name === "AbortError") {
setPhase("ready");
@@ -795,6 +811,17 @@ function PlayInner() {
function onSelectChoice(choice: BeatChoice) {
if (phase !== "ready" || !session || !currentScene) return;
+ const beatNext = currentBeatRef.current?.next;
+ const choiceIndex =
+ beatNext?.type === "choice"
+ ? beatNext.choices.findIndex((c) => c.id === choice.id)
+ : -1;
+ track("choice_select", {
+ scene_index: session.history.length,
+ choice_index: choiceIndex,
+ kind: choice.effect.kind,
+ });
+
if (choice.effect.kind === "advance-beat") {
// Pure local jump. No network. No pool changes.
setCurrentBeatId(choice.effect.targetBeatId);
@@ -863,6 +890,7 @@ function PlayInner() {
throw new Error(j.error ?? visionRes.statusText);
}
const decision = (await visionRes.json()) as VisionResponse;
+ track("vision_click", { result: decision.classify });
if (decision.classify === "insert-beat") {
setPhase("inserting-beat");
diff --git a/components/Analytics.tsx b/components/Analytics.tsx
index 375e3cc..e4145b4 100644
--- a/components/Analytics.tsx
+++ b/components/Analytics.tsx
@@ -1,16 +1,23 @@
import Script from "next/script";
-// Privacy-friendly, cookieless page-view analytics (Umami). Both env vars
-// unset → render nothing, so local dev and forks never report to our instance.
+// Privacy-friendly, cookieless analytics (Umami). Both env vars unset →
+// render nothing, so local dev and forks never report to our instance.
+// - data-do-not-track: honour the visitor's browser Do Not Track setting.
+// - data-domains (NEXT_PUBLIC_UMAMI_DOMAINS): extra guard — the tracker only
+// fires when the live hostname matches, so even a fork that copied our env
+// vars stays silent on a different domain. Unset → run on all hosts.
export function Analytics() {
const src = process.env.NEXT_PUBLIC_UMAMI_SRC;
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
+ const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
if (!src || !websiteId) return null;
return (
diff --git a/components/CustomForm.tsx b/components/CustomForm.tsx
index 9d349b4..ba45a1c 100644
--- a/components/CustomForm.tsx
+++ b/components/CustomForm.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
+import { track } from "@/lib/analytics";
export function CustomForm() {
const router = useRouter();
@@ -22,6 +23,7 @@ export function CustomForm() {
"infiplot:custom",
JSON.stringify({ worldSetting, styleGuide }),
);
+ track("game_start", { source: "custom" });
router.push("/play?custom=1");
}
diff --git a/lib/analytics.ts b/lib/analytics.ts
new file mode 100644
index 0000000..83d04b0
--- /dev/null
+++ b/lib/analytics.ts
@@ -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) => 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` 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(
+ event: E,
+ ...[data]: AnalyticsEventData[E] extends never ? [] : [AnalyticsEventData[E]]
+): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.umami?.track(event, data as Record | undefined);
+ } catch {
+ // Analytics must never throw into the app.
+ }
+}