"""监控导出测试: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()