mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: add low-cardinality observation port
This commit is contained in:
+19
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6232,
|
||||
"edge_sha256": "2139dcdcfc12b4f29c732471744d09b0990c51867d4ad227026b00fa947c90d8",
|
||||
"edge_count": 6244,
|
||||
"edge_sha256": "f187bdbb5e88ce9a6b2ff559b10e5e6cd5a60d14693484ea17663cebf438cc92",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -106,6 +106,8 @@
|
||||
"app.adapters.network.doh -> app.runtime.reload",
|
||||
"app.adapters.network.http -> app.runtime",
|
||||
"app.adapters.network.http -> app.runtime.correlation",
|
||||
"app.adapters.observability.otel -> app.runtime",
|
||||
"app.adapters.observability.otel -> app.runtime.observability",
|
||||
"app.adapters.system.display -> app.foundation",
|
||||
"app.adapters.system.display -> app.foundation.singleton",
|
||||
"app.adapters.system.display -> app.runtime",
|
||||
@@ -157,6 +159,8 @@
|
||||
"app.adapters.web.correlation -> app.runtime.correlation",
|
||||
"app.adapters.web.health -> app.runtime",
|
||||
"app.adapters.web.health -> app.runtime.health",
|
||||
"app.adapters.web.metrics -> app.runtime",
|
||||
"app.adapters.web.metrics -> app.runtime.observability",
|
||||
"app.adapters.web.security.access -> app.runtime",
|
||||
"app.adapters.web.security.access -> app.runtime.cache",
|
||||
"app.adapters.web.security.access -> app.runtime.config",
|
||||
@@ -3722,9 +3726,12 @@
|
||||
"app.domain.title -> app.schemas",
|
||||
"app.domain.title -> app.schemas.types",
|
||||
"app.factory -> app.adapters",
|
||||
"app.factory -> app.adapters.observability",
|
||||
"app.factory -> app.adapters.observability.otel",
|
||||
"app.factory -> app.adapters.web",
|
||||
"app.factory -> app.adapters.web.correlation",
|
||||
"app.factory -> app.adapters.web.health",
|
||||
"app.factory -> app.adapters.web.metrics",
|
||||
"app.factory -> app.adapters.web.plugin",
|
||||
"app.factory -> app.adapters.web.plugin.routes",
|
||||
"app.factory -> app.adapters.web.security",
|
||||
@@ -3743,6 +3750,7 @@
|
||||
"app.factory -> app.runtime.extensions.plugin_manager",
|
||||
"app.factory -> app.runtime.localization",
|
||||
"app.factory -> app.runtime.log",
|
||||
"app.factory -> app.runtime.observability",
|
||||
"app.factory -> app.schemas",
|
||||
"app.factory -> app.schemas.mcp",
|
||||
"app.factory -> app.schemas.openai",
|
||||
@@ -5365,6 +5373,7 @@
|
||||
"app.runtime.event.dispatch -> app.runtime.event.registry",
|
||||
"app.runtime.event.dispatch -> app.runtime.execution",
|
||||
"app.runtime.event.dispatch -> app.runtime.log",
|
||||
"app.runtime.event.dispatch -> app.runtime.observability",
|
||||
"app.runtime.event.dispatch -> app.schemas",
|
||||
"app.runtime.event.dispatch -> app.schemas.types",
|
||||
"app.runtime.event.errors -> app.runtime",
|
||||
@@ -5387,6 +5396,7 @@
|
||||
"app.runtime.events -> app.runtime.event.errors",
|
||||
"app.runtime.events -> app.runtime.event.registry",
|
||||
"app.runtime.events -> app.runtime.log",
|
||||
"app.runtime.events -> app.runtime.observability",
|
||||
"app.runtime.events -> app.runtime.rate",
|
||||
"app.runtime.events -> app.runtime.thread",
|
||||
"app.runtime.events -> app.schemas",
|
||||
@@ -5417,6 +5427,7 @@
|
||||
"app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module",
|
||||
"app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module.contracts",
|
||||
"app.runtime.extensions.module.dispatcher -> app.runtime.log",
|
||||
"app.runtime.extensions.module.dispatcher -> app.runtime.observability",
|
||||
"app.runtime.extensions.module.dispatcher -> app.schemas",
|
||||
"app.runtime.extensions.module.dispatcher -> app.schemas.exception",
|
||||
"app.runtime.extensions.module_manager -> app.foundation",
|
||||
@@ -5573,6 +5584,7 @@
|
||||
"app.scheduler -> app.runtime.extensions.service_config",
|
||||
"app.scheduler -> app.runtime.gc",
|
||||
"app.scheduler -> app.runtime.log",
|
||||
"app.scheduler -> app.runtime.observability",
|
||||
"app.scheduler -> app.runtime.progress",
|
||||
"app.scheduler -> app.runtime.reload",
|
||||
"app.scheduler -> app.runtime.scheduling",
|
||||
@@ -6249,7 +6261,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 778,
|
||||
"module_count": 782,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6271,6 +6283,8 @@
|
||||
"app.adapters.network.doh",
|
||||
"app.adapters.network.http",
|
||||
"app.adapters.network.ip",
|
||||
"app.adapters.observability",
|
||||
"app.adapters.observability.otel",
|
||||
"app.adapters.system",
|
||||
"app.adapters.system.backup",
|
||||
"app.adapters.system.backup.database",
|
||||
@@ -6291,6 +6305,7 @@
|
||||
"app.adapters.web",
|
||||
"app.adapters.web.correlation",
|
||||
"app.adapters.web.health",
|
||||
"app.adapters.web.metrics",
|
||||
"app.adapters.web.plugin",
|
||||
"app.adapters.web.plugin.routes",
|
||||
"app.adapters.web.security",
|
||||
@@ -6922,6 +6937,7 @@
|
||||
"app.runtime.localization",
|
||||
"app.runtime.log",
|
||||
"app.runtime.managed_resources",
|
||||
"app.runtime.observability",
|
||||
"app.runtime.progress",
|
||||
"app.runtime.rate",
|
||||
"app.runtime.reload",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from app.adapters.observability import otel
|
||||
from app.adapters.web.metrics import HttpMetricsMiddleware
|
||||
from app.runtime.observability import (
|
||||
METRIC_SPECS,
|
||||
MetricSpec,
|
||||
NoopObservationPort,
|
||||
configure_observation,
|
||||
observe_duration,
|
||||
record_metric,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingObservationPort:
|
||||
"""测试用端口,保存已经通过核心标签校验的写入。"""
|
||||
|
||||
records: list[tuple[MetricSpec, float, Mapping[str, str]]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
def record(
|
||||
self, spec: MetricSpec, value: float, labels: Mapping[str, str]
|
||||
) -> None:
|
||||
"""追加一条不可变测试快照。"""
|
||||
self.records.append((spec, value, dict(labels)))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_observation_port():
|
||||
"""避免进程级观测端口在用例间泄漏。"""
|
||||
configure_observation(None)
|
||||
yield
|
||||
configure_observation(None)
|
||||
|
||||
|
||||
def test_noop_port_accepts_registered_metric_without_exporter() -> None:
|
||||
"""未安装 exporter 时记录合法指标必须完全可用。"""
|
||||
configure_observation(NoopObservationPort())
|
||||
record_metric(
|
||||
"http.server.duration",
|
||||
0.1,
|
||||
route="/health/live",
|
||||
method="GET",
|
||||
status="200",
|
||||
)
|
||||
|
||||
|
||||
def test_metric_catalog_contains_no_high_cardinality_labels() -> None:
|
||||
"""整个指标目录不得登记用户、插件、媒体、URL 或请求实例标签。"""
|
||||
forbidden = {
|
||||
"user_id",
|
||||
"plugin_id",
|
||||
"media_id",
|
||||
"media_title",
|
||||
"url",
|
||||
"request_id",
|
||||
"job_id",
|
||||
}
|
||||
|
||||
assert METRIC_SPECS
|
||||
assert all(not (spec.labels & forbidden) for spec in METRIC_SPECS.values())
|
||||
|
||||
|
||||
def test_unregistered_label_is_rejected_before_adapter() -> None:
|
||||
"""调用方不能绕过目录向 exporter 注入高基数标签。"""
|
||||
with pytest.raises(ValueError, match="未登记标签"):
|
||||
record_metric(
|
||||
"scheduler.job.duration",
|
||||
1,
|
||||
owner="plugin",
|
||||
outcome="success",
|
||||
job_id="dynamic-123",
|
||||
)
|
||||
|
||||
|
||||
def test_duration_records_success_and_error_outcomes() -> None:
|
||||
"""统一计时器把正常和异常路径收敛为有限 outcome。"""
|
||||
port = RecordingObservationPort()
|
||||
configure_observation(port)
|
||||
|
||||
with observe_duration(
|
||||
"module.provider.duration", method="recognize_media", provider_type="system"
|
||||
):
|
||||
pass
|
||||
with pytest.raises(RuntimeError):
|
||||
with observe_duration(
|
||||
"module.provider.duration",
|
||||
method="recognize_media",
|
||||
provider_type="plugin",
|
||||
):
|
||||
raise RuntimeError("failed")
|
||||
|
||||
assert [record[2]["outcome"] for record in port.records] == ["success", "error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_metrics_use_route_template_not_request_url() -> None:
|
||||
"""HTTP 指标使用路由模板,不能把具体资源 ID 变成 label。"""
|
||||
port = RecordingObservationPort()
|
||||
configure_observation(port)
|
||||
|
||||
async def item(_request):
|
||||
"""返回固定测试响应。"""
|
||||
return PlainTextResponse("ok")
|
||||
|
||||
app = Starlette(routes=[Route("/items/{item_id}", item)])
|
||||
app.add_middleware(HttpMetricsMiddleware)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
response = await client.get("/items/secret-item")
|
||||
|
||||
assert response.status_code == 200
|
||||
_, _, labels = port.records[-1]
|
||||
assert labels == {"route": "/items/{item_id}", "method": "GET", "status": "200"}
|
||||
|
||||
|
||||
def test_optional_otel_adapter_falls_back_to_noop(monkeypatch) -> None:
|
||||
"""显式启用但未安装 OTel API 时启动仍返回 no-op。"""
|
||||
monkeypatch.setenv("MOVIEPILOT_OTEL_METRICS", "1")
|
||||
|
||||
def missing(_name: str):
|
||||
"""模拟可选依赖不存在。"""
|
||||
raise ImportError("missing")
|
||||
|
||||
monkeypatch.setattr(otel.importlib, "import_module", missing)
|
||||
|
||||
assert isinstance(otel.build_observation_port(), NoopObservationPort)
|
||||
Reference in New Issue
Block a user