Add `perf` as the 12th skinny package with full OpenTelemetry instrumentation for pipeline traces, Prometheus metrics, and Loki-correlated logs. - src/perf/: TracerProvider, MeterProvider, LoggerProvider bridge, PipelineCollector, psutil system metrics, FileExporter fallback, FastAPI middleware, auto-file Gitea issues on step/test failure - infra/otel/: OTel Collector fan-out (traces→Jaeger, metrics→Prometheus, logs→Loki), all services migrated from jaeger:4317 to collector - infra/grafana/dashboards/: 8-panel pipeline performance dashboard - Pipeline runner auto-instruments all 189 steps with zero-cost MockTracer/MockSpan no-ops when telemetry is disabled - 82 new tests, 12,055 total passing Closes #203, closes #204, closes #205, closes #206, closes #207, closes #208, closes #209, closes #210, closes #211, closes #212, closes #213
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
"""Tests for perf.hooks — Gitea issue filing on failure/skip."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
class TestOnStepFailure:
|
|
"""on_step_failure builds correct issue title and body."""
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_files_issue_on_exception(self, mock_file):
|
|
mock_file.return_value = {"number": 999}
|
|
from perf.hooks import on_step_failure
|
|
|
|
exc = ValueError("missing column: person_id")
|
|
on_step_failure("core.encounter", exc, pipeline="readmissions")
|
|
|
|
mock_file.assert_called_once()
|
|
title, body = mock_file.call_args[0][:2]
|
|
assert "core.encounter" in title
|
|
assert "readmissions" in title
|
|
assert "failed" in title
|
|
assert "ValueError" in body
|
|
assert "missing column: person_id" in body
|
|
assert "Traceback" in body
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_labels_include_bug_and_pipeline(self, mock_file):
|
|
mock_file.return_value = {"number": 1}
|
|
from perf.hooks import on_step_failure
|
|
|
|
on_step_failure("x.y", RuntimeError("boom"), pipeline="core")
|
|
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
|
assert "bug" in labels
|
|
assert "pipeline" in labels
|
|
|
|
|
|
class TestOnStepSkip:
|
|
"""on_step_skip builds correct issue for skipped steps."""
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_files_issue_on_skip(self, mock_file):
|
|
mock_file.return_value = {"number": 100}
|
|
from perf.hooks import on_step_skip
|
|
|
|
on_step_skip("readmissions._int_enc", pipeline="readmissions")
|
|
|
|
mock_file.assert_called_once()
|
|
title = mock_file.call_args[0][0]
|
|
assert "skipped" in title
|
|
assert "readmissions._int_enc" in title
|
|
|
|
|
|
class TestPytestHook:
|
|
"""pytest_runtest_logreport files issues in CI."""
|
|
|
|
@patch("perf.hooks._file_test_issue")
|
|
def test_ignores_setup_phase(self, mock_file):
|
|
from perf.hooks import pytest_runtest_logreport
|
|
|
|
report = MagicMock()
|
|
report.when = "setup"
|
|
pytest_runtest_logreport(report)
|
|
mock_file.assert_not_called()
|
|
|
|
@patch("perf.hooks._file_test_issue")
|
|
def test_ignores_without_env_var(self, mock_file, monkeypatch):
|
|
monkeypatch.delenv("STACK_FILE_TEST_ISSUES", raising=False)
|
|
from perf.hooks import pytest_runtest_logreport
|
|
|
|
report = MagicMock()
|
|
report.when = "call"
|
|
report.failed = True
|
|
pytest_runtest_logreport(report)
|
|
mock_file.assert_not_called()
|
|
|
|
@patch("perf.hooks._file_test_issue")
|
|
def test_files_on_failure_in_ci(self, mock_file, monkeypatch):
|
|
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
|
|
from perf.hooks import pytest_runtest_logreport
|
|
|
|
report = MagicMock()
|
|
report.when = "call"
|
|
report.failed = True
|
|
report.skipped = False
|
|
pytest_runtest_logreport(report)
|
|
mock_file.assert_called_once_with(report, "failed")
|
|
|
|
@patch("perf.hooks._file_test_issue")
|
|
def test_files_on_skip_in_ci(self, mock_file, monkeypatch):
|
|
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
|
|
from perf.hooks import pytest_runtest_logreport
|
|
|
|
report = MagicMock()
|
|
report.when = "call"
|
|
report.failed = False
|
|
report.skipped = True
|
|
pytest_runtest_logreport(report)
|
|
mock_file.assert_called_once_with(report, "skipped")
|
|
|
|
|
|
class TestFileTestIssue:
|
|
"""_file_test_issue builds correct markdown body."""
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_body_includes_nodeid(self, mock_file):
|
|
mock_file.return_value = {"number": 50}
|
|
from perf.hooks import _file_test_issue
|
|
|
|
report = MagicMock()
|
|
report.nodeid = "tests/aco/test_core.py::test_encounter"
|
|
report.longrepr = "AssertionError: ..."
|
|
report.longreprtext = "AssertionError: expected 10 got 0"
|
|
|
|
_file_test_issue(report, "failed")
|
|
|
|
title, body = mock_file.call_args[0][:2]
|
|
assert "test_core.py::test_encounter" in title
|
|
assert "failed" in title
|
|
assert "AssertionError" in body
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_failed_gets_bug_label(self, mock_file):
|
|
mock_file.return_value = {"number": 51}
|
|
from perf.hooks import _file_test_issue
|
|
|
|
report = MagicMock()
|
|
report.nodeid = "tests/foo.py::test_bar"
|
|
report.longrepr = None
|
|
|
|
_file_test_issue(report, "failed")
|
|
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
|
assert "bug" in labels
|
|
assert "test" in labels
|
|
|
|
@patch("perf.hooks._file_issue")
|
|
def test_skipped_no_bug_label(self, mock_file):
|
|
mock_file.return_value = {"number": 52}
|
|
from perf.hooks import _file_test_issue
|
|
|
|
report = MagicMock()
|
|
report.nodeid = "tests/foo.py::test_baz"
|
|
report.longrepr = None
|
|
|
|
_file_test_issue(report, "skipped")
|
|
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
|
|
assert "bug" not in labels
|
|
assert "test" in labels
|
|
|
|
|
|
class TestRunnerIntegration:
|
|
"""Pipeline runner calls on_step_failure when a step raises."""
|
|
|
|
@patch("perf.hooks.on_step_failure")
|
|
def test_runner_fires_hook_on_failure(self, mock_hook):
|
|
import polars as pl
|
|
|
|
from aco.pipe.runner import run_pipeline
|
|
|
|
def bad_step(input_layer__data):
|
|
raise RuntimeError("data corrupted")
|
|
|
|
data = pl.DataFrame({"id": [1]})
|
|
|
|
try:
|
|
run_pipeline([("ns.bad", bad_step)], lambda ref: data)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
mock_hook.assert_called_once()
|
|
args = mock_hook.call_args
|
|
assert args[0][0] == "ns.bad"
|
|
assert isinstance(args[0][1], RuntimeError)
|