231 lines
8.8 KiB
Python
231 lines
8.8 KiB
Python
"""监控导出测试:MetricsCollector + health_check + HTTP 服务器。
|
|
|
|
覆盖:
|
|
* MetricsCollector.on_event 收集任务计数/耗时/重试。
|
|
* metrics_text 输出 Prometheus 文本格式。
|
|
* record_run 记录运行级指标。
|
|
* reset 清空指标。
|
|
* health_check: healthy/degraded/unhealthy/unknown 状态判定。
|
|
* start_metrics_server HTTP 端点返回指标文本。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import socket
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
import pyflowx as px
|
|
from pyflowx import Graph, MetricsCollector, TaskSpec, health_check, start_metrics_server
|
|
from pyflowx.task import TaskEvent, TaskStatus
|
|
|
|
|
|
class TestMetricsCollector:
|
|
"""MetricsCollector 测试。"""
|
|
|
|
def test_collects_task_count(self) -> None:
|
|
"""on_event 正确收集任务计数。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.RUNNING))
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.5))
|
|
collector.on_event(TaskEvent(task="b", status=TaskStatus.RUNNING))
|
|
collector.on_event(TaskEvent(task="b", status=TaskStatus.FAILED, duration=1.0, error="err"))
|
|
text = collector.metrics_text()
|
|
assert 'pyflowx_task_total{task="a",status="success"} 1' in text
|
|
assert 'pyflowx_task_total{task="b",status="failed"} 1' in text
|
|
assert 'pyflowx_task_total{task="a",status="running"} 1' in text
|
|
|
|
def test_collects_duration(self) -> None:
|
|
"""终态事件记录耗时。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=1.5))
|
|
text = collector.metrics_text()
|
|
assert 'pyflowx_task_duration_seconds{task="a"} 1.5' in text
|
|
assert 'pyflowx_task_duration_seconds_sum{task="a"} 1.5' in text
|
|
|
|
def test_duration_sum_accumulates(self) -> None:
|
|
"""多次执行累加耗时。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=1.0))
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=2.0))
|
|
text = collector.metrics_text()
|
|
assert 'pyflowx_task_duration_seconds_sum{task="a"} 3.0' in text
|
|
|
|
def test_collects_retries(self) -> None:
|
|
"""attempts > 1 时记录重试。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.1, attempts=3))
|
|
text = collector.metrics_text()
|
|
assert 'pyflowx_task_retries_total{task="a"} 2' in text
|
|
|
|
def test_no_retries_not_recorded(self) -> None:
|
|
"""attempts <= 1 时不记录重试指标。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.1, attempts=1))
|
|
text = collector.metrics_text()
|
|
assert "pyflowx_task_retries_total" not in text
|
|
|
|
def test_record_run(self) -> None:
|
|
"""record_run 记录运行级指标。"""
|
|
collector = MetricsCollector()
|
|
graph = Graph.from_specs([TaskSpec("a", fn=lambda: 1)])
|
|
report = px.run(graph, strategy="sequential")
|
|
collector.record_run(report)
|
|
text = collector.metrics_text()
|
|
assert 'pyflowx_run_total{status="success"} 1' in text
|
|
|
|
def test_reset(self) -> None:
|
|
"""reset 清空所有指标。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=1.0))
|
|
assert "pyflowx_task_total" in collector.metrics_text()
|
|
collector.reset()
|
|
text = collector.metrics_text()
|
|
assert "pyflowx_task_total" not in text
|
|
|
|
def test_with_run_integration(self) -> None:
|
|
"""与 px.run 集成:作为 on_event 回调收集指标。"""
|
|
collector = MetricsCollector()
|
|
graph = Graph.from_specs(
|
|
[
|
|
TaskSpec("a", fn=lambda: 1),
|
|
TaskSpec("b", fn=lambda a: a + 1, depends_on=("a",)),
|
|
]
|
|
)
|
|
px.run(graph, strategy="sequential", on_event=collector.on_event)
|
|
text = collector.metrics_text()
|
|
assert 'task="a"' in text
|
|
assert 'task="b"' in text
|
|
assert 'status="success"' in text
|
|
|
|
def test_metrics_text_format(self) -> None:
|
|
"""metrics_text 输出符合 Prometheus 文本格式。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.5))
|
|
text = collector.metrics_text()
|
|
# HELP 和 TYPE 行存在
|
|
assert "# HELP pyflowx_task_total" in text
|
|
assert "# TYPE pyflowx_task_total counter" in text
|
|
# 以换行结尾
|
|
assert text.endswith("\n")
|
|
|
|
|
|
class TestHealthCheck:
|
|
"""health_check 测试。"""
|
|
|
|
def test_healthy(self) -> None:
|
|
"""全部成功 → healthy。"""
|
|
graph = Graph.from_specs(
|
|
[
|
|
TaskSpec("a", fn=lambda: 1),
|
|
TaskSpec("b", fn=lambda: 2),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
result = health_check(report)
|
|
assert result["status"] == "healthy"
|
|
assert result["total"] == 2
|
|
assert result["success"] == 2
|
|
assert result["failed"] == 0
|
|
|
|
def test_degraded(self) -> None:
|
|
"""部分失败 → degraded。"""
|
|
graph = Graph.from_specs(
|
|
[
|
|
TaskSpec("a", fn=lambda: 1),
|
|
TaskSpec("b", fn=lambda: (_ for _ in ()).throw(RuntimeError("fail"))),
|
|
]
|
|
)
|
|
with contextlib.suppress(px.TaskFailedError):
|
|
px.run(graph, strategy="sequential", on_event=lambda _e: None)
|
|
# 构造部分失败报告
|
|
from pyflowx.report import RunReport
|
|
from pyflowx.task import TaskResult
|
|
|
|
report = RunReport(success=False)
|
|
report.results["a"] = TaskResult(spec=graph.specs["a"], status=TaskStatus.SUCCESS, value=1)
|
|
report.results["b"] = TaskResult(spec=graph.specs["b"], status=TaskStatus.FAILED, error=RuntimeError("fail"))
|
|
result = health_check(report)
|
|
assert result["status"] == "degraded"
|
|
assert result["failed"] == 1
|
|
assert result["success"] == 1
|
|
|
|
def test_unhealthy(self) -> None:
|
|
"""全部失败 → unhealthy。"""
|
|
from pyflowx.report import RunReport
|
|
from pyflowx.task import TaskResult
|
|
|
|
report = RunReport(success=False)
|
|
report.results["a"] = TaskResult(spec=TaskSpec("a", fn=lambda: 1), status=TaskStatus.FAILED)
|
|
result = health_check(report)
|
|
assert result["status"] == "unhealthy"
|
|
|
|
def test_unknown_empty(self) -> None:
|
|
"""无任务 → unknown。"""
|
|
from pyflowx.report import RunReport
|
|
|
|
report = RunReport()
|
|
result = health_check(report)
|
|
assert result["status"] == "unknown"
|
|
|
|
def test_includes_duration(self) -> None:
|
|
"""健康检查包含总耗时。"""
|
|
from datetime import datetime
|
|
|
|
from pyflowx.report import RunReport
|
|
from pyflowx.task import TaskResult
|
|
|
|
report = RunReport()
|
|
spec = TaskSpec("a", fn=lambda: 1)
|
|
report.results["a"] = TaskResult(
|
|
spec=spec,
|
|
status=TaskStatus.SUCCESS,
|
|
value=1,
|
|
started_at=datetime(2026, 1, 1, 0, 0, 0),
|
|
finished_at=datetime(2026, 1, 1, 0, 0, 1),
|
|
)
|
|
result = health_check(report)
|
|
assert result["duration"] == 1.0
|
|
|
|
|
|
class TestMetricsServer:
|
|
"""start_metrics_server HTTP 测试。"""
|
|
|
|
@staticmethod
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
def test_serves_metrics(self) -> None:
|
|
"""HTTP 端点返回 Prometheus 格式指标。"""
|
|
collector = MetricsCollector()
|
|
collector.on_event(TaskEvent(task="a", status=TaskStatus.SUCCESS, duration=0.1))
|
|
port = self._free_port()
|
|
stop = start_metrics_server(collector, port=port, host="127.0.0.1")
|
|
try:
|
|
time.sleep(0.1) # 等待服务器启动
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics") as resp:
|
|
body = resp.read().decode("utf-8")
|
|
assert resp.status == 200
|
|
assert "pyflowx_task_total" in body
|
|
finally:
|
|
stop()
|
|
|
|
def test_404_for_unknown_path(self) -> None:
|
|
"""非 /metrics 路径返回 404。"""
|
|
collector = MetricsCollector()
|
|
port = self._free_port()
|
|
stop = start_metrics_server(collector, port=port, host="127.0.0.1")
|
|
try:
|
|
time.sleep(0.1)
|
|
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/unknown")
|
|
assert exc_info.value.code == 404
|
|
finally:
|
|
stop()
|