From dc9433f0dea170fdb1d21cc53f992ad675f30510 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 23 Aug 2026 00:11:15 +0800 Subject: [PATCH] feat: observe legacy module contract hits --- app/runtime/extensions/module/dispatcher.py | 37 +++++++++++ app/runtime/observability/__init__.py | 5 ++ .../backend-architecture-next-stage.md | 2 + tests/test_module_invocation_dispatcher.py | 65 +++++++++++++++++++ 4 files changed, 109 insertions(+) diff --git a/app/runtime/extensions/module/dispatcher.py b/app/runtime/extensions/module/dispatcher.py index 12a0d0c70..8435fdc5b 100644 --- a/app/runtime/extensions/module/dispatcher.py +++ b/app/runtime/extensions/module/dispatcher.py @@ -13,6 +13,7 @@ from app.runtime.observability import observe_duration, record_metric from app.runtime.extensions.module.contracts import ( diagnose_module_callable, get_module_method_contract, + is_explicit_module_method, ) from app.schemas.exception import RateLimitExceededException @@ -124,6 +125,11 @@ class ModuleInvocationDispatcher: func = module_dict.get(method) if not func: continue + self._record_legacy_hit( + method, + caller_type="plugin", + abi_source="third_party_plugin", + ) self._diagnose_callable(method, func, f"插件 {plugin_id}") logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): @@ -172,6 +178,11 @@ class ModuleInvocationDispatcher: func = module_dict.get(method) if not func: continue + self._record_legacy_hit( + method, + caller_type="plugin", + abi_source="third_party_plugin", + ) self._diagnose_callable(method, func, f"插件 {plugin_id}") logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): @@ -219,6 +230,11 @@ class ModuleInvocationDispatcher: module_name = self._module_name(module, module_id) try: func = getattr(module, method) + self._record_legacy_hit( + method, + caller_type="system", + abi_source="host_module", + ) self._diagnose_callable(method, func, f"宿主模块 {module_id}") if self.is_valid_empty(result): result = func(*args, **kwargs) @@ -267,6 +283,11 @@ class ModuleInvocationDispatcher: module_name = self._module_name(module, module_id) try: func = getattr(module, method) + self._record_legacy_hit( + method, + caller_type="system", + abi_source="host_module", + ) self._diagnose_callable(method, func, f"宿主模块 {module_id}") if self.is_valid_empty(result): result = await self._async_call(func, *args, **kwargs) @@ -307,6 +328,22 @@ class ModuleInvocationDispatcher: provider_type=provider_type, ) + @staticmethod + def _record_legacy_hit( + method: str, + *, + caller_type: str, + abi_source: str, + ) -> None: + """记录未知动态方法的兼容命中,便于按真实调用逐项迁移。""" + if not is_explicit_module_method(method): + record_metric( + "module.contract.legacy_hit", + method=method, + caller_type=caller_type, + abi_source=abi_source, + ) + @staticmethod def _diagnose_callable( method: str, diff --git a/app/runtime/observability/__init__.py b/app/runtime/observability/__init__.py index 5a1a3ba10..906d340b0 100644 --- a/app/runtime/observability/__init__.py +++ b/app/runtime/observability/__init__.py @@ -37,6 +37,11 @@ METRIC_SPECS = { 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( + "module.contract.legacy_hit", + MetricKind.COUNTER, + frozenset({"method", "caller_type", "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"})), diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 3ba787b04..486ce6aaa 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -644,6 +644,8 @@ ModuleMethodSpec( - 契约清单现覆盖静态扫描到的 211 个宿主字符串调用,并保留一个暂未被宿主调用的 `send_message` 公开能力, 共 212 个显式 V2 spec。原先仅按 prefix 分类或落入默认 legacy 的宿主方法均获得稳定 family、输入合同、 结果合同、执行、超时和错误语义;未知第三方自定义方法仍走开放 legacy fallback,不拒绝加载或执行。 +- 未知动态方法在真实 provider 命中时记录 `module.contract.legacy_hit`,区分插件/宿主调用方和 ABI 来源; + 该指标只在 callable 实际存在并准备执行时递增,不改变未知第三方方法的开放 fallback、聚合或异常语义。 #### ARCH-241:Event Contract Registry diff --git a/tests/test_module_invocation_dispatcher.py b/tests/test_module_invocation_dispatcher.py index b276b7bc3..56d448af8 100644 --- a/tests/test_module_invocation_dispatcher.py +++ b/tests/test_module_invocation_dispatcher.py @@ -222,6 +222,71 @@ def test_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None: plugin_error.assert_called_once() +def test_unknown_plugin_method_records_legacy_abi_hit(monkeypatch) -> None: + """未知第三方方法继续执行,同时记录可迁移的 legacy ABI 来源。""" + hits = [] + monkeypatch.setattr( + "app.runtime.extensions.module.dispatcher.record_metric", + lambda name, **labels: hits.append((name, labels)), + ) + dispatcher, _, _, _ = _dispatcher( + plugins={("P1", "插件一"): {"third_party_custom": lambda: "ok"}}, + ) + + assert dispatcher.dispatch("third_party_custom") == "ok" + assert hits == [ + ( + "module.contract.legacy_hit", + { + "method": "third_party_custom", + "caller_type": "plugin", + "abi_source": "third_party_plugin", + }, + ) + ] + + +def test_unknown_host_method_records_legacy_abi_hit(monkeypatch) -> None: + """宿主临时新增而未登记的方法保持执行并留下迁移信号。""" + hits = [] + monkeypatch.setattr( + "app.runtime.extensions.module.dispatcher.record_metric", + lambda name, **labels: hits.append((name, labels)), + ) + + class LegacyModule: + """提供未进入清单的宿主兼容方法。""" + + @staticmethod + def get_name() -> str: + """返回测试模块名称。""" + return "旧模块" + + @staticmethod + def get_priority() -> int: + """返回稳定测试优先级。""" + return 1 + + @staticmethod + def third_party_host() -> str: + """返回兼容方法结果。""" + return "ok" + + dispatcher, _, _, _ = _dispatcher(modules=[LegacyModule()]) + + assert dispatcher.dispatch("third_party_host") == "ok" + assert hits == [ + ( + "module.contract.legacy_hit", + { + "method": "third_party_host", + "caller_type": "system", + "abi_source": "host_module", + }, + ) + ] + + @pytest.mark.asyncio async def test_async_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None: """异步路径下坏插件同样被隔离,嵌套补丁场景不再冒泡击穿调度。"""