"""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)) 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'): # Common adapter response contracts expose status without inspecting content. if isinstance(value, dict) and ( value.get('ok') is False or value.get('status') == 'failed' or (type(value.get('retcode')) is int and value['retcode'] != 0) ): set_outcome('failed', reason_code='response_error') if isinstance(value, ActionResponse): if value.code != 0: set_outcome('failed', reason_code='response_error') 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, }, )