一个「告警太吵、飞书天天刷屏」的痛点,怎么长成了一套带 AI 分析、降噪去重、事务性转发和全链路可观测的告警中枢。这篇讲它是什么,也讲关键设计背后的取舍。
一、从痛点说起
监控体系搭起来之后,最先崩的往往不是系统,是人。Prometheus、Grafana、Alertmanager、云监控、飞书机器人……每个都能发告警,于是一线收到的是:同一个故障被十几条告警反复轰炸、半夜的抖动尖峰和真正的 P0 混在一起、飞书群一天几百条没人看得过来。
告警疲劳的本质是信噪比太低。真正需要的不是「更多告警」,而是一层中间大脑:接住所有来源的告警,判断哪些重要、哪些是重复、哪些是噪音,只把该看的、带着上下文的,精准推给该看的人。
WebhookWise 就是这层大脑。它不是一个「收到 webhook 转发出去」的简单中继,而是一个小型 AIOps 控制面。
二、整体架构
技术栈
- API / 接收层:FastAPI,只做鉴权、限流、入队、基础落库,快速返回。
- 异步队列:TaskIQ + Redis Stream(RedisStreamBroker),是「接收」和「处理」之间的解耦带,也是耐久性边界。
- Worker 管道:归一化 → 去重 → AI/规则分析 → 降噪 → 转发决策 → 事务性投递,全在 worker 里。
- Scheduler:定时任务(日/周/月报、数据归档维护),TaskIQ cron,带缺失补偿(missed-fire catch-up)。
- 存储:PostgreSQL(事件、决策追踪、outbox、深度分析)+ Redis(去重记账、限流、缓存、队列)。
- AI:OpenAI 兼容接口(instructor 做结构化输出)+ 可选 OpenClaw 深度分析(延迟任务轮询)。
- 可观测性:OpenTelemetry-first,OTLP 出口对接 Alloy / Prometheus / Tempo / Loki / Pyroscope。
组件拓扑
1 | ┌──────────────── OpenTelemetry (OTLP) ────────────────┐ |
一条告警的完整流程
一条 webhook 进来,会依次经过(括号是管道里对应的处理阶段):
- 接收(
parse_request):验签/鉴权、限流、按来源归一化成统一内部结构(各来源一个 adapter),入队,立刻返回200 OK。 - 去重(
resolve_dedup):算告警指纹(identity → alert_hash / dedup_key),时间窗内重复的收敛,只在状态翻转时继续。原子 Lua 记账 + Postgres advisory lock 串行化同指纹。 - 分析(
_run_fresh_analysis/ 降级到analyze_with_rules):优先 LLM 结构化判定(重要性/摘要/根因线索),熔断打开或失败时退回规则分析。 - 降噪(
compute_noise):识别相关告警组(同根因/级联),抑制从属告警。 - 转发决策(
decide_forwarding):结合重要性、匹配的转发规则、静默规则,决定转发还是跳过(带跳过原因码)。 - 落库 + 投递意图(
persist_and_schedule):事件落库、生成 outbox 记录(同事务),由独立 worker 消费 outbox 做实际投递(重试/退避/幂等)。 - 决策追踪(
record_decision_trace):把整条决策链落一条 trace(SAVEPOINT 隔离,写失败不阻断转发)。
慢的部分(AI、投递)都在入队之后异步做;快返回和耐久性由「入队成功」这条边界保证。
三、几个核心设计,以及为什么这么选
1. 接收即入队:把「耐久性边界」画在最前面
API 层只做四件事:鉴权、限流、入队、基础落库,然后立刻返回 200 OK。真正耗时的处理(归一化、AI 分析、转发)全部丢进 TaskIQ + Redis Stream 异步做。
取舍:上游(Prometheus 之类)的 webhook 发送通常有超时和重试,你在请求里同步跑 AI 分析(几百毫秒到数秒),上游会超时重发,造成重复、雪崩。所以耐久性边界画在「入队成功」——一旦进了队列就保证不丢,剩下的慢慢做。这也意味着入队之前的失败要对上游可见(返回非 2xx 让它重试),入队之后的失败由内部重试兜底。这条边界想清楚了,整个可靠性模型就清晰了。
2. AI 分析 + 规则分析的双轨,和「熔断降级」
每条告警会走一次分析:优先用 LLM 做结构化判断(重要性、摘要、根因线索),拿不到就退回确定性的规则分析(关键词/阈值)。
取舍:LLM 是整条链路里最慢、最不稳定的一环。它超时、限流、抽风,不能拖垮告警投递——告警系统自己挂了是最讽刺的事。所以 AI 调用外面套了个熔断器:连续失败到阈值就 OPEN,之后一段时间直接走规则分析、不再打 LLM,静默降级;等冷却窗口过了再半开试探。宁可某段时间「判得糙一点」,也不能因为模型挂了让告警发不出去。
一个诚实的边界:AI 判定目前没有「人工对不对」的 ground truth,所以我们不吹「准确率」,只暴露代理信号——有多少是新鲜 AI 判定(vs 命中缓存/复用/规则)、规则纠正 AI 重要性的比率(override rate)、降级率。可观测但不自欺。
3. 去重与降噪:把「同一件事」收敛成一条
同一个故障会以多种形态反复进来。去重基于告警指纹(identity → alert_hash / dedup_key),在一个时间窗内把重复的收敛,只在状态翻转时通知。降噪则更进一步,识别「相关的一组告警」(同根因、级联),抑制从属告警。
踩过的坑:去重是「读—改—写」,多 worker 并发下非原子就会重复计数、重复转发。解决办法是把去重记账做成 Redis 里的原子 Lua 脚本(单次往返、EVALSHA 缓存),再加 Postgres 事务级 advisory lock 让同一指纹的处理串行化。并发正确性这种事,测试要在真实 Postgres上验——advisory lock 在 SQLite 上是 no-op,单测过了不代表生产对。
4. 事务性 Outbox:把业务状态和外部副作用解耦
「事件已处理」是数据库里的事实,「飞书已收到」是一次可能失败的外部 HTTP。把这两件事塞进一个流程里,任何一步失败都会让状态不一致。
WebhookWise 用事务性 outbox:转发决策产生一条 outbox 记录(和事件落库同事务),投递由独立的 worker 消费 outbox 做,带重试、指数退避、幂等键。投递失败不影响「事件已处理」这个事实,只是 outbox 那条还没到终态。这样「为什么这条转发了但飞书没响」变成一个可查询、可重放的状态,而不是一次性丢失的副作用。
转发渠道(飞书 / OpenClaw / 通用 webhook)用策略注册表收敛,加新渠道不用改投递主流程——早期是一串 if/elif,重构成注册表之后干净多了。
5. 决策可追溯:每条告警「为什么转发/为什么跳过」
这是我自己最喜欢的一块。每条告警处理完,落一条 decision trace:一条有序的决策链(去重→分析→降噪→静默→规则匹配→转发),加上扁平化的结果字段(转发/跳过、跳过原因码、命中的规则、是否被静默、投递状态)。
取舍:这条 trace 写在事件落库的同一个事务里,但套在 SAVEPOINT 里——trace 写失败只回滚 trace,绝不阻断真正的转发决策。它是可观测记录,不是链路上的一道闸。扁平化那几个字段(结果/跳过码/路由)是为了大盘能便宜地 GROUP BY 聚合,而完整决策链塞进 JSONB 供逐条下钻。有了它,「这条为什么没发」从玄学变成一次查询。
6. 静默收益与「僵尸静默」
手动静默(snooze)是降噪的重要一环——运维知道某类告警在维护窗口内该闭嘴。但静默本身也需要被监控:一条静默规则在生命周期内到底挡掉了多少条告警?一条生效中却从没匹配过的静默,很可能是配错了、或早该删了(僵尸静默),甚至可能在悄悄吃掉本该报的告警。
所以静默页会显示每条规则的拦截数,把「0 拦截」的生效规则标红。这不是 nice-to-have——当大部分告警都被静默挡掉时,「静默会不会过度挡杀」是真实的风险。
四、可观测性:控制面自己也要被看见
一个降噪系统自己变成黑盒是很危险的。以下是 WebhookWise 在可观测性上的具体实践。
1. 为什么可观测性是第一等公民
当系统由 AI 来决定「这条告警该不该转发」时,最怕的一句话是:「它为什么没发?」——如果答不上来,On-Call 工程师迟早不信任它、绕过它。
所以 WebhookWise 的原则是:没有不留痕迹的拦截,也没有不明不白的故障。每一个决策点、每一次投递、每一段耗时,都要可追溯、可解释、可干预。
实现上它是 OTel-first、纯 OTLP 出口的:应用自己不暴露 /metrics 端点、不 tail 文件进 Loki、也不依赖 prometheus_client——所有指标、追踪、日志都通过 OpenTelemetry 走 OTLP 出去,由采集器分发。三个角色(API、Worker、Scheduler)共享同一套 resource(service.name/namespace/version、deployment.environment),schema 版本钉死在 1.41.0,语义规范升级要走显式的 schema 迁移。
2. 遥测管道:从应用到大盘
1 | API / Worker / Scheduler ──OTLP(gRPC/HTTP)──▶ Alloy ──┬─▶ Prometheus (指标, 带 exemplar) |
各组件的角色:
- Alloy:中心采集器,OTLP gRPC :4317 / HTTP :4318 收流,按信号分发到各后端;把 resource 属性摊平成 Prometheus 安全的标签;开启 exemplar 转发(这是「延迟采样点 → 一键跳 trace」的基础)。
- Prometheus:指标库,开了 native-histograms 和 exemplar-storage。
- Tempo:追踪库,还跑 metrics-generator 生成 service graph 和 span metrics 回写 Prometheus。
- Loki:结构化日志。
- Pyroscope:持续剖析。这是唯一不走 OTLP 的一条路——用 Pyroscope SDK 直推(Python 的 profile 导出目前经 Pyroscope 比经 OTel profiles 更成熟)。
- Beyla:eBPF 自动埋点,零改代码地给 API 容器补上 HTTP/SQL/Redis 的 span 和指标。
- Grafana:面板 + 数据源,全部 provision 化。
一个很实用的效果:Grafana 里数据源做了互相关联——Prometheus 的 exemplar 指向 Tempo,Tempo 的 trace 能跳到 Loki 日志(filterByTraceID)和 Pyroscope 火焰图,Loki 的 derivedFields 又能从日志里的 trace_id 跳回 Tempo。指标尖峰 → 具体 trace → 那条日志 → 那段 CPU 火焰图,一路点下去,不用手动拼查询。
3. 异步链路里的一条连贯 trace
WebhookWise 是「接收即入队、异步处理」的,最容易断链的就是 trace:请求在 FastAPI 边界进来,丢进 Redis Stream,由 TaskIQ worker 另一个进程消费。
做法是手动透传 W3C traceparent:入队时 inject_trace_headers() 把当前上下文注入任务头(外加一个业务 X-Request-Id),worker 侧 trace_context_from_headers() 再把它接回来。于是在 Tempo 里看到的是一条连贯的调用链:webhook.parse → dedup → analyze → noise(管道各阶段的 span 名)→ worker.webhook_process_task → GenAI 的 chat span → 转发/outbox span,跨进程也不断。
管道每个阶段的 span 和它的指标是同一处代码发出来的(一个 _instrument_step 包装器同时 set span 属性 + 记 webhook.pipeline.step.duration),所以 span 和 metric 永远对得上、不会漂。LLM 调用的 span 挂的是 OTel GenAI 语义属性(gen_ai.system/request.model/usage.input_tokens…),但prompt 和 completion 正文刻意不进遥测。
4. 结构化日志 + 日志↔追踪关联
日志是结构化 JSON(OTel 日志数据模型:severity_text/severity_number/body/资源属性/exception.*),走 QueueHandler 异步写、不占热路径。每条日志由一个过滤器自动注入当前 span 的 trace_id / span_id / trace_flags(没有 span 时用一个合成 id 兜底,这样 OTel 关掉时日志仍能按请求串起来)。
一个刻意的设计:高基数的东西不进 Loki 标签。trace_id/span_id/request_id/webhook_event_id/url 这些都留在日志正文里(靠 Grafana derived fields 查),Loki 标签只留一小撮低基数的(severity/event.name/signal.name/webhook.source/…)。高基数标签是拖垮 Loki 的头号原因,这条被写进了契约检查(见第六节)。
5. 六类信号:不止指标/追踪/日志
除了三大件,WebhookWise 还显式建模了事件(events)、信号(signals)、剖析(profiles):
- 事件
emit_event(name, ...):一次性的业务里程碑,同时三路发出——给当前 span 加一个 span event、写一条结构化日志、并把observability.events{event.name}计数 +1。比如webhook.analysis.completed、webhook.storm.suppressed。 - 信号
record_signal(name, state, ...):状态迁移,低基数。比如record_signal("circuit_breaker", "open", ...)、record_signal("webhook.task", "completed"/"error"/"suppressed")。 - 剖析:Pyroscope 持续采样,开了 span-profile 关联,能从一条慢 trace 直接看到当时的 CPU 火焰图。
这样「一件事发生了」既能在指标上聚合(多少次)、又能在某条 trace 上定位(这一条)、还能在日志里看到上下文——三个视角同源。
6. 杀手锏:离线契约保驾护航
这是我认为 WebhookWise 可观测性最值得说的一点,也是它区别于「随便挂个 OTel」的地方。
有一条铁律:不允许存在没有消费方的指标——每个 metric 都必须被某个 dashboard 面板、告警规则、SLO 或 preset 查询消费,否则它就不该存在。指标的价值在于被用,不在于数量。
这条铁律不是靠自觉,是靠一个离线契约工具在 CI 里强制的(webhookwise_observe.py contract,不需要连任何后端):
- 它 AST 解析
metrics.py,抽出每一个Counter/Gauge/Histogram(name, ..., unit=...)定义,按 OTel→Prometheus 规则展开成实际的 series 名(Counter→_total、Histogram→_bucket/_count/_sum、Gauge→_ratio等,还带单位后缀s→_seconds、By→_bytes); - 再扫出所有被两张 Grafana dashboard、
alerts.yml的录制/告警规则、以及 preset 目录引用的指标; - 定义了但没被任何一方引用的指标 → CI 直接挂。反向也查:dashboard/告警引用了一个没定义的指标,同样挂。
除了指标消费闭环,同一个契约还跑另外几项(都离线):
- loki 标签基数守卫:Alloy 的 Loki 标签里出现
trace_id/webhook_event_id/url这类高基数键就失败; - 禁敏感标签:Loki 标签里出现
token/secret/password/authorization/cookie/prompt就失败; - 结构化日志强制:全仓不许出现裸
extra={,必须走统一的log_extra()helper; - schema 版本一致性:
1.41.0要在 env/compose/docs 里保持一致; - 无陈旧遥测名 / PromQL 语法平衡 等。
效果:可观测性配置和代码一起被 CI 管住了。加一个指标却忘了上大盘?挂。给 Loki 加了个高基数标签?挂。日志里手滑写了个敏感字段当标签?挂。可观测性不再是「上线后慢慢补」的二等公民。
7. 告警与自我监控
大盘之外,Prometheus 侧有成套的多窗口 SLO 燃尽率告警(API 可用性、ingress、处理、转发投递各有 fast-burn / slow-burn),AI 侧有降级率、错误、p95 延迟告警,基础设施侧有队列积压、死信、DB 连接池近满、Redis 不可用、熔断器打开、outbox 积压过久等。每条告警都带 runbook 注解,很多直接指向 webhookwise_observe.py runbook <AlertName> 这个命令,把「收到告警→该查哪些 PromQL/Loki/Tempo」固化下来。
一个有意思的闭环:Alertmanager 触发的告警,会回推给 WebhookWise 自己的 /v1/webhook/alertmanager——它吃自己的狗粮,用自己这套告警中枢去处理自己的告警。
8. 想要自己跑一遍
这套可观测性栈有一个可本地拉起的学习实验室(一个独立的 docker compose 项目:Alloy + Prometheus + Tempo + Loki + Pyroscope + Beyla + Grafana + Alertmanager + k6),带分步教程(指标 / 日志与追踪 / 剖析 / RUM 与压测):
https://github.com/itswl/WebhookWise/blob/main/docs/operations/observability/local-lab/README.md
9. 观测性小结
WebhookWise 的可观测性不是「挂个 OTel 收 CPU 内存」,而是:
- 纯 OTLP 出口 + Alloy 分发,六类信号(指标/追踪/日志/事件/信号/剖析)同源;
- 异步链路一条连贯 trace,指标↔追踪↔日志↔剖析 一键互跳;
- 高基数守卫 + 敏感字段禁入,让 Loki 不被拖垮、日志不泄密;
- 一个离线契约把「没人看的指标」挡在 CI 门外,让可观测性和代码一起被管住。
一个聪明的告警大脑,还得配一套坚固、透明的神经系统——每个动作都可被追溯、可被解释、可被干预。
五、诚实的部分:一次回退
不是所有设计都留下了。曾经做过一版「按严重度分双队列 + 优先 worker」,想让 P0 告警插队。技术上跑通了、也部署验证了,但最后回退了。原因:这套系统的转发决策依赖 AI 判定的重要性,而 AI 又恰是最慢的一步——真正的「零等待优先级」得把转发语义改成「先按规则快转、AI 再异步增强」,那会引入「规则转发 vs AI 转发」的去重问题,风险和复杂度不划算。
留着的是 batch 1-3(AI 熔断、备份/恢复、渠道注册表),砍掉的是队列分裂。能砍掉自己刚写的东西,也是设计的一部分——尤其当收益撑不起它带来的复杂度时。
六、小结
WebhookWise 想做的其实很朴素:把噪音挡在人和告警之间。
- 接收即入队,把耐久性边界画在最前;
- AI + 规则双轨,熔断降级保证「模型挂了告警也不停」;
- 原子去重 + 降噪,把同一件事收敛成一条;
- 事务性 outbox,把「已处理」和「已送达」解耦、可重放;
- 决策可追溯,每条告警的命运都能查;
- 静默收益 + 可观测自律,让降噪系统自己不变黑盒。
一个 AIOps 控制面不需要很大,但每个决策点都得想清楚取舍——包括什么时候该把已经写好的东西删掉。