mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 00:15:20 +08:00
refactor: remove beta diagnostics collection and reporting (#2616)
This commit is contained in:
@@ -28,7 +28,6 @@ from ..authz import (
|
||||
)
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from ....cloud.support_admin import SupportAdminSessionError
|
||||
from ... import management_diagnostics as diagnostics
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
@@ -238,7 +237,6 @@ class RouterGroup(abc.ABC):
|
||||
observed_at = time.monotonic()
|
||||
try:
|
||||
if request_context is not None:
|
||||
diagnostics.workspace(request_context)
|
||||
with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
|
||||
persistence_mgr = getattr(
|
||||
self.ap,
|
||||
@@ -321,14 +319,7 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
return self.internal_error_response(request_id)
|
||||
|
||||
# Observe outside authentication, using the registered Core handler
|
||||
# identity rather than the URL (which can contain user identifiers).
|
||||
new_f = diagnostics.observe(
|
||||
diagnostics.operation_id('http', f, rule=rule, methods=options.get('methods')),
|
||||
source='http',
|
||||
ap=self.ap,
|
||||
http=True,
|
||||
)(handler_error)
|
||||
new_f = handler_error
|
||||
# Quart/Flask requires a unique endpoint name even when the same URL
|
||||
# intentionally has separate handlers for different HTTP methods.
|
||||
# Include the method set so CRUD routes can declare distinct
|
||||
@@ -735,8 +726,6 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
def fail(self, code: int | str, msg: str) -> quart.Response:
|
||||
"""Return an error response"""
|
||||
|
||||
diagnostics.outcome('failed')
|
||||
return quart.jsonify(
|
||||
{
|
||||
'code': code,
|
||||
|
||||
@@ -7,7 +7,6 @@ import contextlib
|
||||
import json
|
||||
|
||||
import quart
|
||||
from .... import management_diagnostics as diagnostics
|
||||
|
||||
from .....agent.runner.errors import (
|
||||
RunnerError,
|
||||
@@ -43,9 +42,6 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
|
||||
)
|
||||
await queue.put({'kind': 'completed', 'data': result})
|
||||
except Exception as exc:
|
||||
# The stream still uses HTTP 200 when execution returns an
|
||||
# error frame. Mark the inherited request, not its contents.
|
||||
diagnostics.outcome('failed')
|
||||
if isinstance(exc, RunnerExecutionError):
|
||||
code, message = exc.error_code or 'runner_execution_failed', exc.message
|
||||
elif isinstance(exc, RunnerNotFoundError):
|
||||
|
||||
@@ -21,7 +21,6 @@ import httpx
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ..... import management_diagnostics as diagnostics
|
||||
from ......utils import httpclient, paths
|
||||
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
|
||||
from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks
|
||||
@@ -330,24 +329,20 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
# -- Embed WebSocket endpoint ----------------------------------------
|
||||
|
||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||
@diagnostics.observe('websocket.embed.session', source='websocket', ap=self.ap)
|
||||
async def embed_websocket_connect(bot_uuid: str):
|
||||
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||
await quart.websocket.accept()
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||
diagnostics.outcome('rejected')
|
||||
return
|
||||
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||
diagnostics.outcome('rejected')
|
||||
return
|
||||
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
if session_type not in ['person', 'group']:
|
||||
diagnostics.outcome('rejected')
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
@@ -356,7 +351,6 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
session_id = quart.websocket.args.get('session_id', '')
|
||||
if not is_valid_session_id(session_id):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
||||
diagnostics.outcome('rejected')
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -364,16 +358,12 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
await self._assert_execution_active(runtime_bot)
|
||||
except Exception:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
diagnostics.outcome('rejected')
|
||||
return
|
||||
|
||||
diagnostics.workspace(runtime_bot.execution_context)
|
||||
try:
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||
websocket_adapter = proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
diagnostics.outcome('rejected')
|
||||
return
|
||||
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
@@ -431,13 +421,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
await wait_for_duplex_tasks(receive_task, send_task)
|
||||
except Exception as e:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error(f'Embed WebSocket task error: {e}')
|
||||
finally:
|
||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||
|
||||
except Exception as e:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
@@ -451,36 +439,30 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
while connection.is_active:
|
||||
message = await quart.websocket.receive()
|
||||
await ws_connection_manager.update_activity(connection.connection_id)
|
||||
try:
|
||||
data = await asyncio.to_thread(json.loads, message)
|
||||
message_type = data.get('type', 'message')
|
||||
|
||||
with diagnostics.scope(self.ap, 'websocket.embed.message', source='websocket'):
|
||||
diagnostics.workspace(getattr(owner_bot, 'execution_context', None))
|
||||
try:
|
||||
data = await asyncio.to_thread(json.loads, message)
|
||||
message_type = data.get('type', 'message')
|
||||
|
||||
if message_type == 'ping':
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
||||
except Exception:
|
||||
diagnostics.outcome('rejected')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||
elif message_type == 'disconnect':
|
||||
if message_type == 'ping':
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||
break
|
||||
else:
|
||||
diagnostics.outcome('skipped')
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||
elif message_type == 'disconnect':
|
||||
break
|
||||
else:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
diagnostics.outcome('rejected')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
|
||||
except Exception as e:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
@@ -496,13 +478,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
if message is None:
|
||||
break
|
||||
with diagnostics.scope(self.ap, 'websocket.embed.send', source='websocket'):
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
@@ -14,7 +14,6 @@ import quart
|
||||
from ....authz import Permission, permissions_for_role, require_permission
|
||||
from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from ... import group
|
||||
from ..... import management_diagnostics as diagnostics
|
||||
from ......core.task_boundary import run_in_workspace_uow
|
||||
from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
|
||||
from ......utils import bounded_executor
|
||||
@@ -211,7 +210,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.quart_app.websocket(self.path + '/connect')
|
||||
@diagnostics.observe('websocket.dashboard.session', source='websocket', ap=self.ap)
|
||||
async def websocket_connect(pipeline_uuid: str):
|
||||
"""Open one authenticated dashboard debug connection."""
|
||||
|
||||
@@ -219,14 +217,10 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
request_context, token = await self._authenticate_websocket()
|
||||
except Exception:
|
||||
diagnostics.outcome('rejected')
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
return
|
||||
|
||||
diagnostics.workspace(request_context)
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
if session_type not in ['person', 'group']:
|
||||
diagnostics.outcome('rejected')
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
@@ -235,7 +229,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||
if websocket_adapter is None:
|
||||
diagnostics.outcome('rejected')
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
|
||||
return
|
||||
|
||||
@@ -294,13 +287,11 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
await wait_for_duplex_tasks(receive_task, send_task)
|
||||
except Exception as exc:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error(f'WebSocket task execution error: {exc}')
|
||||
finally:
|
||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||
|
||||
except Exception:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error('Dashboard WebSocket connection error', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
@@ -417,35 +408,28 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
while connection.is_active:
|
||||
message = await quart.websocket.receive()
|
||||
await ws_connection_manager.update_activity(connection.connection_id)
|
||||
|
||||
with diagnostics.scope(self.ap, 'websocket.dashboard.message', source='websocket'):
|
||||
diagnostics.workspace(request_context)
|
||||
try:
|
||||
data = await asyncio.to_thread(json.loads, message)
|
||||
message_type = data.get('type', 'message')
|
||||
if message_type == 'ping':
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
diagnostics.outcome('rejected')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
elif message_type == 'disconnect':
|
||||
try:
|
||||
data = await asyncio.to_thread(json.loads, message)
|
||||
message_type = data.get('type', 'message')
|
||||
if message_type == 'ping':
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
break
|
||||
else:
|
||||
diagnostics.outcome('skipped')
|
||||
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
||||
except json.JSONDecodeError:
|
||||
diagnostics.outcome('rejected')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
elif message_type == 'disconnect':
|
||||
break
|
||||
else:
|
||||
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
|
||||
except Exception:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
@@ -461,13 +445,11 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
if message is None:
|
||||
break
|
||||
with diagnostics.scope(self.ap, 'websocket.dashboard.send', source='websocket'):
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error('Dashboard WebSocket send error', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
@@ -200,11 +200,14 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
if invitation_delivery_service is None:
|
||||
invitation_delivery_service = InvitationDeliveryService(self.ap)
|
||||
|
||||
# Detect beta flag from telemetry diagnostics manager
|
||||
is_beta = False
|
||||
diagnostics = getattr(self.ap, 'diagnostics', None)
|
||||
if diagnostics is not None:
|
||||
is_beta = getattr(diagnostics, 'beta', False)
|
||||
# Release channel is independent of diagnostics or usage telemetry.
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
try:
|
||||
release = Version(constants.semantic_version)
|
||||
is_beta = bool(release.pre and release.pre[0] == 'b' and not release.local)
|
||||
except InvalidVersion:
|
||||
is_beta = False
|
||||
|
||||
# Detect Cloud deployment mode
|
||||
is_cloud = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
|
||||
@@ -6,7 +6,6 @@ import fnmatch
|
||||
import time
|
||||
import uuid
|
||||
import typing
|
||||
from ... import management_diagnostics as diagnostics
|
||||
|
||||
import sqlalchemy
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
@@ -135,7 +134,6 @@ class AgentService:
|
||||
|
||||
return None
|
||||
|
||||
@diagnostics.observe('http.agent.debug_agent', source='webui_debug')
|
||||
async def debug_agent(
|
||||
self,
|
||||
context: RequestContext,
|
||||
@@ -150,7 +148,6 @@ class AgentService:
|
||||
delivers outputs to a real platform, and supports both message and
|
||||
non-message event envelopes.
|
||||
"""
|
||||
diagnostics.workspace(context)
|
||||
agent = await self.get_agent(context, agent_uuid)
|
||||
if agent is None or agent.get('kind') not in {AGENT_KIND_AGENT, AGENT_KIND_EVENT_PROCESSOR}:
|
||||
raise ValueError('Agent not found')
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Content-free management boundaries. Diagnostics never participate in I/O.
|
||||
|
||||
Only source-code identities and status categories enter these helpers. Never
|
||||
pass a URL, request, frame, token, tool argument, or serialized result to them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import contextvars
|
||||
import functools
|
||||
import re
|
||||
|
||||
import quart
|
||||
from quart.wrappers.response import IterableBody, ResponseBody
|
||||
|
||||
from ..telemetry import diagnostics as d
|
||||
from ..telemetry import diagnostic_privacy as privacy
|
||||
|
||||
_ACTIVE_BOUNDARY = contextvars.ContextVar('management_diagnostic_boundary', default=None)
|
||||
|
||||
|
||||
def operation_id(source, fn, *, rule='', methods=()):
|
||||
"""Called at registration with a Core function, never a client tool name."""
|
||||
module = fn.__module__.split('.groups.', 1)[-1]
|
||||
if source != 'http':
|
||||
module = ''
|
||||
name = fn.__name__
|
||||
if source == 'http' and name == '_':
|
||||
# Many Core routes use the anonymous function name `_`. Disambiguate
|
||||
# using ONLY the source-declared template and methods at registration.
|
||||
# This is never quart.request.path, url_rule, endpoint or request.method.
|
||||
template = re.sub(r'[^A-Za-z0-9_.:-]+', '.', rule).strip('.') or 'root'
|
||||
name = '.'.join((*sorted(methods or ('GET',)), template))
|
||||
return '.'.join(part for part in (source, module, name) if part)[:128]
|
||||
|
||||
|
||||
def outcome(value, reason_code='response_error'):
|
||||
try:
|
||||
d.set_outcome(value, reason_code=reason_code)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def workspace(context):
|
||||
"""Annotate only this boundary's owned span with a matching trusted context."""
|
||||
try:
|
||||
from .http.context import ExecutionContext, RequestContext
|
||||
|
||||
if not isinstance(context, (ExecutionContext, RequestContext)):
|
||||
return
|
||||
boundary = _ACTIVE_BOUNDARY.get()
|
||||
span = d.current_span()
|
||||
if boundary is None or span is None or span is not boundary.span:
|
||||
return
|
||||
if d._manager(boundary.ap) is not span.manager:
|
||||
return
|
||||
instance = getattr(
|
||||
getattr(boundary.ap, 'workspace_service', None),
|
||||
'instance_uuid',
|
||||
getattr(span.manager, 'instance_id', None),
|
||||
)
|
||||
# Structural recorders need not expose instance metadata. Real managers
|
||||
# do, and must never receive another instance's Workspace annotation.
|
||||
if instance is not None and instance != context.instance_uuid:
|
||||
return
|
||||
d.annotate(workspace_uuid=context.workspace_uuid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Boundary:
|
||||
def __init__(self, ap, operation, source):
|
||||
self.ap = ap
|
||||
self.span = None
|
||||
try:
|
||||
manager = d._manager(ap)
|
||||
if manager is not None:
|
||||
fields = {'source': source, 'stage': 'execute'}
|
||||
if source == 'webui_debug':
|
||||
fields['attributes'] = {'synthetic': True}
|
||||
self.span = d.Span(manager, 'api', operation, fields)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@contextlib.contextmanager
|
||||
def activate(self):
|
||||
# None is an explicit inheritance barrier, not a no-op: disabled or
|
||||
# broken B work must not borrow enabled A's span through ContextVars.
|
||||
token = d._CURRENT.set(self.span)
|
||||
boundary_token = _ACTIVE_BOUNDARY.set(self)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_ACTIVE_BOUNDARY.reset(boundary_token)
|
||||
d._CURRENT.reset(token)
|
||||
|
||||
def finish(self, error=None):
|
||||
if self.span is not None:
|
||||
try:
|
||||
self.span.finish(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _ObservedBody(ResponseBody):
|
||||
"""Carry the HTTP parent through iteration, not generator suspension."""
|
||||
|
||||
def __init__(self, body, boundary):
|
||||
self.body = body
|
||||
self.boundary = boundary
|
||||
self.iterator = None
|
||||
self.exhausted = False
|
||||
|
||||
async def __aenter__(self):
|
||||
try:
|
||||
with self.boundary.activate():
|
||||
entered = await self.body.__aenter__()
|
||||
self.iterator = entered.__aiter__()
|
||||
return self
|
||||
except BaseException as exc:
|
||||
self.boundary.finish(exc)
|
||||
raise
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, tb):
|
||||
try:
|
||||
with self.boundary.activate():
|
||||
result = await self.body.__aexit__(exc_type, exc_value, tb)
|
||||
except BaseException as exc:
|
||||
self.boundary.finish(exc)
|
||||
raise
|
||||
finally:
|
||||
self.boundary.finish(exc_value if exc_value is not None else (None if self.exhausted else GeneratorExit()))
|
||||
return result
|
||||
|
||||
async def __aiter__(self):
|
||||
try:
|
||||
if self.iterator is None:
|
||||
with self.boundary.activate():
|
||||
self.iterator = self.body.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
with self.boundary.activate():
|
||||
value = await anext(self.iterator)
|
||||
except StopAsyncIteration:
|
||||
self.exhausted = True
|
||||
self.boundary.finish()
|
||||
return
|
||||
yield value
|
||||
except BaseException as exc:
|
||||
self.boundary.finish(exc)
|
||||
raise
|
||||
|
||||
|
||||
def _response_status(value):
|
||||
"""Inspect response metadata only; never read or deserialize a body."""
|
||||
response = value[0] if isinstance(value, tuple) else value
|
||||
status = response.status_code if isinstance(response, quart.Response) else 200
|
||||
if isinstance(value, tuple) and len(value) > 1 and type(value[1]) is int:
|
||||
status = value[1]
|
||||
if status in (401, 403):
|
||||
outcome('rejected')
|
||||
elif status >= 400:
|
||||
outcome('failed')
|
||||
elif isinstance(response, dict) and 'code' in response and response['code'] != 0:
|
||||
outcome('failed')
|
||||
return response
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def scope(ap, operation, *, source):
|
||||
"""A fixed operation around an existing non-generator statement block."""
|
||||
try:
|
||||
privacy.code_value('operation', operation)
|
||||
except Exception:
|
||||
pass
|
||||
boundary = Boundary(ap, operation, source)
|
||||
try:
|
||||
with boundary.activate():
|
||||
yield
|
||||
except BaseException as exc:
|
||||
boundary.finish(exc)
|
||||
raise
|
||||
finally:
|
||||
boundary.finish()
|
||||
|
||||
|
||||
def observe(operation, *, source, ap=None, http=False):
|
||||
# The operation is supplied by Core registration code, not request data.
|
||||
try:
|
||||
privacy.code_value('operation', operation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def decorator(fn):
|
||||
@functools.wraps(fn)
|
||||
async def wrapped(*args, **kwargs):
|
||||
try:
|
||||
owner = (ap() if callable(ap) else ap) if ap is not None else getattr(args[0], 'ap', None)
|
||||
except Exception:
|
||||
owner = None
|
||||
boundary = Boundary(owner, operation, source)
|
||||
streaming = False
|
||||
try:
|
||||
with boundary.activate():
|
||||
value = await fn(*args, **kwargs)
|
||||
if http:
|
||||
try:
|
||||
response = _response_status(value)
|
||||
if isinstance(response, quart.Response) and isinstance(response.response, IterableBody):
|
||||
response.response = _ObservedBody(response.response, boundary)
|
||||
streaming = True
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
except BaseException as exc:
|
||||
boundary.finish(exc)
|
||||
raise
|
||||
finally:
|
||||
if not streaming:
|
||||
boundary.finish()
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
@@ -24,7 +24,6 @@ import uuid
|
||||
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from .context import bind_request_context, reset_request_context
|
||||
from .server import LangBotMCPServer
|
||||
from .. import management_diagnostics as diagnostics
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ...core import app as app_module
|
||||
@@ -86,7 +85,6 @@ class MCPMount:
|
||||
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
||||
is_mcp_path = self._is_mcp_path
|
||||
|
||||
@diagnostics.observe('mcp.request', source='mcp', ap=self.ap)
|
||||
async def dispatch_mcp(scope, receive, send):
|
||||
# Authenticate MCP HTTP requests with a LangBot API key.
|
||||
api_key = _extract_api_key(scope.get('headers', []))
|
||||
@@ -96,7 +94,6 @@ class MCPMount:
|
||||
identity = await authenticate_api_key(api_key)
|
||||
|
||||
if identity is None:
|
||||
diagnostics.outcome('rejected')
|
||||
await send(
|
||||
{
|
||||
'type': 'http.response.start',
|
||||
@@ -123,7 +120,6 @@ class MCPMount:
|
||||
entitlement = await resolver.resolve(identity.workspace_uuid)
|
||||
entitlement_revision = entitlement.entitlement_revision
|
||||
except Exception:
|
||||
diagnostics.outcome('rejected')
|
||||
await send(
|
||||
{
|
||||
'type': 'http.response.start',
|
||||
@@ -151,7 +147,6 @@ class MCPMount:
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
diagnostics.workspace(request_context)
|
||||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||||
if not callable(tenant_scope):
|
||||
raise RuntimeError('MCP request persistence scope is unavailable')
|
||||
|
||||
@@ -24,7 +24,6 @@ from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ..http.authz import Permission, require_permission
|
||||
from .context import get_request_context
|
||||
from .. import management_diagnostics as diagnostics
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ...core import app as app_module
|
||||
@@ -53,7 +52,6 @@ def _dump(value: typing.Any) -> str:
|
||||
def _authorized(permission: Permission):
|
||||
context = get_request_context()
|
||||
require_permission(context, permission)
|
||||
diagnostics.workspace(context)
|
||||
return context
|
||||
|
||||
|
||||
@@ -443,7 +441,7 @@ class LangBotMCPServer:
|
||||
"""Register a tool boundary before its authorization and service call."""
|
||||
|
||||
def register(fn):
|
||||
observed = diagnostics.observe(diagnostics.operation_id('mcp', fn), source='mcp', ap=self.ap)(fn)
|
||||
observed = fn
|
||||
return self.mcp.tool(**options)(observed)
|
||||
|
||||
return register
|
||||
|
||||
Reference in New Issue
Block a user