mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
feat: collect beta-only adapter acceptance evidence
This commit is contained in:
@@ -174,6 +174,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
|
||||
@@ -202,6 +202,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
|
||||
@@ -482,6 +482,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
"""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):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
|
||||
@@ -387,7 +387,13 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return event.source_platform_object
|
||||
|
||||
@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):
|
||||
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
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()}')
|
||||
|
||||
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):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
|
||||
@@ -1549,7 +1549,6 @@ class RuntimeBot:
|
||||
|
||||
from ..telemetry.diagnostic_catalog import snapshot_bot
|
||||
|
||||
snapshot_bot(self)
|
||||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||||
supported_events: list[str] = []
|
||||
if callable(get_supported_events):
|
||||
@@ -1621,6 +1620,8 @@ class RuntimeBot:
|
||||
platform_events.EBAEvent,
|
||||
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 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 = {}
|
||||
base = Path(__file__).resolve().parents[1] / 'platform'
|
||||
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', {})
|
||||
name = data['metadata']['name']
|
||||
privacy.code_value('adapter', name)
|
||||
@@ -26,9 +26,16 @@ def catalog():
|
||||
privacy.code_value('platform_event_type', event)
|
||||
for operation in apis:
|
||||
privacy.code_value('operation', operation)
|
||||
specific_apis = []
|
||||
for api in spec.get('platform_specific_apis', []):
|
||||
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
|
||||
|
||||
|
||||
@@ -38,7 +45,7 @@ def adapter_fields(adapter):
|
||||
return {'adapter': entry['adapter']} if entry else {}
|
||||
|
||||
|
||||
def _snapshot_bot(bot):
|
||||
def _snapshot_bot(bot, *, listener_registered=False):
|
||||
from . import diagnostics
|
||||
|
||||
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'])
|
||||
for capability_type, method, declared in (
|
||||
('event', 'get_supported_events', entry['events']),
|
||||
('api', 'get_supported_apis', entry['apis']),
|
||||
('api', 'get_supported_apis', entry['apis'] + entry['specific_apis']),
|
||||
):
|
||||
try:
|
||||
supported = set(getattr(bot.adapter, method)() or [])
|
||||
except Exception:
|
||||
supported = set()
|
||||
for name in declared:
|
||||
if name == 'call_platform_api':
|
||||
continue
|
||||
manager.emit(
|
||||
'capability',
|
||||
name if capability_type == 'api' else 'platform.receive',
|
||||
@@ -69,15 +78,16 @@ def _snapshot_bot(bot):
|
||||
attributes={
|
||||
'capability_type': capability_type,
|
||||
'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,
|
||||
'available': True,
|
||||
**({'listener_registered': listener_registered} if capability_type == 'event' else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def snapshot_bot(bot):
|
||||
def snapshot_bot(bot, *, listener_registered=False):
|
||||
try:
|
||||
_snapshot_bot(bot)
|
||||
_snapshot_bot(bot, listener_registered=listener_registered)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -32,8 +32,12 @@ VOCABULARY: dict[str, set[str]] = {
|
||||
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
|
||||
'database': {'sqlite', 'postgresql'},
|
||||
'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(
|
||||
'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)
|
||||
|
||||
|
||||
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):
|
||||
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
|
||||
if owner is None:
|
||||
@@ -148,6 +185,7 @@ class Span:
|
||||
self.kind = kind
|
||||
self.operation = operation
|
||||
self.fields = dict(fields)
|
||||
self.adapter_api_active = bool(self.fields.pop('_adapter_api_active', False))
|
||||
if (
|
||||
parent
|
||||
and parent.fields.get('workspace_uuid')
|
||||
@@ -167,6 +205,7 @@ class Span:
|
||||
)
|
||||
self.fields['span_id'] = str(uuid4())
|
||||
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']
|
||||
for key in ('workspace_uuid', 'adapter', 'processor_type', 'platform_event_type', 'run_id'):
|
||||
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 isinstance(value, EBAEvent):
|
||||
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:
|
||||
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 value.code != 0:
|
||||
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['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
|
||||
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)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user