Files
LangBot/src/langbot/pkg/telemetry/platform.py
T

124 lines
4.8 KiB
Python

"""Observe adapter calls without collecting their arguments or response content."""
from __future__ import annotations
import asyncio
import functools
from contextvars import ContextVar
from .execution import record
processing_mode: ContextVar[str] = ContextVar('telemetry_processing_mode', default='none')
def result_failure_detail(result) -> str:
"""Pull a bounded human-readable reason out of a failed platform API result."""
raw = getattr(result, 'raw', result)
if isinstance(raw, dict):
for key in ('error', 'message', 'msg', 'detail', 'reason'):
value = raw.get(key)
if isinstance(value, str) and value.strip():
return value
if isinstance(value, dict):
for inner in ('message', 'msg', 'detail'):
if isinstance(value.get(inner), str) and value[inner].strip():
return value[inner]
return ''
def result_outcome(result):
# Empty returns do not prove a remote operation succeeded.
if result is None:
return 'unknown'
if result is False:
return 'failed'
raw = getattr(result, 'raw', result)
if isinstance(raw, dict):
if raw.get('ok') is False or raw.get('success') is False or raw.get('status') == 'failed':
return 'failed'
for key in ('errcode', 'retcode'):
if key in raw and raw[key] not in (0, '0', None):
return 'failed'
if raw.get('error'):
return 'failed'
if isinstance(raw.get('results'), list):
outcomes = [result_outcome(item) for item in raw['results']]
if 'failed' in outcomes:
return 'failed'
if not outcomes or 'unknown' in outcomes:
return 'unknown'
return 'success'
# Arbitrary error envelopes or empty mappings are not affirmative evidence.
if isinstance(raw, dict):
if raw.get('ok') is True or raw.get('success') is True or raw.get('status') == 'ok':
return 'success'
if any(raw.get(key) not in (None, '') for key in ('message_id', 'id')):
return 'success'
if any(key in raw and raw[key] in (0, '0') for key in ('errcode', 'retcode')):
return 'success'
return 'unknown'
return 'success' if result is True or getattr(result, 'message_id', None) is not None else 'unknown'
def observe_adapter(ap, context, adapter):
"""Install once on a concrete bot; identity never comes from task-local tenants."""
if getattr(adapter, '_execution_observed', False):
return
try:
declared = frozenset(adapter.get_supported_apis())
except Exception:
return
for name in declared:
if '.' in name:
continue
original = getattr(adapter, name, None)
if not asyncio.iscoroutinefunction(original):
continue
def make_wrapper(method, operation):
@functools.wraps(method)
async def wrapped(*args, **kwargs):
observed_operation = operation
if operation == 'call_platform_api':
action = args[0] if args else kwargs.get('action')
if action in declared:
observed_operation = action
outcome = 'unknown'
error_detail = ''
try:
result = await method(*args, **kwargs)
outcome = result_outcome(result)
if outcome != 'success':
error_detail = result_failure_detail(result)
return result
except asyncio.CancelledError:
outcome = 'cancelled'
error_detail = 'cancelled'
raise
except TimeoutError as exc:
outcome = 'timeout'
error_detail = str(exc) or 'timeout'
raise
except Exception as exc:
outcome = 'failed'
error_detail = str(exc)
raise
finally:
record(
ap,
context,
family='platform_api',
operation=observed_operation,
adapter=adapter.__class__.__name__,
mode=processing_mode.get(),
outcome=outcome,
error=error_detail,
)
return wrapped
# Adapters are Pydantic models: method instrumentation is not a model
# field assignment. Keep wrappers instance-local, never on the class.
object.__setattr__(adapter, name, make_wrapper(original, name))
object.__setattr__(adapter, '_execution_observed', True)