feat(analytics): 通用 trackEvent hook 与关键交互埋点#277
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
此 PR 为 docs 站点引入通用埋点能力与关键交互事件上报(page_view / search_open / doc_share),并在文档相关页面与搜索弹窗中接入。
Changes:
- 新增
lib/analytics.ts:统一的trackEvent+useAnalyticshook(失败静默) - 文档路由变化上报
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
| 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 ?? {} }), |
There was a problem hiding this comment.
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.
| if (sessionStorage.getItem(dedupeKey)) return; | ||
| // 内存去重:防止 React StrictMode 双重 effect 重复调用 | ||
| if (lastTrackedRef.current === pathname) return; | ||
|
|
||
| lastTrackedRef.current = pathname; | ||
| sessionStorage.setItem(dedupeKey, "1"); |
There was a problem hiding this comment.
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.
| 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 不可用时静默失败,避免影响路由副作用 | |
| } |
| await navigator.clipboard.writeText(url); | ||
| setCopied(true); | ||
| // 2s 后恢复按钮文案 | ||
| setTimeout(() => setCopied(false), 2000); | ||
| } catch { |
There was a problem hiding this comment.
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).
| <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="复制页面链接" | ||
| > |
There was a problem hiding this comment.
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.
|
|
||
| const timer = setTimeout(() => { | ||
| // Umami 埋点: 搜索结果点击 | ||
| // Umami 埋点: 搜索结果点击 |
There was a problem hiding this comment.
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.
| // Umami 埋点: 搜索结果点击 | |
| // Umami 埋点: 搜索词输入 |
| const token = getStoredToken(); | ||
| const headers: HeadersInit = { "Content-Type": "application/json" }; | ||
| // 携带 satoken 让服务端关联用户身份;匿名访问时 header 不传 | ||
| if (token) { | ||
| headers["satoken"] = token; | ||
| } |
There was a problem hiding this comment.
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 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>。
857d9c1 to
3bce9f8
Compare
Summary
lib/analytics.ts通用埋点工具:trackEvent(eventType, eventData?)+useAnalytics()hook,带 Sa-Token 失败静默DocsPageViewTracker:路由变化上报page_view,sessionStorage 去重search_open(搜索弹窗打开)、doc_share(新增 DocShareButton,文档页复制链接)、page_viewAnalyticsEvent表,后端读接口见 involutionhell-backend#feature/analytics-eventsTest plan
pnpm lint && pnpm typecheck && pnpm test全过/api/analytics