feat(analytics): integrate Cloudflare Analytics Engine dashboard (CF-only)#52
Conversation
…only) - AE 数据点写入封装(server/src/analytics/ae-tracker.ts),blob1-blob10 + double1 - Cloudflare AE SQL API 查询封装(server/src/analytics/ae-query.ts),返回 PV/UV/avgDuration/byCountry/byDevice/byBrowser/byOS/byPage/byScreen/byLang - 零依赖 UA 解析(server/src/analytics/ua-parser.ts),识别 device/browser/os - 现有 /api/posts/:slug 处理器双写 D1 visits + AE,保持向后兼容 - 新增 POST /api/track 接收 SPA 埋点,Origin 白名单 + 静默 204 兜底 - 新增 GET /api/admin/analytics/ae,DB_PROVIDER!==d1 返回 501 - 客户端埋点模块 client/src/lib/analytics.ts(FNV-1a vid + sendBeacon + History API hook,跳过 /admin) - 新增 client/public/tracker.js 第三方站点独立埋点脚本 - AdminAnalytics 改造为 Tab 容器:基础统计 / AE 增强(CF 标记) - 新增 AnalyticsAEView 仪表板,501/503 错误状态显示 CF 专属提示 - README 标注 AE 增强为 Cloudflare 部署专属功能 - wrangler.toml 注册 [[analytics_engine_datasets]] AE binding Adapted from HanAnalytics (MIT, https://github.com/uxiaohan/HanAnalytics)
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 23 minutes and 18 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthrough新增完整访客分析管道:客户端注入追踪脚本并生成稳定访客 ID,上报到新增的 Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client Browser
participant Tracker as Tracker.js
participant BeaconAPI as Beacon/Fetch
participant Server as Server
participant AE as Cloudflare AE
participant AdminUI as Admin UI
participant AEQuery as AE Query
Client->>Tracker: Page load / route change
Tracker->>Tracker: 生成/读取 visitorId (localStorage)
Tracker->>Tracker: 捕获 path, referrer, screen, language, duration
Tracker->>BeaconAPI: sendBeacon 或 fetch keepalive 上传
BeaconAPI->>Server: POST /api/track (事件)
Server->>Server: 校验 origin 白名单
Server->>AE: writeAnalyticsPoint (解析 UA、规范字段)
AE->>AE: 存储数据点
AdminUI->>Server: GET /api/admin/analytics/ae?days=7
Server->>AEQuery: queryAEAnalytics(days)
AEQuery->>AE: 执行 SQL 聚合查询
AE->>AEQuery: 返回聚合结果
AEQuery->>Server: 返回归一化 AEAnalyticsResult
Server->>AdminUI: 返回 AEAnalyticsData (JSON)
AdminUI->>AdminUI: 渲染图表与排行
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (3)
client/public/tracker.js (1)
49-64:navigator.sendBeacon返回值未处理,队列已满时静默丢失事件
sendBeacon在 UA 数据队列满或 payload 过大时会返回false,当前实现直接 return,导致最后一页的 duration 在卸载时直接丢失。建议在返回 false 时回退到fetch(..., { keepalive: true })。♻️ 建议修复
function send(body, beacon) { var json = JSON.stringify(body); if (beacon && navigator.sendBeacon) { - navigator.sendBeacon(endpoint, new Blob([json], { type: "application/json" })); - return; + var ok = navigator.sendBeacon(endpoint, new Blob([json], { type: "application/json" })); + if (ok) return; + // 队列满 / payload 过大 → 回退 keepalive fetch }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/public/tracker.js` around lines 49 - 64, The send function currently returns immediately when navigator.sendBeacon(endpoint, ...) returns false, silently dropping events; update send to check the boolean result and, if false, fall back to using fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true, credentials: "omit" }) so the payload is retried via keepalive fetch; modify the send function's beacon branch to capture the return value of navigator.sendBeacon and call the same fetch fallback (handling errors silently as currently) when it returns false.client/src/pages/admin/analytics.tsx (1)
41-54: Tab 来回切换会重复请求基础统计
tab加入依赖数组后,从basic→ae→basic时会再次触发完整 fetch(同一days窗口内通常无变化)。如想避免抖动可以用data是否已存在做短路:♻️ 可选优化
useEffect(() => { if (tab !== "basic") return; + if (data) return; // 已有数据则不重复请求;切换 days 时由 setData(null) 触发 setLoading(true);并在
setDays处补一行setData(null)以保留days变更的刷新行为。主题与响应式:tab 条使用
text-foreground/muted-foregroundtoken,暗/亮主题切换无问题;flex+text-[13px]在窄屏不会断行,验证通过。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/pages/admin/analytics.tsx` around lines 41 - 54, The effect that fetches analytics runs again when switching back to the "basic" tab because `tab` is in the dependency array; short-circuit the fetch when data already exists to avoid redundant requests: inside the useEffect that calls `fetchAnalytics(days)` (the effect referencing `tab`, `days`, `setLoading`, `setData`, `setError`), add a guard like return if `tab !== "basic"` OR `data` is non-null so it only fetches on first mount or when `days` changes; to preserve intended behavior when the user explicitly changes the days window, ensure callers of `setDays` also call `setData(null)` so the effect will re-run and refresh data for the new window.client/src/lib/api.ts (1)
322-342: 抛出普通对象不利于上层错误处理,建议改为Error子类
AEAnalyticsError当前是普通对象字面量,throw err;会触发 ESLintno-throw-literal,且让调用方的err instanceof Error、Error.cause、堆栈追踪等通用模式全部失效(React Error Boundary 也只捕获Error实例)。♻️ 建议修复
-export type AEAnalyticsError = { - /** 501 = 非 CF 部署;503 = 缺 token;502 = AE SQL 失败 */ - status: number; - message: string; -}; +/** 501 = 非 CF 部署;503 = 缺 token;502 = AE SQL 失败 */ +export class AEAnalyticsError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + this.name = "AEAnalyticsError"; + } +} export async function fetchAEAnalytics(days = 7): Promise<AEAnalyticsData> { const res = await fetch(`${API_BASE}/api/admin/analytics/ae?days=${days}`, { headers: authHeaders(), }); if (!res.ok) { let message = `AE 分析数据加载失败 (${res.status})`; try { const body = await res.json() as { error?: string }; if (body.error) message = body.error; } catch { /* ignore */ } - const err: AEAnalyticsError = { status: res.status, message }; - throw err; + throw new AEAnalyticsError(res.status, message); } return res.json(); }
pages/admin/analytics-ae.tsx中如已用(err as AEAnalyticsError).status形式访问,改为类后兼容;如用instanceof判断会更稳妥。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/lib/api.ts` around lines 322 - 342, Replace the plain AEAnalyticsError type with an Error subclass (e.g. export class AEAnalyticsError extends Error { status: number; constructor(message: string, status: number, options?: ErrorOptions) { super(message, options); this.name = 'AEAnalyticsError'; this.status = status; } }) and update fetchAEAnalytics to construct and throw new AEAnalyticsError(message, res.status) instead of throwing a literal; keep the exported symbol name AEAnalyticsError so callers in fetchAEAnalytics and pages/admin/analytics-ae.tsx can still access the type/instance and use (err as AEAnalyticsError).status or instanceof AEAnalyticsError for robust error handling and to satisfy no-throw-literal and Error.cause/stack behaviors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@client/public/tracker.js`:
- Around line 87-102: flush() can be invoked twice (pagehide + visibilitychange)
leading to duplicate/augmented durations; make it idempotent by computing the
duration and calling send(payload(lastPath, duration), true) only if lastPath
and enterAt are valid, then immediately clear/invalidates the tracking state
(e.g. set enterAt = 0 and lastPath = null or similar) so a subsequent flush()
returns early; ensure you compute duration before clearing and reference the
symbols flush, send, payload, lastPath, and enterAt when applying the change.
In `@client/src/lib/analytics.ts`:
- Around line 119-145: The flush logic in bindUnloadTracker (function flush)
repeatedly sends duration computed from Date.now() - pageEnterAt without
resetting pageEnterAt, causing duplicate/increasing duration events when
visibilitychange fires multiple times; fix by making flush idempotent: after a
successful send(...) set pageEnterAt = 0 (or another sentinel) and optionally
clear lastTrackedPath for admin-exclusion, so subsequent visibilitychange calls
won't resend, and keep pagehide listener as the final fallback; ensure this
change references bindUnloadTracker, flush, pageEnterAt, lastTrackedPath,
pagehide and visibilitychange.
- Around line 77-84: The current trackPageview function returns early when path
startsWith("/admin"), which skips recording the previous page's duration; update
trackPageview to first compute/flush the previous page's duration (using
lastTrackedPath and pageEnterAt and the existing "report previous page duration"
logic) before setting lastTrackedPath = path and pageEnterAt = Date.now(), then
return to preserve admin privacy while still reporting the prior page's dwell
time.
In `@client/src/pages/admin/analytics-ae.tsx`:
- Around line 23-59: ListSection can produce NaN widths when every item.count is
0 and also uses a non-unique key; fix by computing a safe max and generating
unique keys: change how max is derived in ListSection (e.g., ensure max =
Math.max(1, ...items.map(i => i.count)) or otherwise coerce to at least 1) and
change the map key to include a stable disambiguator (e.g., use the item index
or a unique id like `${item.name}-${index}`) so React keys are unique even for
same-name entries.
- Around line 12-19: The duration formatting in formatDuration currently uses
Math.round which causes boundary inaccuracies; change the logic to compute sec =
Math.round(ms/1000) or better Math.floor(ms/1000) for consistent lower-bound
behavior, then use explicit thresholds on sec (if sec < 60 -> show `${sec} 秒`;
else if sec < 3600 -> compute minutes = Math.floor(sec/60) and show `${minutes}
分`; else compute hours = (sec / 3600).toFixed(1) and show `${hours} 小时`) so you
avoid rounding that flips values between minute/hour branches; update the
formatDuration function to use Math.floor for minute calculation and use
sec-based comparisons (sec < 60, sec < 3600) rather than rounding minutes.
- Around line 78-104: The error UI uses fixed light-toned classes (e.g.,
text-amber-300, text-red-400, bg-amber-500/5) which lack contrast in light
theme; update the JSX that renders the error blocks (the AE error branches
handling error.status === 501 and 503 and the final error return) to use
theme-aware classes—for example replace text-amber-300 with dark:text-amber-300
text-amber-600 (or text-amber-600 dark:text-amber-300), text-red-400 with
text-red-600 dark:text-red-400, and bg-amber-500/5 with bg-amber-100
dark:bg-amber-500/5 (or use semantic CSS variables like bg-error/ bg-error-dark)
so text/background have sufficient contrast in both light and dark themes;
ensure you update the className strings on the Cloud/AE message divs and the
final error div accordingly.
In `@server/src/analytics/ae-query.ts`:
- Around line 83-143: UV and avg_dur are undercounted because COUNT(DISTINCT
blob10) and AVG(double1) ignore AE sampling; replace the naked COUNT(DISTINCT
blob10) with a sampling-compensated distinct calculation using _sample_interval
(e.g., use AE's weighted-distinct/approx-uniqWeighted function or compute
per-blob10 SUM(_sample_interval) and aggregate that into a weighted unique
count) and replace AVG(double1) with a weighted average like SUM(double1 *
_sample_interval) / SUM(_sample_interval); update the queries invoked via runSql
(references: runSql, COUNT(DISTINCT blob10), AVG(double1),
SUM(_sample_interval), blob10) so UV and avg_dur are computed using sampling
weights consistently with PV.
In `@server/src/analytics/ae-tracker.ts`:
- Around line 68-79: The isWebsiteAllowed function currently treats an empty
ANALYTICS_WEBSITE_WHITELIST as allow-all — change to fail-closed: update
isWebsiteAllowed (in ae-tracker.ts) so that if whitelist is undefined/empty it
returns false, and adjust /api/track to explicitly reject requests when website
is not allowed; additionally add a startup warning log when
ANALYTICS_WEBSITE_WHITELIST is missing and add a README/deployment note
requiring this env var (or, as a mitigation, implement basic validation of
payload.visitorId and rate-limit unknown website values in /api/track).
In `@server/src/analytics/ua-parser.ts`:
- Around line 15-17: BOT_RE is too broad and matches social app WebViews (e.g.,
facebook/twitter/whatsapp) causing real users to be flagged as bots; replace the
general tokens in BOT_RE with explicit crawler tokens (e.g., googlebot, bingbot,
baiduspider, facebookexternalhit, twitterbot, telegrambot, preview-specific
crawler tokens) and remove or narrow patterns like
facebook|twitter|telegram|whatsapp|preview to specific allowlisted crawler
identifiers or precise variants (e.g., whatsapp\/\d+ if you intentionally detect
app clients). For TABLET_RE, stop relying only on /ipad|tablet|playbook|silk/i
because iPadOS13+ reports Macintosh; instead add server-side fallback using
CF-Device-Type when available or rely on client-side navigator.maxTouchPoints >
1 to disambiguate iPad from macOS (or detect “Macintosh” + an explicit ipad
token if present). Keep MOBILE_RE but ensure it does not overlap with new BOT_RE
choices; update tests and any code referencing BOT_RE, TABLET_RE, MOBILE_RE to
use the new, stricter patterns.
---
Nitpick comments:
In `@client/public/tracker.js`:
- Around line 49-64: The send function currently returns immediately when
navigator.sendBeacon(endpoint, ...) returns false, silently dropping events;
update send to check the boolean result and, if false, fall back to using
fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json"
}, body: json, keepalive: true, credentials: "omit" }) so the payload is retried
via keepalive fetch; modify the send function's beacon branch to capture the
return value of navigator.sendBeacon and call the same fetch fallback (handling
errors silently as currently) when it returns false.
In `@client/src/lib/api.ts`:
- Around line 322-342: Replace the plain AEAnalyticsError type with an Error
subclass (e.g. export class AEAnalyticsError extends Error { status: number;
constructor(message: string, status: number, options?: ErrorOptions) {
super(message, options); this.name = 'AEAnalyticsError'; this.status = status; }
}) and update fetchAEAnalytics to construct and throw new
AEAnalyticsError(message, res.status) instead of throwing a literal; keep the
exported symbol name AEAnalyticsError so callers in fetchAEAnalytics and
pages/admin/analytics-ae.tsx can still access the type/instance and use (err as
AEAnalyticsError).status or instanceof AEAnalyticsError for robust error
handling and to satisfy no-throw-literal and Error.cause/stack behaviors.
In `@client/src/pages/admin/analytics.tsx`:
- Around line 41-54: The effect that fetches analytics runs again when switching
back to the "basic" tab because `tab` is in the dependency array; short-circuit
the fetch when data already exists to avoid redundant requests: inside the
useEffect that calls `fetchAnalytics(days)` (the effect referencing `tab`,
`days`, `setLoading`, `setData`, `setError`), add a guard like return if `tab
!== "basic"` OR `data` is non-null so it only fetches on first mount or when
`days` changes; to preserve intended behavior when the user explicitly changes
the days window, ensure callers of `setDays` also call `setData(null)` so the
effect will re-run and refresh data for the new window.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9d3acf72-4d23-41ea-96e7-1d72214097e6
📒 Files selected for processing (12)
README.mdclient/public/tracker.jsclient/src/app.tsxclient/src/lib/analytics.tsclient/src/lib/api.tsclient/src/pages/admin/analytics-ae.tsxclient/src/pages/admin/analytics.tsxserver/src/analytics/ae-query.tsserver/src/analytics/ae-tracker.tsserver/src/analytics/ua-parser.tsserver/src/index.tsserver/wrangler.toml
📜 Review details
⏰ Context from checks skipped due to timeout of 120000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (2)
client/src/pages/**
⚙️ CodeRabbit configuration file
client/src/pages/**: 页面级组件。审查时请关注: 1. 数据加载和错误处理是否完善 2. SEO 相关(页面标题、meta 标签) 3. 导航和路由是否正确
Files:
client/src/pages/admin/analytics.tsxclient/src/pages/admin/analytics-ae.tsx
server/src/index.ts
⚙️ CodeRabbit configuration file
server/src/index.ts: Hono Workers API 路由总入口。审查时请关注: 1. JWT 认证中间件是否正确保护管理接口 2. CORS 配置是否安全 3. 请求参数验证
Files:
server/src/index.ts
🔇 Additional comments (4)
server/wrangler.toml (1)
24-30: LGTMAE 数据集绑定声明清晰,
binding = "AE"与Bindings.AE类型一致,dataset = "monolith_analytics"与ae-query.ts中查询的目标表名一致。注释也明确了非 CF 后端会跳过 AE 写入。server/src/index.ts (1)
593-606: LGTM — AE 管理接口在守卫与状态码上选择得当接口处于
/api/admin/*下,已被 JWT 中间件覆盖;501(非 D1 后端)/503(缺凭据)/502(上游 AE 失败)的状态码语义清晰,days已做parseInt + isNaN + cap 31三重校验,符合编码规范的「请求参数验证」要求。client/src/app.tsx (1)
62-66: 当前实现已通过幂等保护,无需拆分 effect
bindUnloadTracker()虽然在路由变化时重复调用,但其内部已通过模块级unloadBound标志保证幂等性(见client/src/lib/analytics.ts第 119-123 行):let unloadBound = false; export function bindUnloadTracker() { if (unloadBound) return; // 已绑定则直接返回 unloadBound = true; // 注册监听... }首次调用时注册监听并设置标志,后续调用直接返回,不会产生监听器累积。当前代码设计已满足需求,无需重构。
server/src/analytics/ae-tracker.ts (1)
46-66: 该审查意见基于不完整的代码检查,实际上/api/track处理器已在调用writeAnalyticsPoint前进行了运行时校验。在
server/src/index.ts第 11-12 行,handler 明确进行了以下验证:const path = typeof body.path === "string" ? body.path : ""; if (!path || path.length > 256) return c.json({ error: "invalid path" }, 400);
- 非字符串输入被强制转为空字符串后立即被拒绝(返回 400)
- 仅非空的有效字符串才会传入
writeAnalyticsPoint- 因此不存在
TypeError风险所有
writeAnalyticsPoint调用点都使用了安全的输入(handler 验证或硬编码路径)。建议意见中提到的 typeof 防御检查实际上已存在于上游调用方,不需要在函数内重复实现。> Likely an incorrect or invalid review comment.
| 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(); | ||
| }); |
There was a problem hiding this comment.
重复 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
Verify each finding against the current code and only fix it if needed.
In `@client/public/tracker.js` around lines 87 - 102, flush() can be invoked twice
(pagehide + visibilitychange) leading to duplicate/augmented durations; make it
idempotent by computing the duration and calling send(payload(lastPath,
duration), true) only if lastPath and enterAt are valid, then immediately
clear/invalidates the tracking state (e.g. set enterAt = 0 and lastPath = null
or similar) so a subsequent flush() returns early; ensure you compute duration
before clearing and reference the symbols flush, send, payload, lastPath, and
enterAt when applying the change.
| export function trackPageview(path: string, website = "default"): void { | ||
| if (!path || path === lastTrackedPath) return; | ||
| // 后台路径不上报,保护管理员隐私 + 减少噪音 | ||
| if (path.startsWith("/admin")) { | ||
| lastTrackedPath = path; | ||
| pageEnterAt = Date.now(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
进入 /admin 时丢失上一页的停留时长
从 /blog/foo 跳转到 /admin/x 会在 Line 80 提前 return,跳过 Line 86-100 的"上报上一页 duration"分支。前一页的停留时长就此丢失,统计偏低。
🛠️ 建议先 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
Verify each finding against the current code and only fix it if needed.
In `@client/src/lib/analytics.ts` around lines 77 - 84, The current trackPageview
function returns early when path startsWith("/admin"), which skips recording the
previous page's duration; update trackPageview to first compute/flush the
previous page's duration (using lastTrackedPath and pageEnterAt and the existing
"report previous page duration" logic) before setting lastTrackedPath = path and
pageEnterAt = Date.now(), then return to preserve admin privacy while still
reporting the prior page's dwell time.
| 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(); | ||
| }); | ||
| } |
There was a problem hiding this comment.
visibilitychange 重复触发会污染 duration 指标
flush() 每次都用 Date.now() - pageEnterAt 计算时长,但 pageEnterAt 在 flush 后没有重置。用户在同一页面内多次切换标签页(hidden ↔ visible)会持续触发 visibilitychange → 每次发送一条 duration 单调递增的事件,最终关闭时 pagehide 再发一条。
服务端 AE 查询使用 AVG(double1) 与 SUM(_sample_interval),重复点会显著拉高平均停留并放大 PV(每条 writeDataPoint 都计入一次 _sample_interval)。简单页面切几次标签就能让指标失真。
🛠️ 推荐修复(任选其一)
方案 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:仅在 pagehide 上 flush,visibilitychange 仅用于"暂停计时"语义(更精确但实现更复杂)。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@client/src/lib/analytics.ts` around lines 119 - 145, The flush logic in
bindUnloadTracker (function flush) repeatedly sends duration computed from
Date.now() - pageEnterAt without resetting pageEnterAt, causing
duplicate/increasing duration events when visibilitychange fires multiple times;
fix by making flush idempotent: after a successful send(...) set pageEnterAt = 0
(or another sentinel) and optionally clear lastTrackedPath for admin-exclusion,
so subsequent visibilitychange calls won't resend, and keep pagehide listener as
the final fallback; ensure this change references bindUnloadTracker, flush,
pageEnterAt, lastTrackedPath, pagehide and visibilitychange.
| function formatDuration(ms: number): string { | ||
| if (!ms || ms <= 0) return "-"; | ||
| const sec = Math.round(ms / 1000); | ||
| if (sec < 60) return `${sec} 秒`; | ||
| const min = Math.round(sec / 60); | ||
| if (min < 60) return `${min} 分`; | ||
| return `${(sec / 3600).toFixed(1)} 小时`; | ||
| } |
There was a problem hiding this comment.
formatDuration 取整边界问题
Math.round(sec/60) 在 sec=29.5 时返回 1(OK),但 sec=59 时返回 1(显示 "1 分",实际还不到 1 分);min=59.5 → 60 但 min < 60 为 false,落到小时分支显示 0.0166 小时,最后 toFixed(1) 得到 "0.0 小时" —— 显示明显失真。
🛠️ 建议改用 floor 并校验阈值
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return "-";
- const sec = Math.round(ms / 1000);
+ const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec} 秒`;
- const min = Math.round(sec / 60);
+ const min = Math.floor(sec / 60);
if (min < 60) return `${min} 分`;
- return `${(sec / 3600).toFixed(1)} 小时`;
+ return `${(min / 60).toFixed(1)} 小时`;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatDuration(ms: number): string { | |
| if (!ms || ms <= 0) return "-"; | |
| const sec = Math.round(ms / 1000); | |
| if (sec < 60) return `${sec} 秒`; | |
| const min = Math.round(sec / 60); | |
| if (min < 60) return `${min} 分`; | |
| return `${(sec / 3600).toFixed(1)} 小时`; | |
| } | |
| function formatDuration(ms: number): string { | |
| if (!ms || ms <= 0) return "-"; | |
| const sec = Math.floor(ms / 1000); | |
| if (sec < 60) return `${sec} 秒`; | |
| const min = Math.floor(sec / 60); | |
| if (min < 60) return `${min} 分`; | |
| return `${(min / 60).toFixed(1)} 小时`; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@client/src/pages/admin/analytics-ae.tsx` around lines 12 - 19, The duration
formatting in formatDuration currently uses Math.round which causes boundary
inaccuracies; change the logic to compute sec = Math.round(ms/1000) or better
Math.floor(ms/1000) for consistent lower-bound behavior, then use explicit
thresholds on sec (if sec < 60 -> show `${sec} 秒`; else if sec < 3600 -> compute
minutes = Math.floor(sec/60) and show `${minutes} 分`; else compute hours = (sec
/ 3600).toFixed(1) and show `${hours} 小时`) so you avoid rounding that flips
values between minute/hour branches; update the formatDuration function to use
Math.floor for minute calculation and use sec-based comparisons (sec < 60, sec <
3600) rather than rounding minutes.
| function ListSection({ title, icon: Icon, items, accent, mono }: { | ||
| title: string; | ||
| icon: typeof Globe; | ||
| items: ListItem[]; | ||
| accent: "blue" | "green" | "violet" | "amber"; | ||
| mono?: boolean; | ||
| }) { | ||
| const max = items.length > 0 ? items[0].count : 1; | ||
| return ( | ||
| <div className="analytics-section"> | ||
| <h2 className="analytics-section__title"> | ||
| <Icon className="h-[14px] w-[14px]" /> | ||
| {title} | ||
| </h2> | ||
| <div className="analytics-list"> | ||
| {items.length === 0 ? ( | ||
| <div className="analytics-list__empty">暂无数据</div> | ||
| ) : ( | ||
| items.map((item) => ( | ||
| <div key={item.name} className="analytics-list__row"> | ||
| <span className={`analytics-list__name${mono ? " analytics-list__name--mono" : ""}`}> | ||
| {item.name} | ||
| </span> | ||
| <div className="analytics-list__bar-track"> | ||
| <div | ||
| className={`analytics-list__bar-fill analytics-list__bar-fill--${accent}`} | ||
| style={{ width: `${(item.count / max) * 100}%` }} | ||
| /> | ||
| </div> | ||
| <span className="analytics-list__count">{item.count}</span> | ||
| </div> | ||
| )) | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
max=0 时进度条宽度计算为 NaN
Line 30 const max = items.length > 0 ? items[0].count : 1; 假设排序降序且首项 > 0。当所有 count=0 时 (item.count / max) * 100 = 0 / 0 = NaN,行内 style 会注入 width: NaN%。
另:key={item.name} 在同名条目(如同一 referer host 大小写不同)下会触发 React key 冲突警告。
🛡️ 防御性修复
- const max = items.length > 0 ? items[0].count : 1;
+ const max = Math.max(items[0]?.count ?? 0, 1);- items.map((item) => (
- <div key={item.name} className="analytics-list__row">
+ items.map((item, idx) => (
+ <div key={`${item.name}-${idx}`} className="analytics-list__row">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@client/src/pages/admin/analytics-ae.tsx` around lines 23 - 59, ListSection
can produce NaN widths when every item.count is 0 and also uses a non-unique
key; fix by computing a safe max and generating unique keys: change how max is
derived in ListSection (e.g., ensure max = Math.max(1, ...items.map(i =>
i.count)) or otherwise coerce to at least 1) and change the map key to include a
stable disambiguator (e.g., use the item index or a unique id like
`${item.name}-${index}`) so React keys are unique even for same-name entries.
| async function runSql<T = Record<string, unknown>>(env: AEQueryEnv, sql: string): Promise<T[]> { | ||
| if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) { | ||
| throw new Error("Missing CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN secrets for AE query"); | ||
| } | ||
| const res = await fetch(AE_SQL_ENDPOINT(env.CLOUDFLARE_ACCOUNT_ID), { | ||
| method: "POST", | ||
| headers: { | ||
| "Authorization": `Bearer ${env.CLOUDFLARE_API_TOKEN}`, | ||
| "Content-Type": "text/plain", | ||
| }, | ||
| body: sql, | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text().catch(() => ""); | ||
| throw new Error(`AE SQL HTTP ${res.status}: ${text.slice(0, 256)}`); | ||
| } | ||
| const json = await res.json() as { data?: T[]; meta?: unknown; error?: string }; | ||
| if (json.error) throw new Error(`AE SQL error: ${json.error}`); | ||
| return json.data || []; | ||
| } |
There was a problem hiding this comment.
AE SQL fetch 缺少超时与错误聚合策略
Promise.all 会让 10 条查询任一失败都让整个 dashboard 502;同时 fetch 无 AbortSignal.timeout,AE 慢响应时管理后台会长时间挂起(CF Worker subrequest 默认 cap 较宽松)。
🛠️ 建议
const res = await fetch(AE_SQL_ENDPOINT(env.CLOUDFLARE_ACCOUNT_ID), {
method: "POST",
headers: {
"Authorization": `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
"Content-Type": "text/plain",
},
body: sql,
+ signal: AbortSignal.timeout(15_000), // 单查询 15s 上限
});并发层可考虑 Promise.allSettled + 部分失败时返回降级结果(保证主图能渲染)。
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function runSql<T = Record<string, unknown>>(env: AEQueryEnv, sql: string): Promise<T[]> { | |
| if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) { | |
| throw new Error("Missing CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN secrets for AE query"); | |
| } | |
| const res = await fetch(AE_SQL_ENDPOINT(env.CLOUDFLARE_ACCOUNT_ID), { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${env.CLOUDFLARE_API_TOKEN}`, | |
| "Content-Type": "text/plain", | |
| }, | |
| body: sql, | |
| }); | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| throw new Error(`AE SQL HTTP ${res.status}: ${text.slice(0, 256)}`); | |
| } | |
| const json = await res.json() as { data?: T[]; meta?: unknown; error?: string }; | |
| if (json.error) throw new Error(`AE SQL error: ${json.error}`); | |
| return json.data || []; | |
| } | |
| async function runSql<T = Record<string, unknown>>(env: AEQueryEnv, sql: string): Promise<T[]> { | |
| if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) { | |
| throw new Error("Missing CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN secrets for AE query"); | |
| } | |
| const res = await fetch(AE_SQL_ENDPOINT(env.CLOUDFLARE_ACCOUNT_ID), { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${env.CLOUDFLARE_API_TOKEN}`, | |
| "Content-Type": "text/plain", | |
| }, | |
| body: sql, | |
| signal: AbortSignal.timeout(15_000), // 单查询 15s 上限 | |
| }); | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| throw new Error(`AE SQL HTTP ${res.status}: ${text.slice(0, 256)}`); | |
| } | |
| const json = await res.json() as { data?: T[]; meta?: unknown; error?: string }; | |
| if (json.error) throw new Error(`AE SQL error: ${json.error}`); | |
| return json.data || []; | |
| } |
| /** | ||
| * 校验站点白名单。空白名单 = 全部放行。 | ||
| * 格式: "example.com|blog.foo.com" | ||
| */ | ||
| export function isWebsiteAllowed(origin: string | null | undefined, whitelist: string | undefined): boolean { | ||
| if (!whitelist || whitelist.trim() === "") return true; | ||
| if (!origin) return false; | ||
| let host = origin; | ||
| try { host = new URL(origin).hostname; } catch { /* origin 已是 host */ } | ||
| const allowed = whitelist.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean); | ||
| return allowed.some((d) => host.toLowerCase() === d || host.toLowerCase().endsWith("." + d)); | ||
| } |
There was a problem hiding this comment.
默认开放白名单:公网任何人可写脏数据到 AE
注释明确"空白名单 = 全部放行",但 /api/track 是公网 POST 端点。一旦 ANALYTICS_WEBSITE_WHITELIST 漏配(默认未设),任何脚本都能向 AE 注入伪造事件——blob1-blob10 全部用户可控(只做长度截断),管理后台所有维度(country/device/browser/path/screen/language/referer)都会被污染,且 monolith_analytics 数据集付费按写入计量。
虽然有"灵感来源 HanAnalytics"的同款语义,但建议在生产部署中:
- 默认拒绝、显式允许(fail-closed),或
- 至少在 README/部署清单中红字标注必须配置
ANALYTICS_WEBSITE_WHITELIST,并加入启动日志提醒。
🛡️ 修复建议(fail-closed 默认)
export function isWebsiteAllowed(origin: string | null | undefined, whitelist: string | undefined): boolean {
- if (!whitelist || whitelist.trim() === "") return true;
+ if (!whitelist || whitelist.trim() === "") {
+ // 未配置白名单视为禁用(避免误用导致 AE 数据污染)
+ return false;
+ }
if (!origin) return false;若必须保留默认放行,至少在 /api/track 处理函数里对 payload.visitorId 做基础校验、对未知 website 限流。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/src/analytics/ae-tracker.ts` around lines 68 - 79, The
isWebsiteAllowed function currently treats an empty ANALYTICS_WEBSITE_WHITELIST
as allow-all — change to fail-closed: update isWebsiteAllowed (in ae-tracker.ts)
so that if whitelist is undefined/empty it returns false, and adjust /api/track
to explicitly reject requests when website is not allowed; additionally add a
startup warning log when ANALYTICS_WEBSITE_WHITELIST is missing and add a
README/deployment note requiring this env var (or, as a mitigation, implement
basic validation of payload.visitorId and rate-limit unknown website values in
/api/track).
| const BOT_RE = /bot|crawl|spider|slurp|bing|google|baidu|yandex|duckduck|facebook|twitter|telegram|whatsapp|preview/i; | ||
| const TABLET_RE = /ipad|tablet|playbook|silk/i; | ||
| const MOBILE_RE = /mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Cloudflare AE blob length limits and Worker writeDataPoint per-invocation cap 2026
💡 Result:
Cloudflare Workers Analytics Engine (AE) blob length limits and writeDataPoint per-invocation cap as of 2026: - Total size of all blobs per data point: 16 KB (increased from 5 KB in June 2025). - Maximum blobs per writeDataPoint call: 20. - Maximum doubles per writeDataPoint call: 20. - Maximum indexes per writeDataPoint call: 1 (each <= 96 bytes). - Maximum writeDataPoint calls per Worker invocation: 250. Note: "AE" refers to Workers Analytics Engine, where blobs are string fields for custom data. No product named "Cloudflare AE" with separate blob limits was found; results point to Analytics Engine. Limits confirmed current via official docs (published Dec 2025) with no 2026 changes indicated.
Citations:
- 1: https://developers.cloudflare.com/analytics/analytics-engine/limits/
- 2: https://developers.cloudflare.com/changelog/2025-06-20-increased-blob-size-limits-in-workers-analytics
- 3: https://developers.cloudflare.com/analytics/analytics-engine/limits/index.md
🏁 Script executed:
# First, check if the file exists and read the implementation
fd -t f "ua-parser.ts" --search-path server/src/analytics/ 2>/dev/null || fd -t f "ua-parser.ts"Repository: one-ea/Monolith
Length of output: 93
🏁 Script executed:
# Check the structure of the analytics directory
ls -la server/src/analytics/ 2>/dev/null || find . -type d -name analytics 2>/dev/null | head -5Repository: one-ea/Monolith
Length of output: 357
🌐 Web query:
Facebook app internal browser user agent FBAN FBIOS 2025
💡 Result:
The Facebook iOS app's in-app (internal) browser uses a user agent string that starts with the standard iOS Safari Mobile UA, appended with [FBAN/FBIOS; ...] containing app and device details. "FBAN" indicates Facebook Android (though used in iOS too in some contexts), but "FBIOS" specifically identifies the Facebook iOS app. This format has been consistent into 2026. Example recent UA (iPhone OS 18_7, FBAV 548, ~2026): Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22H217 Safari/604.1 [FBAN/FBIOS;FBAV/548.0.0.36.68;FBBV/880335931;FBDV/iPhone16,1;FBMD/iPhone;FBSN/iOS;FBSV/18.7.3;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/885455457;IABMV/1] Key parameters: - FBAN/FBIOS: Facebook iOS app identifier - FBAV: Facebook App Version (e.g., 548.0.0.36.68) - FBBV: Facebook Build Version - FBDV: Device model (e.g., iPhone16,1) - FBMD: Device type (iPhone) - FBSN/iOS: OS name/version - FBSS: Scale (screen scale) - FBID: Form factor (phone/tablet) - FBLC: Locale - FBOP: Orientation preference - FBRV: Revision - IABMV: In-App Browser marker (sometimes) To detect: Check for 'FBAN' or 'FBIOS' in navigator.userAgent. No official Meta documentation details the exact format; it's reverse-engineered from real-world examples in UA databases and developer discussions. Versions evolve (e.g., FBAV up to 552+ in 2026 samples), but the [FBAN/FBIOS;...] structure persists.
Citations:
- 1: https://useragents.io/explore/browsers/types/application/maker/facebook-ec4/uas/mozilla-5-0-iphone-cpu-iphone-os-18-7-like-mac-os-x-applewebkit-605-1-15-khtml-like-gecko-mobile-22h217-safari-604-1-fban-fbiosfbav-548-0-0-36-68fbbv-880335931fbdv-iphone16-1fbmd-iphonefbsn-iosfbsv-18-7-3fbss-3fbid-phonefblc-en-usfbop-5fbrv-885455457iabmv_2a04293ed3e6657b2d824c32e609a1b3
- 2: Add Facebook Mobile in-app browser detection bowser-js/bowser#609
- 3: https://explore.whatismybrowser.com/useragents/explore/software_name/facebook-app/
- 4: https://useragents.io/explore/platforms/ios/maker/facebook-ec4
- 5: https://stackoverflow.com/questions/11414006/is-this-a-facebook-for-ios-webview-user-agent
- 6: https://explore.whatismybrowser.com/useragents/parse/3390720-facebook-app-ios-apple-iphone-webkit
- 7: https://stackoverflow.com/questions/29094232/what-is-the-user-agent-string-for-facebook-app-from-android
- 8: https://medium.com/geekculture/how-to-detect-if-a-user-is-using-the-facebook-in-app-browser-41ccc2c5deca
🌐 Web query:
WhatsApp WebView user agent pattern 2025
💡 Result:
There isn’t a single stable “WhatsApp WebView user-agent pattern (2025)” published by WhatsApp/Meta. In practice, the “pattern” is that WhatsApp’s in-app browser/webview requests send User-Agent strings that typically include the regular web engine UA fragments plus an in-app identifier like “WhatsApp/”, and the exact numbers vary by platform/app version. Observed UA structure examples (recent, seen in 2025-era traffic listings): 1) Android in-app/webview-style UA (example): Mozilla/5.0 (Linux; Android 15.0; iPhone12,1 Build/Build6298537) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/645.98 Mobile Safari/537.36 WhatsApp/3.20.993 2) Another Android example: Mozilla/5.0 (Linux; Android 13; oppo-4742 Build/Build2536255) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/116.0.7823.376 Mobile Safari/537.36 WhatsApp/2.22.726 3) iOS in-app/webview-style UA (example): Mozilla/5.0 (iPhone13,2; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 WhatsApp/2.25.37.77 Key “pattern” takeaways you can rely on (vs exact values): - The User-Agent commonly ends with an explicit WhatsApp token: “WhatsApp/”. - The rest of the UA still looks like a WebKit/Chrome-family UA (e.g., “AppleWebKit/537.36 … Version/4.0 Chrome/ … Mobile Safari/537.36” on Android; and “AppleWebKit/605.1.15 … Mobile/… ” on iOS), but these embedded Chrome numbers are not trustworthy for determining real WebView/Chrome version. - Therefore, you should avoid hardcoding a single full UA string; if you’re doing detection/classification, treat it as a pattern match around “WhatsApp/” plus WebKit and/or platform tokens. Related note about WebView detection: - Many general-purpose rules for detecting Android WebView rely on UA substrings like “wv” or “Version/<x.y>”. One Stack Overflow answer notes you should check “Version/X.X” and/or “wv” rather than relying on older “0.0.0” heuristics, because “Android.*.0.0.0” can appear in non-WebView browsers too. If you tell me your exact goal (e.g., “block/allow requests to my site from WhatsApp in-app browser”, “simulate WhatsApp WebView in tests”, or “extract the WhatsApp app version”), I can propose a concrete regex/pattern specifically for that use-case.
Citations:
- 1: https://explore.whatismybrowser.com/useragents/explore/software_name/whatsapp/
- 2: https://explore.whatismybrowser.com/useragents/parse/3114311-whatsapp-android-apple-iphone-11-webkit
- 3: https://explore.whatismybrowser.com/useragents/parse/3114451-whatsapp-android-webkit
- 4: https://explore.whatismybrowser.com/useragents/parse/3114323-whatsapp-android-webkit
- 5: https://explore.whatismybrowser.com/useragents/parse/3240632-whatsapp-ios-apple-iphone-12-webkit
- 6: https://stackoverflow.com/questions/71459572/not-possible-to-detect-webview-with-javascript-and-user-agent-on-old-android-ver
🌐 Web query:
iPad iPadOS 13+ user agent Mac detection 2025
💡 Result:
Starting with iPadOS 13, Safari on iPad by default uses a desktop-class User-Agent string that mimics macOS Safari (e.g., "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"), due to the "Request Desktop Website" setting enabled by default under Settings > Safari. This makes server-side detection via UA alone unreliable, as it appears identical to macOS. When "Request Desktop Website" is disabled or in mobile mode (e.g., via AA icon > Request Mobile Website), it reveals the true iPad UA: "Mozilla/5.0 (iPad; CPU OS 18_x like Mac OS X) AppleWebKit/605.1.15 ... Version/18.x Mobile/... Safari/...". Client-side JavaScript detection for iPad on iPadOS 13+ combines UA sniffing with feature detection, as navigator.platform is 'MacIntel': Common reliable snippet (works as of 2026): const isIPad = /iPad/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); Alternatives: - Check navigator.maxTouchPoints === 5 (iPad-specific). - typeof navigator.standalone !== 'undefined' (iOS Safari only). - 'ontouchstart' in window && /Mac/.test(navigator.userAgent). This distinguishes iPad from macOS (no touch/multitouch). Libraries like ua-parser-js support .withFeatureCheck for client-side correction. No official Apple docs specify UA formats, but behavior persists in iPadOS 18+ per 2025-2026 sources. Server-side: impossible via UA alone; use client hints or accept as desktop.
Citations:
- 1: https://stackoverflow.com/questions/57765958/how-to-detect-ipad-and-ipad-os-version-in-ios-13-and-up
- 2: https://stackoverflow.com/questions/56578799/tell-ipados-from-macos-on-the-web
- 3: ipadOS ua for iOS 13 includes 'Macintosh' instead of iPad juliangruber/is-mobile#17
- 4: iPadOS Safari 13 is detected as Safari 13 on MacOS 10.15 bowser-js/bowser#329
- 5: https://evilmartians.com/chronicles/how-to-detect-safari-and-ios-versions-with-ease
- 6: iPad recognised as macOS faisalman/ua-parser-js#671
- 7: https://stackoverflow.com/questions/56934826/distinguish-between-ipad-and-mac-on-ipad-with-ipados
- 8: https://discussions.apple.com/thread/256062962
- 9: https://whatmyuseragent.com/platforms/ipa/ipados/18
- 10: https://whatmyuseragent.com/browser/mf/mobile-safari/18
- 11: https://forums.developer.apple.com/forums/thread/119186
- 12: https://www.whatismybrowser.com/guides/the-latest-user-agent/safari
🏁 Script executed:
# Read the ua-parser.ts file
cat -n server/src/analytics/ua-parser.tsRepository: one-ea/Monolith
Length of output: 2419
🏁 Script executed:
# Search for usages of the parser and BOT_RE
rg -n "ua-parser|BOT_RE|isBot|classifyBot" --type ts --type js -A 2 -B 2Repository: one-ea/Monolith
Length of output: 1474
BOT_RE 正则表达式过度匹配社交 App 内置浏览器,导致真实用户被误判为爬虫
facebook|twitter|telegram|whatsapp|preview 这些关键字会匹配社交 App 内置 WebView 的 User-Agent 中的真实标识符(如 [FBAN/FBIOS;...]、WhatsApp/2.x 等),进而将来自 App 内浏览器的真实用户错误归类为 bot,造成 PV/UV 统计偏低。
同时 TABLET_RE 对 iPadOS 13+ 也无法生效,因 Apple 已将 iPad 的默认 UA 改报为 Macintosh; Intel Mac OS X,与 macOS 无法区分。
建议优化方向:
- 区分明确的爬虫标识(如
googlebot、baiduspider)与 App 内浏览器标识 - 对社交平台链接预览爬虫采用更精确的 token(如
facebookexternalhit、twitterbot)而非通用词 - 若需保留 App 内浏览器检测,应改用 allowlist 或更具体的模式(如
whatsapp\/\d+),但注意此类匹配仍可能有边界情况
iPad 检测可在客户端补充 navigator.maxTouchPoints > 1 辅助判断,或在服务端利用 Cloudflare CF-Device-Type 头部。
参考修复方向
-const BOT_RE = /bot|crawl|spider|slurp|bing|google|baidu|yandex|duckduck|facebook|twitter|telegram|whatsapp|preview/i;
+const BOT_RE = /bot|crawl|spider|slurp|bingpreview|googlebot|baiduspider|yandex|duckduckbot|facebookexternalhit|twitterbot|telegrambot/i;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/src/analytics/ua-parser.ts` around lines 15 - 17, BOT_RE is too broad
and matches social app WebViews (e.g., facebook/twitter/whatsapp) causing real
users to be flagged as bots; replace the general tokens in BOT_RE with explicit
crawler tokens (e.g., googlebot, bingbot, baiduspider, facebookexternalhit,
twitterbot, telegrambot, preview-specific crawler tokens) and remove or narrow
patterns like facebook|twitter|telegram|whatsapp|preview to specific allowlisted
crawler identifiers or precise variants (e.g., whatsapp\/\d+ if you
intentionally detect app clients). For TABLET_RE, stop relying only on
/ipad|tablet|playbook|silk/i because iPadOS13+ reports Macintosh; instead add
server-side fallback using CF-Device-Type when available or rely on client-side
navigator.maxTouchPoints > 1 to disambiguate iPad from macOS (or detect
“Macintosh” + an explicit ipad token if present). Keep MOBILE_RE but ensure it
does not overlap with new BOT_RE choices; update tests and any code referencing
BOT_RE, TABLET_RE, MOBILE_RE to use the new, stricter patterns.
| app.post("/api/track", async (c) => { | ||
| // 白名单校验:通过 Origin 头判断站点合法性 | ||
| const origin = c.req.header("Origin") || c.req.header("Referer") || ""; | ||
| if (!isWebsiteAllowed(origin, c.env.ANALYTICS_WEBSITE_WHITELIST)) { | ||
| return c.json({ error: "origin not allowed" }, 403); | ||
| } | ||
|
|
||
| let body: Record<string, unknown> = {}; | ||
| try { body = await c.req.json(); } catch { return c.json({ error: "invalid json" }, 400); } | ||
|
|
||
| const path = typeof body.path === "string" ? body.path : ""; | ||
| if (!path || path.length > 256) return c.json({ error: "invalid path" }, 400); | ||
|
|
||
| // AE 不可用(Turso/PG 部署)→ 直接 204,前端无感 | ||
| if (!c.env.AE) { | ||
| c.status(204); | ||
| return c.body(null); | ||
| } | ||
|
|
||
| writeAnalyticsPoint( | ||
| { | ||
| website: typeof body.website === "string" ? body.website : "default", | ||
| path, | ||
| referer: typeof body.referer === "string" ? body.referer : c.req.header("Referer"), | ||
| screen: typeof body.screen === "string" ? body.screen : "", | ||
| language: typeof body.language === "string" ? body.language : c.req.header("Accept-Language")?.split(",")[0], | ||
| visitorId: typeof body.visitorId === "string" ? body.visitorId : "", | ||
| duration: typeof body.duration === "number" ? body.duration : 0, | ||
| }, | ||
| { | ||
| ae: c.env.AE, | ||
| userAgent: c.req.header("User-Agent"), | ||
| country: c.req.header("CF-IPCountry") || "XX", | ||
| }, | ||
| ); | ||
| c.status(204); | ||
| return c.body(null); | ||
| }); |
There was a problem hiding this comment.
/api/track 缺少限流与字段长度上限,存在 AE 配额被打爆的风险
此为公开端点(非 /api/admin/* 不走 JWT),目前防护只有 Origin/Referer 白名单(且 ANALYTICS_WEBSITE_WHITELIST 为空时全部放行),其他保护点缺失:
- 没有限流:恶意脚本可在循环中调用
/api/track,每次都触发writeDataPoint。AE 单 Worker 写入有 25 次/请求、每月配额上限,且 blob 总长 25 KiB / 单 blob 5 KiB;远端可轻易耗尽配额或推高账单。 - 字段长度未校验:除
path外,referer、screen、language、visitorId、website全部无长度上限,攻击者可塞入 5KiB+ 字符串使writeDataPoint静默失败或浪费配额。 Origin头可被任意脚本伪造(在浏览器中受 CORS 约束,但非浏览器客户端无效),白名单只能挡误用,不能挡攻击。writeAnalyticsPoint未 await 也未waitUntil包裹:如内部为异步且抛错,错误可能逃逸到全局 unhandled rejection。
按编码规范「请求参数验证」要求,建议补齐字段校验并对该端点加 IP 维度限流。
🛡️ 建议修复
app.post("/api/track", async (c) => {
const origin = c.req.header("Origin") || c.req.header("Referer") || "";
if (!isWebsiteAllowed(origin, c.env.ANALYTICS_WEBSITE_WHITELIST)) {
return c.json({ error: "origin not allowed" }, 403);
}
+
+ // 简易限流:基于 CF-Connecting-IP,60s 内最多 120 次
+ const ip = c.req.header("CF-Connecting-IP") || "unknown";
+ if (!trackRateLimitOk(ip)) return c.json({ error: "rate limited" }, 429);
let body: Record<string, unknown> = {};
try { body = await c.req.json(); } catch { return c.json({ error: "invalid json" }, 400); }
const path = typeof body.path === "string" ? body.path : "";
if (!path || path.length > 256) return c.json({ error: "invalid path" }, 400);
+
+ // 收紧字段长度上限以契合 AE blob 限制
+ const clip = (v: unknown, max: number) =>
+ typeof v === "string" && v.length <= max ? v : "";
+ const website = clip(body.website, 64) || "default";
+ const referer = clip(body.referer, 512);
+ const screen = clip(body.screen, 16);
+ const language = clip(body.language, 16);
+ const visitorId = clip(body.visitorId, 64);
+ const duration = typeof body.duration === "number" && body.duration < 86_400_000 ? body.duration : 0;
if (!c.env.AE) { c.status(204); return c.body(null); }
- writeAnalyticsPoint(
- {
- website: typeof body.website === "string" ? body.website : "default",
- path,
- referer: typeof body.referer === "string" ? body.referer : c.req.header("Referer"),
- ...
- },
- { ae: c.env.AE, userAgent: c.req.header("User-Agent"), country: c.req.header("CF-IPCountry") || "XX" },
- );
+ writeAnalyticsPoint(
+ { website, path, referer: referer || c.req.header("Referer"), screen, language, visitorId, duration },
+ { ae: c.env.AE, userAgent: c.req.header("User-Agent"), country: c.req.header("CF-IPCountry") || "XX" },
+ );可参考文件内已有的 loginAttempts Map 实现做轻量限流;如担心 Worker 实例隔离不一致,可考虑后续接入 Durable Object / Rate Limiting Binding。
- ae-query.ts: 新增 6 条聚合 SQL(hourlyHeatmap / durationBuckets / entryPages / exitPages / bounceStats / topReferersFull)+ visitor 新老分类查询 - AEAnalyticsResult/Data 类型扩展 8 个新字段 - analytics-ae.tsx: 新增 HeatmapChart(7×24 SVG 热力图)/ DurationBucketsChart(6 桶水平柱图)/ VisitorRatio(双色胶囊条)三个组件 - 核心指标卡从 4 张扩到 8 张(+ 跳出率 / 人均页数 / 浏览器种类 / 引荐来源数) - 引荐来源 Top 10 升级 Top 20 + 直接访问归类 - 入口/出口页 Top 10 双列展示 - analytics chunk 20.83 → 29.81 KB / gzip 5.23 → 7.21 KB
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (5)
server/src/analytics/ae-query.ts (1)
234-258: 「新/老访客」语义偏窄,建议在响应中保留窗口提示或重命名当前定义:在
[NOW()-days, NOW()]窗口内MIN(timestamp)落在最近 24h 内的 visitor 视为「new」,否则「returning」。这意味着 7 天前才首次出现的访客,在窗口里也会被标为「returning」——但在 AE 全量历史里他可能仍是新访客。代码注释(line 234-236)已提示该简化策略,但前端VisitorRatio直接渲染为「新访客 / 回访」可能引发运营误读。可选改进:
- 字段重命名:
visitorTypes→visitorRecency,new/returning→last24h/earlier;或- 在返回结构里加
windowDays与recencyHours: 24,由前端展示「24h 内首访 / 24h 前首访」。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/src/analytics/ae-query.ts` around lines 234 - 258, The current visitor recency labels produced by the visitorAge query and stored into newVisitors/returningVisitors are misleading; update the response so the fields are explicit about the window and recency threshold (e.g., rename result keys from "new"/"returning" to "last24h"/"earlier" or wrap counts under visitorRecency), and include metadata like windowDays (from since) and recencyHours: 24 in the returned payload that is built after the runSql call (use the same visitorAge parsing logic but map row.first_seen to the new keys and attach windowDays and recencyHours), so frontend can display “24h 内首访 / 24h 前首访” unambiguously.client/src/pages/admin/analytics.tsx (3)
152-165:tab加入依赖会触发不必要的重复 fetch从
basic切到ae再切回basic时会重新请求/api/admin/analytics(即便days未变)。当前版本无缓存,能接受;如未来加入轻量缓存可考虑:仅在tab === "basic"且data === null或days变化时再发起。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/pages/admin/analytics.tsx` around lines 152 - 165, The effect is re-running when `tab` changes causing redundant fetches; remove `tab` from the dependency array and keep the existing in-effect guard (`if (tab !== "basic") return;`) so the effect only runs on `days` changes (or on mount), and optionally add a guard to only call `fetchAnalytics(days)` when `data === null` or `days` changed to avoid refetching when switching away/back; update the `useEffect` that uses `fetchAnalytics`, `setData`, `setError`, `setLoading`, `days`, `tab`, and `data` accordingly.
196-221: Tab 按钮缺少aria语义两个
<button>实现 tab 切换,但未声明role="tab"/aria-selected/aria-controls,键盘 + 屏幕阅读器无法识别为 Tab 组件。♿ 建议补 ARIA 语义
- <div className="mt-[20px] flex items-center gap-[2px] border-b border-border/30"> + <div role="tablist" aria-label="访客分析视图切换" className="mt-[20px] flex items-center gap-[2px] border-b border-border/30"> <button + role="tab" + aria-selected={tab === "basic"} onClick={() => setTab("basic")} ... > 基础统计 </button> <button + role="tab" + aria-selected={tab === "ae"} onClick={() => setTab("ae")} ... >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/pages/admin/analytics.tsx` around lines 196 - 221, Wrap the tab buttons with role="tablist" and mark each button with role="tab" plus ARIA attributes: set aria-selected={tab === "basic"/"ae"} and aria-controls pointing to the corresponding panel id (e.g., "tab-panel-basic" and "tab-panel-ae"), and ensure the panels rendered by the component use those matching ids; update the onClick handlers (setTab) to remain the state toggle but keep the aria-selected logic in the button rendering so screen readers and keyboard users can recognize the active tab.
217-220: CF 徽标text-amber-400/70在亮色主题下对比度偏低与 analytics-ae.tsx 错误态用色同源问题。可改为主题感知:
🎨 建议
- <span className="text-[10px] text-amber-400/70 font-mono">CF</span> + <span className="text-[10px] text-amber-600/80 dark:text-amber-400/70 font-mono">CF</span>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/pages/admin/analytics.tsx` around lines 217 - 220, The CF badge uses a hardcoded low-contrast class ("text-amber-400/70" on the <span> inside the AE 增强 button) which is too light in the light theme; change it to a theme-aware color approach (e.g., replace the static class with a Tailwind theme-aware class set such as a darker amber for light mode and the existing tone for dark mode using dark: or conditional class logic via clsx/useTheme) so the span (the CF badge) has sufficient contrast in light mode while preserving the dark-mode styling.client/src/pages/admin/analytics-ae.tsx (1)
263-306:VisitorRatio健壮性 OK,但建议补一个语义说明逻辑上
total === 0时两段宽度均为 0%、不会出现 NaN,渲染降级到空胶囊条;外层第 425 行也已用every(t.count===0)走空状态分支,整体冗余但安全。不过「回访」实际语义为「窗口内首访早于 24h」(见
ae-query.ts简化策略),非真正历史回访。建议在 hover/title 上补一行说明,避免运营误读。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@client/src/pages/admin/analytics-ae.tsx` around lines 263 - 306, VisitorRatio 的“回访”标签语义可能被误解为历史回访,建议在组件 VisitorRatio 为整条胶囊或回访标签添加一个 hover 提示(title 或项目内统一 Tooltip 组件),内容说明回访的真实含义,例如:“回访 = 窗口内首访早于 24 小时(非历史回访)——见 ae-query.ts 的简化策略”;在实现时修改 VisitorRatio(或其回访 span/根容器)以加入 title/Tooltip,并保持现有百分比/样式不变。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/src/analytics/ae-query.ts`:
- Around line 215-222: The two queries are inconsistent: byReferer (used to
build topReferers) excludes empty blob4 via the condition in byReferer, while
referersFull (the runSql call producing topReferersFull) maps empty blob4 to
"(直接访问)" and includes it, causing the front-end metric
data.topReferersFull.length to count direct visits as a referer; fix by making
the server-side output consistent—either exclude direct visits from referersFull
(apply AND blob4 != '' or filter out where blob4 = '') or adjust the
construction of topReferersFull to replace "(直接访问)" with a special tag but do
not count it in the length; locate the runSql producing referersFull and the
byReferer query and ensure both use the same blob4 filtering logic (refer to
runSql<{ referer: string; cnt: number }>, referersFull, byReferer,
topReferersFull, topReferers) so the front-end metric uses consistent counts.
---
Nitpick comments:
In `@client/src/pages/admin/analytics-ae.tsx`:
- Around line 263-306: VisitorRatio 的“回访”标签语义可能被误解为历史回访,建议在组件 VisitorRatio
为整条胶囊或回访标签添加一个 hover 提示(title 或项目内统一 Tooltip 组件),内容说明回访的真实含义,例如:“回访 = 窗口内首访早于 24
小时(非历史回访)——见 ae-query.ts 的简化策略”;在实现时修改 VisitorRatio(或其回访 span/根容器)以加入
title/Tooltip,并保持现有百分比/样式不变。
In `@client/src/pages/admin/analytics.tsx`:
- Around line 152-165: The effect is re-running when `tab` changes causing
redundant fetches; remove `tab` from the dependency array and keep the existing
in-effect guard (`if (tab !== "basic") return;`) so the effect only runs on
`days` changes (or on mount), and optionally add a guard to only call
`fetchAnalytics(days)` when `data === null` or `days` changed to avoid
refetching when switching away/back; update the `useEffect` that uses
`fetchAnalytics`, `setData`, `setError`, `setLoading`, `days`, `tab`, and `data`
accordingly.
- Around line 196-221: Wrap the tab buttons with role="tablist" and mark each
button with role="tab" plus ARIA attributes: set aria-selected={tab ===
"basic"/"ae"} and aria-controls pointing to the corresponding panel id (e.g.,
"tab-panel-basic" and "tab-panel-ae"), and ensure the panels rendered by the
component use those matching ids; update the onClick handlers (setTab) to remain
the state toggle but keep the aria-selected logic in the button rendering so
screen readers and keyboard users can recognize the active tab.
- Around line 217-220: The CF badge uses a hardcoded low-contrast class
("text-amber-400/70" on the <span> inside the AE 增强 button) which is too light
in the light theme; change it to a theme-aware color approach (e.g., replace the
static class with a Tailwind theme-aware class set such as a darker amber for
light mode and the existing tone for dark mode using dark: or conditional class
logic via clsx/useTheme) so the span (the CF badge) has sufficient contrast in
light mode while preserving the dark-mode styling.
In `@server/src/analytics/ae-query.ts`:
- Around line 234-258: The current visitor recency labels produced by the
visitorAge query and stored into newVisitors/returningVisitors are misleading;
update the response so the fields are explicit about the window and recency
threshold (e.g., rename result keys from "new"/"returning" to
"last24h"/"earlier" or wrap counts under visitorRecency), and include metadata
like windowDays (from since) and recencyHours: 24 in the returned payload that
is built after the runSql call (use the same visitorAge parsing logic but map
row.first_seen to the new keys and attach windowDays and recencyHours), so
frontend can display “24h 内首访 / 24h 前首访” unambiguously.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2c2e1dee-7d18-4b03-9895-d5d1009a5a4d
📒 Files selected for processing (4)
client/src/lib/api.tsclient/src/pages/admin/analytics-ae.tsxclient/src/pages/admin/analytics.tsxserver/src/analytics/ae-query.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- client/src/lib/api.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 120000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (1)
client/src/pages/**
⚙️ CodeRabbit configuration file
client/src/pages/**: 页面级组件。审查时请关注: 1. 数据加载和错误处理是否完善 2. SEO 相关(页面标题、meta 标签) 3. 导航和路由是否正确
Files:
client/src/pages/admin/analytics-ae.tsxclient/src/pages/admin/analytics.tsx
🔇 Additional comments (9)
client/src/pages/admin/analytics-ae.tsx (5)
12-19:formatDuration边界仍存在精度失真历史评审已指出
Math.round在 59 秒/59.5 分附近会出现「1 分实际不足 1 分」「0.0 小时」等显示问题。当前实现未修改,建议参考已提议的改用Math.floor+sec阈值判断的方案。
131-167:ListSection的max=0仍会产生NaN%,且 key 仍存在冲突风险第 138 行的
max与第 150 行的key={item.name}与历史评审完全一致,建议合入既有修复(Math.max(items[0]?.count ?? 0, 1)+${item.name}-${idx}key)。
325-351: 亮色主题下 amber/red 文本对比度不足问题尚未处理
text-amber-300/text-amber-400/text-red-400/bg-amber-500/5在白底下未通过 WCAG AA。历史评审已提供dark:变体方案(如text-amber-700 dark:text-amber-300、text-red-600 dark:text-red-400),当前 commit 未合入,建议同步修复。
169-226: HeatmapChart 实现 LGTM
toDayOfWeek1..7 → 0..6(周日 7→0)的映射与dows数组顺序一致,hour % 3稀疏化标签策略合理,每格<title>提供原生 tooltip。空数据时maxC=1不会产生 NaN。
308-319:AEAnalyticsData类型定义已完整,无需修改检查表明
client/src/lib/api.ts中的AEAnalyticsData类型(第 307-328 行)已包含所有组件依赖的字段:bounceRate、pagesPerVisitor、visitorTypes、durationBuckets、hourlyHeatmap、entryPages、exitPages、topReferersFull、topScreens、topLanguages、totalVisits、uniqueVisitors和avgDuration。类型定义与服务端AEAnalyticsResult对齐,无 TS 编译错误或运行时风险。> Likely an incorrect or invalid review comment.server/src/analytics/ae-query.ts (3)
44-63:runSql仍缺超时与失败聚合策略历史评审已指出
fetch无AbortSignal.timeout+Promise.all全部串联失败的问题。queryAEAnalytics现已扩展到 16 条并发查询(line 80-223),任一慢/失败查询都会令整个 AE Dashboard 502,建议同时引入:
runSql内signal: AbortSignal.timeout(15_000);- 外层并发改
Promise.allSettled,对失败项返回空数组降级,确保主图能渲染。
98-223: 采样补偿仍不一致:UV / avg_dur / 入口出口页 / bounceStats 均未按_sample_interval加权历史评审已就
COUNT(DISTINCT blob10)和AVG(double1)与 PV 口径不一致提出修复。本次新增的 line 184-189(entryPages)、194-199(exitPages)、202-213(bounceStats)使用裸COUNT()/COUNT(DISTINCT blob2),与 byDay 的SUM(_sample_interval)同样存在采样偏差风险——一旦 AE 触发采样,跳出率/人均页数会被系统性低估。建议统一在子查询里把_sample_interval作为权重列保留,外层用加权聚合(如sum(weight)、uniqCombined等 AE 文档支持的近似函数)替代裸COUNT()。
65-72:safeDays与端点上限校验 LGTM
Math.max(1, Math.min(31, Math.floor(days || 7)))同时拦截NaN、负数、>31 天、非整数;与server/src/index.ts中Math.min(days, 31)形成双重防护,注入到INTERVAL '${d}' DAY安全。client/src/pages/admin/analytics.tsx (1)
30-139:TrendChart整体实现良好柱+折线+面积叠加、网格刻度、hover 高亮、透明热区交互区分离,结构清晰。
safeMax = max || 1避免除零,step = n>1 ? innerW/(n-1) : 0处理单点退化也已覆盖。
| // —— 新增:扩展引荐 Top 20(含直接访问) —— | ||
| runSql<{ referer: string; cnt: number }>( | ||
| env, | ||
| `SELECT if(blob4 = '', '(直接访问)', blob4) AS referer, SUM(_sample_interval) AS cnt | ||
| FROM ${DATASET} | ||
| WHERE timestamp > NOW() - ${since} | ||
| GROUP BY referer ORDER BY cnt DESC LIMIT 20 FORMAT JSON`, | ||
| ), |
There was a problem hiding this comment.
referersFull 与 byReferer 对空 referer 处理不一致(口径差异)
- byReferer (line 113-115):
AND blob4 != '',剔除直接访问; - referersFull (line 218):
if(blob4='', '(直接访问)', blob4),包含直接访问。
两者并存于同一返回结构(topReferers + topReferersFull),前端核心指标卡 引荐来源 = data.topReferersFull.length 会把「(直接访问)」也算成一个引荐源,与卡片字面含义有出入。建议要么前端从 topReferers.length 取数,要么在 topReferersFull.length 计数时减去直接访问条目。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/src/analytics/ae-query.ts` around lines 215 - 222, The two queries are
inconsistent: byReferer (used to build topReferers) excludes empty blob4 via the
condition in byReferer, while referersFull (the runSql call producing
topReferersFull) maps empty blob4 to "(直接访问)" and includes it, causing the
front-end metric data.topReferersFull.length to count direct visits as a
referer; fix by making the server-side output consistent—either exclude direct
visits from referersFull (apply AND blob4 != '' or filter out where blob4 = '')
or adjust the construction of topReferersFull to replace "(直接访问)" with a special
tag but do not count it in the length; locate the runSql producing referersFull
and the byReferer query and ensure both use the same blob4 filtering logic
(refer to runSql<{ referer: string; cnt: number }>, referersFull, byReferer,
topReferersFull, topReferers) so the front-end metric uses consistent counts.
…ries into parallel SUM/COUNT
摘要
将 HanAnalytics (MIT) 的 Cloudflare Analytics Engine 采集理念整合进 Monolith,形成「基础统计 (D1) + AE 增强 (CF 专属)」双层访客分析。
设计要点
后端 (
server/src/analytics/)ae-tracker.ts— AE writeDataPoint 封装,blob1-blob10 + double1,AE 不可用时静默跳过ae-query.ts— Cloudflare AE SQL API 查询,10 个并发 SELECT 聚合 PV/UV/avgDuration/byCountry/byDevice/byBrowser/byOS/byPage/byScreen/byLangua-parser.ts— 零依赖 30 行正则识别 device/browser/os/api/posts/:slug双写 D1 visits + AE,完全向后兼容POST /api/trackSPA 埋点接口(Origin 白名单)GET /api/admin/analytics/ae(DB_PROVIDER !== "d1"返回 501,缺 token 返回 503)前端
client/src/lib/analytics.ts— FNV-1a vid (localStorage 30 天) + sendBeacon + History API hook,自动跳过 /adminclient/public/tracker.js— 第三方站点独立埋点脚本(HanAnalytics 风格<script defer>)app.tsx路由变化时触发 trackPageviewpages/admin/analytics.tsx改造为 Tab 容器,AE Tab 带<Cloud /> CF标记pages/admin/analytics-ae.tsx仪表板(PV/UV 双线、浏览器/OS/分辨率/语言额外维度)隐私
monolith_analytics部署须知(CF 专属)
验收
npm run lint全绿tsc --noEmit(client + server) 通过npm run build成功 (analytics chunk 15.81 KB / gzip 3.84 KB)风险评估
writeAnalyticsPointctx.ae 可空,静默跳过License
新增模块头部注明
Adapted from HanAnalytics (MIT, https://github.com/uxiaohan/HanAnalytics)。主人请审阅,绝不会自动合并。 🌸