refactor: remove beta diagnostics collection and reporting (#2616)

This commit is contained in:
RockChinQ
2026-09-30 20:24:35 +08:00
committed by GitHub
parent 5f3eb34baf
commit 30585c8657
82 changed files with 72 additions and 4934 deletions
@@ -1,252 +0,0 @@
"""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
if (
entry['adapter'] == 'lark-omni'
and resolved
in {
'platform_api.check_tenant_access_token',
'platform_api.refresh_app_access_token',
'platform_api.refresh_tenant_access_token',
}
and getattr(bound.get('self'), 'config', {}).get('app_type', 'self') != 'isv'
):
# Self-built apps return {'ok': True} without checking/refreshing a token.
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',
'_adapter_void_ack': completed_void_api(module, resolved),
'_adapter_read_ack': completed_read_api(module, resolved),
'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
# These exact API implementations always await an exception-raising SDK call,
# including SDKs whose successful acknowledgement has no return payload.
# Do not extend this to methods with conditional no-op or queued work.
VOID_ACKNOWLEDGED_APIS = {
'aiocqhttp': frozenset(
{
'delete_message',
'set_group_name',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'approve_friend_request',
'approve_group_invite',
}
),
'discord': frozenset(
{
'edit_message',
'delete_message',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
}
),
'kook': frozenset({'delete_message'}),
}
def completed_void_api(module, operation):
return module.endswith('.api_impl') and any(
directory in module.split('.') and operation in operations
for directory, operations in VOID_ACKNOWLEDGED_APIS.items()
)
# These read methods return actual fetched/cached data or raise. Exclude the
# known identity-only placeholders and file-ID passthroughs: a typed result alone
# is not evidence that a lookup worked. Unsupported methods still raise normally.
_READ_APIS = frozenset(
{
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'get_file_url',
}
)
READ_ACKNOWLEDGED_APIS = {
'aiocqhttp': _READ_APIS,
'telegram': _READ_APIS,
'discord': _READ_APIS - {'get_file_url'},
'dingtalk': _READ_APIS - {'get_group_info', 'get_user_info'},
'kook': _READ_APIS - {'get_file_url'},
'lark': _READ_APIS - {'get_user_info', 'get_group_member_info', 'get_file_url'},
'officialaccount': _READ_APIS,
'qqofficial': _READ_APIS,
'slack': _READ_APIS,
'wecom': _READ_APIS - {'get_user_info'},
'wecombot': _READ_APIS,
'wecomcs': _READ_APIS,
}
def completed_read_api(module, operation):
return module.endswith('.api_impl') and any(
directory in module.split('.') and operation in operations
for directory, operations in READ_ACKNOWLEDGED_APIS.items()
)
def response_outcome(value, adapter=None):
"""Finite acknowledgement contracts; never inspect or export message content.
A MessageResult may contain an *inbound* message ID even when nothing was
sent. Only its actual raw acknowledgement is eligible. Empty/queued/unknown
returns are unconfirmed, not failures. Unknown SDK shapes need an explicit
confirmation at their operation boundary, not a truthiness fallback.
"""
from langbot_plugin.api.entities.builtin.platform.events import MessageResult
message_result = isinstance(value, MessageResult)
if message_result:
value = value.raw
if not isinstance(value, dict):
return 'skipped'
if (
value.get('ok') is False
or value.get('status') == 'failed'
or any(type(value.get(key)) is int and value[key] != 0 for key in ('retcode', 'errcode', 'code'))
):
return 'failed'
if value.get('queued') is True or value.get('status') == 'async':
return 'skipped'
# The wrappers that batch sends retain the real responses in these fields.
if 'results' in value:
results = value['results']
if not isinstance(results, list) or not results:
return 'skipped'
outcomes = [response_outcome(item, adapter) for item in results]
return 'failed' if 'failed' in outcomes else 'skipped' if 'skipped' in outcomes else 'succeeded'
for key in ('result', 'raw'):
if key in value:
return response_outcome(value[key], adapter)
if (
value.get('ok') is True
or value.get('status') == 'ok'
or any(type(value.get(key)) is int and value[key] == 0 for key in ('retcode', 'errcode', 'code'))
):
return 'succeeded'
if message_result:
# These wrappers put only SDK-returned IDs in raw (never the source ID).
if adapter in {'aiocqhttp-omni', 'discord-omni', 'telegram-omni'}:
message_id = value.get('message_id')
if type(message_id) in (str, int) and message_id:
return 'succeeded'
if adapter == 'lark-omni':
ids = value.get('message_ids')
if isinstance(ids, list) and ids and all(isinstance(item, str) and item for item in ids):
return 'succeeded'
return 'skipped'
def record_api_result(value, *, edited_content=None):
"""Record Telegram's Message/True acknowledgement and return it unchanged.
This runs after the SDK await, not after conversion or local stream setup.
No payload is retained; exceptions and the adapter's public return stay intact.
"""
try:
from .diagnostics import current_span
span = current_span()
if span is not None and span.kind == 'api' and span.fields.get('attributes', {}).get('adapter_evidence'):
from telegram import Message
outcome = (
'succeeded'
if value is True or isinstance(value, Message)
else 'failed'
if value is False
else 'skipped'
)
if outcome == 'succeeded' and edited_content is not None:
from langbot_plugin.api.entities.builtin.platform.message import Plain
if any(not isinstance(item, Plain) for item in edited_content):
outcome = 'partial'
# One unacknowledged component must not be hidden by a later success.
prior = span.adapter_api_result
span.adapter_api_result = (
'failed'
if 'failed' in (prior, outcome)
else 'skipped'
if 'skipped' in (prior, outcome)
else 'partial'
if 'partial' in (prior, outcome)
else outcome
)
except Exception:
pass
return value
@@ -1,93 +0,0 @@
"""Packaged capability catalog and projections from existing runtime declarations."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import yaml
from . import diagnostic_privacy as privacy
@lru_cache(maxsize=1)
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(encoding='utf-8'))
spec = data.get('spec', {})
name = data['metadata']['name']
privacy.code_value('adapter', name)
events = spec.get('supported_events', [])
apis = spec.get('supported_apis', {})
apis = apis if isinstance(apis, list) else [v for items in apis.values() for v in items]
for event in events:
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'])
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
def adapter_fields(adapter):
module = type(adapter).__module__.split('.')
entry = next((entry for directory, entry in catalog().items() if directory in module), None)
return {'adapter': entry['adapter']} if entry else {}
def _snapshot_bot(bot, *, listener_registered=False):
from . import diagnostics
manager = getattr(bot.ap, 'diagnostics', None)
if not isinstance(manager, diagnostics.DiagnosticsManager) or not manager.enabled:
return
fields = adapter_fields(bot.adapter)
if not fields:
return
fields['workspace_uuid'] = bot.execution_context.workspace_uuid
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'] + 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',
'succeeded',
source='platform',
stage='snapshot',
**fields,
platform_event_type=name if capability_type == 'event' else '',
attributes={
'capability_type': capability_type,
'capability_name': name,
'supported': name in supported
or (name in entry['specific_apis'] and 'call_platform_api' in supported),
'configured': True,
**({'listener_registered': listener_registered} if capability_type == 'event' else {}),
},
)
def snapshot_bot(bot, *, listener_registered=False):
try:
_snapshot_bot(bot, listener_registered=listener_registered)
except Exception:
pass
@@ -1,117 +0,0 @@
"""Closed, content-free projection for Beta diagnostics; never serialize payloads."""
from __future__ import annotations
import builtins
import hashlib
import math
import re
from uuid import UUID
KINDS = frozenset('lifecycle event route run api delivery interaction capability transport summary'.split())
SOURCES = frozenset(
'platform pipeline agent event_processor webui_debug http websocket mcp plugin runtime startup internal synthetic'.split()
)
OUTCOMES = frozenset('started succeeded failed cancelled timeout skipped rejected waiting partial unknown'.split())
PROCESSORS = frozenset(('', 'pipeline', 'agent', 'event_processor'))
# These sets are populated ONLY by source-code decorators / packaged manifests,
# never by requests, installed third-party plugin declarations or configuration.
VOCABULARY: dict[str, set[str]] = {
'operation': {'diagnostics.transport', 'startup', 'route.primary', 'route.subscription', 'runner.run'},
'stage': {'execute', 'prepare', 'convert', 'dispatch', 'accepted', 'ack', 'resume', 'shutdown', 'snapshot'},
'adapter': set(),
'platform_event_type': set(),
'reason_code': set(
'runner_failed response_error route_not_found processor_incompatible processor_not_found discarded not_matched matched delivered waiting interaction_rejected generator_closed transport_loss'.split()
),
'capability_type': {'event', 'api', 'processor'},
'tool_category': {'native', 'plugin', 'mcp', 'skill', 'platform', 'unknown'},
'transport': {'stdio', 'websocket', 'http', 'unknown'},
'runner_usage': {'agent', 'event'},
'os': {'linux', 'darwin', 'windows'},
'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 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()
)
VERSIONS = frozenset('sdk_version plugin_version runner_version python_version'.split())
def code_value(field: str, value: str) -> str:
"""Register a literal from trusted Core source, not a runtime string."""
VOCABULARY.setdefault(field, set()).add(value)
return value
def category(field: str, value) -> str:
return value if type(value) is str and value in VOCABULARY.get(field, ()) else ''
def opaque(value) -> str:
if not isinstance(value, str):
return ''
try:
return str(UUID(value))
except (ValueError, TypeError, AttributeError):
return ''
def version_string(value) -> str:
# Public package versions only; do not permit arbitrary PEP440 local labels.
return value if isinstance(value, str) and re.fullmatch(r'[0-9][0-9.abrcdevpost-]{0,47}', value) else ''
def attributes(values) -> dict:
if not isinstance(values, dict):
return {}
result = {}
for key, value in values.items():
if key in BOOLS and type(value) is bool:
result[key] = value
elif key in NUMBERS and type(value) is int and math.isfinite(value) and 0 <= value <= 1_000_000:
result[key] = value
elif key in VERSIONS and version_string(value):
result[key] = value
elif key in VOCABULARY and key not in {'operation', 'stage', 'adapter', 'platform_event_type', 'reason_code'}:
if category(key, value):
result[key] = value
elif key == 'capability_name' and any(category(f, value) for f in ('operation', 'platform_event_type')):
result[key] = value
elif key == 'capability_name' and value in PROCESSORS - {''}:
result[key] = value
elif key in ('event_types', 'api_operations', 'processor_types') and isinstance(value, (tuple, list)):
allowed = (
VOCABULARY['platform_event_type']
if key == 'event_types'
else VOCABULARY['operation']
if key == 'api_operations'
else PROCESSORS
)
result[key] = [v for v in value[:128] if type(v) is str and v in allowed]
elif (
key in ('source_revision', 'target_revision')
and isinstance(value, str)
and re.fullmatch('[a-f0-9]{40}', value)
):
result[key] = value
elif key == 'latency_buckets' and isinstance(value, (list, tuple)):
result[key] = [v for v in value[:128] if type(v) is int and 0 <= v <= 1_000_000]
return result
def error_fields(error: BaseException, operation: str, stage: str) -> dict:
cls = type(error)
# Third party exception names may be dynamically constructed from input.
name = cls.__name__ if getattr(builtins, cls.__name__, None) is cls else 'Exception'
if isinstance(error, TimeoutError):
name = 'TimeoutError'
# No message, traceback, filename, line number, or locals are inspected.
fingerprint = hashlib.sha256(f'{operation}|{stage}|{cls.__module__}|{cls.__qualname__}'.encode()).hexdigest()
return {'error_type': name, 'error_fingerprint': fingerprint}
@@ -1,399 +0,0 @@
"""Bounded, memory-only Beta diagnostics transport, isolated from usage telemetry."""
from __future__ import annotations
import asyncio
import contextlib
import importlib.metadata
import json
import os
import random
import re
import time
import platform
import sys
from pathlib import Path
from collections import Counter
from datetime import datetime, timezone
from uuid import uuid4
import httpx
from packaging.version import Version, InvalidVersion
from . import diagnostic_privacy as privacy
class DiagnosticsManager:
"""All producers are synchronous; only this owned worker performs I/O.
Retention: 2048 events / 15 minutes, 5 attempts, 8 KiB per event. An in-flight
event stays in the same bounded queue until explicitly ACKed. No disk spool
means abrupt process loss cannot be counted after restart (documented).
"""
def __init__(self, ap, *, version=None, instance_id=None, capacity=2048, marker_path=None):
from ..utils import constants
from .diagnostic_catalog import catalog
with contextlib.suppress(Exception):
catalog()
self.ap = ap
self.marker_path = Path(marker_path) if marker_path is not None else None
self._session_id = str(uuid4())
self._marker_active = False
raw_version = version or importlib.metadata.version('langbot')
try:
parsed = Version(raw_version)
self.version = str(parsed)
self.beta = bool(parsed.pre and parsed.pre[0] == 'b' and not parsed.local)
except InvalidVersion:
self.version, self.beta = '', False
self.instance_id = instance_id or constants.instance_id
try:
from langbot._build_info import CORE_REVISION
except ImportError:
CORE_REVISION = ''
candidate = os.getenv('LANGBOT_BUILD_REVISION', CORE_REVISION)
self.revision = candidate.lower() if re.fullmatch('[a-fA-F0-9]{40}', candidate or '') else ''
try:
self.sdk_version = privacy.version_string(importlib.metadata.version('langbot-plugin'))
except importlib.metadata.PackageNotFoundError:
self.sdk_version = ''
self.capacity = max(1, min(capacity, 2048))
self.pending: list[dict] = []
self.counters = Counter()
self._reported = Counter()
self._last_retry = False
self.max_attempts = 5
self.retention_seconds = 900
self.request_timeout = 10
self.client: httpx.AsyncClient | None = None
self._attempts: dict[str, int] = {}
self._born: dict[str, float] = {}
self._worker = None
self._request = None
self._closing = False
self._wake = asyncio.Event()
self._flush_lock = asyncio.Lock()
async def start_session(self):
"""One bounded, content-free marker per process; never per-event disk I/O."""
if not self.enabled:
await self._clear_marker()
return
previous = False
if self.marker_path is not None:
def mark():
previous = self.marker_path.exists()
self.marker_path.parent.mkdir(parents=True, exist_ok=True)
self.marker_path.write_text(self._session_id)
return previous
with contextlib.suppress(Exception):
previous = await asyncio.to_thread(mark)
self._marker_active = True
privacy.code_value('operation', 'startup.session')
self.emit(
'lifecycle',
'startup.session',
'started',
source='startup',
stage='snapshot',
attributes={
'previous_session_unclean': previous,
'os': platform.system().lower(),
'arch': platform.machine().lower(),
'python_version': '.'.join(map(str, sys.version_info[:3])),
'database': self.ap.instance_config.data.get('database', {}).get('use', ''),
'edition': self.ap.instance_config.data.get('system', {}).get('edition', ''),
},
)
async def _clear_marker(self):
if self.marker_path is None:
return
def remove():
if self.marker_path.exists():
with self.marker_path.open() as handle:
owner = handle.read(64)
if owner == self._session_id or not self._marker_active:
self.marker_path.unlink(missing_ok=True)
with contextlib.suppress(Exception):
await asyncio.to_thread(remove)
self._marker_active = False
@property
def enabled(self):
config = getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('space', {})
return (
self.beta
and not self._closing
and not config.get('disable_telemetry', False)
and not config.get('disable_beta_diagnostics', False)
and bool(config.get('url'))
)
def clear(self):
self.pending.clear()
self._attempts.clear()
self._born.clear()
if self._request is not None and not self._request.done():
self._request.cancel()
def emit(
self,
kind,
operation,
outcome,
*,
source='internal',
workspace_uuid='',
attributes=None,
stage='execute',
adapter='',
processor_type='',
platform_event_type='',
reason_code='',
duration_ms=0,
error=None,
trace_id='',
span_id='',
parent_span_id='',
run_id='',
**ignored,
):
"""Project known scalar fields; failures in diagnostics never escape."""
try:
if not self.enabled:
self.clear()
return
if kind not in privacy.KINDS or outcome not in privacy.OUTCOMES:
return
self.counters['generated'] += 1
event = {
'schema_version': 1,
'event_id': str(uuid4()),
'kind': kind,
'instance_id': self.instance_id,
'workspace_uuid': privacy.opaque(workspace_uuid),
'core_version': self.version,
'core_revision': self.revision,
'sdk_version': self.sdk_version,
'release_channel': 'beta',
'source': source if source in privacy.SOURCES else 'internal',
'operation': privacy.category('operation', operation),
'stage': privacy.category('stage', stage),
'outcome': outcome,
'reason_code': privacy.category('reason_code', reason_code),
'adapter': privacy.category('adapter', adapter),
'processor_type': processor_type if processor_type in privacy.PROCESSORS else '',
'platform_event_type': privacy.category('platform_event_type', platform_event_type),
'occurred_at': datetime.now(timezone.utc).isoformat(),
'count': 1,
'sample_rate': 1,
'duration_ms': max(0, min(float(duration_ms), 86400000)),
'attributes': privacy.attributes(attributes),
}
for key, value in (
('trace_id', trace_id),
('span_id', span_id),
('parent_span_id', parent_span_id),
('run_id', run_id),
):
if privacy.opaque(value):
event[key] = privacy.opaque(value)
if error is not None:
event.update(privacy.error_fields(error, event['operation'], event['stage']))
encoded = json.dumps(event, allow_nan=False).encode()
if (
not isinstance(self.instance_id, str)
or not 0 < len(self.instance_id) <= 128
or len(encoded) > 8192
or len(self.pending) >= self.capacity
):
self.counters['dropped'] += 1
return
self.pending.append(event)
self._born[event['event_id']] = time.monotonic()
self.counters['queued'] += 1
self._wake.set()
except Exception:
self.counters['dropped'] += 1
async def credentials(self, workspace_uuid):
# Resolve each Workspace independently in the background. No OSS secret.
token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
if token:
return {'X-LangBot-Telemetry-Token': token}
if not workspace_uuid:
return {}
users = getattr(self.ap, 'user_service', None)
space = getattr(self.ap, 'space_service', None)
if users is None or space is None:
return {}
owner = await users.get_workspace_owner(workspace_uuid)
email = getattr(owner, 'user', None)
token = await space.get_valid_access_token(email) if email else None
return {'Authorization': f'Bearer {token}'} if token else {}
def _remove(self, ids):
self.pending[:] = [e for e in self.pending if e['event_id'] not in ids]
for event_id in ids:
self._attempts.pop(event_id, None)
self._born.pop(event_id, None)
async def flush_once(self):
async with self._flush_lock:
if not self.enabled:
self.clear()
return
expired = {
e['event_id']
for e in self.pending
if time.monotonic() - self._born.get(e['event_id'], 0) > self.retention_seconds
}
self.counters['dropped'] += len(expired)
self._remove(expired)
if not self.pending:
return
# Do not authenticate a mixed-Workspace batch with one owner's token.
workspace = self.pending[0]['workspace_uuid']
batch, size = [], 64
for event in self.pending:
n = len(json.dumps(event).encode()) + 2
if event['workspace_uuid'] != workspace:
continue
if len(batch) == 50 or size + n > 256 * 1024:
break
batch.append(event)
size += n
ids = {e['event_id'] for e in batch}
for event_id in ids:
self._attempts[event_id] = self._attempts.get(event_id, 0) + 1
acked, rejected = set(), set()
permanent = False
try:
async with asyncio.timeout(self.request_timeout):
headers = {}
try:
async with asyncio.timeout(min(1.0, self.request_timeout / 3)):
headers = await self.credentials(workspace)
except Exception:
pass
if not self.enabled:
self.clear()
return
if self.client is None:
self.client = httpx.AsyncClient(timeout=self.request_timeout, follow_redirects=False)
url = (
self.ap.instance_config.data['space']['url'].rstrip('/') + '/api/v1/telemetry/diagnostics/batch'
)
# Stream the response to bound malicious/old server responses too.
async with self.client.stream(
'POST', url, json={'schema_version': 1, 'events': batch}, headers=headers
) as response:
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > 65536:
raise ValueError('diagnostics_response_limit')
if response.status_code == 200:
payload = json.loads(body)
if payload.get('code') == 200 and isinstance(payload.get('data'), dict):
data = payload['data']
acked = {x for x in data.get('accepted_event_ids', []) if isinstance(x, str)} & ids
rejected = {
x.get('event_id')
for x in data.get('rejected', [])
if isinstance(x, dict) and x.get('code') == 'invalid_event'
} & ids
elif isinstance(payload.get('code'), int):
permanent = 400 <= payload['code'] < 500 and payload['code'] != 429
else:
permanent = 400 <= response.status_code < 500 and response.status_code != 429
except asyncio.CancelledError:
raise
except Exception:
pass
if not self.enabled:
self.clear()
return
rejected -= acked
remaining = ids - acked - rejected
if remaining:
self.counters['failed'] += 1
exhausted = {i for i in remaining if permanent or self._attempts.get(i, 0) >= self.max_attempts}
self.counters['acked'] += len(acked)
self.counters['dropped'] += len(rejected | exhausted)
self._last_retry = bool(remaining - exhausted)
self.counters['retried'] += len(remaining - exhausted)
self._remove(acked | rejected | exhausted)
def report_transport(self, stage='snapshot'):
# Snapshot interval deltas before enqueue; retries retain this event ID.
snapshot = self.counters.copy()
delta = snapshot - self._reported
before = self.counters['queued']
self.emit(
'transport',
'diagnostics.transport',
'partial' if delta['dropped'] else 'succeeded',
stage=stage,
attributes={**dict(delta), 'queue_size': len(self.pending), 'capacity': self.capacity},
)
if self.counters['queued'] > before:
self._reported = snapshot
def start(self):
if self.beta and self._worker is None:
self._worker = asyncio.create_task(self._loop(), name='beta-diagnostics')
async def _loop(self):
next_send, next_summary, backoff = 0.0, time.monotonic() + 60, 0.0
try:
while not self._closing:
if not self.enabled:
self.clear()
if self._marker_active:
await self._clear_marker()
elif time.monotonic() >= next_summary:
self.report_transport()
next_summary = time.monotonic() + 60
if self._request is not None and self._request.done():
with contextlib.suppress(asyncio.CancelledError, Exception):
self._request.result()
self._request = None
backoff = min(30, max(1, backoff * 2)) if self._last_retry else 0
next_send = time.monotonic() + random.uniform(0.5, 1.5) * backoff
if self.enabled and self.pending and self._request is None and time.monotonic() >= next_send:
self._request = asyncio.create_task(self.flush_once(), name='beta-diagnostics-batch')
self._wake.clear()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._wake.wait(), 0.1)
finally:
if self._request is not None:
self._request.cancel()
await asyncio.gather(self._request, return_exceptions=True)
async def shutdown(self, drain_timeout=2):
if self._worker is not None:
self._worker.cancel()
await asyncio.gather(self._worker, return_exceptions=True)
self._worker = None
if self.enabled and drain_timeout > 0:
self.report_transport(stage='shutdown')
with contextlib.suppress(Exception):
async with asyncio.timeout(drain_timeout):
while self.pending:
await self.flush_once()
if self.pending:
await asyncio.sleep(0.1)
self.counters['dropped'] += len(self.pending)
self._closing = True
self.clear()
await self._clear_marker()
if self.client is not None:
await self.client.aclose()
-546
View File
@@ -1,546 +0,0 @@
"""Explicit diagnostic boundaries, preserving coroutine and generator semantics."""
from __future__ import annotations
import asyncio
import contextlib
import contextvars
import functools
import inspect
import time
from uuid import uuid4
from .diagnostic_transport import DiagnosticsManager # noqa: F401
from . import diagnostic_privacy as privacy
_CURRENT = contextvars.ContextVar('beta_diagnostic_span', default=None)
def current_span():
return _CURRENT.get()
def set_outcome(outcome, *, reason_code=''):
span = current_span()
if span is not None:
span.outcome = outcome if outcome in privacy.OUTCOMES else 'unknown'
span.fields['reason_code'] = privacy.category('reason_code', reason_code)
def annotate(**fields):
"""Trusted hook metadata is still projected at the transport boundary."""
span = current_span()
if span is not None:
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:
return None, False
if hasattr(owner, 'ap'):
return owner.ap, True
if hasattr(owner, 'diagnostics') or hasattr(owner, 'instance_config'):
return owner, True
for name in ('requester', 'logger', 'adapter'):
nested = getattr(owner, name, None)
if nested is not None and nested is not owner:
app, explicit = _owner_app(nested)
if explicit:
return app, True
return None, False
def _manager(owner):
app, _ = _owner_app(owner)
manager = getattr(app, 'diagnostics', None)
if manager is None or not callable(getattr(manager, 'emit', None)) or not getattr(manager, 'enabled', False):
return None
# Honor the shared Span/management producer interface without inheriting a
# different producer just because this application's producer is absent.
config = getattr(getattr(app, 'instance_config', None), 'data', {}).get('space', {})
if config.get('disable_telemetry', False) or config.get('disable_beta_diagnostics', False):
return None
# Direct manager holders (e.g. ReplyStreamSession) are intentional. An app
# must never borrow another app's manager, policy, or credential resolver.
if hasattr(app, 'instance_config') and getattr(manager, 'ap', app) is not app:
return None
return manager
def _owner_context(owner):
context = getattr(owner, 'execution_context', None)
if context is not None:
return context
for name in ('requester', 'logger', 'adapter'):
nested = getattr(owner, name, None)
if nested is not None and nested is not owner:
context = _owner_context(nested)
if context is not None:
return context
return None
def _execution_context(owner, bound):
context = bound.get('execution_context') or _owner_context(owner)
adapter_context = bound.get('adapter_context')
if isinstance(adapter_context, dict):
context = adapter_context.get('_execution_context') or context
query = bound.get('query')
if query is not None:
context = getattr(query, '_execution_context', None) or context
return context
def _context_matches(manager, owner, context):
from ..api.http.context import ExecutionContext
if not isinstance(context, ExecutionContext):
return True
instance = getattr(getattr(manager.ap, 'workspace_service', None), 'instance_uuid', manager.instance_id)
if instance != context.instance_uuid:
return False
owned = _owner_context(owner)
return not isinstance(owned, ExecutionContext) or (
owned.instance_uuid,
owned.workspace_uuid,
owned.placement_generation,
) == (context.instance_uuid, context.workspace_uuid, context.placement_generation)
def _context_fields(owner, bound):
context = _execution_context(owner, bound)
query = bound.get('query')
from .diagnostic_catalog import adapter_fields
adapter = getattr(owner, 'adapter', None) or owner
fields = adapter_fields(adapter)
# ExecutionContext is constructed/validated by the existing auth boundary;
# do not infer Workspace identity from arbitrary payload dicts or event IDs.
if context is not None:
from ..api.http.context import ExecutionContext
if isinstance(context, ExecutionContext):
fields['workspace_uuid'] = context.workspace_uuid
saved = getattr(query, '_diagnostic_context', None) if query is not None else None
if isinstance(saved, dict) and saved.get('workspace_uuid') == context.workspace_uuid:
fields.update(saved)
binding = bound.get('binding')
if binding is not None:
fields['processor_type'] = getattr(binding, 'processor_type', '')
event = bound.get('event')
if event is not None:
fields['platform_event_type'] = getattr(event, 'event_type', None) or getattr(event, 'type', '')
delivery = getattr(event, 'delivery', None)
if getattr(delivery, 'surface', None) == 'webui':
fields['source'] = 'webui_debug'
fields['attributes'] = {'synthetic': True}
if getattr(owner, 'mock', False) is True:
fields['source'] = 'webui_debug'
fields['attributes'] = {'synthetic': True}
return fields
class Span:
def __init__(self, manager, kind, operation, fields):
parent = current_span()
self.manager = manager
self.kind = kind
self.operation = operation
self.fields = dict(fields)
self.adapter_api_active = bool(self.fields.pop('_adapter_api_active', False))
self.adapter_void_ack = bool(self.fields.pop('_adapter_void_ack', False))
self.adapter_read_ack = bool(self.fields.pop('_adapter_read_ack', False))
self.adapter_api_result = None
if (
parent
and parent.fields.get('workspace_uuid')
and self.fields.get('workspace_uuid')
and parent.fields['workspace_uuid'] != self.fields['workspace_uuid']
):
parent = None
if self.fields.get('operation'):
self.operation = privacy.category('operation', self.fields.pop('operation')) or operation
self.outcome = None
self.finished = False
self.started = time.monotonic()
self.fields['trace_id'] = (
parent.fields['trace_id']
if parent and parent.manager is manager
else (privacy.opaque(self.fields.get('trace_id')) or str(uuid4()))
)
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):
self.fields[key] = parent.fields[key]
if parent.fields.get('source') in ('webui_debug', 'synthetic'):
self.fields['source'] = parent.fields['source']
self.fields['attributes'] = {**self.fields.get('attributes', {}), 'synthetic': True}
self.emit('started')
def emit(self, outcome, **extra):
if self.manager is not None:
self.manager.emit(self.kind, self.operation, outcome, **{**self.fields, **extra})
@contextlib.contextmanager
def activate(self):
token = _CURRENT.set(self)
try:
yield self
finally:
_CURRENT.reset(token)
def finish(self, error=None):
if self.finished:
return
self.finished = True
error = error if error is not None else self.fields.pop('error', None)
if isinstance(error, (asyncio.CancelledError, GeneratorExit)):
outcome = 'cancelled'
elif isinstance(error, TimeoutError):
outcome = 'timeout'
elif error is not None:
outcome = self.outcome if self.outcome in ('timeout', 'partial', 'rejected') else 'failed'
else:
outcome = self.outcome or 'succeeded'
self.emit(outcome, error=error, duration_ms=(time.monotonic() - self.started) * 1000)
def result_outcome(value):
"""Inspect only the SDK response's status, not arbitrary result contents."""
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
from langbot_plugin.runtime.io.handler import ActionResponse
span = current_span()
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'):
from .adapter_diagnostics import response_outcome
# A normal return is not an acknowledgement. Fail closed for acceptance
# (but never for the business call) even if result inspection raises.
if span.outcome is None:
set_outcome('skipped')
outcome = span.adapter_api_result
if outcome is None:
if (value is None and span.adapter_void_ack) or (
span.adapter_read_ack and value is not None and value != ''
):
outcome = 'succeeded'
else:
outcome = response_outcome(value, span.fields.get('adapter'))
set_outcome(outcome, **({'reason_code': 'response_error'} if outcome == 'failed' else {}))
if isinstance(value, ActionResponse):
if value.code != 0:
set_outcome('failed', reason_code='response_error')
def observe(kind, operation, *, source='internal', stage='execute', ap=None, fields=None):
"""Explicit boundary with a stable/off fast path and transparent generators."""
privacy.code_value('operation', operation)
privacy.code_value('stage', stage)
def decorate(fn):
signature = inspect.signature(fn)
def span_for(args, kwargs):
try:
bound = signature.bind_partial(*args, **kwargs).arguments
owner = bound.get(next(iter(signature.parameters), ''))
manager_owner = (ap() if callable(ap) else ap) if ap is not None else owner
manager = _manager(manager_owner)
_, explicit = _owner_app(manager_owner)
context = _execution_context(owner, bound)
parent = current_span()
if manager is None and not explicit and ap is None and parent is not None:
# Stateless converters may inherit, but a different explicit
# Workspace must not select a parent's credentials/policy.
if context is not None and getattr(context, 'workspace_uuid', None) != parent.fields.get(
'workspace_uuid'
):
return None
manager = parent.manager
if manager is None or not manager.enabled:
return None
if not _context_matches(manager, owner, context):
return None
metadata = {'source': source, 'stage': stage}
metadata.update(_context_fields(owner, bound))
from .diagnostic_catalog import catalog
for directory, entry in catalog().items():
if directory in fn.__module__.split('.') and '.platform.' in fn.__module__:
metadata['adapter'] = entry['adapter']
break
if fields:
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
def inspect_result(value):
with contextlib.suppress(Exception):
result_outcome(value)
def finish(span, error=None):
if span is not None:
with contextlib.suppress(Exception):
span.finish(error)
if inspect.isasyncgenfunction(fn):
from collections.abc import AsyncGenerator
class ObservedGenerator(AsyncGenerator):
"""Delegate each native protocol operation, without extra close.
A yield-based proxy cannot distinguish athrow(GeneratorExit)
(which may yield) from aclose() (which must reject a yield).
Let the native generator implement that distinction and retain
its own primary/cleanup exception and cancellation semantics.
"""
def __init__(self, args, kwargs):
self.gen = fn(*args, **kwargs)
self.args, self.kwargs = args, kwargs
self.span = None
self.started = False
def __getattr__(self, name):
return getattr(self.gen, name)
async def _advance(self, method, *values):
if not self.started:
self.started = True
self.span = span_for(self.args, self.kwargs)
token = _CURRENT.set(self.span)
try:
value = await method(*values)
if self.span is not None and method != self.gen.aclose:
inspect_result(value)
if method == self.gen.aclose:
finish(self.span, GeneratorExit())
return value
except StopAsyncIteration:
finish(self.span)
raise
except BaseException as exc:
# Rejected protocol calls (e.g. concurrent asend, a
# yielded GeneratorExit) need not terminate the stream.
if self.gen.ag_frame is None:
finish(self.span, exc)
raise
finally:
_CURRENT.reset(token)
def __anext__(self):
return self._advance(self.gen.__anext__)
def asend(self, value):
return self._advance(self.gen.asend, value)
def athrow(self, *values):
return self._advance(self.gen.athrow, *values)
def aclose(self):
return self._advance(self.gen.aclose)
@functools.wraps(fn)
def stream(*args, **kwargs):
return ObservedGenerator(args, kwargs)
return stream
@functools.wraps(fn)
async def call(*args, **kwargs):
span = span_for(args, kwargs)
if span is None:
token = _CURRENT.set(None)
try:
return await fn(*args, **kwargs)
finally:
_CURRENT.reset(token)
try:
with span.activate():
value = await fn(*args, **kwargs)
inspect_result(value)
finish(span)
return value
except BaseException as exc:
finish(span, exc)
raise
return call
return decorate
def event(owner, kind, operation, outcome, **fields):
"""Emit a point-in-time fact from an existing state transition."""
try:
manager = _manager(owner)
_, explicit = _owner_app(owner)
parent = current_span()
context = _owner_context(owner)
if manager is None and not explicit and parent:
if context is not None and getattr(context, 'workspace_uuid', None) != parent.fields.get('workspace_uuid'):
return
manager = parent.manager
if manager is not None and _context_matches(manager, owner, context):
inherited = dict(parent.fields) if parent and parent.manager is manager else {}
if context is not None and getattr(context, 'workspace_uuid', None) != inherited.get('workspace_uuid'):
inherited = _context_fields(owner, {})
inherited.update(fields)
manager.emit(kind, operation, outcome, **inherited)
except Exception:
pass
def capture_context():
span = current_span()
if span is None or not span.manager.enabled:
return None
result = {
k: span.fields[k]
for k in ('trace_id', 'workspace_uuid', 'run_id', 'adapter', 'processor_type', 'platform_event_type', 'source')
if k in span.fields
}
result['parent_span_id'] = span.fields['span_id']
return result
def link_context(saved):
"""Link only after the existing Host run/installation validator accepted it."""
span = current_span()
if span is None or not isinstance(saved, dict):
return
if span.fields.get('workspace_uuid') and span.fields['workspace_uuid'] != saved.get('workspace_uuid'):
return
for key in ('trace_id', 'parent_span_id', 'workspace_uuid', 'run_id'):
if privacy.opaque(saved.get(key)):
span.fields[key] = saved[key]
for key in ('adapter', 'platform_event_type'):
if privacy.category(key, saved.get(key)):
span.fields[key] = saved[key]
if saved.get('processor_type') in privacy.PROCESSORS:
span.fields['processor_type'] = saved['processor_type']
if saved.get('source') in ('webui_debug', 'synthetic'):
span.fields['source'] = saved['source']
span.fields['attributes'] = {**span.fields.get('attributes', {}), 'synthetic': True}
def declare_runner(descriptor):
"""Allow only identifiers from a validated installed public Runner manifest."""
import re
pairs = {'plugin_id': descriptor.get_plugin_id(), 'runner_id': descriptor.id}
for field, value in pairs.items():
allowed = privacy.VOCABULARY.setdefault(field, set())
if (
isinstance(value, str)
and len(value) <= 128
and len(allowed) < 1024
and re.fullmatch(r'(?:plugin:)?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?', value)
):
allowed.add(value)
def runner_metadata(owner, descriptor, processor_type):
manager = _manager(owner)
if manager is None or not manager.enabled:
return
metadata = {
'plugin_id': descriptor.get_plugin_id(),
'runner_id': descriptor.id,
'plugin_version': getattr(descriptor, 'plugin_version', ''),
'runner_usage': 'event' if processor_type == 'event_processor' else 'agent',
}
annotate(attributes=metadata)
event(
owner,
'capability',
'runner.run',
'succeeded',
source='runtime',
stage='snapshot',
processor_type=processor_type,
attributes={
**metadata,
'capability_type': 'processor',
'capability_name': processor_type,
'supported': True,
'configured': True,
'available': True,
},
)