mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
feat: add low-cardinality observation port
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""运行观测导出器适配器。"""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""可选 OpenTelemetry metrics adapter。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.runtime.observability import MetricKind, MetricSpec, NoopObservationPort, ObservationPort
|
||||
|
||||
|
||||
class OpenTelemetryObservationPort:
|
||||
"""把内部指标合同映射到可选安装的 OpenTelemetry Metrics API。"""
|
||||
|
||||
def __init__(self, meter: Any) -> None:
|
||||
"""保存 meter,并按名称惰性创建 instrument。"""
|
||||
self._meter = meter
|
||||
self._instruments: dict[str, Any] = {}
|
||||
|
||||
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
|
||||
"""按合同类型使用 OTel counter、histogram 或 up/down counter。"""
|
||||
instrument = self._instruments.get(spec.name)
|
||||
if instrument is None:
|
||||
instrument = self._create_instrument(spec)
|
||||
self._instruments[spec.name] = instrument
|
||||
if spec.kind == MetricKind.HISTOGRAM:
|
||||
instrument.record(value, attributes=dict(labels))
|
||||
else:
|
||||
instrument.add(value, attributes=dict(labels))
|
||||
|
||||
def _create_instrument(self, spec: MetricSpec) -> Any:
|
||||
"""为内部指标类型创建对应 OTel instrument。"""
|
||||
normalized = spec.name.replace(".", "_")
|
||||
if spec.kind == MetricKind.HISTOGRAM:
|
||||
return self._meter.create_histogram(normalized)
|
||||
if spec.kind == MetricKind.COUNTER:
|
||||
return self._meter.create_counter(normalized)
|
||||
return self._meter.create_up_down_counter(normalized)
|
||||
|
||||
|
||||
def build_observation_port() -> ObservationPort:
|
||||
"""仅在显式启用且 API 可导入时创建 OTel adapter,否则返回 no-op。"""
|
||||
if os.getenv("MOVIEPILOT_OTEL_METRICS") != "1":
|
||||
return NoopObservationPort()
|
||||
try:
|
||||
metrics = importlib.import_module("opentelemetry.metrics")
|
||||
except ImportError:
|
||||
return NoopObservationPort()
|
||||
return OpenTelemetryObservationPort(metrics.get_meter("moviepilot"))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""HTTP route/status/latency 指标 ASGI 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.observability import record_metric
|
||||
from starlette.routing import Match
|
||||
|
||||
|
||||
class HttpMetricsMiddleware:
|
||||
"""按路由模板、方法和状态码记录低基数 HTTP 时延。"""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
"""保存下游 ASGI 应用。"""
|
||||
self._app = app
|
||||
|
||||
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||
"""只治理 HTTP scope,并在响应开始后读取路由模板。"""
|
||||
if scope.get("type") != "http":
|
||||
await self._app(scope, receive, send)
|
||||
return
|
||||
started_at = time.perf_counter()
|
||||
status = "500"
|
||||
|
||||
async def send_with_metrics(message: dict) -> None:
|
||||
"""捕获响应状态并原样转发 ASGI 消息。"""
|
||||
nonlocal status
|
||||
if message.get("type") == "http.response.start":
|
||||
status = str(message.get("status", 500))
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self._app(scope, receive, send_with_metrics)
|
||||
finally:
|
||||
route_path = self._resolve_route_template(scope)
|
||||
record_metric(
|
||||
"http.server.duration",
|
||||
time.perf_counter() - started_at,
|
||||
route=route_path,
|
||||
method=str(scope.get("method", "UNKNOWN")),
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _resolve_route_template(self, scope: dict) -> str:
|
||||
"""遍历 ASGI wrapper 找到匹配路由模板,绝不回退到具体请求 path。"""
|
||||
route = scope.get("route")
|
||||
if getattr(route, "path", None):
|
||||
return route.path
|
||||
candidate = self._app
|
||||
while candidate is not None:
|
||||
for registered_route in getattr(candidate, "routes", ()):
|
||||
match, _ = registered_route.matches(scope)
|
||||
if match == Match.FULL:
|
||||
return getattr(registered_route, "path", "unmatched")
|
||||
candidate = getattr(candidate, "app", None)
|
||||
return "unmatched"
|
||||
@@ -9,6 +9,8 @@ from starlette.exceptions import HTTPException
|
||||
|
||||
from app.api.response import ResponseAPIRoute
|
||||
from app.adapters.web.correlation import CorrelationIdMiddleware
|
||||
from app.adapters.web.metrics import HttpMetricsMiddleware
|
||||
from app.adapters.observability.otel import build_observation_port
|
||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||
from app.adapters.web.health import install_health_routes
|
||||
from app.application.plugin.routes import configure_plugin_routes
|
||||
@@ -23,6 +25,7 @@ from app.runtime.config import settings
|
||||
from app.runtime.correlation import get_correlation_id
|
||||
from app.runtime.localization import LocaleHelper
|
||||
from app.runtime.log import configure_correlation_id_provider, logger
|
||||
from app.runtime.observability import configure_observation
|
||||
from app.schemas.openai import (
|
||||
AnthropicErrorDetail,
|
||||
AnthropicErrorResponse,
|
||||
@@ -294,6 +297,7 @@ def create_app() -> FastAPI:
|
||||
创建并配置 FastAPI 应用实例。
|
||||
"""
|
||||
configure_correlation_id_provider(get_correlation_id)
|
||||
configure_observation(build_observation_port())
|
||||
_app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=APP_VERSION,
|
||||
@@ -321,6 +325,7 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
_app.add_middleware(CorrelationIdMiddleware)
|
||||
_app.add_middleware(HttpMetricsMiddleware)
|
||||
|
||||
@_app.middleware("http")
|
||||
async def locale_context_middleware(
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.runtime.event.registry import EventRegistry
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.correlation import correlation_scope
|
||||
from app.runtime.observability import observe_duration
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
@@ -147,7 +148,12 @@ class EventDispatcher:
|
||||
method, binding, class_name, method_name = resolved
|
||||
with correlation_scope(event.correlation_id):
|
||||
try:
|
||||
method(event)
|
||||
with observe_duration(
|
||||
"event.handler.duration",
|
||||
event_type=event.event_type.value,
|
||||
handler_type="bound" if class_name else "function",
|
||||
):
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
@@ -165,12 +171,17 @@ class EventDispatcher:
|
||||
method, binding, class_name, method_name = resolved
|
||||
with correlation_scope(event.correlation_id):
|
||||
try:
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
elif binding.run_sync_in_threadpool or not class_name:
|
||||
await run_in_threadpool(method, event)
|
||||
else:
|
||||
method(event)
|
||||
with observe_duration(
|
||||
"event.handler.duration",
|
||||
event_type=event.event_type.value,
|
||||
handler_type="bound" if class_name else "function",
|
||||
):
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method(event)
|
||||
elif binding.run_sync_in_threadpool or not class_name:
|
||||
await run_in_threadpool(method, event)
|
||||
else:
|
||||
method(event)
|
||||
except Exception as err:
|
||||
self._error_handler(
|
||||
event=event,
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
|
||||
from app.runtime.event.registry import EventRegistry
|
||||
from app.runtime.event.contracts import validate_event_payload
|
||||
from app.runtime.correlation import get_correlation_id
|
||||
from app.runtime.observability import record_metric
|
||||
|
||||
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
||||
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
||||
@@ -317,6 +318,11 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
logger.debug(f"Triggering broadcast event: {event}")
|
||||
self.__event_queue.put((event.priority, event))
|
||||
record_metric(
|
||||
"event.queue.depth",
|
||||
self.__event_queue.qsize(),
|
||||
delivery="broadcast",
|
||||
)
|
||||
|
||||
def __dispatch_chain_event(self, event: Event) -> bool:
|
||||
"""
|
||||
@@ -421,6 +427,11 @@ class EventManager(metaclass=Singleton):
|
||||
while self.__event.is_set():
|
||||
try:
|
||||
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
|
||||
record_metric(
|
||||
"event.queue.depth",
|
||||
self.__event_queue.qsize(),
|
||||
delivery="broadcast",
|
||||
)
|
||||
rate_limiter.reset()
|
||||
self.__dispatch_broadcast_event(event)
|
||||
except Empty:
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, Protocol
|
||||
from app.foundation.reflection import ObjectUtils
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.observability import observe_duration
|
||||
from app.runtime.extensions.module.contracts import (
|
||||
diagnose_module_callable,
|
||||
get_module_method_contract,
|
||||
@@ -68,29 +69,41 @@ class ModuleInvocationDispatcher:
|
||||
"""先执行插件模块,再按优先级执行宿主模块。"""
|
||||
contract = get_module_method_contract(method)
|
||||
logger.debug("模块方法契约:%s -> %s", method, contract.family)
|
||||
result = self.execute_plugin_modules(method, None, *args, **kwargs)
|
||||
with observe_duration(
|
||||
"module.provider.duration", method=method, provider_type="plugin"
|
||||
):
|
||||
result = self.execute_plugin_modules(method, None, *args, **kwargs)
|
||||
if not self.is_valid_empty(result) and not isinstance(result, list):
|
||||
return result
|
||||
return self.execute_system_modules(method, result, *args, **kwargs)
|
||||
with observe_duration(
|
||||
"module.provider.duration", method=method, provider_type="system"
|
||||
):
|
||||
return self.execute_system_modules(method, result, *args, **kwargs)
|
||||
|
||||
async def async_dispatch(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""以与同步路径相同的聚合规则执行同步或异步模块方法。"""
|
||||
contract = get_module_method_contract(method)
|
||||
logger.debug("异步模块方法契约:%s -> %s", method, contract.family)
|
||||
result = await self.async_execute_plugin_modules(
|
||||
method,
|
||||
None,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
with observe_duration(
|
||||
"module.provider.duration", method=method, provider_type="plugin"
|
||||
):
|
||||
result = await self.async_execute_plugin_modules(
|
||||
method,
|
||||
None,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
if not self.is_valid_empty(result) and not isinstance(result, list):
|
||||
return result
|
||||
return await self.async_execute_system_modules(
|
||||
method,
|
||||
result,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
with observe_duration(
|
||||
"module.provider.duration", method=method, provider_type="system"
|
||||
):
|
||||
return await self.async_execute_system_modules(
|
||||
method,
|
||||
result,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def execute_plugin_modules(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""低基数运行指标端口与进程级 Facade。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Iterator, Mapping, Protocol
|
||||
|
||||
|
||||
class MetricKind(StrEnum):
|
||||
"""声明指标采用 counter、histogram 或 gauge 语义。"""
|
||||
|
||||
COUNTER = "counter"
|
||||
HISTOGRAM = "histogram"
|
||||
GAUGE = "gauge"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MetricSpec:
|
||||
"""声明稳定指标名称、类型与允许的低基数标签。"""
|
||||
|
||||
name: str
|
||||
kind: MetricKind
|
||||
labels: frozenset[str]
|
||||
|
||||
|
||||
METRIC_SPECS = {
|
||||
spec.name: spec
|
||||
for spec in (
|
||||
MetricSpec("http.server.duration", MetricKind.HISTOGRAM, frozenset({"route", "method", "status"})),
|
||||
MetricSpec("db.pool.wait", MetricKind.HISTOGRAM, frozenset({"backend", "outcome"})),
|
||||
MetricSpec("db.pool.checked_out", MetricKind.GAUGE, frozenset({"backend"})),
|
||||
MetricSpec("db.pool.timeout", MetricKind.COUNTER, frozenset({"backend"})),
|
||||
MetricSpec("event.queue.depth", MetricKind.GAUGE, frozenset({"delivery"})),
|
||||
MetricSpec("event.handler.duration", MetricKind.HISTOGRAM, frozenset({"event_type", "handler_type", "outcome"})),
|
||||
MetricSpec("module.provider.duration", MetricKind.HISTOGRAM, frozenset({"method", "provider_type", "outcome"})),
|
||||
MetricSpec("module.provider.timeout", MetricKind.COUNTER, frozenset({"method", "provider_type"})),
|
||||
MetricSpec("scheduler.job.duration", MetricKind.HISTOGRAM, frozenset({"owner", "outcome"})),
|
||||
MetricSpec("scheduler.job.overlap_skip", MetricKind.COUNTER, frozenset({"owner"})),
|
||||
MetricSpec("scheduler.job.retry", MetricKind.COUNTER, frozenset({"owner"})),
|
||||
MetricSpec("scheduler.job.dead_letter", MetricKind.COUNTER, frozenset({"owner"})),
|
||||
MetricSpec("plugin.lifecycle.duration", MetricKind.HISTOGRAM, frozenset({"operation", "outcome"})),
|
||||
MetricSpec("agent.active_tasks", MetricKind.GAUGE, frozenset({"task_type"})),
|
||||
MetricSpec("agent.cancel", MetricKind.COUNTER, frozenset({"task_type", "outcome"})),
|
||||
MetricSpec("agent.provider.duration", MetricKind.HISTOGRAM, frozenset({"provider_type", "outcome"})),
|
||||
MetricSpec("agent.token_usage", MetricKind.COUNTER, frozenset({"provider_type", "direction"})),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class ObservationPort(Protocol):
|
||||
"""Application/runtime 可依赖的最小指标写入端口。"""
|
||||
|
||||
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
|
||||
"""记录一个已经通过标签合同校验的指标值。"""
|
||||
|
||||
|
||||
class NoopObservationPort:
|
||||
"""未安装或未启用 exporter 时完全无副作用的默认实现。"""
|
||||
|
||||
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
|
||||
"""接受合法指标但不分配 exporter 资源。"""
|
||||
|
||||
|
||||
_observation_port: ObservationPort = NoopObservationPort()
|
||||
|
||||
|
||||
def configure_observation(port: ObservationPort | None) -> None:
|
||||
"""由组合根替换进程级端口;None 明确恢复 no-op。"""
|
||||
global _observation_port
|
||||
_observation_port = port or NoopObservationPort()
|
||||
|
||||
|
||||
def record_metric(name: str, value: float = 1, **labels: str) -> None:
|
||||
"""校验名称、类型无关数值和标签白名单后写入当前端口。"""
|
||||
spec = METRIC_SPECS[name]
|
||||
unexpected = set(labels) - spec.labels
|
||||
if unexpected:
|
||||
raise ValueError(f"指标 {name} 包含未登记标签:{sorted(unexpected)}")
|
||||
_observation_port.record(spec, float(value), labels)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def observe_duration(name: str, **labels: str) -> Iterator[None]:
|
||||
"""记录代码块耗时,并把成功或失败收敛为低基数 outcome。"""
|
||||
started_at = time.perf_counter()
|
||||
outcome = "success"
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
outcome = "error"
|
||||
raise
|
||||
finally:
|
||||
duration_labels = dict(labels)
|
||||
if "outcome" in METRIC_SPECS[name].labels:
|
||||
duration_labels["outcome"] = outcome
|
||||
record_metric(name, time.perf_counter() - started_at, **duration_labels)
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import inspect
|
||||
import multiprocessing
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
@@ -46,6 +47,7 @@ from app.runtime.reload import ConfigReloadMixin
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.runtime.scheduling import TimerUtils
|
||||
from app.runtime.correlation import call_with_correlation, get_correlation_id
|
||||
from app.runtime.observability import record_metric
|
||||
|
||||
lock = threading.Lock()
|
||||
SCHEDULER_PROGRESS_PREFIX = "scheduler"
|
||||
@@ -562,7 +564,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
return None
|
||||
if not JobExecutionState.begin(job, started_at):
|
||||
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
|
||||
record_metric(
|
||||
"scheduler.job.overlap_skip",
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
)
|
||||
return None
|
||||
job["_metric_started_at"] = time.perf_counter()
|
||||
progress = ProgressHelper(self._get_progress_key(job_id))
|
||||
progress.start()
|
||||
progress.update(
|
||||
@@ -596,6 +603,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
JobExecutionState.finish(job, finished_at, error)
|
||||
metric_started_at = job.pop("_metric_started_at", None)
|
||||
if metric_started_at is not None:
|
||||
record_metric(
|
||||
"scheduler.job.duration",
|
||||
time.perf_counter() - metric_started_at,
|
||||
owner=str(job.get("owner", "unknown")),
|
||||
outcome="success" if success else "error",
|
||||
)
|
||||
job_name = job.get("name") if job else job_id
|
||||
# 收尾可能发生在事件循环上(__run_coro_job),使用异步进度后端避免阻塞
|
||||
progress = AsyncProgressHelper(self._get_progress_key(job_id))
|
||||
|
||||
@@ -756,6 +756,19 @@ app/scheduler.py # APScheduler 兼容 Facade
|
||||
|
||||
OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-capable observation Protocol。插件 ID、用户 ID、媒体标题等高基数字段不得直接作为 metric label。
|
||||
|
||||
**实施记录(2026-08-21)**:
|
||||
|
||||
- `app.runtime.observability` 定义单一 `ObservationPort`、默认 no-op、指标类型/目录、标签白名单和统一耗时
|
||||
作用域;没有 exporter 时所有调用仍可执行,未登记标签在进入 Adapter 前直接拒绝。
|
||||
- 指标目录覆盖 HTTP、DB pool、Event、Module、Scheduler、Plugin lifecycle 和 Agent 所列能力;标签审计
|
||||
明确禁止 user/plugin/media/request/job 实例 ID、标题和 URL。首批实际接线覆盖 HTTP route/status/latency、
|
||||
Event queue/handler、Module provider 与 Scheduler duration/overlap,剩余能力可按相同端口逐个接入。
|
||||
- `app.adapters.observability.otel` 只在组合根显式读取 `MOVIEPILOT_OTEL_METRICS=1` 后懒加载 OTel API;
|
||||
未安装可选包时稳定回退 no-op,不给核心层增加 SDK 依赖。HTTP Adapter 通过路由匹配输出模板,绝不以原始
|
||||
request path 充当 label。
|
||||
- 专项测试覆盖 exporter 缺失、非法标签、全目录高基数审计、成功/失败 outcome、动态 URL 路由模板;
|
||||
既有 API、Event、Module、Scheduler 与健康探针回归保持通过。
|
||||
|
||||
#### ARCH-270:渐进式类型门禁
|
||||
|
||||
**目标**:不要求全仓一次通过 mypy/pyright;只保证新 canonical contract 和被治理模块完整类型化。
|
||||
|
||||
+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