diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index e39b5f010..f05371852 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -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): """ 插件市场管理,下载安装插件到本地 diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index c989fd2c2..93eab49ae 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -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"} diff --git a/app/runtime/observability/__init__.py b/app/runtime/observability/__init__.py index 906d340b0..cf7f198b3 100644 --- a/app/runtime/observability/__init__.py +++ b/app/runtime/observability/__init__.py @@ -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。""" diff --git a/docs/refactor/backend-architecture-governance.md b/docs/refactor/backend-architecture-governance.md index 2d551ae7f..19c6d00c7 100644 --- a/docs/refactor/backend-architecture-governance.md +++ b/docs/refactor/backend-architecture-governance.md @@ -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`,新实现模块不复制旧对象导出。 diff --git a/tests/test_observability.py b/tests/test_observability.py index 9020915c7..72769e09b 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.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", + }, + ), + ]