-
Notifications
You must be signed in to change notification settings - Fork 56
feat(analytics): integrate Cloudflare Analytics Engine dashboard (CF-only) #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a98c2be
ce77f2e
be98273
e1e8e13
888b98f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| /*! | ||
| * Monolith Analytics Tracker (CF Analytics Engine) | ||
| * Adapted from HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics | ||
| * | ||
| * 用法:在第三方站点的 </body> 前插入: | ||
| * <script defer src="https://your-site.pages.dev/tracker.js" | ||
| * data-website-id="my-blog" | ||
| * data-endpoint="https://your-worker.workers.dev/api/track"></script> | ||
| * | ||
| * 自有站点已通过 React Router 集成,无需手动加载本脚本。 | ||
| */ | ||
| (function () { | ||
| "use strict"; | ||
| var script = document.currentScript || (function () { | ||
| var s = document.getElementsByTagName("script"); | ||
| return s[s.length - 1]; | ||
| })(); | ||
| if (!script) return; | ||
|
|
||
| var website = script.getAttribute("data-website-id") || "default"; | ||
| var endpoint = script.getAttribute("data-endpoint") || "/api/track"; | ||
| var VID_KEY = "monolith_vid"; | ||
| var VID_TTL = 30 * 24 * 60 * 60 * 1000; | ||
| var enterAt = 0; | ||
| var lastPath = ""; | ||
|
|
||
| function hash(s) { | ||
| var h = 0x811c9dc5; | ||
| for (var i = 0; i < s.length; i++) { | ||
| h ^= s.charCodeAt(i); | ||
| h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; | ||
| } | ||
| return h.toString(36); | ||
| } | ||
|
|
||
| function vid() { | ||
| try { | ||
| var raw = localStorage.getItem(VID_KEY); | ||
| if (raw) { | ||
| var p = JSON.parse(raw); | ||
| if (Date.now() - p.ts < VID_TTL && p.id) return p.id; | ||
| } | ||
| } catch (_) { /* ignore */ } | ||
| var id = hash(Date.now() + "|" + Math.random() + "|" + navigator.userAgent + "|" + screen.width + "x" + screen.height); | ||
| try { localStorage.setItem(VID_KEY, JSON.stringify({ id: id, ts: Date.now() })); } catch (_) { /* ignore */ } | ||
| return id; | ||
| } | ||
|
|
||
| function send(body, beacon) { | ||
| var json = JSON.stringify(body); | ||
| if (beacon && navigator.sendBeacon) { | ||
| navigator.sendBeacon(endpoint, new Blob([json], { type: "application/json" })); | ||
| return; | ||
| } | ||
| try { | ||
| fetch(endpoint, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: json, | ||
| keepalive: true, | ||
| credentials: "omit", | ||
| }).catch(function () { /* silent */ }); | ||
| } catch (_) { /* ignore */ } | ||
| } | ||
|
|
||
| function payload(path, duration) { | ||
| return { | ||
| website: website, | ||
| path: path, | ||
| referer: document.referrer || "", | ||
| screen: screen.width + "x" + screen.height, | ||
| language: navigator.language || "", | ||
| visitorId: vid(), | ||
| duration: duration || 0, | ||
| }; | ||
| } | ||
|
|
||
| function track() { | ||
| var path = location.pathname + location.search; | ||
| if (path === lastPath) return; | ||
| if (lastPath && enterAt > 0) send(payload(lastPath, Date.now() - enterAt), false); | ||
| lastPath = path; | ||
| enterAt = Date.now(); | ||
| send(payload(path, 0), false); | ||
| } | ||
|
|
||
| function flush() { | ||
| if (!lastPath || enterAt <= 0) return; | ||
| send(payload(lastPath, Date.now() - enterAt), true); | ||
| } | ||
|
|
||
| // 初始 + History API hook | ||
| track(); | ||
| var origPush = history.pushState; | ||
| var origReplace = history.replaceState; | ||
| history.pushState = function () { origPush.apply(this, arguments); track(); }; | ||
| history.replaceState = function () { origReplace.apply(this, arguments); track(); }; | ||
| window.addEventListener("popstate", track); | ||
| window.addEventListener("pagehide", flush); | ||
| document.addEventListener("visibilitychange", function () { | ||
| if (document.visibilityState === "hidden") flush(); | ||
| }); | ||
| })(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * 客户端访客埋点工具(CF Analytics Engine 增强版) | ||
| * 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics | ||
| * | ||
| * 工作原理: | ||
| * 1. 首次加载生成稳定的访客 ID(localStorage 缓存 30 天,不含 PII) | ||
| * 2. 路由变化 / 首屏渲染 时调用 trackPageview | ||
| * 3. 页面 unload / pagehide 时上报本次停留时长(duration) | ||
| * 4. 后端 AE 不可用 → 接口直接 204,前端无感 | ||
| */ | ||
|
|
||
| const API_BASE = import.meta.env.VITE_API_URL || ""; | ||
| const TRACK_ENDPOINT = `${API_BASE}/api/track`; | ||
| const VID_KEY = "monolith_vid"; | ||
| const VID_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天 | ||
|
|
||
| let pageEnterAt = 0; | ||
| let lastTrackedPath = ""; | ||
|
|
||
| /** 32-bit FNV-1a 哈希,输出 base36 字符串(不含 PII) */ | ||
| function hash(input: string): string { | ||
| let h = 0x811c9dc5; | ||
| for (let i = 0; i < input.length; i++) { | ||
| h ^= input.charCodeAt(i); | ||
| h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; | ||
| } | ||
| return h.toString(36); | ||
| } | ||
|
|
||
| /** 获取或生成稳定的访客 ID(仅写本地,不入 cookie) */ | ||
| function getVisitorId(): string { | ||
| try { | ||
| const raw = localStorage.getItem(VID_KEY); | ||
| if (raw) { | ||
| const parsed = JSON.parse(raw) as { id: string; ts: number }; | ||
| if (Date.now() - parsed.ts < VID_TTL_MS && parsed.id) return parsed.id; | ||
| } | ||
| } catch { /* ignore */ } | ||
|
|
||
| const seed = `${Date.now()}|${Math.random()}|${navigator.userAgent}|${screen.width}x${screen.height}`; | ||
| const id = hash(seed); | ||
| try { | ||
| localStorage.setItem(VID_KEY, JSON.stringify({ id, ts: Date.now() })); | ||
| } catch { /* localStorage 满或被禁,忽略 */ } | ||
| return id; | ||
| } | ||
|
|
||
| type TrackBody = { | ||
| website: string; | ||
| path: string; | ||
| referer: string; | ||
| screen: string; | ||
| language: string; | ||
| visitorId: string; | ||
| duration: number; | ||
| }; | ||
|
|
||
| function send(body: TrackBody, useBeacon: boolean): void { | ||
| const json = JSON.stringify(body); | ||
| if (useBeacon && navigator.sendBeacon) { | ||
| // sendBeacon 在 unload 时最可靠 | ||
| const blob = new Blob([json], { type: "application/json" }); | ||
| navigator.sendBeacon(TRACK_ENDPOINT, blob); | ||
| return; | ||
| } | ||
| // keepalive 让请求在页面切换时也能完成 | ||
| fetch(TRACK_ENDPOINT, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: json, | ||
| keepalive: true, | ||
| credentials: "omit", | ||
| }).catch(() => { /* 静默失败,埋点永远不影响业务 */ }); | ||
| } | ||
|
|
||
| /** 上报一次 pageview(路由变化时调用) */ | ||
| export function trackPageview(path: string, website = "default"): void { | ||
| if (!path || path === lastTrackedPath) return; | ||
| // 后台路径不上报,保护管理员隐私 + 减少噪音 | ||
| if (path.startsWith("/admin")) { | ||
| lastTrackedPath = path; | ||
| pageEnterAt = Date.now(); | ||
| return; | ||
| } | ||
|
Comment on lines
+77
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 进入 从 🛠️ 建议先 flush 再切到 admin 隐私模式- // 后台路径不上报,保护管理员隐私 + 减少噪音
- if (path.startsWith("/admin")) {
- lastTrackedPath = path;
- pageEnterAt = Date.now();
- return;
- }
-
- // 上报上一页停留时长
- if (lastTrackedPath && pageEnterAt > 0) {
+ // 先上报上一页停留时长(即使下一页是 /admin)
+ if (lastTrackedPath && pageEnterAt > 0 && !lastTrackedPath.startsWith("/admin")) {
send(/* ... */);
}
+
+ // 后台路径不再上报新 PV,保护管理员隐私
+ if (path.startsWith("/admin")) {
+ lastTrackedPath = path;
+ pageEnterAt = Date.now();
+ return;
+ }🤖 Prompt for AI Agents |
||
|
|
||
| // 上报上一页停留时长 | ||
| if (lastTrackedPath && pageEnterAt > 0) { | ||
| send( | ||
| { | ||
| website, | ||
| path: lastTrackedPath, | ||
| referer: document.referrer || "", | ||
| screen: `${screen.width}x${screen.height}`, | ||
| language: navigator.language || "", | ||
| visitorId: getVisitorId(), | ||
| duration: Date.now() - pageEnterAt, | ||
| }, | ||
| false, | ||
| ); | ||
| } | ||
|
|
||
| // 上报新页面 pageview | ||
| lastTrackedPath = path; | ||
| pageEnterAt = Date.now(); | ||
| send( | ||
| { | ||
| website, | ||
| path, | ||
| referer: document.referrer || "", | ||
| screen: `${screen.width}x${screen.height}`, | ||
| language: navigator.language || "", | ||
| visitorId: getVisitorId(), | ||
| duration: 0, | ||
| }, | ||
| false, | ||
| ); | ||
| } | ||
|
|
||
| let unloadBound = false; | ||
| /** 绑定 pagehide 事件,确保最后一次停留时长能上报(仅绑定一次) */ | ||
| export function bindUnloadTracker(website = "default"): void { | ||
| if (unloadBound) return; | ||
| unloadBound = true; | ||
| const flush = () => { | ||
| if (!lastTrackedPath || pageEnterAt <= 0) return; | ||
| if (lastTrackedPath.startsWith("/admin")) return; | ||
| send( | ||
| { | ||
| website, | ||
| path: lastTrackedPath, | ||
| referer: document.referrer || "", | ||
| screen: `${screen.width}x${screen.height}`, | ||
| language: navigator.language || "", | ||
| visitorId: getVisitorId(), | ||
| duration: Date.now() - pageEnterAt, | ||
| }, | ||
| true, | ||
| ); | ||
| }; | ||
| // pagehide 比 unload 更可靠(兼容 BFCache) | ||
| window.addEventListener("pagehide", flush); | ||
| document.addEventListener("visibilitychange", () => { | ||
| if (document.visibilityState === "hidden") flush(); | ||
| }); | ||
| } | ||
|
Comment on lines
+119
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
服务端 AE 查询使用 🛠️ 推荐修复(任选其一)方案 A:flush 后重置起点,避免重复累计: const flush = () => {
if (!lastTrackedPath || pageEnterAt <= 0) return;
if (lastTrackedPath.startsWith("/admin")) return;
send(
{
website,
path: lastTrackedPath,
referer: document.referrer || "",
screen: `${screen.width}x${screen.height}`,
language: navigator.language || "",
visitorId: getVisitorId(),
duration: Date.now() - pageEnterAt,
},
true,
);
+ pageEnterAt = 0; // 防止 visibilitychange 多次触发产生重复 duration
};方案 B:仅在 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
重复 flush 隐患:pagehide 与 visibilitychange 可能连续触发
在大多数浏览器中页面被切到后台或关闭时,
visibilitychange(hidden)与pagehide会先后触发,导致同一段 duration 被上报两次(第二次的Date.now() - enterAt还会被错误放大)。建议在flush()内做幂等保护,发送后清空enterAt/lastPath。♻️ 建议修复
function flush() { if (!lastPath || enterAt <= 0) return; send(payload(lastPath, Date.now() - enterAt), true); + enterAt = 0; // 防止 pagehide + visibilitychange 重复上报 }🤖 Prompt for AI Agents