Add external monitoring stack and alerting docs
This commit is contained in:
@@ -18,6 +18,12 @@ OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
UID=1000
|
||||
GID=1000
|
||||
POLYWEATHER_STATE_STORAGE_MODE=dual
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
|
||||
########################################
|
||||
# 2) Telegram bot minimal
|
||||
@@ -72,6 +78,7 @@ TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
|
||||
########################################
|
||||
# 6) Frontend-facing shared values
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- 已上线支付运行态与审计接口:`/api/payments/runtime`。
|
||||
- 已上线轻量运营后台:`/ops`(会员、周榜、补分、支付异常单)。
|
||||
- 已上线轻量可观测性:`/healthz`、`/api/system/status`、`/metrics`。
|
||||
- 已补最小外部监控栈:Prometheus + Alertmanager + Grafana + Telegram 告警 relay。
|
||||
- 运行态状态与缓存已支持 SQLite 渐进迁移:`file / dual / sqlite`。
|
||||
- 已接入 EMOS/CRPS 校准链路,但当前仍保持 `emos_shadow`。
|
||||
|
||||
@@ -122,6 +123,22 @@ curl http://127.0.0.1:8000/metrics
|
||||
docker compose logs -f polyweather | egrep "payment event loop started|payment confirm loop started|payment auto-confirmed"
|
||||
```
|
||||
|
||||
### 外部监控栈
|
||||
|
||||
```bash
|
||||
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
|
||||
```
|
||||
|
||||
- Prometheus:`http://127.0.0.1:${POLYWEATHER_PROMETHEUS_PORT:-9090}`
|
||||
- Alertmanager:`http://127.0.0.1:${POLYWEATHER_ALERTMANAGER_PORT:-9093}`
|
||||
- Grafana:`http://127.0.0.1:${POLYWEATHER_GRAFANA_PORT:-3001}`
|
||||
|
||||
手动巡检:
|
||||
|
||||
```bash
|
||||
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### 支付运行态
|
||||
|
||||
```bash
|
||||
@@ -171,6 +188,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
|
||||
- 支付审计说明:[docs/payments/PAYMENT_AUDIT_ZH.md](docs/payments/PAYMENT_AUDIT_ZH.md)
|
||||
- 支付 V2 升级方案:[docs/payments/PAYMENT_UPGRADE_V2_ZH.md](docs/payments/PAYMENT_UPGRADE_V2_ZH.md)
|
||||
- 运营后台说明:[docs/OPS_ADMIN_ZH.md](docs/OPS_ADMIN_ZH.md)
|
||||
- 外部监控说明:[docs/MONITORING_ZH.md](docs/MONITORING_ZH.md)
|
||||
- 深度评估报告:[docs/deep-research-report.md](docs/deep-research-report.md)
|
||||
- 前端报告:[FRONTEND_REDESIGN_REPORT.md](FRONTEND_REDESIGN_REPORT.md)
|
||||
- 发布流程:[RELEASE.md](RELEASE.md)
|
||||
|
||||
@@ -33,3 +33,69 @@ services:
|
||||
- "8000:8000"
|
||||
# UID/GID are mainly useful on Linux hosts to avoid root-owned output files.
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_prometheus:
|
||||
image: prom/prometheus:v3.4.1
|
||||
container_name: polyweather_prometheus
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_web
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=15d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/prometheus:/prometheus
|
||||
ports:
|
||||
- "${POLYWEATHER_PROMETHEUS_PORT:-9090}:9090"
|
||||
|
||||
polyweather_alertmanager:
|
||||
image: prom/alertmanager:v0.28.1
|
||||
container_name: polyweather_alertmanager
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_alert_relay
|
||||
command:
|
||||
- "--config.file=/etc/alertmanager/alertmanager.yml"
|
||||
- "--storage.path=/alertmanager"
|
||||
volumes:
|
||||
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/alertmanager:/alertmanager
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERTMANAGER_PORT:-9093}:9093"
|
||||
|
||||
polyweather_alert_relay:
|
||||
<<: *polyweather-base
|
||||
container_name: polyweather_alert_relay
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
command: python scripts/alertmanager_telegram_relay.py
|
||||
volumes:
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||
ports:
|
||||
- "${POLYWEATHER_ALERT_RELAY_PORT:-9099}:9099"
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
polyweather_grafana:
|
||||
image: grafana/grafana-oss:12.0.2
|
||||
container_name: polyweather_grafana
|
||||
restart: unless-stopped
|
||||
profiles: ["monitoring"]
|
||||
depends_on:
|
||||
- polyweather_prometheus
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${POLYWEATHER_GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${POLYWEATHER_GRAFANA_ADMIN_PASSWORD:-polyweather}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}/monitoring/grafana:/var/lib/grafana
|
||||
ports:
|
||||
- "${POLYWEATHER_GRAFANA_PORT:-3001}:3000"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 外部监控与告警说明
|
||||
|
||||
最后更新:`2026-04-01`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在现有轻量可观测性基础上,把 PolyWeather 补成最小可用的外部监控链路:
|
||||
|
||||
- Prometheus 抓取 `/metrics`
|
||||
- Alertmanager 根据规则聚合告警
|
||||
- Telegram relay 把告警推到运营频道
|
||||
- Grafana 展示趋势面板
|
||||
- 巡检脚本补健康检查
|
||||
|
||||
## 2. 组件
|
||||
|
||||
本仓库现在内置 4 个监控组件:
|
||||
|
||||
- `polyweather_prometheus`
|
||||
- `polyweather_alertmanager`
|
||||
- `polyweather_alert_relay`
|
||||
- `polyweather_grafana`
|
||||
|
||||
对应配置目录:
|
||||
|
||||
- [monitoring/prometheus/prometheus.yml](../monitoring/prometheus/prometheus.yml)
|
||||
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
|
||||
- [monitoring/alertmanager/alertmanager.yml](../monitoring/alertmanager/alertmanager.yml)
|
||||
- [monitoring/grafana/dashboards/polyweather-overview.json](../monitoring/grafana/dashboards/polyweather-overview.json)
|
||||
|
||||
## 3. 启动
|
||||
|
||||
```bash
|
||||
docker compose --profile monitoring up -d polyweather_prometheus polyweather_alertmanager polyweather_alert_relay polyweather_grafana
|
||||
```
|
||||
|
||||
默认端口:
|
||||
|
||||
- Prometheus: `9090`
|
||||
- Alertmanager: `9093`
|
||||
- Grafana: `3001`
|
||||
- Alert relay: `9099`
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
在 [.env.example](../.env.example) 里新增了这些配置:
|
||||
|
||||
```env
|
||||
POLYWEATHER_PROMETHEUS_PORT=9090
|
||||
POLYWEATHER_ALERTMANAGER_PORT=9093
|
||||
POLYWEATHER_ALERT_RELAY_PORT=9099
|
||||
POLYWEATHER_GRAFANA_PORT=3001
|
||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `POLYWEATHER_MONITORING_ALERT_CHAT_IDS` 为空时,relay 会自动回退到:
|
||||
- `TELEGRAM_CHAT_IDS`
|
||||
- `TELEGRAM_CHAT_ID`
|
||||
- 告警发送仍复用现有 `TELEGRAM_BOT_TOKEN`
|
||||
|
||||
## 5. 当前告警规则
|
||||
|
||||
当前默认规则:
|
||||
|
||||
- `PolyWeatherWebDown`
|
||||
- `PolyWeatherHttp5xxBurst`
|
||||
- `PolyWeatherHighSourceErrorRate`
|
||||
- `PolyWeatherOpenMeteoCooldownLoop`
|
||||
- `PolyWeatherSlowHttpAverage`
|
||||
|
||||
规则文件:
|
||||
|
||||
- [monitoring/prometheus/alerts.yml](../monitoring/prometheus/alerts.yml)
|
||||
|
||||
## 6. 当前 Grafana 面板
|
||||
|
||||
预置了一个最小仪表板:
|
||||
|
||||
- `PolyWeather Overview`
|
||||
|
||||
包含这些图:
|
||||
|
||||
- HTTP Requests by Status
|
||||
- HTTP Latency
|
||||
- Source Requests by Outcome
|
||||
- Source Error Rate (15m)
|
||||
|
||||
## 7. 巡检脚本
|
||||
|
||||
手动巡检:
|
||||
|
||||
```bash
|
||||
python scripts/check_ops_health.py --base-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
这个脚本会检查:
|
||||
|
||||
- `/healthz`
|
||||
- `/api/system/status`
|
||||
- `/metrics`
|
||||
|
||||
任何一项失败都会非零退出,适合挂到 crontab 或 systemd timer。
|
||||
|
||||
## 8. 备注
|
||||
|
||||
这套监控现在已经具备:
|
||||
|
||||
- 外部抓取
|
||||
- 告警规则
|
||||
- Telegram 推送
|
||||
- 趋势面板
|
||||
- 巡检脚本
|
||||
|
||||
但它仍是“最小可用版”,还没有覆盖:
|
||||
|
||||
- 节点级 CPU / 内存 / 磁盘
|
||||
- 数据库体积趋势
|
||||
- 更细粒度支付指标
|
||||
- 按城市/来源拆分的业务 SLA
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ops 运营后台说明
|
||||
|
||||
最后更新:`2026-03-21`
|
||||
最后更新:`2026-04-01`
|
||||
|
||||
## 1. 入口
|
||||
|
||||
@@ -28,6 +28,7 @@ POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
|
||||
- 当前会员
|
||||
- 周榜
|
||||
- 支付异常单
|
||||
- 漏斗转化面板
|
||||
|
||||
### 写能力
|
||||
|
||||
@@ -102,3 +103,7 @@ python scripts/reconcile_subscription_by_email.py --email <user_email>
|
||||
|
||||
- 让会员、积分、支付事故、系统状态可查
|
||||
- 让常见人工操作不必再直接写 SQL
|
||||
|
||||
外部监控与告警栈说明见:
|
||||
|
||||
- [MONITORING_ZH.md](./MONITORING_ZH.md)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
receiver: telegram-relay
|
||||
group_by: ["alertname"]
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 3h
|
||||
|
||||
receivers:
|
||||
- name: telegram-relay
|
||||
webhook_configs:
|
||||
- url: http://polyweather_alert_relay:9099/alerts
|
||||
send_resolved: true
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (status) (rate(polyweather_http_requests_total[5m]))",
|
||||
"legendFormat": "{{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "HTTP Requests by Status",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(polyweather_http_request_duration_ms_sum[5m])) / clamp_min(sum(rate(polyweather_http_request_duration_ms_count[5m])), 1)",
|
||||
"legendFormat": "avg",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "max(polyweather_http_request_duration_ms_max)",
|
||||
"legendFormat": "max",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "HTTP Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (source, outcome) (rate(polyweather_source_requests_total[5m]))",
|
||||
"legendFormat": "{{source}} / {{outcome}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Source Requests by Outcome",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 25
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(sum(increase(polyweather_source_requests_total{outcome!~\"success|cache_hit\"}[15m])) / clamp_min(sum(increase(polyweather_source_requests_total[15m])), 1))",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Source Error Rate (15m)",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 41,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"polyweather",
|
||||
"ops"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PolyWeather Overview",
|
||||
"uid": "polyweather-overview",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: PolyWeather Dashboards
|
||||
orgId: 1
|
||||
folder: PolyWeather
|
||||
type: file
|
||||
disableDeletion: false
|
||||
editable: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: PolyWeather Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://polyweather_prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
@@ -0,0 +1,58 @@
|
||||
groups:
|
||||
- name: polyweather-runtime
|
||||
rules:
|
||||
- alert: PolyWeatherWebDown
|
||||
expr: up{job="polyweather-web"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "PolyWeather web metrics endpoint is down"
|
||||
description: "/metrics on polyweather_web has been unreachable for more than 2 minutes."
|
||||
|
||||
- alert: PolyWeatherHttp5xxBurst
|
||||
expr: sum(increase(polyweather_http_requests_total{status=~"5.."}[10m])) > 10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "PolyWeather HTTP 5xx burst"
|
||||
description: "More than 10 server errors were observed in the last 10 minutes."
|
||||
|
||||
- alert: PolyWeatherHighSourceErrorRate
|
||||
expr: |
|
||||
(
|
||||
sum(increase(polyweather_source_requests_total{outcome!~"success|cache_hit"}[15m]))
|
||||
/
|
||||
clamp_min(sum(increase(polyweather_source_requests_total[15m])), 1)
|
||||
) > 0.25
|
||||
and sum(increase(polyweather_source_requests_total[15m])) > 20
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "PolyWeather source error rate is high"
|
||||
description: "External weather source errors exceeded 25% over the last 15 minutes."
|
||||
|
||||
- alert: PolyWeatherOpenMeteoCooldownLoop
|
||||
expr: sum(increase(polyweather_source_requests_total{source="open_meteo",outcome=~"cooldown_skip|error"}[15m])) > 20
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Open-Meteo is rate-limited or erroring"
|
||||
description: "Open-Meteo has produced repeated cooldown skips or errors in the last 15 minutes."
|
||||
|
||||
- alert: PolyWeatherSlowHttpAverage
|
||||
expr: |
|
||||
(
|
||||
sum(rate(polyweather_http_request_duration_ms_sum[10m]))
|
||||
/
|
||||
clamp_min(sum(rate(polyweather_http_request_duration_ms_count[10m])), 1)
|
||||
) > 2000
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "PolyWeather average HTTP latency is high"
|
||||
description: "Average HTTP request duration exceeded 2s over the last 10 minutes."
|
||||
@@ -0,0 +1,19 @@
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/alerts.yml
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
- polyweather_alertmanager:9093
|
||||
|
||||
scrape_configs:
|
||||
- job_name: polyweather-web
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets:
|
||||
- polyweather_web:8000
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _chat_ids() -> List[str]:
|
||||
raw = (
|
||||
os.getenv("POLYWEATHER_MONITORING_ALERT_CHAT_IDS")
|
||||
or os.getenv("TELEGRAM_CHAT_IDS")
|
||||
or os.getenv("TELEGRAM_CHAT_ID")
|
||||
or ""
|
||||
)
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _format_alerts(payload: Dict[str, Any]) -> str:
|
||||
alerts = payload.get("alerts") or []
|
||||
if not isinstance(alerts, list) or not alerts:
|
||||
return "PolyWeather monitoring received an empty alert payload."
|
||||
lines = ["PolyWeather monitoring alert"]
|
||||
for alert in alerts[:10]:
|
||||
if not isinstance(alert, dict):
|
||||
continue
|
||||
status = str(alert.get("status") or "unknown").upper()
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
alert_name = labels.get("alertname") or "unknown_alert"
|
||||
severity = labels.get("severity") or "info"
|
||||
summary = annotations.get("summary") or annotations.get("description") or ""
|
||||
lines.append(f"- [{status}] {alert_name} ({severity})")
|
||||
if summary:
|
||||
lines.append(f" {summary}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _send_telegram_message(text: str) -> None:
|
||||
token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
chat_ids = _chat_ids()
|
||||
if not token or not chat_ids:
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
for chat_id in chat_ids:
|
||||
try:
|
||||
requests.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=10,
|
||||
).raise_for_status()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path.rstrip("/") == "/healthz":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path.rstrip("/") != "/alerts":
|
||||
self.send_error(404)
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
self.send_error(400, "invalid json")
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
self.send_error(400, "invalid payload")
|
||||
return
|
||||
_send_telegram_message(_format_alerts(payload))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = 9099
|
||||
server = HTTPServer(("0.0.0.0", port), _Handler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: float) -> Tuple[int, Dict]:
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.status_code, response.json()
|
||||
|
||||
|
||||
def _get_text(url: str, timeout: float) -> Tuple[int, str]:
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.status_code, response.text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run basic PolyWeather ops checks.")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--timeout", type=float, default=8.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
timeout = args.timeout
|
||||
report = {"checks": []}
|
||||
failed = False
|
||||
|
||||
try:
|
||||
_, health = _get_json(f"{base}/healthz", timeout)
|
||||
ok = str(health.get("status") or "").lower() == "ok"
|
||||
report["checks"].append({"name": "healthz", "ok": ok, "detail": health})
|
||||
failed = failed or not ok
|
||||
except Exception as exc:
|
||||
report["checks"].append({"name": "healthz", "ok": False, "detail": str(exc)})
|
||||
failed = True
|
||||
|
||||
try:
|
||||
_, status = _get_json(f"{base}/api/system/status", timeout)
|
||||
features = status.get("features") or {}
|
||||
ok = status.get("status") == "ok" and bool((status.get("db") or {}).get("ok"))
|
||||
report["checks"].append({"name": "system_status", "ok": ok, "detail": {"features": features, "db": status.get("db")}})
|
||||
failed = failed or not ok
|
||||
except Exception as exc:
|
||||
report["checks"].append({"name": "system_status", "ok": False, "detail": str(exc)})
|
||||
failed = True
|
||||
|
||||
try:
|
||||
_, metrics = _get_text(f"{base}/metrics", timeout)
|
||||
ok = "polyweather_http_requests_total" in metrics or "polyweather_source_requests_total" in metrics
|
||||
report["checks"].append({"name": "metrics", "ok": ok, "detail": "metrics exposed"})
|
||||
failed = failed or not ok
|
||||
except Exception as exc:
|
||||
report["checks"].append({"name": "metrics", "ok": False, "detail": str(exc)})
|
||||
failed = True
|
||||
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user