mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat: observe legacy facade method hits
This commit is contained in:
Vendored
+2
@@ -43,6 +43,7 @@ from app.adapters.system.plugin.manifest import (
|
||||
load_dependency_manifest,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.observability import observe_compat_facade
|
||||
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
||||
from app.foundation.singleton import WeakSingleton
|
||||
|
||||
@@ -165,6 +166,7 @@ def merge_plugin_market_repos(
|
||||
return merged_repos
|
||||
|
||||
|
||||
@observe_compat_facade("PluginHelper")
|
||||
class PluginHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
插件市场管理,下载安装插件到本地
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.foundation.crypto import RSAUtils
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.observability import observe_compat_facade
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.events import EventHandlerBinding, eventmanager
|
||||
from app.runtime.reload import ConfigReloadMixin
|
||||
@@ -120,6 +121,7 @@ def configure_plugin_catalog_factory(factory: PluginCatalogFactory) -> None:
|
||||
_plugin_catalog_factory = factory
|
||||
|
||||
|
||||
@observe_compat_facade("PluginManager")
|
||||
class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""插件管理器"""
|
||||
CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import functools
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Iterator, Mapping, Protocol
|
||||
from typing import Any, Callable, Iterator, Mapping, Protocol, TypeVar, cast
|
||||
|
||||
|
||||
class MetricKind(StrEnum):
|
||||
@@ -42,6 +44,11 @@ METRIC_SPECS = {
|
||||
MetricKind.COUNTER,
|
||||
frozenset({"method", "caller_type", "abi_source"}),
|
||||
),
|
||||
MetricSpec(
|
||||
"compat.facade.hit",
|
||||
MetricKind.COUNTER,
|
||||
frozenset({"facade", "operation", "visibility", "abi_source"}),
|
||||
),
|
||||
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"})),
|
||||
@@ -71,6 +78,73 @@ class NoopObservationPort:
|
||||
|
||||
_observation_port: ObservationPort = NoopObservationPort()
|
||||
|
||||
_FacadeClass = TypeVar("_FacadeClass", bound=type)
|
||||
|
||||
|
||||
def observe_compat_facade(facade: str) -> Callable[[_FacadeClass], _FacadeClass]:
|
||||
"""为旧 ABI Facade 的公开和私有方法记录低基数命中,不改变方法合同。"""
|
||||
|
||||
def decorate(cls: _FacadeClass) -> _FacadeClass:
|
||||
for name, descriptor in tuple(vars(cls).items()):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
continue
|
||||
visibility = "private" if name.startswith("_") else "public"
|
||||
if isinstance(descriptor, classmethod):
|
||||
wrapped = _wrap_compat_method(
|
||||
descriptor.__func__, facade, name, visibility
|
||||
)
|
||||
setattr(cls, name, classmethod(wrapped))
|
||||
elif isinstance(descriptor, staticmethod):
|
||||
wrapped = _wrap_compat_method(
|
||||
descriptor.__func__, facade, name, visibility
|
||||
)
|
||||
setattr(cls, name, staticmethod(wrapped))
|
||||
elif callable(descriptor):
|
||||
setattr(
|
||||
cls,
|
||||
name,
|
||||
_wrap_compat_method(descriptor, facade, name, visibility),
|
||||
)
|
||||
return cls
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def _wrap_compat_method(
|
||||
method: Callable[..., Any],
|
||||
facade: str,
|
||||
operation: str,
|
||||
visibility: str,
|
||||
) -> Callable[..., Any]:
|
||||
"""包装一个兼容方法并保持同步/异步调用形态及反射元数据。"""
|
||||
if inspect.iscoroutinefunction(method):
|
||||
|
||||
@functools.wraps(method)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
record_metric(
|
||||
"compat.facade.hit",
|
||||
facade=facade,
|
||||
operation=operation,
|
||||
visibility=visibility,
|
||||
abi_source="legacy_facade",
|
||||
)
|
||||
return await method(*args, **kwargs)
|
||||
|
||||
return cast(Callable[..., Any], async_wrapper)
|
||||
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
record_metric(
|
||||
"compat.facade.hit",
|
||||
facade=facade,
|
||||
operation=operation,
|
||||
visibility=visibility,
|
||||
abi_source="legacy_facade",
|
||||
)
|
||||
return method(*args, **kwargs)
|
||||
|
||||
return cast(Callable[..., Any], wrapper)
|
||||
|
||||
|
||||
def configure_observation(port: ObservationPort | None) -> None:
|
||||
"""由组合根替换进程级端口;None 明确恢复 no-op。"""
|
||||
|
||||
@@ -1129,6 +1129,12 @@ startup 注入具体依赖
|
||||
|
||||
阶段 5 的“拆分”是职责入口和组合依赖的拆分,不等于本轮把旧 `PluginHelper` 的全部 3,066 行算法复制到新文件。旧类仍是正式 V3 ABI,保留原类名、对象/静态方法和旧私有调用;新宿主路径使用上述 canonical client、package、dependency 和 Application command。后续如需继续内移算法,必须先增加旧私有调用命中统计和逐方法行为快照。
|
||||
|
||||
**实施记录(2026-08-23)**:`app.runtime.observability.observe_compat_facade()` 为
|
||||
`PluginManager`、`PluginHelper`、`MoviePilotServerHelper` 的公开及旧私有方法记录
|
||||
`compat.facade.hit`。指标只使用 Facade 名称、稳定方法名、公开/私有可见性和固定 ABI 来源,保留
|
||||
同步/异步 descriptor、签名和对象身份;三类入口的离线测试已覆盖命中记录。该统计是迁移取证,不代表
|
||||
算法已全部内移,后续仍需按命中最高的方法建立行为快照后逐项迁移。
|
||||
|
||||
这里的“兼容”分为两类,后续 AI 不得混淆:
|
||||
|
||||
1. 已迁移、只需恢复旧模块路径的入口,统一登记到 `app/runtime/compat/manifest.py`,新实现模块不复制旧对象导出。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import inspect
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
@@ -11,9 +12,12 @@ from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from app.adapters.observability import otel
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.web.metrics import HttpMetricsMiddleware
|
||||
from app.db.engine import _register_database_pool_metrics
|
||||
from app.runtime.extensions.plugin.lifecycle import observe_plugin_lifecycle
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.runtime.observability import (
|
||||
METRIC_SPECS,
|
||||
@@ -174,3 +178,52 @@ def test_plugin_lifecycle_failed_status_records_error_outcome() -> None:
|
||||
spec, _, labels = port.records[-1]
|
||||
assert spec.name == "plugin.lifecycle.duration"
|
||||
assert labels == {"operation": "start", "outcome": "error"}
|
||||
|
||||
|
||||
def test_legacy_facades_record_public_and_private_hits() -> None:
|
||||
"""三个正式旧 ABI Facade 的公开/私有调用都应留下可审计命中。"""
|
||||
port = RecordingObservationPort()
|
||||
configure_observation(port)
|
||||
|
||||
assert PluginHelper.is_local_repo_url("local://example")
|
||||
assert PluginManager._normalize_plugin_label("example") == "example"
|
||||
assert MoviePilotServerHelper._has_header({}, "X-Test") is False
|
||||
assert inspect.iscoroutinefunction(MoviePilotServerHelper.async_subscribe_done)
|
||||
assert str(inspect.signature(MoviePilotServerHelper.async_subscribe_done)) == (
|
||||
"(payload: Dict[str, Any])"
|
||||
)
|
||||
|
||||
hits = [
|
||||
(spec.name, labels)
|
||||
for spec, _value, labels in port.records
|
||||
if spec.name == "compat.facade.hit"
|
||||
]
|
||||
assert hits == [
|
||||
(
|
||||
"compat.facade.hit",
|
||||
{
|
||||
"facade": "PluginHelper",
|
||||
"operation": "is_local_repo_url",
|
||||
"visibility": "public",
|
||||
"abi_source": "legacy_facade",
|
||||
},
|
||||
),
|
||||
(
|
||||
"compat.facade.hit",
|
||||
{
|
||||
"facade": "PluginManager",
|
||||
"operation": "_normalize_plugin_label",
|
||||
"visibility": "private",
|
||||
"abi_source": "legacy_facade",
|
||||
},
|
||||
),
|
||||
(
|
||||
"compat.facade.hit",
|
||||
{
|
||||
"facade": "MoviePilotServerHelper",
|
||||
"operation": "_has_header",
|
||||
"visibility": "private",
|
||||
"abi_source": "legacy_facade",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user