为什么不继续用前端埋点
我一开始做流量统计,走的是自建前端埋点:在页面里插一段脚本,把访问上报到自己的后端。要区分人和机器人,就得在埋点逻辑里加判断——站点一多,每来一个新站、每改一次版,都得回去动一遍前端代码。
站点少的时候这不算什么,站点多起来之后,这种侵入性开始变得难以接受:埋点点位散落在各个页面模板里,改版容易漏、容易重复;更要命的是它天然只看得到「能跑 JS、装了脚本、又没被拦截」的那部分访问者。
于是我转头去找现成的统计工具,就这样发现了 GoAccess。这条路走通之后,原来那套前端埋点整个撤掉了。
回头看,这个取舍背后是三条理由,按重要性排序:
一、不可侵入。 统计从业务代码里彻底剥离:站点再多、页面怎么改版,流量监控都不用跟着动。这一点在站点变多之后成了决定性因素。
二、埋点式的观测是有偏的。 前端脚本会被广告拦截器、隐私插件、无 JS 客户端干掉。你看到的是 ” 装了统计脚本并且没被拦的那部分人 “,而不是全部访问者。日志视角没有这个问题——请求打到服务器上就一定有一行记录。
三、可解释性。 埋点给你一个 ” 用户数 “,但你很难回答 ” 这个数是怎么算出来的 “。而日志分析可以一路追到源码,这在我后面踩口径坑的时候救了很多次。
代价当然也有:日志里没有 Cookie、没有会话、没有事件,想知道 ” 用户点了哪个按钮 ” 是做不到的。所以这不是替代关系,而是知道自己在数什么的问题。对于 ” 有多少人来、看了什么、从哪来 “,日志足够且更可信。
以下内容基于 GoAccess(一个用 C 写的实时日志分析器,MIT 协议)。它把 nginx 的 access log 解析成终端面板或自包含的 HTML 报表,支持 WebSocket 实时推送。
一、落地:最小可用架构
目标:几个站点各自的实时仪表盘,统一入口、统一登录,只在已有的 HTTPS 端口上对外。
浏览器 ── https://blog.example.com/stats/ ┐
└─ wss://blog.example.com/stats/<站点>/ws ─┤
▼
nginx (443, TLS)
/stats/ → 鉴权服务(登录页 / 索引 / 会话)
/stats/<站点>/ → nginx 直接读盘提供该站点的实时页面
/stats/<站点>/ws → 反代到该站点的 GoAccess WebSocket
/__authz (internal) → 反代到鉴权服务的会话校验端点
要点:GoAccess 只负责解析日志和推 WebSocket,它不提供网页,也没有登录系统。 页面托管和鉴权都得自己补——这是后面第三节的主题。
1.1 没有 root 也能从源码构建
我的环境一开始没有 sudo,而且缺 autoconf/automake/m4;GoAccess 的仓库里只有 configure.ac,没有生成好的 configure,官方又没有预编译包。可行的路子是用 apt-get download(不需要 root)把工具链的 deb 解包到自己的目录里自举:
mkdir -p ~/toolchain && cd ~/debs
apt-get download m4 autoconf automake gettext autotools-dev
for d in *.deb; do dpkg -x "$d" ~/toolchain/; done
export PATH=$HOME/toolchain/usr/bin:$PATH
这一步有两个坑,都不是 ” 报错就停 ” 那种,值得记下来:
坑一:deb 里的脚本硬编码了绝对路径。 解包出来的 autoconf/automake 是 Perl 脚本,内嵌 /usr/share/autoconf、/usr/bin/m4 这类路径,指向不存在的位置。需要把这些默认值改写成解包前缀,并让 PERL5LIB 指向解包出的模块目录。
坑二:msgfmt 加载失败时 configure 不报错,而是静默降级。 解包出的 gettext 工具依赖同目录的 libgettextsrc-*.so;加载不到时 configure 不会失败,而是把 MSGFMT 悄悄替换成 :(一个 no-op 命令),直到 make 阶段才以 mv: cannot stat 't-de.gmo' 这种风马牛不相及的错误炸掉。排查方式是看 config.log 里 ac_cv_path_MSGFMT 的值:
grep ac_cv_path_MSGFMT config.log # 期望是真实路径,不是 ":"
中文化也有一处反直觉的地方:GoAccess 没有任何 --lang 之类的参数,界面语言完全走 setlocale(LC_ALL, "") + gettext。所以前提是目标 locale 必须存在。系统里只有 en_US.UTF-8 时,可以在不装 locale 包的前提下自己生成并让 glibc 找到它:
localedef --no-archive -i zh_CN -f UTF-8 /opt/goaccess/locale/zh_CN.UTF-8
LOCPATH=/opt/goaccess/locale LC_ALL=zh_CN.UTF-8 date '+%A %B' # 验证:星期四 九月
顺带一个细节:把这个 locale 交给 systemd 服务时,不要 export LC_ALL 就完事,而是用 Environment= 声明。因为 shell 在启动时就缓存了 locale,之后再改 LC_ALL 会报警告(setlocale: LC_ALL: cannot change locale)——警告无害但会让人以为出事了。
构建选项上,--enable-utf8(宽字符终端)、--with-openssl、--with-zlib(能直接读 .gz 的轮转日志,不用手动 zcat)都值得开;--enable-geoip=mmdb 需要额外的 .mmdb 国家库,注意 MaxMind 的免费库现在需要账号,没有库时对应面板就是空的。
1.2 实时仪表盘的三个坑
实时模式本身很简单:
goaccess /var/log/nginx/blog-access.log \
--config-file=/opt/goaccess/goaccess.conf \
--real-time-html \
--port=9300 \
--ws-url=wss://blog.example.com/stats/blog/ws \
--output=/srv/reports/blog/index.html
它先解析整个文件,然后持续 tail,通过 WebSocket 把增量推给浏览器。但把这条命令放进「nginx 反代 + 只对外开 443」的架构里,会连撞三个坑:
坑一:--port 默认监听 0.0.0.0,绑定地址得另用 --addr 指定。
--port 只决定端口号,监听地址默认落在 0.0.0.0(gwsocket.c 里 ws_init ("0.0.0.0", "7890", …))。也就是说:如果不额外处理,未鉴权的实时流量数据会直接暴露在公网——任何人连上就能读走。
GoAccess 提供了 --addr=<addr> 来指定绑定地址,--addr=127.0.0.1 就是官方 README 里给出的写法:
goaccess /var/log/nginx/blog-access.log \
--real-time-html --addr=127.0.0.1 --port=9300
我这里最终是在防火墙上把这一段端口限制为回环,并且单独写一个 systemd unit 来管这条规则:
iptables -A INPUT ! -i lo -p tcp --dport 9300:9309 -j DROP
这里有个容易做错的地方:如果把规则写进每个实例的 ExecStartPre/ExecStopPost,停掉任意一个实例就会删掉规则,导致其余仍在运行的实例端口裸奔。规则的生命周期必须覆盖所有实例,所以它得是独立的 unit,由各实例 Requires= 它。
坑二:--port 不只是监听端口,它还会被写进页面,当作「浏览器该连的端口」。
源码 output.c 里两行说明了一切:
fpskeysval (fp, "url", (conf.ws_url ? conf.ws_url : "")); // 来自 --ws-url
fpskeyival (fp, "port", (conf.port ? atoi (conf.port) : 7890)); // 只能来自 --port(7890 是上游内置默认值)
生成出来的页面里是:
{"url": "wss://blog.example.com/stats/blog/ws", "port": 9300, "ping_interval": 0}
前端 buildWSURI() 会把这两者拼起来:wss://<hostname>:<port><pathname>。所以浏览器会去连 :9300——而 9300 正是我刚刚在防火墙上封掉的内部端口。
症状特别有误导性:页面能打开,数据却半天不出现,最后显示一句 “Unable to authenticate WebSocket.”。实际上根本不是鉴权失败——查 app.js 会发现这句话挂在 socket.onclose 上,是个通用的断开提示。真正的证据在 nginx 访问日志里:页面加载是 200,但完全没有 /ws 的握手记录,因为请求根本没发到服务器。
GoAccess 没法把 ” 监听端口 ” 和 ” 对外端口 ” 分开配置——页面里写的端口就是它监听的端口。在我的架构里,最省事的解法是在 nginx 出站时改写这一个字段:
location /stats/blog/ {
alias /srv/reports/blog/;
index index.html;
gzip off; # sub_filter 不能处理压缩响应
sub_filter '"port": 9300' '"port": 443';
sub_filter_once on;
sub_filter_types text/html application/javascript;
}
坑三:会话 Cookie 的路径匹配规则比直觉严格。
所有页面都挂在 /stats/ 前缀下,会话 Cookie 的 Path=/stats。我最初把实时页面放在 /stats-live/,结果浏览器根本不带 Cookie,永远处于未登录状态。
原因是 RFC 6265 的路径匹配规则:cookie 路径的最后一个字符必须是 /,或者请求路径中紧跟在它后面的字符必须是 /。所以 /stats 能匹配 /stats/、/stats/blog/,但匹配不上 /stats-live/(下一个字符是 -)。
结论:共享会话的路径必须挂在同一前缀之下,而不是去放宽 Cookie 的作用域到 /。
二、比搭建更容易踩的坑:数字口径
架构搭好之后,真正的麻烦才开始:报出来的数字和你心里预期对不上。 我差不多把每个关键指标都追到了源码,下面按 ” 最容易误读 ” 的顺序排列。
2.1 「独立访客」的指纹是什么
parser.c 里生成访客指纹的函数注释写得很直白:
The fingerprint is made out of the IP and user agent hash; the date is implied by the per-date storage partitioning.
即:指纹 = hash(IP, User-Agent),日期由按天分区隐含。URL 不在其中。
而汇总函数(gkmhash.c)的注释是:
Get the total number of unique visitors across all dates. The per-date counters are authoritative…
它是把每天的计数相加。这两点合起来决定了一切:
| 场景 | 计几次访客 | 说明 |
|---|---|---|
| 同一天、同 IP、同 UA,访问 10 个不同页面 | 1 | URL 不参与指纹 |
| 同一天、同 IP,但换了 UA(浏览器 vs 脚本) | 2 | UA 参与指纹 |
| 同 IP + 同 UA,连续 5 天每天来一次 | 5 | 跨天不去重 |
| 同 IP + 同 UA,同时访问两个不同站点 | 1 | 虚拟主机也不参与指纹 |
| 返回 4xx 的请求 | 0 | 见下 |
| 返回 301 / 500 的请求 | 1 | 只有 4xx 特殊 |
我用一份构造日志实测过,结论与源码一致:同一 IP 同一 UA 请求 4 个不同页面(含一个 302),访客数是 1;跨一天再访问一次,变成 2。
所以「独立访客」的准确含义是「人·天」,不是「人数」。 这就是它和第三方分析对不上的根本原因——后者通常用 Cookie 在整个统计周期内去重。
顺带一个反直觉的细节:4xx 默认不计入访客。gstorage.c 的 include_uniq() 只对 4xx 返回 ” 不计数 “,除非显式加 --4xx-to-unique-count。这个设计其实很合理(扫描器打出来的 404 不该算人),但意味着:过滤掉大量 404 时,” 总请求数 ” 会明显下降,而 ” 独立访客 ” 几乎不动。 我实测过滤掉一批 404 之后,请求数降了约 16%,而访客数只降了 0.3%。
2.2 爬虫:排到哪一层是个取舍
这里有三档口径,从宽到严:
| 档位 | 参数 | 行为 |
|---|---|---|
| 原始 | 无 | 全部计入 |
| 排除已知爬虫 | --ignore-crawlers | 只排 GoAccess 内置表能识别的爬虫 / AI 爬虫 |
| 连未知也排 | 再加 --unknowns-as-crawlers | 把 ” 未知 OS / 浏览器 ” 也归类为爬虫 |
实测某台服务器上,爬虫占 ” 独立访客 ” 的比例:全站合并约 24%,而博客类站点高达 41%——内容站被爬是常态。所以这一档选择对结论影响很大。
两个必须知道的行为:
一、--ignore-crawlers 不会降低「总请求数」。 受控实测:同一份输入,加不加这个参数,总请求数一模一样(差 0)。GoAccess 在 ” 总请求 ” 这个指标上保留原始解析计数,过滤只作用于独立访客、传输量和各面板。所以看 ” 真实访客 ” 要看独立访客那一栏,别盯着总请求数。
二、--unknowns-as-crawlers 会误伤真实客户端。 它对应用/API 类站点尤其明显:脚本、CLI、自定义客户端的 UA 往往无法被识别,会被一并当作爬虫清掉。我的一台应用服务器上,切到这一档后访客数掉了两成多(约 -23%),其中一部分很可能是正常的 API 调用。
所以取舍是: 内容站(博客、文档)大胆用第三档,因为爬虫占比高且大部分是没有分析价值的抓取;API/应用站建议只用第二档,或者分站点设置不同口径。
2.3 静态资源会淹没内容
博客类站点的请求里,JS/CSS/字体/图片这类静态资源的占比往往过半。如果不把它们单独归类,你的 ” 请求文件(URL)” 面板会变成一长串 /_astro/xxx.webp、/_astro/xxx.woff2,真正的文章页被埋在下面。
GoAccess 有 --static-file=<扩展名> 来做这件事,但这里有两个坑,第一个尤其隐蔽:
坑一:使用了配置文件、却没有提供任何 static-file 时,默认列表根本不会加载。 源码 settings.c:
/* If a configuration file is used and, if no static-file extensions are provided,
do not set the default static-file extensions. */
if (conf.iconfigfile != NULL && conf.static_file_idx == 0)
return;
也就是说,只要你用了 --config-file,静态资源识别就整个被关掉了——” 静态文件 ” 永远是 0。这是我最开始怎么调都不对的原因。
坑二:默认列表本身有一批条目匹配不上。 即使触发了默认列表,set_default_static_files() 里的这一串字符串末尾多了空格:
".js ", ".doc ", ".ppt ", ".iso ", ".gz ", ".rar ",
".svg ", ".bmp ", ".tar ", ".tgz ", ".tif ", ".ttf ", ".flv ",
而 verify_static_content() 只比较请求路径末尾 N 个字符:
if (nul - req > elen && !strncasecmp (nul - elen, ext, elen))
多一个空格的 .js 长度是 4,拿去和 /a.js 的最后 4 个字符比较,永远不相等。所以 .js 必须自己写进配置,别指望默认值。
配的时候还要注意白名单的方向。 我刻意没有把 .php、.env、.yml、.bak 收进来:这些扩展名在你自己的站点里几乎不会出现,但在扫描器的探测请求里出现频率极高(.env、/wp-admin/install.php 这类)。把它们当静态资源,等于把 ” 有人正在扫我 ” 这件事从 URL 面板里藏掉。同理 .txt/.xml 我也没有收——robots.txt、rss.xml 是真实端点,留在面板里更有意义。
配置示例:
static-file .js
static-file .mjs
static-file .css
static-file .map
static-file .png
static-file .webp
static-file .svg
static-file .woff
static-file .woff2
static-file .avif
效果:某博客当天的 ” 请求文件 ” 从几十项收敛到二十几项,剩下的正好是文章与标签页;访客数完全不变(静态资源本来也不改变访客指纹)。
另外一个反直觉的实测结论:--ignore-panel=REQUESTS_STATIC(不显示静态面板)不会让资源重新混回 URL 面板,它只是不显示静态计数与面板。所以 ” 精简面板 ” 和 ” 静态资源识别 ” 可以共存。
2.4 按站点分的判据是域名,不是文件名
这是我踩得最狠的一个坑,值得单独讲。
nginx 的默认 combined 日志格式里只有这些字段:
$remote_addr $time_local "$request" $status $body_bytes_sent "$referer" "$user_agent"
没有 $host。 所以日志本身无法告诉你 ” 这个请求打的是哪个域名 “。我一开始想当然地按文件名推断站点——blog-access.log 就当成 ” 博客站点 “。结果:
| 我以为是 | 实际是 |
|---|---|
| 某个站点的日志 | listen 8081; server_name _; 的端口 catch-all,而且那份配置根本没启用,日志恒为空 |
| 另一个站点的日志 | server_name _ 的 catch-all,里面几乎全是扫描器(/login.asp、zgrab 的 UA、RDP 探测) |
| 又是一个站点 | 没有任何 nginx 配置写这个文件,恒为空 |
| ” 主站 “ | nginx.conf 里的全局兜底 access_log,收的是所有未匹配 server_name 的请求,内容基本是 /.env、/wp-admin/install.php |
七个 ” 站点 ” 里有四个是空文件或扫描噪音桶。
正确的判据是 server_name + DNS 是否解析:只有配了独立 server_name、且域名确实解析的 server 块,它的日志才对应一个真实站点。
而如果日志里要真正带上域名(比如一个 server 块配了多个域名,或者想看兜底流量打的是谁),就得改 log_format:
log_format with_host '$host $remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
对应的 GoAccess 格式串把 %v 放在最前面:
log-format %v %h %^[%d:%t %^] "%r" %s %b "%R" "%u"
但直接换格式有个迁移问题:日志是按天轮转、追加写入的,改格式当天,同一个文件里会新旧格式混杂,而 GoAccess 一次只能用一种格式解析;同时,最近一个月的轮转日志会变成无法解析。
零破坏的做法是双写:nginx 允许一个 server 块配多条 access_log,所以保留原日志不动,另加一路带域名的:
access_log /var/log/nginx/blog-access.log; # 老格式,历史与既有分析继续可用
access_log /var/log/nginx/host-blog.log with_host; # 新格式,从启用时刻起干净
这样一来:历史日志一字未动、照常可解析;新日志从启用时刻起就是干净的带域名版本。
一个小细节:新日志要平铺在 /var/log/nginx/ 根目录下,不要放子目录。 因为 /etc/logrotate.d/nginx 里的 glob 是 /var/log/nginx/*.log,不递归——放子目录会绕开轮转规则、无限增长。
三、鉴权:GoAccess 自己没有登录系统
前面提过,GoAccess 只解析日志和推 WebSocket。它确实有一组看起来像鉴权的参数(--ws-auth、--ws-auth-url、--ws-auth-expire),但看清楚它们的语义会发现:它只做 JWT 的验证,签发仍然要靠你自己的后端。换句话说,它把 ” 谁来签发 token” 这件事留给了你。
我选择不引入 JWT 这一套,而是让 nginx 复用网页登录的会话:
- 自己做一个小服务提供登录页(口令用 PBKDF2-HMAC-SHA256 + 随机盐存哈希,绝不明文落盘;登录限速;会话 Cookie 带
HttpOnly/SameSite=Lax/Secure) - 它额外暴露一个仅限回环的内部端点
/__authz,按会话 Cookie 返回 200/401 - nginx 用
auth_request在需要保护的位置调用它
location = /__authz { # 内部端点,internal 保证外部无法直接访问
internal;
proxy_pass http://127.0.0.1:9391/__authz;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
location = /stats/blog/ws { # WebSocket 同样要过鉴权
auth_request /__authz;
proxy_pass http://127.0.0.1:9300;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
这样网页和 WebSocket 共用同一个会话,不需要第二套密钥体系。几个值得抄的细节:
- 限速要按 IP 计数,而且只在直连方是回环地址时才信任
X-Real-IP——否则有人绕过反代直连就能伪造该头逃避限速。 ?next=和X-Forwarded-Prefix这类参数必须做白名单。我在这里漏过一个真实的开放重定向:正则里字符类包含了/,导致X-Forwarded-Prefix: //evil.com能拼出Location: //evil.com/login。协议相对 URL 必须以显式判断挡掉,不能只靠字符类。- 自签目录下的静态文件也要防目录穿越:
translate_path之后再做一次realpath包含性校验。
四、可复用的取舍清单
把上面所有决定压缩成一张表:
| 决策点 | 选项 | 我的选择与理由 |
|---|---|---|
| 数据来源 | 前端埋点 vs 直接读日志 | 读日志:埋在业务代码里的统计被剥离,且不受脚本拦截影响 |
| 页面托管 | GoAccess 自带 vs 反代读盘 | 反代:它不提供网页,且反代能统一复用会话鉴权 |
| WebSocket 暴露 | 直接对外 vs 仅回环 | 仅回环(--addr=127.0.0.1,必要时再加防火墙兜底) |
| 对外端口 | 新开端口 vs 复用 443 | 复用 443,避免动安全组、复用现有证书 |
| 访客口径 | 原始 / 排已知爬虫 / 连未知也排 | 内容站用最严;API 站只用中间档 |
| 静态资源 | 混在 URL 面板 vs 单独归类 | 单独归类(否则内容被资源淹没),且用白名单 |
| 按站点分 | 文件名 vs 日志里的 $host | 短期按 server_name 判定;长期双写 $host 日志 |
| 鉴权 | GoAccess JWT vs 复用网页会话 | 复用会话,少一套密钥体系 |
| 界面 | 全面板 vs 按需裁剪 | 裁剪(--ignore-panel 连解析一起关,省资源) |
小结
三条我最有体会的经验:
一、” 这个数字的定义是什么 ” 要先于 ” 这个数字对不对 ”。 我最初以为访客数对不上是配置问题,查完源码才发现是定义问题——「人·天」和「去重人数」本来就是两个东西。任何监控指标,先搞清楚它的指纹/口径,再去和别的系统对比。
二、过滤类参数的 ” 作用层 ” 必须确认,不能想当然。 --ignore-crawlers 不降低总请求数、--ignore-panel=REQUESTS_STATIC 不同时禁用静态识别——这类行为只能靠实测确认,文档不会告诉你。
三、把自己顺手的假设写成判据。 ” 文件名就是站点名 ” 这个假设舒服又自然,但它错了四个。最后我把它换成了一条可验证的判据:看 server_name 和 DNS,不看文件名。
至于工具本身,GoAccess 是个踏实的选择:单文件、无依赖、能直接读压缩日志、实时模式够用。它的短板(没有登录、无法限制绑定地址、口径不能分离配置)都可以用很薄的运维层补上,而且源码可读——在口径这件事上,能读源码本身就是最大的优势。