Files
2026-07-13 12:34:57 +08:00

286 lines
9.8 KiB
Python

"""
F5 内置 Prometheus 指标模块单元测试
覆盖:
- prometheus-client 未安装时的优雅退化(no-op 桩、Application 只告警不挂)
- setup_multiproc_env 的各种生效/不生效分支
- get_counter / get_histogram 幂等缓存
- HttpMetricsMiddleware 路由模板标签、unmatched 归类与 metrics 自身路径排除
"""
import importlib.util
import os
import sys
import uuid
import pytest
from fastapi.testclient import TestClient
import myboot.metrics as metrics
from myboot.core.application import Application
from myboot.core.config import get_settings
def _uniq(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex}"
class FakeConfig:
"""支持点号 key 的最小配置桩"""
def __init__(self, data=None):
self.data = data or {}
def get(self, key, default=None):
return self.data.get(key, default)
# ==================== 可用性与 no-op 退化 ====================
class TestAvailability:
def test_is_available_true_when_installed(self):
# prometheus-client 已随 test extra 安装
assert metrics.is_available() is True
def test_is_available_false_when_not_installed(self, monkeypatch):
monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
assert metrics.is_available() is False
def test_get_counter_noop_when_unavailable(self, monkeypatch):
monkeypatch.setattr(metrics, "is_available", lambda: False)
counter = metrics.get_counter(_uniq("noop_counter"), "doc", ("a", "b"))
# 链式调用不报错
counter.labels(a="1", b="2").inc()
counter.inc(5)
assert isinstance(counter, metrics._NoopMetric)
def test_get_histogram_noop_when_unavailable(self, monkeypatch):
monkeypatch.setattr(metrics, "is_available", lambda: False)
hist = metrics.get_histogram(_uniq("noop_hist"), "doc", ("stage",))
hist.labels(stage="x").observe(0.1)
assert isinstance(hist, metrics._NoopMetric)
def test_observe_stage_and_time_stage_noop_when_unavailable(self, monkeypatch):
monkeypatch.setattr(metrics, "is_available", lambda: False)
metrics.observe_stage("recall", 0.05)
with metrics.time_stage("rank"):
pass
def test_application_only_warns_when_enabled_but_not_installed(self, monkeypatch):
monkeypatch.setattr(metrics, "is_available", lambda: False)
settings = get_settings()
settings.set("metrics.enabled", True)
try:
app = Application(name="metrics-warn-test", auto_configuration=False)
fastapi_app = app.get_fastapi_app()
# 未安装 → 不挂载 /metrics、不加中间件,应用可正常服务
client = TestClient(fastapi_app)
resp = client.get("/health")
assert resp.status_code == 200
resp = client.get("/metrics")
assert resp.status_code == 404
finally:
settings.set("metrics.enabled", False)
# ==================== is_enabled ====================
class TestIsEnabled:
@pytest.mark.parametrize(
"value,expected",
[
(True, True),
(False, False),
("true", True),
("True", True),
("1", True),
("yes", True),
("on", True),
("false", False),
("0", False),
(None, False),
],
)
def test_bool_and_string_values(self, value, expected):
cfg = FakeConfig({"metrics.enabled": value})
assert metrics.is_enabled(cfg) is expected
def test_default_false(self):
assert metrics.is_enabled(FakeConfig()) is False
# ==================== setup_multiproc_env ====================
@pytest.fixture
def clean_prom_env(monkeypatch):
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
monkeypatch.delenv("MYBOOT_WORKER_ID", raising=False)
return monkeypatch
class TestSetupMultiprocEnv:
def _cfg(self, workers=4, enabled=True, multiproc_dir=None):
return FakeConfig(
{
"metrics.enabled": enabled,
"server.workers": workers,
"metrics.multiproc_dir": multiproc_dir,
}
)
def test_single_worker_noop(self, clean_prom_env):
clean_prom_env.setattr(sys, "platform", "linux")
metrics.setup_multiproc_env(self._cfg(workers=1), "demo")
assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ
def test_disabled_noop(self, clean_prom_env):
clean_prom_env.setattr(sys, "platform", "linux")
metrics.setup_multiproc_env(self._cfg(workers=4, enabled=False), "demo")
assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ
def test_win32_noop(self, clean_prom_env):
clean_prom_env.setattr(sys, "platform", "win32")
metrics.setup_multiproc_env(self._cfg(workers=4), "demo")
assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ
def test_multiworker_sets_env_and_cleans_stale_db(self, clean_prom_env, tmp_path):
clean_prom_env.setattr(sys, "platform", "linux")
target = tmp_path / "prom_dir"
target.mkdir()
stale = target / "counter_12345.db"
stale.write_bytes(b"stale")
keep = target / "note.txt"
keep.write_text("keep")
metrics.setup_multiproc_env(
self._cfg(workers=4, multiproc_dir=str(target)), "demo"
)
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(target)
assert target.is_dir()
assert not stale.exists() # 陈旧 .db 被父进程清理
assert keep.exists() # 非 .db 文件保留
def test_worker_process_does_not_clean(self, clean_prom_env, tmp_path):
clean_prom_env.setattr(sys, "platform", "linux")
clean_prom_env.setenv("MYBOOT_WORKER_ID", "2")
target = tmp_path / "prom_dir"
target.mkdir()
stale = target / "counter_12345.db"
stale.write_bytes(b"stale")
metrics.setup_multiproc_env(
self._cfg(workers=4, multiproc_dir=str(target)), "demo"
)
assert stale.exists() # 非父进程不清理
def test_user_preset_env_respected(self, clean_prom_env, tmp_path):
clean_prom_env.setattr(sys, "platform", "linux")
user_dir = tmp_path / "user_dir"
clean_prom_env.setenv("PROMETHEUS_MULTIPROC_DIR", str(user_dir))
metrics.setup_multiproc_env(
self._cfg(workers=4, multiproc_dir=str(tmp_path / "other")), "demo"
)
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(user_dir)
assert user_dir.is_dir()
def test_default_dir_under_tempdir_with_app_slug(self, clean_prom_env):
import tempfile
clean_prom_env.setattr(sys, "platform", "linux")
metrics.setup_multiproc_env(self._cfg(workers=2), "My Demo App!")
value = os.environ["PROMETHEUS_MULTIPROC_DIR"]
assert value.startswith(tempfile.gettempdir())
assert "myboot_prometheus_my_demo_app" in value
# ==================== 指标工厂幂等性 ====================
class TestMetricFactories:
def test_get_counter_idempotent(self):
name = _uniq("idem_counter")
c1 = metrics.get_counter(name, "doc", ("x",))
c2 = metrics.get_counter(name, "doc", ("x",))
assert c1 is c2
c1.labels(x="a").inc()
def test_get_histogram_idempotent(self):
name = _uniq("idem_hist")
h1 = metrics.get_histogram(name, "doc", ("x",))
h2 = metrics.get_histogram(name, "doc", ("x",))
assert h1 is h2
h1.labels(x="a").observe(0.5)
def test_observe_stage_negative_ignored(self):
metrics.observe_stage("noop", -1.0) # 不报错
def test_time_stage_records(self):
with metrics.time_stage(_uniq("stage")):
pass
# ==================== HttpMetricsMiddleware 端到端 ====================
class TestHttpMetricsMiddleware:
@pytest.fixture
def metrics_app(self):
settings = get_settings()
settings.set("metrics.enabled", True)
try:
app = Application(name="metrics-mw-test", auto_configuration=False)
async def get_item(item_id: str):
return {"id": item_id}
app.add_route("/items/{item_id}", get_item, ["GET"])
yield app
finally:
settings.set("metrics.enabled", False)
def test_http_metrics_collected(self, metrics_app):
client = TestClient(metrics_app.get_fastapi_app())
assert client.get("/items/1").status_code == 200
assert client.get("/items/2").status_code == 200
assert client.get("/no/such/route").status_code == 404
scrape = client.get("/metrics")
assert scrape.status_code == 200
text = scrape.text
# path 标签是路由模板,两个不同 id 归并到同一序列
template_lines = [
line
for line in text.splitlines()
if line.startswith("myboot_http_requests_total")
and 'path="/items/{item_id}"' in line
]
assert len(template_lines) == 1
assert 'method="GET"' in template_lines[0]
assert 'status="200"' in template_lines[0]
assert template_lines[0].rstrip().endswith("2.0")
# 具体 id 不出现为独立标签(避免高基数)
assert 'path="/items/1"' not in text
assert 'path="/items/2"' not in text
# 404 归入 unmatched
assert 'path="unmatched"' in text
# metrics 自身路径不被统计
assert 'path="/metrics"' not in text
# 耗时直方图存在
assert "myboot_http_request_duration_seconds" in text
def test_metrics_endpoint_not_wrapped_by_response_formatter(self, metrics_app):
client = TestClient(metrics_app.get_fastapi_app())
resp = client.get("/metrics")
assert resp.status_code == 200
# Prometheus 文本格式而非统一 JSON 包装
assert "text/plain" in resp.headers.get("content-type", "")