Skip to content

Commit 4950113

Browse files
feat: 目标级节奏/敏感度分档、纯观测模式、飞书签名校验、kinds 分群路由、告警来源与自定义字段
- pace: fast(15s/30包)|normal|slow,显式 interval_sec 最优先 - sensitivity: strict|normal|relaxed 三档阈值缩放;alerts:false 只看不叫 - webhook: secret 签名(HmacSHA256)、kinds 消息白名单 - 告警卡片元信息区:来源实例 · 目标地址 · 开始时间 · 持续时长 · extra 自定义字段 - 新增单元测试(签名/分档/元信息/节奏优先级)
1 parent cf76ee9 commit 4950113

9 files changed

Lines changed: 345 additions & 77 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ setcap cap_net_raw+ep ./pingping
6060

6161
冷却 30 分钟,持续正常 15 分钟发恢复通知。阈值都可配,默认值即最佳实践。
6262

63+
## 按目标分档
64+
65+
不同链路值得不同的对待,每个目标可选三个旋钮:
66+
67+
| 旋钮 | 档位 | 含义 |
68+
|---|---|---|
69+
| `pace` | `fast` / `normal` / `slow` | 15s(每轮 30 包)/ 60s / 300s;显式 `interval_sec` 优先级最高 |
70+
| `sensitivity` | `strict` / `normal` / `relaxed` | 早叫(核心链路)/ 默认 / 少叫(天生就抖的公网链路) |
71+
| `alerts` | `false` | 纯观测:烟雾图和突发标记照常,永远不推告警 |
72+
73+
目标还可以带 `extra` 自定义字段(如机房、负责人、runbook 链接),与全局 `extra` 合并后渲染进告警卡片 —— 收到告警的人不用再查这条链路归谁管。
74+
75+
## 消息路由与安全
76+
77+
每个 webhook 可配 `kinds` 白名单(`alert` `recovery` `heartbeat` `daily` `manual`),告警进运维群、日报进领导群互不打扰;开启了飞书"签名校验"的机器人填 `secret` 即可,签名自动计算。若机器人使用"自定义关键词"模式,关键词填 `pingping`(所有卡片标题均含)。告警卡片自带来源实例、目标地址、开始时间与持续时长。
78+
6379
## 数据即文本
6480

6581
```

config.example.jsonc

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,24 @@
55
"listen": "0.0.0.0:8517", // Web 监听地址
66
"data_dir": "./data", // 数据目录(JSONL 文本文件,可 grep 可 tar)
77
"web_base_url": "", // 飞书卡片按钮的链接,如 http://10.0.0.5:8517,留空则卡片不带按钮
8+
"instance": "", // 告警来源标识,多实例部署时区分是谁在叫;不填默认取主机名
9+
"extra": { "环境": "生产" }, // 全局自定义字段,渲染进所有告警/恢复卡片
810

911
// 探测目标。type: icmp(默认)| tcp
12+
// 节奏三档 pace: fast(15s/每轮30包) normal(60s) slow(300s);显式 interval_sec 优先级最高
13+
// 敏感度三档 sensitivity: strict(核心链路,早叫) normal relaxed(天生就抖的链路,少叫)
1014
"targets": [
11-
{ "name": "广州电信", "type": "icmp", "host": "202.96.128.86" },
12-
{ "name": "上海联通", "type": "icmp", "host": "210.22.70.3" },
13-
{ "name": "API 网关", "type": "tcp", "host": "10.0.0.5", "port": 443 }
15+
{ "name": "跨境专线", "type": "icmp", "host": "59.43.247.1",
16+
"pace": "fast", "sensitivity": "strict",
17+
"extra": { "机房": "GZ1", "负责人": "张三" } },
18+
{ "name": "一般业务", "type": "icmp", "host": "202.96.128.86" },
19+
{ "name": "留档观察", "type": "icmp", "host": "210.22.70.3",
20+
"pace": "slow", "alerts": false },
21+
{ "name": "API 网关", "type": "tcp", "host": "10.0.0.5", "port": 443,
22+
"interval_sec": 30 }
1423
],
1524

16-
// 探测参数。默认值 = 每分钟一轮,每轮 20 包 —— 烟雾图的"烟"来自这 20 个样本
25+
// 全局探测默认值(pace 未命中时生效)
1726
"probe": {
1827
"interval_sec": 60,
1928
"packets": 20,
@@ -22,12 +31,16 @@
2231
},
2332

2433
// 飞书自定义机器人 webhook(群设置 → 群机器人 → 自定义机器人)
25-
// 四类消息都推到这里:告警(红)/ 恢复(绿)/ 周心跳(灰)/ 报告(蓝)
34+
// secret: 机器人开启"签名校验"后的密钥,没开留空
35+
// kinds: 这个群只收哪些消息(alert recovery heartbeat daily manual),不填 = 全收
2636
"webhooks": [
27-
{ "name": "运维群", "url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx" }
37+
{ "name": "运维群", "url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx",
38+
"secret": "", "kinds": ["alert", "recovery", "heartbeat"] },
39+
{ "name": "领导群", "url": "https://open.feishu.cn/open-apis/bot/v2/hook/yyyyyyyy",
40+
"kinds": ["daily"] }
2841
],
2942

30-
// 告警规则(默认值经过校准,一般不需要动)
43+
// 告警基准阈值(sensitivity 三档在此基础上缩放,一般不需要动)
3144
"alerts": {
3245
"cooldown_min": 30, // 同一告警重复推送冷却
3346
"recover_min": 15, // 持续正常多久才发恢复

config.go

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99
)
1010

1111
type Config struct {
12-
Listen string `json:"listen"`
13-
DataDir string `json:"data_dir"`
14-
WebBaseURL string `json:"web_base_url"` // 飞书卡片按钮链接,留空则不带按钮
12+
Listen string `json:"listen"`
13+
DataDir string `json:"data_dir"`
14+
WebBaseURL string `json:"web_base_url"` // 飞书卡片按钮链接,留空则不带按钮
15+
Instance string `json:"instance"` // 告警来源标识,不填默认取主机名
16+
Extra map[string]string `json:"extra"` // 全局自定义字段,进所有告警/恢复卡片
1517
Targets []TargetCfg `json:"targets"`
1618
Probe ProbeCfg `json:"probe"`
1719
Webhooks []WebhookCfg `json:"webhooks"`
@@ -22,11 +24,16 @@ type Config struct {
2224
}
2325

2426
type TargetCfg struct {
25-
Name string `json:"name"`
26-
Type string `json:"type"` // "icmp" | "tcp"
27-
Host string `json:"host"`
28-
Port int `json:"port"` // 仅 tcp
29-
dir string // 数据目录名(sanitized)
27+
Name string `json:"name"`
28+
Type string `json:"type"` // "icmp" | "tcp"
29+
Host string `json:"host"`
30+
Port int `json:"port"` // 仅 tcp
31+
Pace string `json:"pace"` // "fast"(15s) | "normal"(60s) | "slow"(300s),不填走全局
32+
IntervalSec int `json:"interval_sec"` // 显式秒数,优先级最高
33+
Sensitivity string `json:"sensitivity"` // "strict" | "normal" | "relaxed"
34+
Alerts *bool `json:"alerts"` // false = 只观测不告警
35+
Extra map[string]string `json:"extra"` // 目标级自定义字段,覆盖全局同名键
36+
dir string // 数据目录名(sanitized)
3037
}
3138

3239
type ProbeCfg struct {
@@ -37,8 +44,10 @@ type ProbeCfg struct {
3744
}
3845

3946
type WebhookCfg struct {
40-
Name string `json:"name"`
41-
URL string `json:"url"`
47+
Name string `json:"name"`
48+
URL string `json:"url"`
49+
Secret string `json:"secret"` // 飞书"签名校验"密钥,机器人未开签名则留空
50+
Kinds []string `json:"kinds"` // 只接收哪些消息:alert recovery heartbeat daily manual;不填 = 全收
4251
}
4352

4453
type AlertCfg struct {
@@ -103,6 +112,34 @@ func LoadConfig(path string) (*Config, error) {
103112
if cfg.Probe.Packets < 3 {
104113
cfg.Probe.Packets = 3 // 少于 3 个样本谈不上分布
105114
}
115+
for i := range cfg.Targets {
116+
t := &cfg.Targets[i]
117+
switch t.Pace {
118+
case "", "fast", "normal", "slow":
119+
default:
120+
return nil, fmt.Errorf("目标 %q 的 pace %q 无效(fast|normal|slow)", t.Name, t.Pace)
121+
}
122+
switch t.Sensitivity {
123+
case "", "strict", "normal", "relaxed":
124+
default:
125+
return nil, fmt.Errorf("目标 %q 的 sensitivity %q 无效(strict|normal|relaxed)", t.Name, t.Sensitivity)
126+
}
127+
}
128+
validKinds := map[string]bool{"alert": true, "recovery": true, "heartbeat": true, "daily": true, "manual": true}
129+
for _, w := range cfg.Webhooks {
130+
for _, k := range w.Kinds {
131+
if !validKinds[k] {
132+
return nil, fmt.Errorf("webhook %q 的 kinds 含无效值 %q", w.Name, k)
133+
}
134+
}
135+
}
136+
if cfg.Instance == "" {
137+
if hn, err := os.Hostname(); err == nil {
138+
cfg.Instance = hn
139+
} else {
140+
cfg.Instance = "pingping"
141+
}
142+
}
106143
return cfg, nil
107144
}
108145

detect.go

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,65 @@ import (
1616
// burst 丢包突发 —— 单轮丢包数相对历史分布的 robust z 超阈,30 分钟内 N 次升级为告警
1717
// degrade P99 劣化 —— 近 15 分钟 P99 相对 1 小时前基线超倍数,连续 3 次确认
1818
type Detector struct {
19-
mu sync.Mutex
20-
cfg AlertCfg
21-
store *Store
22-
notify *Notifier
23-
states map[string]*tstate
19+
mu sync.Mutex
20+
cfg AlertCfg
21+
store *Store
22+
notify *Notifier
23+
states map[string]*tstate
24+
targets map[string]TargetCfg // 告警事件需要 host/extra/敏感度
25+
extra map[string]string // 全局自定义字段
26+
}
27+
28+
// sensParams 敏感度三档:对基准阈值的整体缩放。
29+
// strict 给核心链路(早叫),relaxed 给天生就抖的公网链路(少叫)。
30+
type sensParams struct {
31+
ratio float64 // P99 劣化倍数
32+
burstN int // 30 分钟内突发次数升级线
33+
streak int // 劣化连续确认次数
34+
}
35+
36+
func sensOf(t TargetCfg, base AlertCfg) sensParams {
37+
switch t.Sensitivity {
38+
case "strict":
39+
return sensParams{1.3, 2, 2}
40+
case "relaxed":
41+
return sensParams{2.0, 5, 5}
42+
default:
43+
return sensParams{base.DegradeRatio, base.BurstAlertN, 3}
44+
}
2445
}
2546

2647
type tstate struct {
2748
degradeStreak int // 连续劣化确认次数
2849
alertActive bool
2950
alertKind string
51+
alertSince int64 // 本次告警的开始时间
3052
lastNotify int64
3153
clearSince int64 // 条件消失的起点(恢复确认用)
32-
// 告警证据(推送和 UI 共用)
33-
evP99Base, evP99Cur float64
34-
evBursts int
35-
evWorstLoss float64
3654
}
3755

3856
func NewDetector(cfg *Config, store *Store, n *Notifier) *Detector {
39-
d := &Detector{cfg: cfg.Alerts, store: store, notify: n, states: map[string]*tstate{}}
57+
d := &Detector{cfg: cfg.Alerts, store: store, notify: n,
58+
states: map[string]*tstate{}, targets: map[string]TargetCfg{}, extra: cfg.Extra}
4059
for _, t := range cfg.Targets {
4160
d.states[t.Name] = &tstate{}
61+
d.targets[t.Name] = t
4262
}
4363
return d
4464
}
4565

66+
// mergedExtra 全局字段 + 目标字段,目标覆盖同名键。
67+
func (d *Detector) mergedExtra(t TargetCfg) map[string]string {
68+
out := map[string]string{}
69+
for k, v := range d.extra {
70+
out[k] = v
71+
}
72+
for k, v := range t.Extra {
73+
out[k] = v
74+
}
75+
return out
76+
}
77+
4678
// CheckBurst 在落盘前判定当前轮是否丢包突发(基线不含当前轮)。
4779
func (d *Detector) CheckBurst(name string, r Round) bool {
4880
loss := r.S - r.R
@@ -98,6 +130,11 @@ func median(s []float64) float64 {
98130

99131
// AfterAppend 是每轮落盘后的完整评估:劣化检查 + 突发计数 + 状态机。
100132
func (d *Detector) AfterAppend(name string) {
133+
tc := d.targets[name]
134+
if tc.Alerts != nil && !*tc.Alerts {
135+
return // 纯观测目标:烟雾图和突发标记照常,但永远不叫
136+
}
137+
sp := sensOf(tc, d.cfg)
101138
now := time.Now()
102139
ring24 := d.store.Recent(name, now.Add(-24*time.Hour).Unix())
103140

@@ -121,7 +158,7 @@ func (d *Detector) AfterAppend(name string) {
121158
sort.Float64s(curPool)
122159
sort.Float64s(basePool)
123160
p99c, p99b = pct(curPool, 99), pct(basePool, 99)
124-
degrade = p99c > p99b*d.cfg.DegradeRatio && p99c-p99b > d.cfg.DegradeMinMs
161+
degrade = p99c > p99b*sp.ratio && p99c-p99b > d.cfg.DegradeMinMs
125162
}
126163

127164
// --- 突发计数(30 分钟窗) ---
@@ -143,12 +180,13 @@ func (d *Detector) AfterAppend(name string) {
143180
} else {
144181
st.degradeStreak = 0
145182
}
146-
degradeAlert := st.degradeStreak >= 3
147-
burstAlert := bursts30 >= d.cfg.BurstAlertN
183+
degradeAlert := st.degradeStreak >= sp.streak
184+
burstAlert := bursts30 >= sp.burstN
148185
condition := degradeAlert || burstAlert
149186

150187
var fire, recovered bool
151188
var kind string
189+
var since int64
152190
if condition {
153191
st.clearSince = 0
154192
switch {
@@ -161,11 +199,13 @@ func (d *Detector) AfterAppend(name string) {
161199
}
162200
cooldown := int64(d.cfg.CooldownMin) * 60
163201
if !st.alertActive || now.Unix()-st.lastNotify >= cooldown {
202+
if !st.alertActive {
203+
st.alertSince = now.Unix()
204+
}
164205
fire = true
165206
st.alertActive = true
166207
st.alertKind = kind
167208
st.lastNotify = now.Unix()
168-
st.evP99Base, st.evP99Cur, st.evBursts, st.evWorstLoss = p99b, p99c, bursts30, worstLoss
169209
}
170210
} else if st.alertActive {
171211
if st.clearSince == 0 {
@@ -177,17 +217,31 @@ func (d *Detector) AfterAppend(name string) {
177217
kind = st.alertKind
178218
}
179219
}
180-
ev := *st
220+
since = st.alertSince
181221
d.mu.Unlock()
182222

183223
if fire {
184224
log.Printf("[%s] 告警: %s", name, kind)
185-
d.notify.SendAlert(name, kind, ev.evP99Base, ev.evP99Cur, ev.evBursts, ev.evWorstLoss)
225+
d.notify.SendAlert(AlertEvent{
226+
Target: name, Host: targetAddr(tc), Kind: kind,
227+
P99Base: p99b, P99Cur: p99c, Bursts: bursts30, WorstLoss: worstLoss,
228+
Since: since, Extra: d.mergedExtra(tc),
229+
})
186230
}
187231
if recovered {
188232
log.Printf("[%s] 恢复: %s", name, kind)
189-
d.notify.SendRecovery(name, kind)
233+
d.notify.SendRecovery(AlertEvent{
234+
Target: name, Host: targetAddr(tc), Kind: kind,
235+
Since: since, Extra: d.mergedExtra(tc),
236+
})
237+
}
238+
}
239+
240+
func targetAddr(t TargetCfg) string {
241+
if t.Type == "tcp" {
242+
return fmt.Sprintf("%s:%d", t.Host, t.Port)
190243
}
244+
return t.Host
191245
}
192246

193247
// Status 给 Web 和报告用的目标状态快照。

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func scheduler(cfg *Config, store *Store, n *Notifier, stop chan struct{}) {
8989
if cfg.DailyReport.Enabled && now.Hour() == cfg.DailyReport.Hour &&
9090
now.Minute() == 0 && lastDaily != day {
9191
lastDaily = day
92-
n.SendReport("📊 链路健康日报")
92+
n.SendReport("📊 链路健康日报", "daily")
9393
}
9494

9595
// 周心跳:证明自己活着,给沉默赋予语义

0 commit comments

Comments
 (0)