mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +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,
|
load_dependency_manifest,
|
||||||
)
|
)
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
from app.runtime.observability import observe_compat_facade
|
||||||
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
||||||
from app.foundation.singleton import WeakSingleton
|
from app.foundation.singleton import WeakSingleton
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@ def merge_plugin_market_repos(
|
|||||||
return merged_repos
|
return merged_repos
|
||||||
|
|
||||||
|
|
||||||
|
@observe_compat_facade("PluginHelper")
|
||||||
class PluginHelper(metaclass=WeakSingleton):
|
class PluginHelper(metaclass=WeakSingleton):
|
||||||
"""
|
"""
|
||||||
插件市场管理,下载安装插件到本地
|
插件市场管理,下载安装插件到本地
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.foundation.crypto import RSAUtils
|
|||||||
from app.foundation.singleton import Singleton
|
from app.foundation.singleton import Singleton
|
||||||
from app.foundation.version import compare_version
|
from app.foundation.version import compare_version
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
from app.runtime.observability import observe_compat_facade
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
from app.runtime.events import EventHandlerBinding, eventmanager
|
from app.runtime.events import EventHandlerBinding, eventmanager
|
||||||
from app.runtime.reload import ConfigReloadMixin
|
from app.runtime.reload import ConfigReloadMixin
|
||||||
@@ -120,6 +121,7 @@ def configure_plugin_catalog_factory(factory: PluginCatalogFactory) -> None:
|
|||||||
_plugin_catalog_factory = factory
|
_plugin_catalog_factory = factory
|
||||||
|
|
||||||
|
|
||||||
|
@observe_compat_facade("PluginManager")
|
||||||
class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||||
"""插件管理器"""
|
"""插件管理器"""
|
||||||
CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"}
|
CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Iterator, Mapping, Protocol
|
from typing import Any, Callable, Iterator, Mapping, Protocol, TypeVar, cast
|
||||||
|
|
||||||
|
|
||||||
class MetricKind(StrEnum):
|
class MetricKind(StrEnum):
|
||||||
@@ -42,6 +44,11 @@ METRIC_SPECS = {
|
|||||||
MetricKind.COUNTER,
|
MetricKind.COUNTER,
|
||||||
frozenset({"method", "caller_type", "abi_source"}),
|
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.duration", MetricKind.HISTOGRAM, frozenset({"owner", "outcome"})),
|
||||||
MetricSpec("scheduler.job.overlap_skip", MetricKind.COUNTER, frozenset({"owner"})),
|
MetricSpec("scheduler.job.overlap_skip", MetricKind.COUNTER, frozenset({"owner"})),
|
||||||
MetricSpec("scheduler.job.retry", MetricKind.COUNTER, frozenset({"owner"})),
|
MetricSpec("scheduler.job.retry", MetricKind.COUNTER, frozenset({"owner"})),
|
||||||
@@ -71,6 +78,73 @@ class NoopObservationPort:
|
|||||||
|
|
||||||
_observation_port: ObservationPort = 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:
|
def configure_observation(port: ObservationPort | None) -> None:
|
||||||
"""由组合根替换进程级端口;None 明确恢复 no-op。"""
|
"""由组合根替换进程级端口;None 明确恢复 no-op。"""
|
||||||
|
|||||||
@@ -1129,6 +1129,12 @@ startup 注入具体依赖
|
|||||||
|
|
||||||
阶段 5 的“拆分”是职责入口和组合依赖的拆分,不等于本轮把旧 `PluginHelper` 的全部 3,066 行算法复制到新文件。旧类仍是正式 V3 ABI,保留原类名、对象/静态方法和旧私有调用;新宿主路径使用上述 canonical client、package、dependency 和 Application command。后续如需继续内移算法,必须先增加旧私有调用命中统计和逐方法行为快照。
|
阶段 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 不得混淆:
|
这里的“兼容”分为两类,后续 AI 不得混淆:
|
||||||
|
|
||||||
1. 已迁移、只需恢复旧模块路径的入口,统一登记到 `app/runtime/compat/manifest.py`,新实现模块不复制旧对象导出。
|
1. 已迁移、只需恢复旧模块路径的入口,统一登记到 `app/runtime/compat/manifest.py`,新实现模块不复制旧对象导出。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
|
"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
import inspect
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -11,9 +12,12 @@ from starlette.responses import PlainTextResponse
|
|||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
from app.adapters.observability import otel
|
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.adapters.web.metrics import HttpMetricsMiddleware
|
||||||
from app.db.engine import _register_database_pool_metrics
|
from app.db.engine import _register_database_pool_metrics
|
||||||
from app.runtime.extensions.plugin.lifecycle import observe_plugin_lifecycle
|
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.schemas.plugin import PluginRuntimeStatus
|
||||||
from app.runtime.observability import (
|
from app.runtime.observability import (
|
||||||
METRIC_SPECS,
|
METRIC_SPECS,
|
||||||
@@ -174,3 +178,52 @@ def test_plugin_lifecycle_failed_status_records_error_outcome() -> None:
|
|||||||
spec, _, labels = port.records[-1]
|
spec, _, labels = port.records[-1]
|
||||||
assert spec.name == "plugin.lifecycle.duration"
|
assert spec.name == "plugin.lifecycle.duration"
|
||||||
assert labels == {"operation": "start", "outcome": "error"}
|
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