feat: observe legacy facade method hits

This commit is contained in:
jxxghp
2026-08-23 00:37:03 +08:00
parent 2031f3c420
commit a001291c0b
5 changed files with 138 additions and 1 deletions
+2
View File
@@ -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):
"""
插件市场管理,下载安装插件到本地
+2
View File
@@ -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"}
+75 -1
View File
@@ -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`,新实现模块不复制旧对象导出。
+53
View File
@@ -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",
},
),
]