test: verify daily metric formulas
This commit is contained in:
@@ -169,7 +169,7 @@ python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8
|
|||||||
|
|
||||||
`go_systemd_prod_smoke.py` 通过 SSH 检查四个 Go systemd 服务是否 `active` 且 `enabled`,并确认 `808`、`32960` 由 `gateway` 监听、`20210` 由 `realtime-api` 监听,同时要求 `/opt/lingniu-go-native/spool/gateway` 没有待回放文件,避免旧 Java/Docker 进程占用生产端口或 Kafka spool 静默堆积。运行环境需要已配置 SSH 免密或已建立可用的 SSH 认证方式。
|
`go_systemd_prod_smoke.py` 通过 SSH 检查四个 Go systemd 服务是否 `active` 且 `enabled`,并确认 `808`、`32960` 由 `gateway` 监听、`20210` 由 `realtime-api` 监听,同时要求 `/opt/lingniu-go-native/spool/gateway` 没有待回放文件,避免旧 Java/Docker 进程占用生产端口或 Kafka spool 静默堆积。运行环境需要已配置 SSH 免密或已建立可用的 SSH 认证方式。
|
||||||
|
|
||||||
`go_native_prod_smoke.py` 通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询及结构化 `parsed_json`,三类协议的位置和里程点查询及 `frame_id` RAW 回链,GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计,以及三类协议的 Redis realtime snapshot、online、protocol 查询。默认检查今天东八区数据时,还会要求三类 RAW 最新样本不超过 15 分钟,避免旧数据误判为接收正常;任一检查不达标时退出码为非 0。
|
`go_native_prod_smoke.py` 通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询及结构化 `parsed_json`,三类协议的位置和里程点查询及 `frame_id` RAW 回链,GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计公式,以及三类协议的 Redis realtime snapshot、online、protocol 查询。默认检查今天东八区数据时,还会要求三类 RAW 最新样本不超过 15 分钟,避免旧数据误判为接收正常;任一检查不达标时退出码为非 0。
|
||||||
|
|
||||||
## 部署命令
|
## 部署命令
|
||||||
|
|
||||||
@@ -219,5 +219,5 @@ journalctl -u lingniu-go-realtime-api --since '5 minutes ago' --no-pager
|
|||||||
- GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据,且最新 RAW 样本包含结构化 `parsed_json`。
|
- GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据,且最新 RAW 样本包含结构化 `parsed_json`。
|
||||||
- GB32960、JT808、YUTONG_MQTT 的最新 RAW 样本均在 15 分钟内。
|
- GB32960、JT808、YUTONG_MQTT 的最新 RAW 样本均在 15 分钟内。
|
||||||
- GB32960、JT808、YUTONG_MQTT 的 `vehicle_locations`、`vehicle_mileage_points` 查询均可命中生产数据,且样本包含 `frame_id` 回链 RAW。
|
- GB32960、JT808、YUTONG_MQTT 的 `vehicle_locations`、`vehicle_mileage_points` 查询均可命中生产数据,且样本包含 `frame_id` 回链 RAW。
|
||||||
- `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`。
|
- `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`,且公式满足 `daily_mileage_km = latest_total_mileage_km - first_total_mileage_km`、`daily_total_mileage_km = latest_total_mileage_km`。
|
||||||
- Redis realtime 可查到 GB32960、JT808、YUTONG_MQTT 的在线状态和协议快照。
|
- Redis realtime 可查到 GB32960、JT808、YUTONG_MQTT 的在线状态和协议快照。
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ python3 tools/go_prod_acceptance.py --date 2026-07-02
|
|||||||
|
|
||||||
- `go_systemd_prod_smoke.py`:应用 ECS systemd 服务 active/enabled、端口归属和 gateway spool
|
- `go_systemd_prod_smoke.py`:应用 ECS systemd 服务 active/enabled、端口归属和 gateway spool
|
||||||
- `go_kafka_prod_smoke.py`:Kafka topic 和消费组 lag
|
- `go_kafka_prod_smoke.py`:Kafka topic 和消费组 lag
|
||||||
- `go_native_prod_smoke.py`:HTTP API、RAW `parsed_json`、历史核心表 `frame_id` 回链、TDengine/MySQL/Redis 数据链路
|
- `go_native_prod_smoke.py`:HTTP API、RAW `parsed_json`、历史核心表 `frame_id` 回链、每日指标公式、TDengine/MySQL/Redis 数据链路
|
||||||
|
|
||||||
如需单独检查 systemd:
|
如需单独检查 systemd:
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class CheckSpec:
|
|||||||
max_age_minutes: float | None = None
|
max_age_minutes: float | None = None
|
||||||
require_parsed_json: bool = False
|
require_parsed_json: bool = False
|
||||||
require_frame_id: bool = False
|
require_frame_id: bool = False
|
||||||
|
require_metric_formula: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -70,6 +71,7 @@ def check_total(
|
|||||||
now: dt.datetime | None = None,
|
now: dt.datetime | None = None,
|
||||||
require_parsed_json: bool = False,
|
require_parsed_json: bool = False,
|
||||||
require_frame_id: bool = False,
|
require_frame_id: bool = False,
|
||||||
|
require_metric_formula: bool = False,
|
||||||
) -> Check:
|
) -> Check:
|
||||||
count = int(payload.get("total") or 0)
|
count = int(payload.get("total") or 0)
|
||||||
items = payload.get("items") or []
|
items = payload.get("items") or []
|
||||||
@@ -99,11 +101,18 @@ def check_total(
|
|||||||
frame_message = frame_id_message(items)
|
frame_message = frame_id_message(items)
|
||||||
if not frame_message.startswith("frame_id=ok"):
|
if not frame_message.startswith("frame_id=ok"):
|
||||||
return Check(name, "fail", count, minimum, f"count={count}; {frame_message}; sample={sample}")
|
return Check(name, "fail", count, minimum, f"count={count}; {frame_message}; sample={sample}")
|
||||||
|
metric_message = ""
|
||||||
|
if require_metric_formula:
|
||||||
|
metric_message = metric_formula_message(items)
|
||||||
|
if not metric_message.startswith("metric_formula=ok"):
|
||||||
|
return Check(name, "fail", count, minimum, f"count={count}; {metric_message}; sample={sample}")
|
||||||
details = [f"count={count}", age_message]
|
details = [f"count={count}", age_message]
|
||||||
if parsed_message:
|
if parsed_message:
|
||||||
details.append(parsed_message)
|
details.append(parsed_message)
|
||||||
if frame_message:
|
if frame_message:
|
||||||
details.append(frame_message)
|
details.append(frame_message)
|
||||||
|
if metric_message:
|
||||||
|
details.append(metric_message)
|
||||||
return Check(name, "pass", count, minimum, "; ".join(details) + f"; sample={sample}")
|
return Check(name, "pass", count, minimum, "; ".join(details) + f"; sample={sample}")
|
||||||
return Check(name, "fail", count, minimum, f"count={count}; expected >= {minimum}; {age_message}; sample={sample}")
|
return Check(name, "fail", count, minimum, f"count={count}; expected >= {minimum}; {age_message}; sample={sample}")
|
||||||
|
|
||||||
@@ -161,6 +170,28 @@ def frame_id_message(items: list[Any]) -> str:
|
|||||||
return "missing frame_id"
|
return "missing frame_id"
|
||||||
|
|
||||||
|
|
||||||
|
def metric_formula_message(items: list[Any]) -> str:
|
||||||
|
if not items or not isinstance(items[0], dict):
|
||||||
|
return "missing metric row"
|
||||||
|
row = items[0]
|
||||||
|
try:
|
||||||
|
metric_value = float(row.get("metric_value"))
|
||||||
|
first_total = float(row.get("first_total_mileage_km"))
|
||||||
|
latest_total = float(row.get("latest_total_mileage_km"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "missing metric formula fields"
|
||||||
|
metric_key = str(row.get("metric_key") or "")
|
||||||
|
if metric_key == "daily_mileage_km":
|
||||||
|
expected = max(0.0, latest_total - first_total)
|
||||||
|
elif metric_key == "daily_total_mileage_km":
|
||||||
|
expected = latest_total
|
||||||
|
else:
|
||||||
|
return "unsupported metric_key=" + metric_key
|
||||||
|
if abs(metric_value - expected) <= 0.001:
|
||||||
|
return "metric_formula=ok"
|
||||||
|
return f"metric_formula mismatch value={metric_value:.3f} expected={expected:.3f}"
|
||||||
|
|
||||||
|
|
||||||
def check_realtime(name: str, payload: dict[str, Any]) -> Check:
|
def check_realtime(name: str, payload: dict[str, Any]) -> Check:
|
||||||
if payload.get("online") is False:
|
if payload.get("online") is False:
|
||||||
return Check(name, "fail", 0, 1, "online=false")
|
return Check(name, "fail", 0, 1, "online=false")
|
||||||
@@ -272,6 +303,7 @@ def build_check_specs(
|
|||||||
"limit": 1,
|
"limit": 1,
|
||||||
},
|
},
|
||||||
min_stat,
|
min_stat,
|
||||||
|
require_metric_formula=True,
|
||||||
),
|
),
|
||||||
CheckSpec(
|
CheckSpec(
|
||||||
"gb32960.daily_total_mileage",
|
"gb32960.daily_total_mileage",
|
||||||
@@ -284,6 +316,7 @@ def build_check_specs(
|
|||||||
"limit": 1,
|
"limit": 1,
|
||||||
},
|
},
|
||||||
min_stat,
|
min_stat,
|
||||||
|
require_metric_formula=True,
|
||||||
),
|
),
|
||||||
CheckSpec(
|
CheckSpec(
|
||||||
"jt808.daily_mileage",
|
"jt808.daily_mileage",
|
||||||
@@ -296,6 +329,7 @@ def build_check_specs(
|
|||||||
"limit": 1,
|
"limit": 1,
|
||||||
},
|
},
|
||||||
min_stat,
|
min_stat,
|
||||||
|
require_metric_formula=True,
|
||||||
),
|
),
|
||||||
CheckSpec(
|
CheckSpec(
|
||||||
"jt808.daily_total_mileage",
|
"jt808.daily_total_mileage",
|
||||||
@@ -308,6 +342,7 @@ def build_check_specs(
|
|||||||
"limit": 1,
|
"limit": 1,
|
||||||
},
|
},
|
||||||
min_stat,
|
min_stat,
|
||||||
|
require_metric_formula=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -359,6 +394,7 @@ def run_checks(base_url: str, specs: list[CheckSpec], timeout: float) -> list[Ch
|
|||||||
spec.max_age_minutes,
|
spec.max_age_minutes,
|
||||||
require_parsed_json=spec.require_parsed_json,
|
require_parsed_json=spec.require_parsed_json,
|
||||||
require_frame_id=spec.require_frame_id,
|
require_frame_id=spec.require_frame_id,
|
||||||
|
require_metric_formula=spec.require_metric_formula,
|
||||||
))
|
))
|
||||||
except Exception as exc: # pragma: no cover - exercised by live failures.
|
except Exception as exc: # pragma: no cover - exercised by live failures.
|
||||||
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
||||||
@@ -379,6 +415,7 @@ def query_payloads(base_url: str, specs: list[CheckSpec], timeout: float) -> tup
|
|||||||
spec.max_age_minutes,
|
spec.max_age_minutes,
|
||||||
require_parsed_json=spec.require_parsed_json,
|
require_parsed_json=spec.require_parsed_json,
|
||||||
require_frame_id=spec.require_frame_id,
|
require_frame_id=spec.require_frame_id,
|
||||||
|
require_metric_formula=spec.require_metric_formula,
|
||||||
))
|
))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
checks.append(Check(spec.name, "fail", 0, spec.minimum, f"request failed: {exc}"))
|
||||||
|
|||||||
@@ -229,6 +229,44 @@ class GoNativeProdSmokeTest(unittest.TestCase):
|
|||||||
self.assertEqual(check.status, "pass")
|
self.assertEqual(check.status, "pass")
|
||||||
self.assertIn("frame_id=ok", check.message)
|
self.assertIn("frame_id=ok", check.message)
|
||||||
|
|
||||||
|
def test_daily_mileage_formula_requires_latest_minus_first(self):
|
||||||
|
check = smoke.check_total(
|
||||||
|
"jt808.daily_mileage",
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"items": [{
|
||||||
|
"metric_key": "daily_mileage_km",
|
||||||
|
"metric_value": 40.0,
|
||||||
|
"first_total_mileage_km": 4434.9,
|
||||||
|
"latest_total_mileage_km": 4481.0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
minimum=1,
|
||||||
|
require_metric_formula=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(check.status, "fail")
|
||||||
|
self.assertIn("metric_formula mismatch", check.message)
|
||||||
|
|
||||||
|
def test_daily_total_formula_requires_latest_total(self):
|
||||||
|
check = smoke.check_total(
|
||||||
|
"jt808.daily_total_mileage",
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"items": [{
|
||||||
|
"metric_key": "daily_total_mileage_km",
|
||||||
|
"metric_value": 4481.0,
|
||||||
|
"first_total_mileage_km": 4434.9,
|
||||||
|
"latest_total_mileage_km": 4481.0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
minimum=1,
|
||||||
|
require_metric_formula=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(check.status, "pass")
|
||||||
|
self.assertIn("metric_formula=ok", check.message)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user