mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 07:17:18 +00:00
feat: collect beta-only adapter acceptance evidence
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Beta 适配器诊断开发说明
|
||||||
|
|
||||||
|
此诊断用于 4.11 适配器事件与 API 的 Beta 验收。仅受支持的 Beta 版本启用;稳定版、Alpha、RC、开发/本地标记版本以及关闭遥测或 Beta 诊断的实例不采集、不上传。
|
||||||
|
|
||||||
|
## 采集位置
|
||||||
|
|
||||||
|
- `pkg/platform/botmgr.py`:完成适配器事件监听器注册后产生能力快照。`listener_registered` 表示注册完成,不代表网络连接可用。
|
||||||
|
- 各适配器的 `_dispatch_eba_event`:调用 `diagnostics.adapter_event_received(self, event)`,记录一次事件已收到。必须位于调用监听器之前,避免路由、插件或模型失败改变接收事实。
|
||||||
|
- `pkg/telemetry/adapter_diagnostics.py`:识别适配器 API 最外层调用、平台专用 API 名、聊天与媒体类型。通过内部上下文标记抑制嵌套 API 重复计数。
|
||||||
|
- 转换失败无法可靠确定事件种类时,保留未知事件失败;转换成功本身不重复计算事件接收。
|
||||||
|
|
||||||
|
新增适配器或原生事件入口时,应检查事件是否经过统一分发入口。只在转换器上添加埋点不足以覆盖直接构造事件及交互回调。
|
||||||
|
|
||||||
|
## 数据约束
|
||||||
|
|
||||||
|
`adapter_evidence` 标记可用于验收的边界记录;`listener_registered` 标记注册事实;`chat_type`、`content_type` 仅允许有限类别。消息正文、API 参数、用户/群 ID、异常正文、媒体地址不进入这些字段。内部嵌套调用标记不得序列化。
|
||||||
|
|
||||||
|
平台专用动作优先匹配清单,名称为 `platform_api.<动作名>`;标准交互 API 保持其标准名称。未知动作不能借通用 `call_platform_api` 包装方法被计为已验收功能。清单读取显式使用 UTF-8,兼容 Windows 默认编码环境。
|
||||||
|
|
||||||
|
## 联动 Space
|
||||||
|
|
||||||
|
适配器清单或诊断操作变更后,同步生成 Space 的 `internal/diagnostics/catalog_v1.json`,并运行其目录一致性测试。Space 从清单生成完整矩阵,旧诊断记录不作为新增验收证据。部署时应先更新 Space 接收端与数据库迁移,再更新 Core Beta。
|
||||||
|
|
||||||
|
Core 回归命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run pytest tests/unit_tests/telemetry -q
|
||||||
|
```
|
||||||
|
|
||||||
|
重点测试在 `test_adapter_acceptance.py`:版本与开关、隐私、嵌套计数、专用/交互 API、分发入口覆盖、监听器失败。Space 仓库的 `docs/beta-adapter-acceptance.md` 说明验收界面、统计口径及联调方式。
|
||||||
@@ -174,6 +174,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
|||||||
await self._dispatch_eba_event(eba_event)
|
await self._dispatch_eba_event(eba_event)
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
|
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
await self.logger.error(f'Error in lark message event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in lark message event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.Event):
|
async def _dispatch_eba_event(self, event: platform_events.Event):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
await self.logger.error(f'Error in slack native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in slack native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
"""Dispatch once, preferring the most specific registered listener."""
|
"""Dispatch once, preferring the most specific registered listener."""
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -387,7 +387,13 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
@diagnostics.observe(
|
||||||
|
'event',
|
||||||
|
'platform.target2yiri',
|
||||||
|
source='platform',
|
||||||
|
stage='convert',
|
||||||
|
fields=lambda _: {'attributes': {'adapter_evidence': False}},
|
||||||
|
)
|
||||||
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
||||||
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
|
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
await self.logger.error(f'Error in wecomcs native event: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecomcs native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||||
|
diagnostics.adapter_event_received(self, event)
|
||||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||||
callback = self.listeners.get(event_type)
|
callback = self.listeners.get(event_type)
|
||||||
if callback:
|
if callback:
|
||||||
|
|||||||
@@ -1549,7 +1549,6 @@ class RuntimeBot:
|
|||||||
|
|
||||||
from ..telemetry.diagnostic_catalog import snapshot_bot
|
from ..telemetry.diagnostic_catalog import snapshot_bot
|
||||||
|
|
||||||
snapshot_bot(self)
|
|
||||||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||||||
supported_events: list[str] = []
|
supported_events: list[str] = []
|
||||||
if callable(get_supported_events):
|
if callable(get_supported_events):
|
||||||
@@ -1621,6 +1620,8 @@ class RuntimeBot:
|
|||||||
platform_events.EBAEvent,
|
platform_events.EBAEvent,
|
||||||
tenant_scoped_listener(on_eba_event),
|
tenant_scoped_listener(on_eba_event),
|
||||||
)
|
)
|
||||||
|
# Registration is evidence of an installed listener, not a live connection.
|
||||||
|
snapshot_bot(self, listener_registered=True)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
async def exception_wrapper():
|
async def exception_wrapper():
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Finite adapter acceptance evidence, without message contents or target IDs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .diagnostic_catalog import catalog
|
||||||
|
|
||||||
|
|
||||||
|
def boundary_fields(module, kind, operation, bound, parent):
|
||||||
|
"""Only the actual adapter API / EBA conversion boundary counts as a test."""
|
||||||
|
entry = next((v for k, v in catalog().items() if k in module.split('.')), None)
|
||||||
|
if entry is None or '.platform.adapters.' not in module:
|
||||||
|
return {}
|
||||||
|
evidence = kind == 'event' and operation == 'platform.target2yiri'
|
||||||
|
resolved = operation
|
||||||
|
if kind == 'api':
|
||||||
|
evidence = True
|
||||||
|
if operation == 'call_platform_api':
|
||||||
|
action = bound.get('action')
|
||||||
|
if isinstance(action, str) and 'platform_api.' + action in entry['specific_apis']:
|
||||||
|
resolved = 'platform_api.' + action
|
||||||
|
elif action in entry['apis'] and action != 'call_platform_api':
|
||||||
|
resolved = action
|
||||||
|
else:
|
||||||
|
# Keep the generic boundary for investigation, not a false named test.
|
||||||
|
evidence = False
|
||||||
|
# A forwarding API may call another decorated method. Count the outer call.
|
||||||
|
if parent and parent.adapter_api_active:
|
||||||
|
evidence = False
|
||||||
|
if not evidence:
|
||||||
|
return {'_adapter_api_active': kind == 'api'}
|
||||||
|
return {
|
||||||
|
'_adapter_api_active': kind == 'api',
|
||||||
|
'operation': resolved,
|
||||||
|
'attributes': {'adapter_evidence': True, **message_scenario(bound)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message_scenario(bound):
|
||||||
|
"""Read typed routing/media categories only; never serialize a payload."""
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.message import MessageChain
|
||||||
|
|
||||||
|
event = bound.get('event') or bound.get('message_source')
|
||||||
|
target = bound.get('target_type') or bound.get('chat_type')
|
||||||
|
message = bound.get('message') or bound.get('new_content')
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
target = event.chat_type
|
||||||
|
message = event.message_chain
|
||||||
|
result = {}
|
||||||
|
if target is not None:
|
||||||
|
target = getattr(target, 'value', target)
|
||||||
|
if target == 'private':
|
||||||
|
target = 'person'
|
||||||
|
result['chat_type'] = target if target in ('person', 'group') else 'unknown'
|
||||||
|
if isinstance(message, MessageChain):
|
||||||
|
types = set()
|
||||||
|
for item in message:
|
||||||
|
name = type(item).__name__
|
||||||
|
types.add(
|
||||||
|
{
|
||||||
|
'Plain': 'text',
|
||||||
|
'Image': 'image',
|
||||||
|
'Voice': 'audio',
|
||||||
|
'Audio': 'audio',
|
||||||
|
'Video': 'video',
|
||||||
|
'File': 'file',
|
||||||
|
}.get(name, 'other')
|
||||||
|
)
|
||||||
|
result['content_type'] = next(iter(types)) if len(types) == 1 else 'mixed' if types else 'unknown'
|
||||||
|
return result
|
||||||
@@ -15,7 +15,7 @@ def catalog():
|
|||||||
result = {}
|
result = {}
|
||||||
base = Path(__file__).resolve().parents[1] / 'platform'
|
base = Path(__file__).resolve().parents[1] / 'platform'
|
||||||
for manifest in sorted((base / 'adapters').glob('*/manifest.yaml')):
|
for manifest in sorted((base / 'adapters').glob('*/manifest.yaml')):
|
||||||
data = yaml.safe_load(manifest.read_text())
|
data = yaml.safe_load(manifest.read_text(encoding='utf-8'))
|
||||||
spec = data.get('spec', {})
|
spec = data.get('spec', {})
|
||||||
name = data['metadata']['name']
|
name = data['metadata']['name']
|
||||||
privacy.code_value('adapter', name)
|
privacy.code_value('adapter', name)
|
||||||
@@ -26,9 +26,16 @@ def catalog():
|
|||||||
privacy.code_value('platform_event_type', event)
|
privacy.code_value('platform_event_type', event)
|
||||||
for operation in apis:
|
for operation in apis:
|
||||||
privacy.code_value('operation', operation)
|
privacy.code_value('operation', operation)
|
||||||
|
specific_apis = []
|
||||||
for api in spec.get('platform_specific_apis', []):
|
for api in spec.get('platform_specific_apis', []):
|
||||||
privacy.code_value('operation', api['action'])
|
privacy.code_value('operation', api['action'])
|
||||||
result[manifest.parent.name] = {'adapter': name, 'events': events, 'apis': apis}
|
specific_apis.append(privacy.code_value('operation', 'platform_api.' + api['action']))
|
||||||
|
result[manifest.parent.name] = {
|
||||||
|
'adapter': name,
|
||||||
|
'events': events,
|
||||||
|
'apis': apis,
|
||||||
|
'specific_apis': specific_apis,
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ def adapter_fields(adapter):
|
|||||||
return {'adapter': entry['adapter']} if entry else {}
|
return {'adapter': entry['adapter']} if entry else {}
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_bot(bot):
|
def _snapshot_bot(bot, *, listener_registered=False):
|
||||||
from . import diagnostics
|
from . import diagnostics
|
||||||
|
|
||||||
manager = getattr(bot.ap, 'diagnostics', None)
|
manager = getattr(bot.ap, 'diagnostics', None)
|
||||||
@@ -51,13 +58,15 @@ def _snapshot_bot(bot):
|
|||||||
entry = next(e for e in catalog().values() if e['adapter'] == fields['adapter'])
|
entry = next(e for e in catalog().values() if e['adapter'] == fields['adapter'])
|
||||||
for capability_type, method, declared in (
|
for capability_type, method, declared in (
|
||||||
('event', 'get_supported_events', entry['events']),
|
('event', 'get_supported_events', entry['events']),
|
||||||
('api', 'get_supported_apis', entry['apis']),
|
('api', 'get_supported_apis', entry['apis'] + entry['specific_apis']),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
supported = set(getattr(bot.adapter, method)() or [])
|
supported = set(getattr(bot.adapter, method)() or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
supported = set()
|
supported = set()
|
||||||
for name in declared:
|
for name in declared:
|
||||||
|
if name == 'call_platform_api':
|
||||||
|
continue
|
||||||
manager.emit(
|
manager.emit(
|
||||||
'capability',
|
'capability',
|
||||||
name if capability_type == 'api' else 'platform.receive',
|
name if capability_type == 'api' else 'platform.receive',
|
||||||
@@ -69,15 +78,16 @@ def _snapshot_bot(bot):
|
|||||||
attributes={
|
attributes={
|
||||||
'capability_type': capability_type,
|
'capability_type': capability_type,
|
||||||
'capability_name': name,
|
'capability_name': name,
|
||||||
'supported': name in supported,
|
'supported': name in supported
|
||||||
|
or (name in entry['specific_apis'] and 'call_platform_api' in supported),
|
||||||
'configured': True,
|
'configured': True,
|
||||||
'available': True,
|
**({'listener_registered': listener_registered} if capability_type == 'event' else {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_bot(bot):
|
def snapshot_bot(bot, *, listener_registered=False):
|
||||||
try:
|
try:
|
||||||
_snapshot_bot(bot)
|
_snapshot_bot(bot, listener_registered=listener_registered)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ VOCABULARY: dict[str, set[str]] = {
|
|||||||
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
|
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
|
||||||
'database': {'sqlite', 'postgresql'},
|
'database': {'sqlite', 'postgresql'},
|
||||||
'edition': {'community', 'cloud', 'enterprise'},
|
'edition': {'community', 'cloud', 'enterprise'},
|
||||||
|
'chat_type': {'person', 'group', 'unknown'},
|
||||||
|
'content_type': {'text', 'image', 'audio', 'video', 'file', 'mixed', 'other', 'unknown'},
|
||||||
}
|
}
|
||||||
BOOLS = frozenset('stream synthetic configured available previous_session_unclean recovered supported'.split())
|
BOOLS = frozenset(
|
||||||
|
'stream synthetic configured available previous_session_unclean recovered supported adapter_evidence listener_registered'.split()
|
||||||
|
)
|
||||||
NUMBERS = frozenset(
|
NUMBERS = frozenset(
|
||||||
'attempts successes failures cancellations timeouts partial unknown generated queued acked dropped retried failed queue_size capacity result_count input_tokens output_tokens'.split()
|
'attempts successes failures cancellations timeouts partial unknown generated queued acked dropped retried failed queue_size capacity result_count input_tokens output_tokens'.split()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,43 @@ def annotate(**fields):
|
|||||||
span.fields.update(fields)
|
span.fields.update(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def adapter_event_received(owner, event):
|
||||||
|
"""Record one converted event before dispatch, including native callback paths."""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
manager = _manager(owner)
|
||||||
|
if manager is None:
|
||||||
|
return
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
|
||||||
|
from .adapter_diagnostics import message_scenario
|
||||||
|
|
||||||
|
if not isinstance(event, EBAEvent) or not _context_matches(manager, owner, _owner_context(owner)):
|
||||||
|
return
|
||||||
|
fields = _context_fields(owner, {'event': event})
|
||||||
|
fields['attributes'] = {
|
||||||
|
**fields.get('attributes', {}),
|
||||||
|
'adapter_evidence': True,
|
||||||
|
**message_scenario({'event': event}),
|
||||||
|
}
|
||||||
|
parent = current_span()
|
||||||
|
if (
|
||||||
|
parent
|
||||||
|
and parent.manager is manager
|
||||||
|
and not (
|
||||||
|
fields.get('workspace_uuid')
|
||||||
|
and parent.fields.get('workspace_uuid')
|
||||||
|
and fields['workspace_uuid'] != parent.fields['workspace_uuid']
|
||||||
|
)
|
||||||
|
):
|
||||||
|
fields['trace_id'] = parent.fields['trace_id']
|
||||||
|
fields['parent_span_id'] = parent.fields['span_id']
|
||||||
|
if parent.fields.get('source') in ('webui_debug', 'synthetic'):
|
||||||
|
fields['source'] = parent.fields['source']
|
||||||
|
fields['attributes']['synthetic'] = True
|
||||||
|
fields.setdefault('source', 'platform')
|
||||||
|
privacy.code_value('operation', 'platform.adapter_event')
|
||||||
|
manager.emit('event', 'platform.adapter_event', 'succeeded', stage='accepted', **fields)
|
||||||
|
|
||||||
|
|
||||||
def _owner_app(owner):
|
def _owner_app(owner):
|
||||||
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
|
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
|
||||||
if owner is None:
|
if owner is None:
|
||||||
@@ -148,6 +185,7 @@ class Span:
|
|||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.operation = operation
|
self.operation = operation
|
||||||
self.fields = dict(fields)
|
self.fields = dict(fields)
|
||||||
|
self.adapter_api_active = bool(self.fields.pop('_adapter_api_active', False))
|
||||||
if (
|
if (
|
||||||
parent
|
parent
|
||||||
and parent.fields.get('workspace_uuid')
|
and parent.fields.get('workspace_uuid')
|
||||||
@@ -167,6 +205,7 @@ class Span:
|
|||||||
)
|
)
|
||||||
self.fields['span_id'] = str(uuid4())
|
self.fields['span_id'] = str(uuid4())
|
||||||
if parent and parent.manager is manager:
|
if parent and parent.manager is manager:
|
||||||
|
self.adapter_api_active = self.adapter_api_active or parent.adapter_api_active
|
||||||
self.fields['parent_span_id'] = parent.fields['span_id']
|
self.fields['parent_span_id'] = parent.fields['span_id']
|
||||||
for key in ('workspace_uuid', 'adapter', 'processor_type', 'platform_event_type', 'run_id'):
|
for key in ('workspace_uuid', 'adapter', 'processor_type', 'platform_event_type', 'run_id'):
|
||||||
if not self.fields.get(key) and parent.fields.get(key):
|
if not self.fields.get(key) and parent.fields.get(key):
|
||||||
@@ -213,8 +252,25 @@ def result_outcome(value):
|
|||||||
if span is not None and span.fields.get('stage') == 'convert':
|
if span is not None and span.fields.get('stage') == 'convert':
|
||||||
if isinstance(value, EBAEvent):
|
if isinstance(value, EBAEvent):
|
||||||
annotate(platform_event_type=value.type)
|
annotate(platform_event_type=value.type)
|
||||||
|
if span.fields.get('attributes', {}).get('adapter_evidence'):
|
||||||
|
from .adapter_diagnostics import message_scenario
|
||||||
|
|
||||||
|
annotate(attributes={**span.fields['attributes'], **message_scenario({'event': value})})
|
||||||
|
# Successful conversion is counted once at dispatch, which also
|
||||||
|
# covers adapters constructing EBA events in native callbacks.
|
||||||
|
span.fields['attributes']['adapter_evidence'] = False
|
||||||
elif value is None:
|
elif value is None:
|
||||||
set_outcome('skipped', reason_code='not_matched')
|
set_outcome('skipped', reason_code='not_matched')
|
||||||
|
if not isinstance(value, EBAEvent) and span.fields.get('attributes', {}).get('adapter_evidence'):
|
||||||
|
span.fields['attributes']['adapter_evidence'] = False
|
||||||
|
if span is not None and span.kind == 'api' and span.fields.get('attributes', {}).get('adapter_evidence'):
|
||||||
|
# Common adapter response contracts expose status without inspecting content.
|
||||||
|
if isinstance(value, dict) and (
|
||||||
|
value.get('ok') is False
|
||||||
|
or value.get('status') == 'failed'
|
||||||
|
or (type(value.get('retcode')) is int and value['retcode'] != 0)
|
||||||
|
):
|
||||||
|
set_outcome('failed', reason_code='response_error')
|
||||||
if isinstance(value, ActionResponse):
|
if isinstance(value, ActionResponse):
|
||||||
if value.code != 0:
|
if value.code != 0:
|
||||||
set_outcome('failed', reason_code='response_error')
|
set_outcome('failed', reason_code='response_error')
|
||||||
@@ -261,6 +317,24 @@ def observe(kind, operation, *, source='internal', stage='execute', ap=None, fie
|
|||||||
extra = fields(bound)
|
extra = fields(bound)
|
||||||
extra['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
|
extra['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
|
||||||
metadata.update(extra)
|
metadata.update(extra)
|
||||||
|
from .adapter_diagnostics import boundary_fields
|
||||||
|
|
||||||
|
if parent and (
|
||||||
|
parent.manager is not manager
|
||||||
|
or (
|
||||||
|
metadata.get('workspace_uuid')
|
||||||
|
and parent.fields.get('workspace_uuid')
|
||||||
|
and metadata['workspace_uuid'] != parent.fields['workspace_uuid']
|
||||||
|
)
|
||||||
|
):
|
||||||
|
parent = None
|
||||||
|
evidence = (
|
||||||
|
boundary_fields(fn.__module__, kind, operation, bound, parent)
|
||||||
|
if metadata.get('attributes', {}).get('adapter_evidence') is not False
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
metadata['attributes'] = {**metadata.get('attributes', {}), **evidence.pop('attributes', {})}
|
||||||
|
metadata.update(evidence)
|
||||||
return Span(manager, kind, operation, metadata)
|
return Span(manager, kind, operation, metadata)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Adapter acceptance boundaries and strict Beta-only production."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain, Image
|
||||||
|
|
||||||
|
from langbot.pkg.telemetry import diagnostics as d
|
||||||
|
from langbot.pkg.telemetry import adapter_diagnostics as adapter
|
||||||
|
from langbot.pkg.telemetry.diagnostic_catalog import snapshot_bot, catalog
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(version='4.11.0b2', **config):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
|
||||||
|
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test', capacity=2048)
|
||||||
|
return ap.diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def boundary(fn, operation='send_message', kind='api'):
|
||||||
|
fn.__module__ = 'langbot.pkg.platform.adapters.telegram.adapter'
|
||||||
|
return d.observe(kind, operation, source='platform', stage='convert' if kind == 'event' else 'accepted')(fn)
|
||||||
|
|
||||||
|
|
||||||
|
def evidence(m):
|
||||||
|
return [e for e in m.pending if e['attributes'].get('adapter_evidence') and e['outcome'] != 'started']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'version,config',
|
||||||
|
[
|
||||||
|
('4.11.0', {}),
|
||||||
|
('4.11.0a1', {}),
|
||||||
|
('4.11.0rc1', {}),
|
||||||
|
('4.11.0.dev1', {}),
|
||||||
|
('4.11.0b2+local', {}),
|
||||||
|
('invalid', {}),
|
||||||
|
('4.11.0b2', {'disable_telemetry': True}),
|
||||||
|
('4.11.0b2', {'disable_beta_diagnostics': True}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_disabled_adapter_produces_nothing(version, config, monkeypatch):
|
||||||
|
m = make_manager(version, **config)
|
||||||
|
|
||||||
|
def forbidden(*args, **kwargs):
|
||||||
|
raise AssertionError('disabled producer projected adapter metadata')
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, 'boundary_fields', forbidden)
|
||||||
|
|
||||||
|
async def call(self):
|
||||||
|
return 42
|
||||||
|
|
||||||
|
assert await boundary(call)(SimpleNamespace(ap=m.ap)) == 42
|
||||||
|
snapshot_bot(SimpleNamespace(ap=m.ap), listener_registered=True)
|
||||||
|
d.adapter_event_received(SimpleNamespace(ap=m.ap), MessageReceivedEvent())
|
||||||
|
assert not m.pending
|
||||||
|
assert m.counters['generated'] == 0
|
||||||
|
await m.flush_once()
|
||||||
|
assert m.counters['acked'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nested_api_counted_once_and_only_finite_scenario():
|
||||||
|
m = make_manager()
|
||||||
|
owner = SimpleNamespace(ap=m.ap)
|
||||||
|
|
||||||
|
async def leaf(self, message, target_type):
|
||||||
|
return {'ok': True, 'private_result': 'SECRET_CANARY'}
|
||||||
|
|
||||||
|
third = boundary(leaf)
|
||||||
|
|
||||||
|
async def middle(self, message, target_type):
|
||||||
|
return await third(self, message, target_type)
|
||||||
|
|
||||||
|
second = boundary(middle)
|
||||||
|
|
||||||
|
async def outer(self, message, target_type):
|
||||||
|
return await second(self, message, target_type)
|
||||||
|
|
||||||
|
await boundary(outer)(
|
||||||
|
owner, MessageChain([Plain(text='SECRET_CANARY'), Image(url='https://secret.invalid')]), 'person'
|
||||||
|
)
|
||||||
|
rows = evidence(m)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['attributes'] == {'adapter_evidence': True, 'chat_type': 'person', 'content_type': 'mixed'}
|
||||||
|
assert len(m.pending) == 6
|
||||||
|
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
|
||||||
|
assert '_adapter_api_active' not in json.dumps(list(m.pending))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_specific_interaction_unknown_and_failure():
|
||||||
|
m = make_manager()
|
||||||
|
|
||||||
|
async def call(self, action, params):
|
||||||
|
return {'ok': False, 'description': 'SECRET_CANARY'}
|
||||||
|
|
||||||
|
call = boundary(call, 'call_platform_api')
|
||||||
|
actions = catalog()['telegram']['specific_apis']
|
||||||
|
assert actions
|
||||||
|
action = actions[0].removeprefix('platform_api.')
|
||||||
|
for name in (action, 'interaction.request', 'SECRET_CANARY'):
|
||||||
|
await call(SimpleNamespace(ap=m.ap), name, {'token': 'SECRET_CANARY'})
|
||||||
|
rows = evidence(m)
|
||||||
|
assert [r['operation'] for r in rows] == [actions[0], 'interaction.request']
|
||||||
|
assert all(r['outcome'] == 'failed' for r in rows)
|
||||||
|
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conversion_count_and_private_message_scenario():
|
||||||
|
m = make_manager()
|
||||||
|
|
||||||
|
async def convert(self, event):
|
||||||
|
return event
|
||||||
|
|
||||||
|
call = boundary(convert, 'platform.target2yiri', 'event')
|
||||||
|
event = await call(
|
||||||
|
SimpleNamespace(ap=m.ap), MessageReceivedEvent(message_chain=MessageChain([Plain(text='SECRET_CANARY')]))
|
||||||
|
)
|
||||||
|
assert not evidence(m)
|
||||||
|
d.adapter_event_received(SimpleNamespace(ap=m.ap), event)
|
||||||
|
await call(SimpleNamespace(ap=m.ap), None)
|
||||||
|
await call(SimpleNamespace(ap=m.ap), {'legacy': 'SECRET_CANARY'})
|
||||||
|
rows = evidence(m)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['platform_event_type'] == 'message.received'
|
||||||
|
assert rows[0]['attributes']['chat_type'] == 'person'
|
||||||
|
assert rows[0]['attributes']['content_type'] == 'text'
|
||||||
|
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_snapshot_includes_specific_apis_without_claiming_connection():
|
||||||
|
m = make_manager()
|
||||||
|
entry = catalog()['telegram']
|
||||||
|
cls = type(
|
||||||
|
'Adapter',
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
'__module__': 'langbot.pkg.platform.adapters.telegram.adapter',
|
||||||
|
'get_supported_events': lambda self: entry['events'],
|
||||||
|
'get_supported_apis': lambda self: entry['apis'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
snapshot_bot(
|
||||||
|
SimpleNamespace(ap=m.ap, adapter=cls(), execution_context=SimpleNamespace(workspace_uuid=str(uuid4()))),
|
||||||
|
listener_registered=True,
|
||||||
|
)
|
||||||
|
rows = list(m.pending)
|
||||||
|
assert rows
|
||||||
|
assert all('available' not in r['attributes'] for r in rows)
|
||||||
|
assert all(r['attributes']['listener_registered'] for r in rows if r['attributes']['capability_type'] == 'event')
|
||||||
|
names = {r['attributes']['capability_name'] for r in rows}
|
||||||
|
assert set(entry['specific_apis']) <= names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_dispatch_success_precedes_listener_failure():
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||||
|
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
|
||||||
|
|
||||||
|
m = make_manager()
|
||||||
|
owner = SimpleNamespace(ap=m.ap, listeners={EBAEvent: AsyncMock(side_effect=ValueError('SECRET_CANARY'))})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await TelegramAdapter._dispatch_eba_event(owner, MessageReceivedEvent())
|
||||||
|
rows = evidence(m)
|
||||||
|
assert len(rows) == 1 and rows[0]['outcome'] == 'succeeded'
|
||||||
|
assert rows[0]['operation'] == 'platform.adapter_event'
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_dispatch_does_not_borrow_another_workspace_trace():
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
|
||||||
|
m = make_manager()
|
||||||
|
workspace = str(uuid4())
|
||||||
|
owner = SimpleNamespace(
|
||||||
|
ap=m.ap,
|
||||||
|
execution_context=ExecutionContext(
|
||||||
|
instance_uuid=m.instance_id, workspace_uuid=workspace, placement_generation=1
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with d.Span(m, 'event', 'platform.receive', {'workspace_uuid': str(uuid4()), 'source': 'webui_debug'}).activate():
|
||||||
|
d.adapter_event_received(owner, MessageReceivedEvent())
|
||||||
|
row = evidence(m)[0]
|
||||||
|
assert row['workspace_uuid'] == workspace
|
||||||
|
assert not row.get('parent_span_id')
|
||||||
|
assert row['source'] == 'platform'
|
||||||
|
assert not row['attributes'].get('synthetic')
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_adapter_records_native_and_interaction_dispatch():
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/platform/adapters'
|
||||||
|
for directory in catalog():
|
||||||
|
tree = ast.parse((base / directory / 'adapter.py').read_text(encoding='utf-8'))
|
||||||
|
dispatch = next(
|
||||||
|
n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef) and n.name == '_dispatch_eba_event'
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
isinstance(n, ast.Call) and ast.unparse(n.func) == 'diagnostics.adapter_event_received'
|
||||||
|
for n in ast.walk(dispatch)
|
||||||
|
), directory
|
||||||
Reference in New Issue
Block a user