mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
feat(telemetry): add isolated beta quality diagnostics and release identity
This commit is contained in:
@@ -26,6 +26,7 @@ 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
|
||||
@@ -223,6 +224,7 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
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,
|
||||
@@ -274,7 +276,14 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
return self.internal_error_response(request_id)
|
||||
|
||||
new_f = handler_error
|
||||
# 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)
|
||||
# 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
|
||||
@@ -561,6 +570,7 @@ 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,6 +7,7 @@ import contextlib
|
||||
import json
|
||||
|
||||
import quart
|
||||
from .... import management_diagnostics as diagnostics
|
||||
|
||||
from .....agent.runner.errors import (
|
||||
RunnerError,
|
||||
@@ -29,6 +30,9 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
|
||||
result = await service.debug_agent(context, agent_uuid, payload, on_result=on_result)
|
||||
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,6 +21,7 @@ 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
|
||||
@@ -327,20 +328,24 @@ 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'})
|
||||
)
|
||||
@@ -349,6 +354,7 @@ 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:
|
||||
@@ -356,13 +362,16 @@ 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(
|
||||
@@ -420,11 +429,13 @@ 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'}))
|
||||
@@ -439,28 +450,35 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
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:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||
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':
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||
elif message_type == 'disconnect':
|
||||
break
|
||||
else:
|
||||
diagnostics.outcome('skipped')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
except json.JSONDecodeError:
|
||||
diagnostics.outcome('rejected')
|
||||
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
|
||||
@@ -476,11 +494,13 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
if message is None:
|
||||
break
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
with diagnostics.scope(self.ap, 'websocket.embed.send', source='websocket'):
|
||||
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,6 +14,7 @@ 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
|
||||
@@ -210,6 +211,7 @@ 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."""
|
||||
|
||||
@@ -217,11 +219,14 @@ 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'})
|
||||
)
|
||||
@@ -230,6 +235,7 @@ 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
|
||||
|
||||
@@ -288,11 +294,13 @@ 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'}))
|
||||
@@ -410,28 +418,34 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
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')
|
||||
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'})
|
||||
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':
|
||||
break
|
||||
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'})
|
||||
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'})
|
||||
|
||||
except Exception:
|
||||
diagnostics.outcome('failed')
|
||||
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
@@ -447,11 +461,13 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
if message is None:
|
||||
break
|
||||
encoded = await asyncio.to_thread(json.dumps, message)
|
||||
await quart.websocket.send(encoded)
|
||||
with diagnostics.scope(self.ap, 'websocket.dashboard.send', source='websocket'):
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -130,6 +131,7 @@ class AgentService:
|
||||
|
||||
return None
|
||||
|
||||
@diagnostics.observe('http.agent.debug_agent', source='webui_debug')
|
||||
async def debug_agent(
|
||||
self,
|
||||
context: RequestContext,
|
||||
@@ -144,6 +146,7 @@ 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')
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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,6 +24,7 @@ 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
|
||||
@@ -85,13 +86,8 @@ class MCPMount:
|
||||
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
||||
is_mcp_path = self._is_mcp_path
|
||||
|
||||
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
# Pass through non-HTTP scopes (lifespan, websocket) to Quart so its
|
||||
# own startup/shutdown and websocket routes keep working.
|
||||
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
|
||||
await quart_asgi(scope, receive, send)
|
||||
return
|
||||
|
||||
@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', []))
|
||||
identity = None
|
||||
@@ -100,6 +96,7 @@ class MCPMount:
|
||||
identity = await authenticate_api_key(api_key)
|
||||
|
||||
if identity is None:
|
||||
diagnostics.outcome('rejected')
|
||||
await send(
|
||||
{
|
||||
'type': 'http.response.start',
|
||||
@@ -126,6 +123,7 @@ 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',
|
||||
@@ -153,6 +151,7 @@ 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')
|
||||
@@ -165,4 +164,12 @@ class MCPMount:
|
||||
finally:
|
||||
reset_request_context(token)
|
||||
|
||||
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
# Non-MCP traffic keeps its existing Quart boundaries. In
|
||||
# particular, never derive an operation from an ASGI request path.
|
||||
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
|
||||
await quart_asgi(scope, receive, send)
|
||||
return
|
||||
await dispatch_mcp(scope, receive, send)
|
||||
|
||||
return dispatcher
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -52,6 +53,7 @@ def _dump(value: typing.Any) -> str:
|
||||
def _authorized(permission: Permission):
|
||||
context = get_request_context()
|
||||
require_permission(context, permission)
|
||||
diagnostics.workspace(context)
|
||||
return context
|
||||
|
||||
|
||||
@@ -76,7 +78,7 @@ class LangBotMCPServer:
|
||||
# ------------------------------------------------------------------ #
|
||||
def _register_tools(self) -> None:
|
||||
ap = self.ap
|
||||
mcp = self.mcp
|
||||
mcp = self
|
||||
|
||||
# ----- System (read-only) -------------------------------------- #
|
||||
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
|
||||
@@ -322,6 +324,15 @@ class LangBotMCPServer:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.skill_service.get_skill(context, skill_name))
|
||||
|
||||
def tool(self, **options):
|
||||
"""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)
|
||||
return self.mcp.tool(**options)(observed)
|
||||
|
||||
return register
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ASGI app
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in New Issue
Block a user