Skip to content

Observability

Hades edited this page Aug 23, 2026 · 7 revisions

链路记录与统计

每个请求处理完记一条:谁处理的、用了哪个适配器、状态码、耗时、重试几次。 Web 管理端的「请求流」和统计就是看这个。

这套内置记录是唯一的可观测通路,没有 Prometheus 导出——理由见为什么不是 Prometheus

配置

[TRACE]
memory_size = 500              # 内存里保留最近多少条;0 = 关掉内存缓冲
sqlite_enabled = false         # 落盘。开了才能查历史与跨天统计
sqlite_path = "ipclick-trace.db"   # 默认值,不带 {port};同目录起多实例时可自己
                                    # 改成 "ipclick-trace.{port}.db" 按端口分开,见下
retention_days = 30            # 超期每小时清理一次;0 = 永久保留
only_errors = false            # 只记失败的
queue_size = 5000              # 写队列容量。最小 100,写小了会告警并回落到 5000
record_url = true              # false = 只记 host

两层,各管一件事:

内存 SQLite
默认 (500 条)
开销 零磁盘、上限固定 一个 db 文件 + -wal / -shm
重启
能查 最近 N 条 历史、跨天统计、目标站点排行

SQLite 默认关,是因为多数人只需要"刚才发生了什么",不该默认在工作目录里造一个数据库 文件。要用 Web 端查历史就打开它。

{port} 占位符。 同一目录用 --port 起多个实例时,这一项不岔开会让 多个进程写同一个库,链路记录会混在一起。{port} 会被替换成运行时实际生效的端口 (--port 覆盖之后的那个)。[LOG].output 同样支持。详见 配置体系

真撞上了还会打一条明确告警,而不是静默合并。

记什么,不记什么

:时间、请求 uuid、执行节点、适配器、方法、URL、状态码、耗时、响应体大小、 重试次数、是否为转发请求、排队耗时、错误信息、是否流式。

刻意不记:请求头、cookie、请求体、代理串。

落盘那一份有截断:URL 只存前 512 字符,错误信息只存前 500 字符;内存缓冲里是完整的。 查带一长串 query 的接口时,这个差异容易被当成"记错了"。

那些含机密,而这些记录是要在 Web 端展示的。要看完整请求内容请把 [LOG].level 调成 debug——那是日志的事,日志有它自己的访问控制。

record_url = false 时只记 host,用于不想把抓取目标落盘的场景。它同时作用于 urlerror 两个字段——错误信息里常嵌着完整 URL(重定向超上限、浏览器导航失败都会带上), 只脱敏 url 的话查询串里的密钥照样落库、照样上页面。

注意它管的是链路记录。日志文件里的完整 URL 不受这个开关约束(那是 [LOG] 的事), 需要一并收敛请调整日志级别或日志的访问控制。

三条设计约束

1. 绝不反压业务

写盘走独立的写线程 + 有界队列。队列满了就丢弃并计数,业务请求一秒都不等。

可观测性数据让业务请求变慢,是把工具变成故障源。

2. 丢了必须能看见

丢弃会累加 dropped 计数并打日志:

链路记录队列已满,累计丢弃 1234 条(写盘跟不上请求速率;可关闭 [TRACE].sqlite_enabled)

静默丢弃比丢弃更糟——你会拿着一份不完整的数据下结论,还不知道它不完整。 Web 端的统计里也会显示 droppedsqlite_failed

3. 写盘挂了不影响服务

SQLite 写失败(磁盘满、权限、文件损坏)会把 sink 标记为 failed,之后只用内存, 服务照常跑。Web 端会显示数据来自 memory 而不是 sqlite

SQLite 细节

  • WAL 模式 + synchronous = NORMAL —— 读写不互相阻塞
  • 批量提交 —— 不是一条一次事务
  • auto_vacuum = INCREMENTAL —— 保留期清理后能真正把空间还给文件系统。 这个 PRAGMA 本身只在建库时生效,但每次启动都会自动检测并转换:已有库若还没 开启且体积 ≤256 MB,会自动跑一次 VACUUM 补上。只有超过 256 MB 的库才会跳过 自动转换(并打一条日志),需要手工执行一次 VACUUM
  • 专门的失败索引 —— WHERE status_code < 200 OR status_code >= 400 的部分索引, 查失败请求不用全表扫
  • 自动迁移 —— 每次启动都会检查表结构,已有的库缺列时自动补上(如目标站点排行用的 host 列,补列时顺带从 url 回填),不用手动处理

查询

Web 端的请求流页是主要入口。程序里也能直接用:

from ipclick.trace import get_recorder

r = get_recorder()
r.recent(limit=50)          # 内存里最近的,返回 list[TraceRecord]

# query() 返回 (记录列表, 数据来源) 两个值
records, source = r.query(limit=100, status_class="failed")
records, source = r.query(keyword="example.com", adapter="browser")

r.stats(days=7)             # 统计,结构见下

query()recent() 多返回一个 source"sqlite""memory"。 落盘没开、或 sink 写坏了的时候它会自动退回内存,source 就是唯一能看出这件事的地方 ——否则你会以为在查历史,其实只查到了最近 500 条。

status_class 可选 2xx / 3xx / 4xx / 5xx / failure / failederrorfailed 的别名)。failed 的定义是 status_code < 200 或 >= 400failure 只要 < 200(也就是压根没拿到 HTTP 响应的那些)。 没有 ok 这个取值,填了等于不筛。

关键词是字面匹配——%_ 会被转义,搜 api_v2 不会匹配到 apiXv2。 URL 里这两个字符太常见(/api_v2/utm_source=),不转义的话筛出来的东西 跟你以为的不是一回事。

统计

r.stats(days=30)
# {
#   # 本进程的实时计数(重启归零)
#   "process": {"total": 12045, "ok": 11890, "failed": 155,
#               "success_rate": 98.7, "avg_ms": 412.3,
#               "bytes": 1502398112, "by_adapter": {...}},
#   # 记录后端自己的状态
#   "recorder": {"source": "sqlite", "dropped": 0, "sqlite_failed": False},
#
#   # 以下四项只在 sqlite_enabled 且 sink 健康时才出现
#   "window_days": 30,
#   "window": {...},        # 这个时间窗的聚合
#   "daily": [...],         # 按天趋势
#   "top_hosts": [...],     # 目标站点排行
# }

process 里除了上面这些,还有两组容易被忽略的字段:

  • in_flight / peak_in_flight —— 当前正在执行、尚未返回的请求数,以及本次 启动以来的峰值。总览页和请求流页都会显示这两个数字。
  • retries / rejected —— 按"原因"字符串分类的累计次数,key 是原因本身: 适配器重试的 browser:status_codebrowser:exception(来自重试逻辑), 准入阶段拒绝的 unauthenticated(鉴权失败)、以及按错误分类给出的其他标签 (如 failed_preconditioninvalid_argument)。这两组目前只能通过 stats() 取到,页面上没有单独的展示位。

两层的分工要分清:process 是本进程从启动至今的实时计数(重启就归零,也不跨节点), window / daily / top_hosts 才是落盘数据的聚合。只看 process 会把"刚重启过" 误读成"没有流量"。

统计结果有 10 秒的窗口缓存。Web 端默认每 5 秒轮询一次(请求流页最快可切到 1 秒), 不缓存的话每次都会对整个保留期做一次聚合——记录一多就是每几秒扫一遍几十万行。 缓存 TTL 取 10 秒就是按这个轮询周期定的。

目标站点排行走 SQL 的 GROUP BYhost 是独立的列,建库时就算好),不是把记录全拉到 Python 里再统计。

host 用的是和按 host 限流同一套定义limiter.host_of)。两处对 "host" 的理解 必须一致,否则排行榜和限流说的不是一回事。record_url = falseurl 存的已经是 host 了,解析不出来就原样用——否则整个排行会全是 -

磁盘占用

一条记录大约 200~400 字节(取决于 URL 长度)。

日请求量 30 天大约
1 万 100 MB
10 万 1 GB
100 万 10 GB

量大又只关心异常的话,only_errors = true 能把库缩小一两个数量级。 或者把 retention_days 调小。

为什么不是 Prometheus

没有 [metrics] extra,也没有 Prometheus 导出端点。

Prometheus 给的是聚合指标:"过去 5 分钟错误率 3%"。而实际排查时要问的是 "刚才那个请求为什么 403"——具体哪个 URL、哪个节点、哪个适配器、重试了几次。 聚合指标答不了这个,你还得再去翻日志。

内置记录答的正是这一头:单请求可查、Web 端直接看、零外部依赖、零额外端口。代价是没有 长期时序曲线——真需要的话,请求流数据自己导出去接 Grafana 也不难。

也因此不需要 prometheus-client 这个依赖,也不用多守一个必须防护的监听端口。

健康检查

[MONITOR]
health_check = true

注册标准的 grpc.health.v1免鉴权,供 K8s 探针和服务网格用。集群探活也走它。

grpc_health_probe -addr=127.0.0.1:9528

关掉后探活会收到 UNIMPLEMENTED

日志

[LOG]
level = "info"      # debug 会打完整请求内容
output = "stdout"   # stdout / 文件路径 / 目录(以 / 结尾),见下

[LOG.rotation]
max_size = 100
max_backups = 5

output 有三种写法:

output = "stdout"           # 只打控制台
output = "logs/app.log"     # 写这个文件(没有扩展名时自动补 .log)
output = "logs/"            # 以 / 结尾 = 写进这个**目录**,文件名用 ipclick.log

第三种是专门处理过的:不这么做的话 logs/ 会被当成"缺扩展名的文件名", 改写成同级的 logs.log,而 logs/ 里空空如也,且不报任何错。 日志是排障的基础设施,在这里静默配错的代价远大于别处。

output 也支持 {port} 占位符,同目录多实例时不岔开会静默抢写同一个文件。

底层是 loguru。占位符是 {time} / {level} / {message} 这种花括号写法,不是 标准库 logging 的 %(asctime)s——写成后者会被识别、告警并忽略,而不是照用(照用的话 每行日志会变成一串字面量)。

下一步

Clone this wiki locally