Skip to content

可观测性与运维接口 ​

框架自带三个只读端点,供容器探针、负载均衡和 Prometheus 使用。它们不依赖任何第三方库,也不需要登录。

端点用途是否需要鉴权端口
GET /healthz存活探针:进程能响应即 200,不查数据库否任意端口
GET /readyz就绪探针:进程运行中 + 启动完成 + 数据库可读否任意端口
GET /metricsPrometheus 文本格式指标默认否,可配令牌仅 API 端口

三个端点都不计入限速、不触发人机验证、不写审计日志——否则探针会自己把自己拦掉,或者把审计表刷满。

配置 ​

yaml
observability:
  health: true          # /healthz 与 /readyz
  metrics: true         # /metrics
  metrics_token: ""     # 非空时 /metrics 需要 Authorization: Bearer <token>

这三项在启动时读取,修改后需要重启(配置热应用 里标注为需重启的段)。

若不想暴露指标端点,把 metrics: false:此时 /metrics 会交给蜜罐的诱饵(返回伪 actuator 内容,扫描器拿不到真实数据)。

/healthz ​

json
{"status": "ok", "uptime_seconds": 128.412, "version": "1.12.0"}

只反映「进程没死」。故意不碰数据库:探针把慢库拖成雪崩是常见事故,数据库健康交给 /readyz。

/readyz ​

就绪时返回 200;任一必需检查失败返回 503,响应体带逐项明细,排障时直接看是哪一环没起来:

json
{
  "status": "not_ready",
  "uptime_seconds": 3.21,
  "version": "1.12.0",
  "checks": {
    "process":  {"ok": true,  "detail": "运行中"},
    "startup":  {"ok": false, "detail": "插件加载中"},
    "database": {"ok": true,  "detail": "0.4ms"},
    "plugins":  {"ok": true,  "required": false,
                 "detail": "已加载 6,失败 0", "loaded": 6, "failed": 0}
  }
}

判定规则:

  • process:框架处于运行状态(start() 之后、stop() 之前);
  • startup:插件加载完成。用 startup.wait_ready: false 异步启动时,加载完成前这里就是 false;
  • database:执行一次 SELECT 1;写缓冲降级时仍算可用,但在 detail 里注明;
  • plugins:只做信息展示(required: false),加载失败数量在这里暴露出来,不影响就绪判定——框架本身仍可服务。

/metrics ​

Prometheus 文本格式(exposition v0.0.4),手写输出,不引入 prometheus_client。

指标类型说明
zcbot_upgauge恒为 1(进程能响应抓取)
zcbot_build_info{version,python,storage}gauge版本 / Python / 存储类型,值恒为 1
zcbot_uptime_secondsgauge运行时长
zcbot_readygauge就绪标记(与 /readyz 同一套判定)
zcbot_plugins_loaded / zcbot_plugins_failedgauge已加载 / 加载失败插件数
zcbot_db_upgauge数据库可读
zcbot_event_buffer_items{layer}gauge事件缓冲各层待处理条目(l1 / l2_pending / l3 / l4)
zcbot_event_buffer_bytes{layer}gauge各层占用字节
zcbot_event_buffer_dropped_totalcounter累计丢弃事件数
zcbot_ws_connectionsgauge已连接接入端数量
zcbot_outbound_sent_total / _failed_totalcounter出站发送成功 / 失败累计
zcbot_outbound_throttled_totalcounter出站因限流被拒累计
zcbot_outbound_retried_totalcounter出站重试累计次数
zcbot_outbound_waited_seconds_totalcounter出站因限流累计等待秒数
zcbot_event_loop_lag_secondsgauge最近一次采样的事件循环延迟
zcbot_event_loop_lag_max_secondsgauge运行期最大延迟(不含启动期)
zcbot_event_loop_blockedgauge当前是否处于阻塞(1=阻塞)
zcbot_event_loop_stalls_totalcounter阻塞次数(超阈值)
zcbot_event_loop_stall_seconds_totalcounter阻塞累计时长
zcbot_process_resident_memory_bytesgauge进程 RSS(缺 psutil 时不输出)
zcbot_process_threadsgauge线程数
zcbot_http_requests_total{method,status}counterHTTP 累计请求数(不含探针自身)

zcbot_http_requests_total 只按「方法 + 状态码」打标签,不带 URL——避免任意路径把时序库基数打爆。

事件循环卡顿看门狗 ​

asyncio 是单线程的:任意一处阻塞调用(requests.get 忘了 timeout、time.sleep、 大批量同步 IO、正则灾难性回溯)会让整个机器人停摆 —— 所有插件、所有接入端、 定时任务一起停。而此时日志里通常什么都没有:没异常、没报错,只有消息不回。

框架常驻一个守护线程测量「回调从投递到被事件循环执行」的延迟,超阈值就打日志, 并抓取事件循环线程的栈,直接指出卡在哪一行:

text
WARNING 事件循环阻塞 已持续 ≥512ms(阈值 500ms):整个机器人此时无法处理任何消息。阻塞位置:
  /opt/zcbot/plugins/weather/main.py:42 in fetch_weather  |  r = requests.get(url)
  /opt/zcbot/plugins/weather/main.py:31 in on_query  |  data = fetch_weather(city)
WARNING 事件循环恢复:本次阻塞约 884ms。若频繁出现,检查插件里的同步网络请求 / 大循环 / 阻塞 IO。

配置(framework.loop_watchdog):

键默认说明
enabledtrue常驻一个守护线程,开销可忽略
interval0.25采样间隔(秒)
threshold0.5延迟超过该值判定为阻塞(秒)
stall_wait3回调多久没执行判定为循环完全卡死(秒)

三条设计取舍需要知道:

  • 每次卡顿只在开始和恢复各打一条,不刷屏;
  • 启动期不告警:装依赖、建表、同步加载全部插件必然阻塞循环,那段报警会让 真卡顿被当成背景噪音。启动期延迟仍单独记账(startup_max_lag_ms);
  • 卡顿发生时抓栈,不是事后:等循环恢复再抓只能看到空闲栈,对定位毫无价值。

怎么用

偶发一次几百毫秒不用管(GC、日志刷盘都会造成)。同一个插件反复出现在阻塞栈里, 就是它在阻塞:把同步请求换成 asyncio.to_thread 或直接用带 timeout 的异步客户端。

跑非 asyncio 引擎(--iipc,DB 在 Go 侧)时 Python 循环压力小得多,但插件仍在线程里执行, 看门狗照常工作。

抓取配置 ​

同机抓取(未设令牌):

yaml
scrape_configs:
  - job_name: zcbot
    static_configs:
      - targets: ["127.0.0.1:8080"]

跨机抓取建议设置令牌:

yaml
observability:
  metrics_token: "换成一串随机字符"
yaml
scrape_configs:
  - job_name: zcbot
    authorization:
      type: Bearer
      credentials: "换成一串随机字符"
    static_configs:
      - targets: ["10.0.0.5:8080"]

也支持 ?token=xxx 查询参数,方便临时用 curl 排查:

bash
curl -s "http://127.0.0.1:8080/metrics?token=xxx" | head

端口隔离时的可见性 ​

开启 web.api_port(WebUI 与 API 分端口)后:

  • /healthz、/readyz:两个端口都可用(编排系统不知道你的端口划分);
  • /metrics:只在 API 端口给出真实指标;WebUI 端口按「不暴露真实 API」的规则交给诱饵。

纯 API 模式(web.ui_enabled: false)下三个端点都可用。

后台「运行状态」页 ​

不想 curl 的时候,后台 运行状态 页读的是同一份数据(GET /api/runtime/stats 新增 health、event_buffer、requests、db_degraded 四个字段,原有字段不变):

区块内容
健康检查与 /readyz 的 checks 完全同构:进程 / 启动 / 数据库 / 插件逐项状态与详情,右上角就绪标记
事件缓冲L1 内存 / L2 待刷 / L3 缓冲 / L4 落盘 四层的条目数与占用(KB),以及累计丢弃数
HTTP 请求分布按 方法 + 状态码 聚合的 Top 10 累计次数

页面每 5 秒轮询一次。三处(/readyz、/metrics、运行状态页)在代码里共用 framework/api/observability.py 的同一批函数,若发现展示口径不一致,那是 bug 而不是设计。

注意:该页面走正常后台鉴权。若 security.silent_auth: true 且开启了双请求防破解认证, 用脚本直接 curl 登录接口会被静默丢弃(444),这是主动防御而非故障。

结构化日志(JSON Lines) ​

默认输出是给人看的文本。要接 Loki / ELK / Vector 这类采集器,把 log.format 改成 json:

yaml
log:
  level: INFO
  format: json     # text(默认)/ json

改完即时生效,不必重启(log 段在热应用白名单里)。输出变成每行一个 JSON 对象:

json
{"ts":"2026-11-04T00:12:33.123+08:00","level":"INFO","logger":"zcbot","msg":"插件已加载: llm_core","pid":4231,"thread":"MainThread","src":"lifecycle.py:312","service":"zcbot"}
字段含义
ts本地时间,带时区偏移(RFC 3339)
levelDEBUG / INFO / WARNING / ERROR / CRITICAL
loggerlogger 名(框架统一为 zcbot,插件为插件名)
msg已格式化后的消息正文
pid / thread进程号 / 线程名(双进程部署时区分 host 与 core 用)
src文件名:行号
service固定 zcbot,多服务共用一套日志管道时便于过滤
fields可选,附加结构化字段(见下)
exc可选,异常堆栈全文

附加结构化字段(写入 fields,不污染 msg):

python
from framework import jsonlog

logger.info("用户登录失败: %s", uid, extra=jsonlog.fields(user=uid, ip=addr))

输出:{"msg":"用户登录失败: 1001", ..., "fields":{"user":1001,"ip":"1.2.3.4"}}

两点必须知道的边界 ​

  • 只换 formatter,不换 handler:文件轮转(10MB × 5 份)、级别过滤、重复错误合并去重全部照旧。
  • 后台日志中心不受影响:WebUI 的日志页读的是 LogRecord 原始消息,仍显示纯文本。桥接 handler 自带格式化,不会被套上 JSON formatter——否则那一页会变成一堆 JSON 字符串。

采集端不需要 multiline 配置,一行就是一条完整记录。

容器与 systemd 示例 ​

Docker:

yaml
healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).status==200 else 1)"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 40s

Kubernetes:

yaml
livenessProbe:
  httpGet: {path: /healthz, port: 8080}
  periodSeconds: 20
readinessProbe:
  httpGet: {path: /readyz, port: 8080}
  periodSeconds: 10
  failureThreshold: 3

systemd 只在就绪后标记服务可用:

ini
[Service]
ExecStartPost=/bin/sh -c 'until curl -sf http://127.0.0.1:8080/readyz >/dev/null; do sleep 1; done'

配置热应用 ​

PUT /api/config/yaml/<section> 写盘后会把新值应用到运行中的框架,并在响应的 msg / data 里说明哪些字段已经即时生效、哪些仍需重启:

json
{
  "code": 0,
  "msg": "配置段 [log] 已保存,日志级别即时生效: DEBUG",
  "data": {
    "section": "log",
    "live": true,
    "restart_required": false,
    "restart_reason": "",
    "notes": ["日志级别即时生效: DEBUG"]
  }
}
配置段是否即时生效说明
log即时日志级别直接改 root logger
framework.blackbox即时黑匣子的开关与内存上限
security.rate_limit即时重建限速器;honeypot / human_check 仍需重启
plugin.hot_reload即时心跳读取最新值;heartbeat_interval 仍需重启
database / web / onebot / ssl / event_queue / dual_process / iipc / console / observability需重启连接、监听端口、证书、队列在启动时创建
其它(插件自定义段等)需重启未注册即时应用能力的段一律保守判定

插件若想在配置变更时做自己的热应用,可注册 applier:

python
from framework import hotapply

def _apply(framework, section_cfg):
    # section_cfg 是合并后的完整配置段
    return ['我的字段已即时生效']      # 返回已生效的说明,会回显给前端

hotapply.register('my_plugin_section', _apply)

API 版本前缀 ​

所有真实 /api/* 端点同时挂在 /api/v1/* 下,行为完全一致:

bash
curl http://127.0.0.1:8080/api/version      # 旧路径,继续可用
curl http://127.0.0.1:8080/api/v1/version   # 版本化路径
  • 旧路径不会移除,插件与脚本无需改动;
  • 蜜罐兜底(/api/<path> 诱饵)不会被复制到 v1 下;
  • 未来若出现破坏性变更,将在 /api/v2/* 提供新语义,v1 保持旧语义。

基于 MIT + Apache 2.0 双协议发布