Skip to content

feat(analytics): integrate Cloudflare Analytics Engine dashboard (CF-only)#52

Merged
one-ea merged 5 commits into
mainfrom
feat/analytics-ae-integration
Apr 26, 2026
Merged

feat(analytics): integrate Cloudflare Analytics Engine dashboard (CF-only)#52
one-ea merged 5 commits into
mainfrom
feat/analytics-ae-integration

Conversation

@one-ea

@one-ea one-ea commented Apr 25, 2026

Copy link
Copy Markdown
Owner

摘要

HanAnalytics (MIT) 的 Cloudflare Analytics Engine 采集理念整合进 Monolith,形成「基础统计 (D1) + AE 增强 (CF 专属)」双层访客分析。

该功能明确标注为 Cloudflare 部署专属,Turso / PostgreSQL 后端仅基础统计可用,AE 增强 Tab 会显示 501 提示。

设计要点

后端 (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/byLang
  • ua-parser.ts — 零依赖 30 行正则识别 device/browser/os
  • /api/posts/:slug 双写 D1 visits + AE,完全向后兼容
  • 新增 POST /api/track SPA 埋点接口(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,自动跳过 /admin
  • client/public/tracker.js — 第三方站点独立埋点脚本(HanAnalytics 风格 <script defer>
  • app.tsx 路由变化时触发 trackPageview
  • pages/admin/analytics.tsx 改造为 Tab 容器,AE Tab 带 <Cloud /> CF 标记
  • 新增 pages/admin/analytics-ae.tsx 仪表板(PV/UV 双线、浏览器/OS/分辨率/语言额外维度)

隐私

  • 不上报 IP / cookie,vid 仅 localStorage
  • /admin 路径不埋点
  • AE 数据集 dataset 名 monolith_analytics

部署须知(CF 专属)

# wrangler.toml 已声明 AE binding:
# [[analytics_engine_datasets]]
# binding = "AE"
# dataset = "monolith_analytics"

# 仪表板查询需注入 secret:
wrangler secret put CLOUDFLARE_ACCOUNT_ID    # CF Account ID
wrangler secret put CLOUDFLARE_API_TOKEN     # 需 Account Analytics:Read 权限
# 可选白名单:
wrangler secret put ANALYTICS_WEBSITE_WHITELIST  # 逗号分隔

验收

  • npm run lint 全绿
  • tsc --noEmit (client + server) 通过
  • npm run build 成功 (analytics chunk 15.81 KB / gzip 3.84 KB)
  • 双主题 + 偶数模数 + 响应式三档 (ui-dual-theme-audit Skill 检查通过)
  • D1 后端 → AE Tab 显示「CF 专属功能」501 提示
  • D1 后端 + AE binding + secrets → AE Tab 显示完整仪表板

风险评估

风险 缓解
AE binding 缺失(旧部署) writeAnalyticsPoint ctx.ae 可空,静默跳过
Turso/PG 部署看到 AE Tab 显示明确 501 提示 + 当前 provider
第三方依赖 零新增 npm 依赖(自写 UA parser)
隐私合规 无 IP / 无 cookie / 跳过 admin

License

新增模块头部注明 Adapted from HanAnalytics (MIT, https://github.com/uxiaohan/HanAnalytics)


主人请审阅,绝不会自动合并。 🌸

…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)
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@one-ea has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 23 minutes and 18 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9aa51ff0-f56c-426b-973a-9331a9d752dd

📥 Commits

Reviewing files that changed from the base of the PR and between ce77f2e and 888b98f.

📒 Files selected for processing (2)
  • scripts/deploy-cloudflare.mjs
  • server/src/analytics/ae-query.ts
📝 Walkthrough

Walkthrough

新增完整访客分析管道:客户端注入追踪脚本并生成稳定访客 ID,上报到新增的 /api/track 接口;服务器在 Cloudflare 环境下将事件写入 Analytics Engine(AE);新增后端 AE 查询与前端管理页面(AE 选项卡)用于展示聚合统计与可视化。

Changes

Cohort / File(s) Summary
文档
README.md
添加“访客分析”说明,声明 D1 的 visits 表和 Cloudflare 专属的 AE 丰富化能力。
浏览器端追踪脚本
client/public/tracker.js
新增自执行脚本:从 script 标签读取配置,生成/缓存 visitorId(localStorage,30天 TTL),拦截 History API 与 popstate,记录页面/时长事件,使用 sendBeacon / fetch keepalive 上报;去重连续相同路径。
客户端分析库
client/src/lib/analytics.ts
新增导出:trackPageview(path, website)bindUnloadTracker(website);实现 route 变更上报、卸载/visibility 钩子、时长上报和 admin 路径过滤。
客户端 API & 类型
client/src/lib/api.ts
新增 AE 类型 AEAnalyticsDataAEAnalyticsErrorfetchAEAnalytics(days),通过管理接口请求 AE 聚合数据并在错误时抛出结构化错误。
客户端路由初始化
client/src/app.tsx
在 App 路由变化时绑定卸载追踪器并调用页面上报(结合 Wouter location 更改)。
管理后台 UI
client/src/pages/admin/analytics.tsx, client/src/pages/admin/analytics-ae.tsx
新增 basic/ae 双标签切换;在 AE 标签下渲染 AnalyticsAEView(days),包含加载/错误/空态处理、8 个摘要卡、PV/UV 趋势图、停留时长分布、7×24 小时热力图和多个维度排行列表。
服务器 AE 写入/白名单
server/src/analytics/ae-tracker.ts, server/src/analytics/ua-parser.ts
新增轻量 UA 解析与 referer 主机提取,新增 writeAnalyticsPoint 将事件写入 AE(解析 UA、规范字段、时长上限 24h)、并实现 isWebsiteAllowed(origin, whitelist) 白名单匹配(支持通配符、空白表允许全部)。
服务器 AE 查询
server/src/analytics/ae-query.ts
新增 queryAEAnalytics(env, days):对 AE 执行多组 SQL 聚合(按天 PV/UV、国家、设备、浏览器、OS、页面、屏幕、语言、时段热图、时长桶、进出页、bounce/ppv 等),对缺失子查询做降级处理,返回归一化 AEAnalyticsResult
服务器路由与绑定
server/src/index.ts, server/wrangler.toml
新增 /api/track 接口并在页面/帖子获取时写入 AE,新增 /api/admin/analytics/ae 管理查询端点;wrangler.toml 添加 AE dataset 绑定(binding = "AE", dataset = "monolith_analytics")。

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: 渲染图表与排行
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题严格遵循 Conventional Commits 格式 (feat(analytics): ...) 并准确概括主要变更(集成 Cloudflare Analytics Engine 仪表板)。
Description check ✅ Passed PR 描述详尽且完全相关,涵盖后端/前端设计、部署须知、验收清单和风险评估,与 changeset 高度相符。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/analytics-ae-integration
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/analytics-ae-integration

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added frontend 前端 (React/Vite) 相关变更 backend 后端 (Hono Workers) 相关变更 feature 新功能 labels Apr 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 加入依赖数组后,从 basicaebasic 时会再次触发完整 fetch(同一 days 窗口内通常无变化)。如想避免抖动可以用 data 是否已存在做短路:

♻️ 可选优化
   useEffect(() => {
     if (tab !== "basic") return;
+    if (data) return; // 已有数据则不重复请求;切换 days 时由 setData(null) 触发
     setLoading(true);

并在 setDays 处补一行 setData(null) 以保留 days 变更的刷新行为。

主题与响应式:tab 条使用 text-foreground/muted-foreground token,暗/亮主题切换无问题;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; 会触发 ESLint no-throw-literal,且让调用方的 err instanceof ErrorError.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

📥 Commits

Reviewing files that changed from the base of the PR and between b253b6c and a98c2be.

📒 Files selected for processing (12)
  • README.md
  • client/public/tracker.js
  • client/src/app.tsx
  • client/src/lib/analytics.ts
  • client/src/lib/api.ts
  • client/src/pages/admin/analytics-ae.tsx
  • client/src/pages/admin/analytics.tsx
  • server/src/analytics/ae-query.ts
  • server/src/analytics/ae-tracker.ts
  • server/src/analytics/ua-parser.ts
  • server/src/index.ts
  • server/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.tsx
  • client/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: LGTM

AE 数据集绑定声明清晰,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.

Comment thread client/public/tracker.js
Comment on lines +87 to +102
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();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

重复 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.

Comment on lines +77 to +84
export function trackPageview(path: string, website = "default"): void {
if (!path || path === lastTrackedPath) return;
// 后台路径不上报,保护管理员隐私 + 减少噪音
if (path.startsWith("/admin")) {
lastTrackedPath = path;
pageEnterAt = Date.now();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

进入 /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.

Comment on lines +119 to +145
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();
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +12 to +19
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)} 小时`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +23 to +59
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +35 to +54
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 || [];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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 || [];
}

Comment thread server/src/analytics/ae-query.ts
Comment on lines +68 to +79
/**
* 校验站点白名单。空白名单 = 全部放行。
* 格式: "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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

默认开放白名单:公网任何人可写脏数据到 AE

注释明确"空白名单 = 全部放行",但 /api/track 是公网 POST 端点。一旦 ANALYTICS_WEBSITE_WHITELIST 漏配(默认未设),任何脚本都能向 AE 注入伪造事件——blob1-blob10 全部用户可控(只做长度截断),管理后台所有维度(country/device/browser/path/screen/language/referer)都会被污染,且 monolith_analytics 数据集付费按写入计量。

虽然有"灵感来源 HanAnalytics"的同款语义,但建议在生产部署中:

  1. 默认拒绝、显式允许(fail-closed),或
  2. 至少在 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).

Comment on lines +15 to +17
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 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:


🏁 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 -5

Repository: 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:


🌐 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:


🌐 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:


🏁 Script executed:

# Read the ua-parser.ts file
cat -n server/src/analytics/ua-parser.ts

Repository: 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 2

Repository: 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 无法区分。

建议优化方向:

  • 区分明确的爬虫标识(如 googlebotbaiduspider)与 App 内浏览器标识
  • 对社交平台链接预览爬虫采用更精确的 token(如 facebookexternalhittwitterbot)而非通用词
  • 若需保留 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.

Comment thread server/src/index.ts
Comment on lines +109 to +146
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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

/api/track 缺少限流与字段长度上限,存在 AE 配额被打爆的风险

此为公开端点(非 /api/admin/* 不走 JWT),目前防护只有 Origin/Referer 白名单(且 ANALYTICS_WEBSITE_WHITELIST 为空时全部放行),其他保护点缺失:

  1. 没有限流:恶意脚本可在循环中调用 /api/track,每次都触发 writeDataPoint。AE 单 Worker 写入有 25 次/请求、每月配额上限,且 blob 总长 25 KiB / 单 blob 5 KiB;远端可轻易耗尽配额或推高账单。
  2. 字段长度未校验:除 path 外,refererscreenlanguagevisitorIdwebsite 全部无长度上限,攻击者可塞入 5KiB+ 字符串使 writeDataPoint 静默失败或浪费配额。
  3. Origin 头可被任意脚本伪造(在浏览器中受 CORS 约束,但非浏览器客户端无效),白名单只能挡误用,不能挡攻击。
  4. 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。

Easy added 2 commits April 26, 2026 17:38
- 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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 直接渲染为「新访客 / 回访」可能引发运营误读。

可选改进:

  • 字段重命名:visitorTypesvisitorRecencynew/returninglast24h/earlier;或
  • 在返回结构里加 windowDaysrecencyHours: 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 === nulldays 变化时再发起。

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between a98c2be and ce77f2e.

📒 Files selected for processing (4)
  • client/src/lib/api.ts
  • client/src/pages/admin/analytics-ae.tsx
  • client/src/pages/admin/analytics.tsx
  • server/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.tsx
  • client/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: ListSectionmax=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-300text-red-600 dark:text-red-400),当前 commit 未合入,建议同步修复。


169-226: HeatmapChart 实现 LGTM

toDayOfWeek 1..7 → 0..6(周日 7→0)的映射与 dows 数组顺序一致,hour % 3 稀疏化标签策略合理,每格 <title> 提供原生 tooltip。空数据时 maxC=1 不会产生 NaN。


308-319: AEAnalyticsData 类型定义已完整,无需修改

检查表明 client/src/lib/api.ts 中的 AEAnalyticsData 类型(第 307-328 行)已包含所有组件依赖的字段:bounceRatepagesPerVisitorvisitorTypesdurationBucketshourlyHeatmapentryPagesexitPagestopReferersFulltopScreenstopLanguagestotalVisitsuniqueVisitorsavgDuration。类型定义与服务端 AEAnalyticsResult 对齐,无 TS 编译错误或运行时风险。

			> Likely an incorrect or invalid review comment.
server/src/analytics/ae-query.ts (3)

44-63: runSql 仍缺超时与失败聚合策略

历史评审已指出 fetchAbortSignal.timeout + Promise.all 全部串联失败的问题。queryAEAnalytics 现已扩展到 16 条并发查询(line 80-223),任一慢/失败查询都会令整个 AE Dashboard 502,建议同时引入:

  1. runSqlsignal: AbortSignal.timeout(15_000)
  2. 外层并发改 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.tsMath.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 处理单点退化也已覆盖。

Comment thread server/src/analytics/ae-query.ts Outdated
Comment on lines +215 to +222
// —— 新增:扩展引荐 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`,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

@one-ea one-ea merged commit ccf8ce7 into main Apr 26, 2026
10 checks passed
@one-ea one-ea deleted the feat/analytics-ae-integration branch April 26, 2026 10:23
@github-actions github-actions Bot mentioned this pull request May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend 后端 (Hono Workers) 相关变更 feature 新功能 frontend 前端 (React/Vite) 相关变更

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant