Files
pyflowx/tests/test_monitoring.py
T

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()