mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
refactor: remove beta diagnostics collection and reporting (#2616)
This commit is contained in:
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user