Skip to content

Commit b907d92

Browse files
committed
fix(tasks,alerts,logs): 补渠道死亡与签到失败告警、活跃上报日志类名、zen 429 降噪
日志扫描(2026-11-09)暴露的三个盲区: 1. activity 失败日志无原因:裸 `%s` 打 httpx.ConnectError / TimeoutError 时 str 为空,只剩「activity report failed for cred_x: 」。改走 error_text, 与 checkin / quota_probe 统一。 2. 两类故障全程无告警(alert_events 最新一条是 11-06): - officeace 三条凭证积分耗尽整批回 401,被 fallback 稀释, error_rate 阈值打不中 → 新增 `channel_dead`(窗口内零成功即报, 默认 20 次/60 分钟)。 - raccoon 三号连续 5 天签到失败(任务「跑成功」、上游回业务失败), 任务级连续失败计数看不见 → 新增 `checkin_failing`(同一凭证 连续 N 轮失败,成功一次清零;默认 3 轮/24 小时窗)。 两条规则的阈值都可热更,设置页归「运维告警 → 渠道与签到」。 3. zen 探活 429 刷屏:8 个免费模型每 30 分钟一轮,一天 224 条 WARNING 占全部 WARNING 的 73%。429 是匿名额度耗尽的已知稳态(模型仍保留), 降为 DEBUG;5xx / 连接失败仍是 WARNING。 另: - 新增 `CHECKIN_SKIP_PROVIDERS`(热更):上游长期停发时只停点名渠道的 签到,不影响聊天。与「暂停凭证」(enabled=0,连聊天一起停)不同。 - launchd-server.sh 启动自检:发现残留的 /etc/newsyslog.d/coding2api.conf 就打警告并给出清理命令。该规则只做 rename 轮转,launchd 的 fd 仍指向 旧 inode → 本机实测丢过 19h 日志(11-07 15:30 ~ 11-08 10:29 全空白)。 仓库侧已删并有测试守着,但 /etc 下的副本仓库管不到。 测试:新增 8 个告警用例、2 个签到跳过用例、2 个仓库/统计查询用例、 1 个 zen 日志级别用例、1 个 launchd 自检用例;覆盖率 100%。
1 parent 0b7dbe8 commit b907d92

22 files changed

Lines changed: 557 additions & 8 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 14 张表,`SCHEMA_
206206
| `runtime_settings` | 运行时配置覆盖(纯 key/value),白名单在 `runtime_settings.HOT_SETTINGS` |
207207
| `users` | 账号(PBKDF2 + `role` + `enabled` + `must_change_password` + `session_epoch` + 激活令牌摘要) |
208208
| `audit_events` | 审计流水 |
209-
| `alert_events` | 告警事件(七类规则;`resolved_at` 非空 = 迟滞确认恢复) |
209+
| `alert_events` | 告警事件(九类规则;`resolved_at` 非空 = 迟滞确认恢复) |
210210

211211
- 调度状态与到期阶梯**落库**,重启不丢;签到**去重**、模型列表缓存、任务运行态**进程内**(重启归零,不编造记录)——去重是运行态,与 `checkin_events` 的审计日志是两回事。
212212
- 模型目录落盘 `DATA_DIR/model_catalog.json`,OpenRouter 目录落盘 `data/openrouter_catalog.json`。
@@ -225,7 +225,7 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 14 张表,`SCHEMA_
225225
- 批量导入:`POST /api/credentials/batch-import`(`items[]`,≤100 条)。逐条校验,响应 `{created[], skipped[], failed[]}` 均带 `index`;同渠道内容完全一致去重;逐条审计 + 入队探测。
226226
- 备份:`GET/POST /api/backups`、`GET/DELETE /api/backups/{name}`、`POST /api/backups/restore`(原始字节,≤512MB)。恢复用「校验 → 暂存 → 下次启动应用」而不是运行中换库;启动应用失败只留档、不阻断(`db/backup.apply_pending_restore`)。
227227
- 配置与任务:`GET|PUT /api/settings` + `GET /api/tasks` + `POST /api/tasks/{key}/run`。列表端点统一 `sort` + `order`;未知键回落默认。回调:`GET /authorize`。
228-
- 告警:`GET /api/alerts` + `POST /api/alerts/{id}/resolve`。`AlertTask` 启动时从库里未恢复的行重建集合。规则七类:池耗尽、任务连续失败、token 到期、上游错误率、单 Key 用量暴涨、非工作时段请求、**预算分层(50/80/95/100%)**(后三类默认阈值 0 = 关闭;不存 IP,故不做「多 IP 同 Key」)。
228+
- 告警:`GET /api/alerts` + `POST /api/alerts/{id}/resolve`。`AlertTask` 启动时从库里未恢复的行重建集合。规则九类:池耗尽、任务连续失败、token 到期、上游错误率、单 Key 用量暴涨、非工作时段请求、**预算分层(50/80/95/100%)**、**渠道零成功(`channel_dead`:窗口内有流量但一次都没成功)**、**签到连续失败(`checkin_failing`:同一凭证连续 N 轮业务失败,「任务跑成功但上游回失败」任务级计数看不见)**(单 Key / 非工作时段 / 预算三类默认阈值 0 = 关闭;渠道零成功与签到连续失败默认开启;不存 IP,故不做「多 IP 同 Key」)。
229229
- 上游状态:`GET /api/upstream-status`(operator)返回内存快照 + 任务运行态;**不落盘**。四种解析器归一化到六档,加源只在 `SOURCE_SPECS` 加一行。
230230
- 统计:`GET /api/stats/{overview,by-provider,by-model,by-user,by-credential,timeline,model-timeline,percentile-timeline}`(延迟分位 P50/P90/P95/P99 可切,默认 P95;输出速度 `tps_visible` / `tps` / `tps_estimated`)、`GET /api/stats/errors`(失败归因,`limit` ≤ 5000;硬失败 DEAD/RESTRICTED/INVALID/CAPABILITY 每次尝试一行,限流/额度/超时/上下文等其余分类每次请求一行且类型取最后一次错误的真实归因,`rescued` 为同用户同 `request_id` 另有成功行的次数,`failures − rescued` 是未被救回的失败尝试数)、`GET /api/stats/events`(游标/OFFSET 双模式,钻取过滤,`ok=0`,`?format=csv`)、`GET /api/stats/events/stream`(SSE,订阅者有界缓冲 200、并发上限 8)。
231231
- `timeline` / `model-timeline` 的 `metric` 取值:`requests` / `tokens` / `cost` / `latency` / `ttfb` / `success_rate` / `cache_rate` / `cost_per_request`;**取值域外的拼写显式 400**(不静默回落到 `requests`——否则客户端拼错参数会拿到一份「看起来正常」的请求数图,错误被完全吞掉)。`percentile-timeline` 只认 `latency` / `ttfb`。派生指标(成功率、缓存命中率、单请求成本)在**聚合后相除**(`sum/sum`),不是逐小时比率的平均;无样本给 `null` 而非 0。

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
| ![上游状态](assets/screenshots/upstream.webp) | ![API Key](assets/screenshots/apikeys.webp) |
4545
| **用户管理:三角色与一次性激活** | **审计日志:登录成败都留痕** |
4646
| ![用户管理](assets/screenshots/users.webp) | ![审计日志](assets/screenshots/audit.webp) |
47-
| **服务端日志:环形缓冲 + 脱敏** | **运维告警:七类风险周期评估** |
47+
| **服务端日志:环形缓冲 + 脱敏** | **运维告警:九类风险周期评估** |
4848
| ![服务端日志](assets/screenshots/logs.webp) | ![运维告警](assets/screenshots/alerts.webp) |
4949

5050
**任务与配置:77 项可热更,DB 覆盖值优先于 `.env`**
@@ -604,7 +604,7 @@ tabs 切四维(渠道 / 模型 / 用户 / 凭证),共用一表(渠道 /
604604
605605
### 运维告警
606606

607-
后台周期评估七类风险 → `alert_events`(「运维告警」页,admin-only):
607+
后台周期评估九类风险 → `alert_events`(「运维告警」页,admin-only):
608608

609609
| 规则 | 触发条件 |
610610
|---|---|
@@ -615,6 +615,8 @@ tabs 切四维(渠道 / 模型 / 用户 / 凭证),共用一表(渠道 /
615615
| `key_burst` | 单 Key 窗口内 token 用量达到 `ALERT_KEY_BURST_TOKENS`(滥用 / 泄露信号,不是错误) |
616616
| `off_hours` | 工作时间 `[ALERT_OFF_HOURS_START, ALERT_OFF_HOURS_END)` 之外请求数达到 `ALERT_OFF_HOURS_MIN_REQUESTS` |
617617
| `budget` | 当月成本达 `ALERT_BUDGET_CNY` 的某一 `ALERT_BUDGET_LEVELS` 档,只报最高档;100% 为 critical |
618+
| `channel_dead` | 统计窗内某渠道请求数达到 `ALERT_CHANNEL_DEAD_MIN_REQUESTS` 且**一次都没成功**(抓「渠道整体挂了」:积分耗尽整批 401 会被 fallback 稀释掉错误率) |
619+
| `checkin_failing` | 同一凭证最近连续 `ALERT_CHECKIN_FAIL_MIN_STREAK` 轮签到都失败(中间成功一次即清零;补「任务跑成功、上游业务返回失败」的盲区) |
618620

619621
- **静默窗**:同一 `(规则, 对象)` 窗口内只落库 / 推送一次。
620622
- **迟滞**:连续命中 N 轮才告警;连续未命中 M 轮才确认恢复。恢复时给该 `(规则, 对象)` 全部未恢复行写 `resolved_at`,并外推 `status="resolved"` webhook。
@@ -681,6 +683,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
681683
| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报本地整点窗口 |
682684
| `CHECKIN_TIMES` | `""` | 签到时点 `HH:MM` 逗号分隔;留空 = 全天每 10 分钟查。可热更 |
683685
| `CHECKIN_JITTER_MINUTES` | `0` | 签到抖动(分钟),时点前随机提前。可热更 |
686+
| `CHECKIN_SKIP_PROVIDERS` | `""` | 跳过签到的渠道(逗号分隔,如 `raccoon`):只停签到,聊天不受影响。可热更 |
684687
| `LOG_BUFFER_LINES` | `500` | 服务端日志页缓冲行数;`0` 关该页。可热更 |
685688
| `ALERT_ENABLED` | `true` | 运维告警开关 |
686689
| `ALERT_WEBHOOK_URL` | 空 | 通用 Webhook,逗号分隔(≤16,仅 http(s),拒内网 / 回环防 SSRF),带 `X-Coding2API-Signature` |
@@ -698,6 +701,10 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
698701
| `ALERT_ERROR_RATE_WINDOW_MINUTES` | `15` | 错误率统计窗 |
699702
| `ALERT_KEY_BURST_TOKENS` | `0` | 单 Key 用量暴涨阈值(token);`0` 关闭 |
700703
| `ALERT_KEY_BURST_WINDOW_MINUTES` | `15` | 单 Key 用量统计窗;`0` 关闭 |
704+
| `ALERT_CHANNEL_DEAD_MIN_REQUESTS` | `20` | 渠道零成功告警的最小样本(窗口内请求数);`0` 关闭 |
705+
| `ALERT_CHANNEL_DEAD_WINDOW_MINUTES` | `60` | 渠道零成功统计窗(分钟) |
706+
| `ALERT_CHECKIN_FAIL_MIN_STREAK` | `3` | 签到连续失败轮数阈值;`0` 关闭 |
707+
| `ALERT_CHECKIN_FAIL_WINDOW_HOURS` | `24` | 签到失败回看窗(小时) |
701708
| `ALERT_OFF_HOURS_START` / `_END` | `0` / `0` | 工作时间 `[start, end)`(本地时区,可跨零点);相等 = 关闭 |
702709
| `ALERT_OFF_HOURS_MIN_REQUESTS` | `0` | 非工作时段请求数阈值;`0` 关闭 |
703710
| `ALERT_BUDGET_CNY` | `0` | 当月成本预算(元);`0` 关闭。仅计可定价明细 |

‎docker-compose.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ services:
7676
# 预算分层告警:当月原始成本预算(元);0 关闭。分档默认 50,80,95,100
7777
ALERT_BUDGET_CNY: ${ALERT_BUDGET_CNY:-0}
7878
ALERT_BUDGET_LEVELS: ${ALERT_BUDGET_LEVELS:-50,80,95,100}
79+
# 渠道零成功告警:窗口内请求数达最小样本且一次都没成功才报;0 关闭
80+
ALERT_CHANNEL_DEAD_MIN_REQUESTS: ${ALERT_CHANNEL_DEAD_MIN_REQUESTS:-20}
81+
ALERT_CHANNEL_DEAD_WINDOW_MINUTES: ${ALERT_CHANNEL_DEAD_WINDOW_MINUTES:-60}
82+
# 签到连续失败告警:同一凭证连续 N 轮失败(成功一次清零);0 关闭
83+
ALERT_CHECKIN_FAIL_MIN_STREAK: ${ALERT_CHECKIN_FAIL_MIN_STREAK:-3}
84+
ALERT_CHECKIN_FAIL_WINDOW_HOURS: ${ALERT_CHECKIN_FAIL_WINDOW_HOURS:-24}
7985
# SQLite 备份后台任务:周期(分钟)与保留份数;0 份表示不清理
8086
BACKUP_INTERVAL_MINUTES: ${BACKUP_INTERVAL_MINUTES:-1440}
8187
BACKUP_KEEP: ${BACKUP_KEEP:-7}
@@ -207,6 +213,8 @@ services:
207213
# 签到定时:本地 HH:MM 逗号分隔,留空 = 全天每 10 分钟(旧行为);抖动分钟
208214
CHECKIN_TIMES: ${CHECKIN_TIMES:-}
209215
CHECKIN_JITTER_MINUTES: ${CHECKIN_JITTER_MINUTES:-0}
216+
# 跳过签到的渠道(逗号分隔,如 raccoon):只停签到,聊天不受影响
217+
CHECKIN_SKIP_PROVIDERS: ${CHECKIN_SKIP_PROVIDERS:-}
210218
# 管理台日志页环形缓冲行数(0 关闭该页)
211219
LOG_BUFFER_LINES: ${LOG_BUFFER_LINES:-500}
212220
# 出站剥离思考内容(只过滤响应、不改请求体);true 开启

‎scripts/launchd-server.sh‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ set -uo pipefail
1919
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
2020
cd "$ROOT"
2121

22+
# 残留的 newsyslog 规则会静默吞日志:它只做 rename,launchd 给进程的 fd 仍
23+
# 指向旧 inode → 轮转后到下次重启之间的日志全部丢失(本机实测丢过 19h,见
24+
# scripts/rotate-logs.sh 顶部)。仓库侧已删干净并有测试守着,但 /etc 下的
25+
# 副本属于本机状态、仓库管不到,只能在启动时吼一声,否则又要靠翻日志才发现。
26+
NEWSYSLOG_RULE="/etc/newsyslog.d/coding2api.conf"
27+
if [[ -f "$NEWSYSLOG_RULE" ]]; then
28+
echo "launchd-server: 警告:检测到残留的 newsyslog 规则 $NEWSYSLOG_RULE" >&2
29+
echo "launchd-server: 警告:它会 rename 轮转导致日志丢失,请执行:" >&2
30+
echo "launchd-server: sudo rm $NEWSYSLOG_RULE && sudo launchctl kickstart -k system/com.apple.newsyslog" >&2
31+
fi
32+
2233
echo "launchd-server: 构建前端(失败不阻断启动)…"
2334
if ! "$ROOT/scripts/build-web.sh"; then
2435
echo "launchd-server: 警告:前端构建失败,将使用 web/dist 里的既有产物启动" >&2

‎src/config.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ class Settings(BaseSettings):
185185
# 一堆部署在同一分钟齐刷刷打上游。按「日期 + 时点」记去重键,抖动只改
186186
# 执行时刻、不改归属,不会让同一时点跑两次。
187187
checkin_jitter_minutes: int = 0
188+
# 跳过签到的渠道(逗号分隔,如 `raccoon,lingxi`)。上游长期停发时用它止损:
189+
# 签到任务照常跑、其它渠道不受影响,只把点名的渠道从签到里摘出去。
190+
# 与「暂停凭证」不同——`enabled=0` 会连聊天一起停掉,而这里只停签到。
191+
checkin_skip_providers: str = ""
188192
# 服务端日志页(「任务与配置」→ 日志):进程内环形缓冲的保留行数,
189193
# 0 = 关闭该页(端点返回 404)。只收本进程 logging 记录,不读文件。
190194
log_buffer_lines: int = 500
@@ -228,6 +232,15 @@ class Settings(BaseSettings):
228232
# 0 关闭该规则;成本只覆盖可定价明细(未匹配价表的不计入),detail 里给覆盖度。
229233
alert_budget_cny: float = 0.0
230234
alert_budget_levels: str = "50,80,95,100"
235+
# 渠道死亡告警:统计窗内该渠道有流量但**一次都没成功**。0 关闭该规则。
236+
# 与错误率互补:officeace 积分耗尽整批回 401,会被 fallback 稀释掉。
237+
# 默认开启:20 次请求全失败已足够说明渠道挂了,再少容易误报。
238+
alert_channel_dead_min_requests: int = 20
239+
alert_channel_dead_window_minutes: int = 60
240+
# 签到连续失败告警:同一凭证最近连续 N 轮签到都失败(成功一次即清零)。
241+
# 0 关闭。补 task_failed 看不见的「任务跑成功、上游业务返回失败」。
242+
alert_checkin_fail_min_streak: int = 3
243+
alert_checkin_fail_window_hours: int = 24
231244
# SQLite 备份后台任务:周期性把主库一致性快照(VACUUM INTO)写进
232245
# DATA_DIR/backups/,保留最近 BACKUP_KEEP 份。默认每日一次、留 7 份。
233246
# 备份周期取整日是因为库本身不大(几十 MB 量级),更密只是白占磁盘。

‎src/db/repo.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,42 @@ def recent(self, credential_id: str, limit: int = 20) -> list[dict[str, Any]]:
11541154
(credential_id, max(1, min(200, limit)))).fetchall()
11551155
return [dict(row) for row in rows]
11561156

1157+
def consecutive_failures(self, *, min_streak: int,
1158+
since: int) -> list[dict[str, Any]]:
1159+
"""近窗口内**连续失败**达到阈值的凭证,供签到失败告警。
1160+
1161+
只数**最近一段**连续 `ok=0`:中间只要成功过一次就清零(第二次成功
1162+
即修复,不该继续挂着告警)。`since` 之外的行不参与,避免长期失败
1163+
凭证在保留期边缘反复进出。返回 `[{credential_id, streak, last_ts,
1164+
message}]`,按失败次数降序(最严重的排前面)。
1165+
"""
1166+
if min_streak <= 0:
1167+
return []
1168+
rows = self._db.connect().execute(
1169+
"SELECT credential_id, ts, ok, message FROM checkin_events "
1170+
"WHERE ts >= ? ORDER BY credential_id, ts DESC", (since,)).fetchall()
1171+
streaks: dict[str, dict[str, Any]] = {}
1172+
for row in rows:
1173+
credential_id = row["credential_id"]
1174+
state = streaks.get(credential_id)
1175+
if state is not None and state.get("done"):
1176+
continue # 该凭证已遇到成功,后面更早的行无关
1177+
if row["ok"]:
1178+
streaks[credential_id] = {"done": True}
1179+
continue
1180+
if state is None:
1181+
state = streaks[credential_id] = {
1182+
"streak": 0, "last_ts": int(row["ts"]),
1183+
"message": row["message"] or ""}
1184+
state["streak"] += 1
1185+
result = [
1186+
{"credential_id": credential_id, "streak": state["streak"],
1187+
"last_ts": state["last_ts"], "message": state["message"]}
1188+
for credential_id, state in streaks.items()
1189+
if not state.get("done") and state["streak"] >= min_streak
1190+
]
1191+
return sorted(result, key=lambda item: item["streak"], reverse=True)
1192+
11571193

11581194
class CreditEventRepository:
11591195
"""积分变动流水(credit_events):只读查询,写入在 save_quota 事务内完成。

‎src/provider/zen/client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,13 @@ async def _probe_one(self, model_id: str) -> bool | None:
543543
return True
544544
if status in PROBE_DEAD_STATUSES:
545545
return False
546+
# 429 = 匿名免费额度耗尽。它是**已知的稳态**(同一批免费模型
547+
# 会持续回 429,每轮 8 条),打 WARNING 只会把真问题挤出视野
548+
# ——实测 2026-11-08 一天 224 条,占全部 WARNING 的 73%。
549+
# 降级为 DEBUG:还保留「哪个模型被限流」的排障线索。
550+
if status == 429:
551+
logger.debug("zen 探活 %s 暂不可判定:HTTP 429", model_id)
552+
return None
546553
logger.warning("zen 探活 %s 暂不可判定:HTTP %s", model_id, status)
547554
return None
548555
except (httpx.HTTPError, TimeoutError):

0 commit comments

Comments
 (0)