Skip to content

feat(analytics): 通用 trackEvent hook 与关键交互埋点#277

Merged
longsizhuo merged 4 commits intomainfrom
feature/analytics-events
Apr 14, 2026
Merged

feat(analytics): 通用 trackEvent hook 与关键交互埋点#277
longsizhuo merged 4 commits intomainfrom
feature/analytics-events

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

Summary

  • 抽出 lib/analytics.ts 通用埋点工具:trackEvent(eventType, eventData?) + useAnalytics() hook,带 Sa-Token 失败静默
  • 重构 DocsPageViewTracker:路由变化上报 page_view,sessionStorage 去重
  • 埋 3 类关键交互:search_open(搜索弹窗打开)、doc_share(新增 DocShareButton,文档页复制链接)、page_view
  • 数据写入共享 Neon Postgres 的 AnalyticsEvent 表,后端读接口见 involutionhell-backend#feature/analytics-events

Test plan

  • pnpm lint && pnpm typecheck && pnpm test 全过
  • 本地 3010 联调:F12 Network 确认每类事件触发并 POST /api/analytics
  • Reviewer: 确认 DocShareButton 放置位置(EditOnGithub 旁)符合设计预期

Copilot AI review requested due to automatic review settings April 14, 2026 18:30
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 14, 2026 8:07pm
website-preview Ready Ready Preview, Comment Apr 14, 2026 8:07pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

此 PR 为 docs 站点引入通用埋点能力与关键交互事件上报(page_view / search_open / doc_share),并在文档相关页面与搜索弹窗中接入。

Changes:

  • 新增 lib/analytics.ts:统一的 trackEvent + useAnalytics hook(失败静默)
  • 文档路由变化上报 page_view(sessionStorage + StrictMode 内存去重)
  • 新增“复制链接”按钮并上报 doc_share;搜索弹窗打开上报 search_open

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
lib/analytics.ts 新增通用埋点发送函数与 hook
app/docs/layout.tsx 在 docs layout 挂载 PV tracker
app/docs/[...slug]/page.tsx 文档页标题区新增分享按钮入口
app/components/DocsPageViewTracker.tsx 路由变化触发 page_view,并做 session 去重
app/components/DocShareButton.tsx 复制链接按钮 + doc_share 埋点
app/components/CustomSearchDialog.tsx 搜索弹窗打开时触发 search_open 埋点

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

lib/analytics.ts Outdated
Comment on lines +21 to +30
const headers: HeadersInit = { "Content-Type": "application/json" };
// 携带 satoken 让服务端关联用户身份;匿名访问时 header 不传
if (token) {
headers["satoken"] = token;
}

await fetch("/api/analytics", {
method: "POST",
headers,
body: JSON.stringify({ eventType, eventData: eventData ?? {} }),
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The analytics API resolves user identity from the x-satoken request header (resolveUserId reads req.headers.get("x-satoken")). This helper currently sends the token as satoken, so user association will never work for /api/analytics. Send x-satoken here to match the existing client→Next API convention.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +27
if (sessionStorage.getItem(dedupeKey)) return;
// 内存去重:防止 React StrictMode 双重 effect 重复调用
if (lastTrackedRef.current === pathname) return;

lastTrackedRef.current = pathname;
sessionStorage.setItem(dedupeKey, "1");
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionStorage.getItem/setItem can throw (e.g. storage disabled / Safari private mode). Since this component aims for “failure silent”, wrap the sessionStorage reads/writes in a try/catch (and fall back to only the in-memory lastTrackedRef dedupe) so analytics never breaks routing effects.

Suggested change
if (sessionStorage.getItem(dedupeKey)) return;
// 内存去重:防止 React StrictMode 双重 effect 重复调用
if (lastTrackedRef.current === pathname) return;
lastTrackedRef.current = pathname;
sessionStorage.setItem(dedupeKey, "1");
try {
if (sessionStorage.getItem(dedupeKey)) return;
} catch {
// storage 不可用时静默降级到内存去重
}
// 内存去重:防止 React StrictMode 双重 effect 重复调用
if (lastTrackedRef.current === pathname) return;
lastTrackedRef.current = pathname;
try {
sessionStorage.setItem(dedupeKey, "1");
} catch {
// storage 不可用时静默失败,避免影响路由副作用
}

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
await navigator.clipboard.writeText(url);
setCopied(true);
// 2s 后恢复按钮文案
setTimeout(() => setCopied(false), 2000);
} catch {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTimeout(() => setCopied(false), 2000) isn’t cleaned up on unmount, which can trigger a state update after navigation/unmount. Store the timer id (e.g. in a ref) and clear it in an effect cleanup (or guard with an isMounted ref).

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +33
<button
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-md px-4 h-11 text-base font-medium hover:bg-muted/80 hover:text-foreground"
aria-label="复制页面链接"
>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <button> doesn’t specify a type. If this component is ever rendered inside a <form>, it will default to type="submit" and may trigger unintended form submissions. Set type="button" explicitly.

Copilot uses AI. Check for mistakes.

const timer = setTimeout(() => {
// Umami 埋点: 搜索结果点击
// Umami 埋点: 搜索结果点击
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “搜索结果点击” but this block is tracking search_query (query input), not a result click. Update the comment to match the actual tracked event to avoid misleading future edits.

Suggested change
// Umami 埋点: 搜索结果点击
// Umami 埋点: 搜索词输入

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +25
const token = getStoredToken();
const headers: HeadersInit = { "Content-Type": "application/json" };
// 携带 satoken 让服务端关联用户身份;匿名访问时 header 不传
if (token) {
headers["satoken"] = token;
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackEvent builds headers as HeadersInit and then mutates it via headers["..."] = .... In TS HeadersInit is a union (Headers | string[][] | Record<string,string>), so indexing/mutation is not type-safe and typically fails under strict typecheck. Use a concrete mutable type (e.g. Record<string, string> or new Headers() with .set).

Copilot uses AI. Check for mistakes.
longsizhuo added a commit that referenced this pull request Apr 14, 2026
… 清理等

Copilot CR #277:
- lib/analytics: 客户端 → Next /api/analytics 用 x-satoken(之前用 satoken 导致 resolveUserId 解析不到 userId,uniqueUsers 恒为 0)
- lib/analytics: localStorage 读取用 try/catch,Safari 隐私模式不崩
- lib/analytics: headers 用 Record<string,string> 而不是 HeadersInit 联合类型,可变安全
- DocsPageViewTracker: sessionStorage 读写 try/catch 降级到内存去重
- DocShareButton: useRef 存 setTimeout id,unmount 时 clearTimeout;加 type='button' 避免 form 内误触发提交
- CustomSearchDialog: 注释修正('搜索结果点击' → '搜索词输入')
新增 lib/analytics.ts,封装 trackEvent 函数和 useAnalytics hook:
- 复用 POST /api/analytics 接口,携带 satoken 关联用户身份
- 失败静默,不影响用户主流程
- useAnalytics 返回 memoized trackEvent 避免重复引用

新增 DocsPageViewTracker 组件,监听路由变化上报 page_view 事件,
带 sessionStorage + 内存双重去重防止重复上报。
- DocsPageViewTracker 挂载至 docs layout,路由切换时上报 page_view
- CustomSearchDialog:弹窗从关闭→打开时触发 search_open 埋点
- DocShareButton:新增文档页"复制链接"按钮,点击时触发 doc_share 埋点
  并将当前 URL 写入剪贴板,放置于文档标题区 EditOnGithub 旁边
… 清理等

Copilot CR #277:
- lib/analytics: 客户端 → Next /api/analytics 用 x-satoken(之前用 satoken 导致 resolveUserId 解析不到 userId,uniqueUsers 恒为 0)
- lib/analytics: localStorage 读取用 try/catch,Safari 隐私模式不崩
- lib/analytics: headers 用 Record<string,string> 而不是 HeadersInit 联合类型,可变安全
- DocsPageViewTracker: sessionStorage 读写 try/catch 降级到内存去重
- DocShareButton: useRef 存 setTimeout id,unmount 时 clearTimeout;加 type='button' 避免 form 内误触发提交
- CustomSearchDialog: 注释修正('搜索结果点击' → '搜索词输入')
接前一 commit 补落下的 lib/analytics.ts 改动。Copilot 指出这个是最核心的
bug:resolveUserId 从 x-satoken 读 token,之前传 satoken 永远拿不到 userId,
导致埋点 uniqueUsers 恒为 0。现在修成 x-satoken,配合 storage try/catch 和
headers 类型换成 Record<string,string>。
@longsizhuo longsizhuo force-pushed the feature/analytics-events branch from 857d9c1 to 3bce9f8 Compare April 14, 2026 20:55
@longsizhuo longsizhuo merged commit 62746a7 into main Apr 14, 2026
5 checks passed
@longsizhuo longsizhuo deleted the feature/analytics-events branch April 14, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants