mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
feat(telemetry): add isolated beta quality diagnostics and release identity
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Build identity; release workflows replace the revision before packaging."""
|
||||
|
||||
CORE_REVISION = ''
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
@@ -91,6 +93,7 @@ class InteractionManager:
|
||||
self.ap = ap
|
||||
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.request', source='agent', stage='execute')
|
||||
async def handle_result(
|
||||
self,
|
||||
*,
|
||||
@@ -198,14 +201,17 @@ class InteractionManager:
|
||||
except Exception as exc:
|
||||
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
|
||||
raise
|
||||
diagnostics.set_outcome('waiting', reason_code='waiting')
|
||||
return True
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
|
||||
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
|
||||
"""Best-effort transition of submitted controls into a read-only state."""
|
||||
delivery_result = record.get('delivery_result')
|
||||
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
|
||||
adapter, INTERACTION_ACKNOWLEDGE_API
|
||||
):
|
||||
diagnostics.set_outcome('skipped')
|
||||
return
|
||||
try:
|
||||
delivery_result = await adapter.call_platform_api(
|
||||
@@ -223,6 +229,8 @@ class InteractionManager:
|
||||
delivery_result,
|
||||
)
|
||||
except Exception as exc:
|
||||
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||
diagnostics.annotate(error=exc)
|
||||
self._warning(f'Failed to acknowledge interaction submission: {exc}')
|
||||
|
||||
async def _find_update_target(
|
||||
@@ -273,6 +281,7 @@ class InteractionManager:
|
||||
f'interaction field {field.id} has duplicate option values',
|
||||
)
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.consume', source='platform', stage='dispatch')
|
||||
async def consume_callback(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import contextlib
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
@@ -10,6 +11,7 @@ from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
from ...telemetry import diagnostics as diagnostics
|
||||
from .reply_stream import ReplyStreamSession
|
||||
from ...core import app
|
||||
from ...api.http.context import ExecutionContext
|
||||
@@ -79,6 +81,7 @@ class AgentRunOrchestrator:
|
||||
self.journal = AgentRunJournal(ap)
|
||||
self._session_registry = get_session_registry()
|
||||
|
||||
@diagnostics.observe('run', 'runner.run', source='agent', stage='prepare')
|
||||
async def run(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
@@ -105,6 +108,7 @@ class AgentRunOrchestrator:
|
||||
bound_plugins,
|
||||
)
|
||||
|
||||
diagnostics.runner_metadata(self.ap, descriptor, binding.processor_type)
|
||||
usage = 'event' if binding.processor_type == 'event_processor' else 'agent'
|
||||
if usage not in descriptor.usages:
|
||||
raise ValueError(f'The selected Runner does not support {usage} usage')
|
||||
@@ -158,6 +162,7 @@ class AgentRunOrchestrator:
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
diagnostics.annotate(run_id=run_id, stage='execute')
|
||||
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
|
||||
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
|
||||
for tool in resources.get('tools', [])
|
||||
@@ -168,6 +173,7 @@ class AgentRunOrchestrator:
|
||||
source=(adapter_context or {}).get('_platform_event')
|
||||
or getattr((adapter_context or {}).get('_query'), 'message_event', None),
|
||||
)
|
||||
reply_streams.diagnostics = getattr(self.ap, 'diagnostics', None)
|
||||
available_apis = context.get('context', {}).get('available_apis')
|
||||
run_authorization = {
|
||||
'runner_id': descriptor.id,
|
||||
@@ -236,125 +242,134 @@ class AgentRunOrchestrator:
|
||||
event_log_id=event_log_id,
|
||||
)
|
||||
|
||||
async for result_dict in self.invoker.invoke(descriptor, context):
|
||||
result_dict = dict(result_dict)
|
||||
sequence = result_dict.get('sequence')
|
||||
if sequence is not None:
|
||||
try:
|
||||
sequence_int = int(sequence)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(f'Runner {descriptor.id} returned invalid result sequence: {sequence}')
|
||||
sequence_int = last_sequence + 1
|
||||
result_dict['sequence'] = sequence_int
|
||||
else:
|
||||
if sequence_int in seen_sequences:
|
||||
async with contextlib.aclosing(self.invoker.invoke(descriptor, context)) as results:
|
||||
async for result_dict in results:
|
||||
result_dict = dict(result_dict)
|
||||
sequence = result_dict.get('sequence')
|
||||
if sequence is not None:
|
||||
try:
|
||||
sequence_int = int(sequence)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned duplicate result sequence '
|
||||
f'{sequence_int} for run {run_id}; dropping duplicate'
|
||||
)
|
||||
continue
|
||||
if sequence_int <= 0:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned non-positive result sequence '
|
||||
f'{sequence_int} for run {run_id}'
|
||||
f'Runner {descriptor.id} returned invalid result sequence: {sequence}'
|
||||
)
|
||||
sequence_int = last_sequence + 1
|
||||
result_dict['sequence'] = sequence_int
|
||||
elif last_sequence and sequence_int != last_sequence + 1:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} result sequence gap or out-of-order '
|
||||
f'for run {run_id}: previous={last_sequence}, current={sequence_int}'
|
||||
)
|
||||
else:
|
||||
if sequence_int in seen_sequences:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned duplicate result sequence '
|
||||
f'{sequence_int} for run {run_id}; dropping duplicate'
|
||||
)
|
||||
continue
|
||||
if sequence_int <= 0:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} returned non-positive result sequence '
|
||||
f'{sequence_int} for run {run_id}'
|
||||
)
|
||||
sequence_int = last_sequence + 1
|
||||
result_dict['sequence'] = sequence_int
|
||||
elif last_sequence and sequence_int != last_sequence + 1:
|
||||
self.ap.logger.warning(
|
||||
f'Runner {descriptor.id} result sequence gap or out-of-order '
|
||||
f'for run {run_id}: previous={last_sequence}, current={sequence_int}'
|
||||
)
|
||||
seen_sequences.add(sequence_int)
|
||||
last_sequence = max(last_sequence, sequence_int)
|
||||
else:
|
||||
sequence_int = last_sequence + 1
|
||||
result_dict['sequence'] = sequence_int
|
||||
seen_sequences.add(sequence_int)
|
||||
last_sequence = max(last_sequence, sequence_int)
|
||||
else:
|
||||
sequence_int = last_sequence + 1
|
||||
result_dict['sequence'] = sequence_int
|
||||
seen_sequences.add(sequence_int)
|
||||
last_sequence = sequence_int
|
||||
last_sequence = sequence_int
|
||||
|
||||
result_type = result_dict.get('type')
|
||||
if result_type and not self.result_normalizer.validate_payload(
|
||||
result_type,
|
||||
result_dict.get('data', {}),
|
||||
descriptor,
|
||||
):
|
||||
continue
|
||||
|
||||
await self.journal.append_run_result(
|
||||
result_dict=result_dict,
|
||||
run_id=run_id,
|
||||
sequence=sequence_int,
|
||||
)
|
||||
|
||||
# Trusted Host observers receive validated events before message-only normalization.
|
||||
result_observer = (adapter_context or {}).get('_result_observer')
|
||||
if result_observer is not None:
|
||||
await result_observer(result_dict)
|
||||
|
||||
if result_type == 'state.updated':
|
||||
await self.journal.handle_state_updated_event(
|
||||
result_dict,
|
||||
event,
|
||||
binding,
|
||||
result_type = result_dict.get('type')
|
||||
if result_type and not self.result_normalizer.validate_payload(
|
||||
result_type,
|
||||
result_dict.get('data', {}),
|
||||
descriptor,
|
||||
run_id=run_id,
|
||||
)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'action.requested':
|
||||
if await self.interaction_manager.handle_result(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
run_id=run_id,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'run.completed':
|
||||
terminal_status = 'completed'
|
||||
terminal_reason = (
|
||||
result_dict.get('data', {}).get('finish_reason')
|
||||
if isinstance(result_dict.get('data'), dict)
|
||||
else None
|
||||
)
|
||||
usage = result_dict.get('usage')
|
||||
if isinstance(usage, dict):
|
||||
terminal_usage = usage
|
||||
elif result_type == 'run.failed':
|
||||
terminal_status = 'failed'
|
||||
data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
|
||||
terminal_reason = data.get('error') or data.get('code')
|
||||
usage = result_dict.get('usage')
|
||||
if isinstance(usage, dict):
|
||||
terminal_usage = usage
|
||||
|
||||
has_completed_message = result_type == 'message.completed' or (
|
||||
result_type == 'run.completed'
|
||||
and isinstance(result_dict.get('data'), dict)
|
||||
and bool(result_dict['data'].get('message'))
|
||||
)
|
||||
if has_completed_message and event.conversation_id and not assistant_transcript_written:
|
||||
await self.journal.write_assistant_transcript(
|
||||
await self.journal.append_run_result(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
sequence=sequence_int,
|
||||
)
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
if result is not None:
|
||||
yield result
|
||||
# Trusted Host observers receive validated events before message-only normalization.
|
||||
result_observer = (adapter_context or {}).get('_result_observer')
|
||||
if result_observer is not None:
|
||||
await result_observer(result_dict)
|
||||
|
||||
run_snapshot = await self.journal.get_run(run_id)
|
||||
if run_snapshot and run_snapshot.get('cancel_requested_at') is not None:
|
||||
terminal_status = 'cancelled'
|
||||
terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested'
|
||||
break
|
||||
if result_type == 'state.updated':
|
||||
await self.journal.handle_state_updated_event(
|
||||
result_dict,
|
||||
event,
|
||||
binding,
|
||||
descriptor,
|
||||
run_id=run_id,
|
||||
)
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'action.requested':
|
||||
if await self.interaction_manager.handle_result(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
run_id=run_id,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'run.completed':
|
||||
terminal_status = 'completed'
|
||||
terminal_reason = (
|
||||
result_dict.get('data', {}).get('finish_reason')
|
||||
if isinstance(result_dict.get('data'), dict)
|
||||
else None
|
||||
)
|
||||
usage = result_dict.get('usage')
|
||||
if isinstance(usage, dict):
|
||||
terminal_usage = usage
|
||||
elif result_type == 'run.failed':
|
||||
terminal_status = 'failed'
|
||||
data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
|
||||
terminal_reason = data.get('error') or data.get('code')
|
||||
usage = result_dict.get('usage')
|
||||
if isinstance(usage, dict):
|
||||
terminal_usage = usage
|
||||
|
||||
has_completed_message = result_type == 'message.completed' or (
|
||||
result_type == 'run.completed'
|
||||
and isinstance(result_dict.get('data'), dict)
|
||||
and bool(result_dict['data'].get('message'))
|
||||
)
|
||||
if has_completed_message and event.conversation_id and not assistant_transcript_written:
|
||||
await self.journal.write_assistant_transcript(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
)
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
run_snapshot = await self.journal.get_run(run_id)
|
||||
if run_snapshot and run_snapshot.get('cancel_requested_at') is not None:
|
||||
terminal_status = 'cancelled'
|
||||
terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested'
|
||||
break
|
||||
diagnostics.set_outcome(
|
||||
{'completed': 'succeeded', 'failed': 'failed', 'cancelled': 'cancelled'}.get(
|
||||
terminal_status, 'succeeded'
|
||||
),
|
||||
reason_code='runner_failed' if terminal_status == 'failed' else '',
|
||||
)
|
||||
await self.journal.finalize_run(
|
||||
run_id=run_id,
|
||||
status=terminal_status or 'completed',
|
||||
@@ -362,6 +377,7 @@ class AgentRunOrchestrator:
|
||||
usage=terminal_usage,
|
||||
)
|
||||
except Exception as exc:
|
||||
diagnostics.set_outcome('timeout' if self._is_deadline_exhausted(context) else 'failed')
|
||||
failed_usage = terminal_usage
|
||||
await self.journal.finalize_run(
|
||||
run_id=run_id,
|
||||
@@ -387,6 +403,7 @@ class AgentRunOrchestrator:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runner.query_prepare', source='pipeline', stage='prepare')
|
||||
async def run_from_query(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -403,13 +420,16 @@ class AgentRunOrchestrator:
|
||||
# Materialize inbound attachments into sandbox before running
|
||||
await self._materialize_inbound_attachments(query, plan.event)
|
||||
|
||||
async for result in self.run(
|
||||
plan.event,
|
||||
plan.binding,
|
||||
bound_plugins=plan.bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
yield result
|
||||
async with contextlib.aclosing(
|
||||
self.run(
|
||||
plan.event,
|
||||
plan.binding,
|
||||
bound_plugins=plan.bound_plugins,
|
||||
adapter_context=adapter_context,
|
||||
)
|
||||
) as results:
|
||||
async for result in results:
|
||||
yield result
|
||||
|
||||
async def _materialize_inbound_attachments(
|
||||
self,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...telemetry import diagnostics
|
||||
|
||||
import copy
|
||||
import fnmatch
|
||||
import typing
|
||||
@@ -667,6 +669,7 @@ def resolve_platform_api_call(session, bot_uuid, action, params, context_tool=No
|
||||
raise ValueError(f'Platform API {action} or its target is not authorized for this run')
|
||||
|
||||
|
||||
@diagnostics.observe('api', 'host.platform_tool', source='agent')
|
||||
async def execute_platform_tool(
|
||||
ap: typing.Any,
|
||||
execution_context: typing.Any,
|
||||
@@ -686,6 +689,7 @@ async def execute_platform_tool(
|
||||
normalized = _event_params(definition, context, normalized)
|
||||
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
|
||||
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
|
||||
diagnostics.annotate(source='webui_debug', attributes={'synthetic': True})
|
||||
result = _execute_mock_platform_tool(definition, context, normalized)
|
||||
if message_chain is not None:
|
||||
result['parameters']['message'] = message_chain.model_dump(mode='json')
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
@@ -119,7 +121,7 @@ class RunnerRegistry:
|
||||
typed_manifest = RunnerManifest.model_validate(manifest)
|
||||
config_schema = [item.model_dump(mode='json') for item in typed_manifest.config_schema]
|
||||
|
||||
return RunnerDescriptor(
|
||||
descriptor = RunnerDescriptor(
|
||||
id=runner_id,
|
||||
component_kind=typed_manifest.component_kind,
|
||||
usages=typed_manifest.usages,
|
||||
@@ -136,6 +138,10 @@ class RunnerRegistry:
|
||||
permissions=typed_manifest.permissions,
|
||||
raw_manifest=manifest,
|
||||
)
|
||||
manager = getattr(self.ap, 'diagnostics', None)
|
||||
if isinstance(manager, diagnostics.DiagnosticsManager) and manager.enabled:
|
||||
diagnostics.declare_runner(descriptor)
|
||||
return descriptor
|
||||
|
||||
async def refresh(self, context: TenantContext) -> None:
|
||||
"""Refresh runner cache.
|
||||
|
||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from ...telemetry import diagnostics
|
||||
|
||||
import pydantic
|
||||
from langbot_plugin.api.entities.builtin.platform import events, message
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
@@ -54,6 +56,9 @@ class ReplyStreamSession:
|
||||
self._closed = False
|
||||
self._active: set[asyncio.Task] = set()
|
||||
|
||||
@diagnostics.observe(
|
||||
'delivery', 'reply_stream.apply', source='agent', fields=lambda b: {'attributes': {'stream': True}}
|
||||
)
|
||||
async def apply(self, request: ReplyStreamRequest) -> dict:
|
||||
task = asyncio.create_task(self._apply(request))
|
||||
self._active.add(task)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import typing
|
||||
@@ -170,6 +172,7 @@ class AgentRunSessionRegistry:
|
||||
'query_id': query_id,
|
||||
'execution_query': execution_query,
|
||||
'reply_streams': reply_streams,
|
||||
'_diagnostic_context': diagnostics.capture_context(),
|
||||
'plugin_identity': plugin_identity,
|
||||
'authorization': authorization,
|
||||
'status': {
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
@@ -162,6 +164,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
def _uses_websocket(self) -> bool:
|
||||
return self.uses_websocket()
|
||||
|
||||
@diagnostics.observe('lifecycle', 'box.initialize', source='runtime', stage='execute')
|
||||
async def initialize(self) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
if self._closing:
|
||||
|
||||
@@ -597,6 +597,10 @@ class Application:
|
||||
if self.telemetry is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.telemetry.shutdown()
|
||||
diagnostics_manager = getattr(self, 'diagnostics', None)
|
||||
if diagnostics_manager is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await diagnostics_manager.shutdown()
|
||||
if self.vector_db_mgr is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.vector_db_mgr.shutdown()
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
|
||||
from . import app
|
||||
from . import stage
|
||||
from ..telemetry import diagnostics
|
||||
from ..utils import constants, importutil
|
||||
|
||||
# Import startup stage implementation to register
|
||||
@@ -39,7 +40,33 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
||||
stage_cls = stage.preregistered_stages[stage_name]
|
||||
stage_inst = stage_cls()
|
||||
|
||||
await stage_inst.run(ap)
|
||||
if stage_name == 'GenKeysStage':
|
||||
# Optional diagnostics must not make startup depend on package
|
||||
# metadata, session markers, or its background transport.
|
||||
ap.diagnostics = None
|
||||
space_config = ap.instance_config.data.get('space', {})
|
||||
if not space_config.get('disable_telemetry', False) and not space_config.get(
|
||||
'disable_beta_diagnostics', False
|
||||
):
|
||||
manager = None
|
||||
try:
|
||||
manager = diagnostics.DiagnosticsManager(ap, marker_path='data/labels/beta_diagnostics_session')
|
||||
await manager.start_session()
|
||||
manager.start()
|
||||
ap.diagnostics = manager
|
||||
except BaseException as exc:
|
||||
if manager is not None:
|
||||
# Cleanup faults cannot replace the startup fault.
|
||||
try:
|
||||
await manager.shutdown(drain_timeout=0)
|
||||
except asyncio.CancelledError:
|
||||
if not isinstance(exc, asyncio.CancelledError):
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance(exc, Exception):
|
||||
raise
|
||||
await diagnostics.observe('lifecycle', 'startup.' + stage_name, source='startup', ap=ap)(stage_inst.run)(ap)
|
||||
|
||||
await ap.initialize()
|
||||
except BaseException:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
import sqlite3
|
||||
@@ -153,6 +155,7 @@ class PersistenceManager:
|
||||
default=None,
|
||||
)
|
||||
|
||||
@diagnostics.observe('lifecycle', 'persistence.initialize', source='startup', stage='execute')
|
||||
async def initialize(self):
|
||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||
self.ap.logger.info(f'Initializing database type: {database_type}...')
|
||||
@@ -1648,6 +1651,7 @@ class PersistenceManager:
|
||||
|
||||
# =================================
|
||||
|
||||
@diagnostics.observe('lifecycle', 'persistence.run_alembic_migrations', source='startup', stage='execute')
|
||||
async def _run_alembic_migrations(self, target_revision: str = 'head'):
|
||||
"""Run the supported Alembic-based 4.x migrations."""
|
||||
from . import alembic_runner
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..telemetry import diagnostics
|
||||
|
||||
import dataclasses
|
||||
import typing
|
||||
import traceback
|
||||
@@ -178,6 +180,7 @@ class RuntimePipeline:
|
||||
raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
|
||||
return execution_context
|
||||
|
||||
@diagnostics.observe('run', 'pipeline.run', source='pipeline', fields=lambda b: {'processor_type': 'pipeline'})
|
||||
async def run(self, query: pipeline_query.Query):
|
||||
if (
|
||||
query.instance_uuid != self.execution_context.instance_uuid
|
||||
@@ -254,6 +257,7 @@ class RuntimePipeline:
|
||||
if result.console_notice:
|
||||
self.ap.logger.info(result.console_notice)
|
||||
if result.error_notice:
|
||||
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||
self.ap.logger.error(result.error_notice)
|
||||
# Mark query as having error
|
||||
query.variables['_monitoring_has_error'] = True
|
||||
@@ -426,6 +430,7 @@ class RuntimePipeline:
|
||||
await self._assert_execution_active(query)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
diagnostics.set_outcome('skipped', reason_code='discarded')
|
||||
self.ap.logger.debug(
|
||||
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||
)
|
||||
@@ -468,8 +473,11 @@ class RuntimePipeline:
|
||||
self.ap.logger.error(f'Failed to record query response: {e}')
|
||||
|
||||
except WorkspaceError as e:
|
||||
diagnostics.set_outcome('rejected', reason_code='processor_incompatible')
|
||||
self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
|
||||
except Exception as e:
|
||||
diagnostics.set_outcome('failed', reason_code='response_error')
|
||||
diagnostics.annotate(error=e)
|
||||
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
||||
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
||||
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import inspect
|
||||
@@ -305,6 +307,7 @@ class QueryPool:
|
||||
)
|
||||
object.__setattr__(query, 'query_uuid', query_uuid)
|
||||
object.__setattr__(query, '_execution_context', execution_context)
|
||||
object.__setattr__(query, '_diagnostic_context', diagnostics.capture_context())
|
||||
|
||||
self.queries.append(query)
|
||||
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
@@ -94,6 +96,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -146,6 +149,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
|
||||
await self._dispatch_native_event(event)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
||||
@@ -163,6 +167,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id, self._lookup)
|
||||
if eba_event:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
@@ -15,6 +17,7 @@ from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedErro
|
||||
class AiocqhttpAPIMixin:
|
||||
bot: aiocqhttp.CQHttp
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -35,6 +38,7 @@ class AiocqhttpAPIMixin:
|
||||
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -49,6 +53,7 @@ class AiocqhttpAPIMixin:
|
||||
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -57,6 +62,7 @@ class AiocqhttpAPIMixin:
|
||||
) -> None:
|
||||
await self.bot.delete_msg(message_id=int(message_id))
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -75,6 +81,7 @@ class AiocqhttpAPIMixin:
|
||||
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -100,6 +107,7 @@ class AiocqhttpAPIMixin:
|
||||
)
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raw = await self.bot.get_group_info(group_id=int(group_id))
|
||||
return platform_entities.UserGroup(
|
||||
@@ -108,6 +116,7 @@ class AiocqhttpAPIMixin:
|
||||
member_count=raw.get('member_count'),
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
raw_list = await self.bot.get_group_list()
|
||||
return [
|
||||
@@ -119,6 +128,7 @@ class AiocqhttpAPIMixin:
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -126,6 +136,7 @@ class AiocqhttpAPIMixin:
|
||||
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
|
||||
return [self._member_to_entity(item, group_id) for item in raw_list]
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -134,9 +145,11 @@ class AiocqhttpAPIMixin:
|
||||
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
|
||||
return self._member_to_entity(raw, group_id)
|
||||
|
||||
@diagnostics.observe('api', 'set_group_name', source='platform', stage='accepted')
|
||||
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
|
||||
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -145,15 +158,19 @@ class AiocqhttpAPIMixin:
|
||||
) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
|
||||
return platform_entities.User(
|
||||
@@ -162,6 +179,7 @@ class AiocqhttpAPIMixin:
|
||||
avatar_url=raw.get('avatar_url'),
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
raw_list = await self.bot.get_friend_list()
|
||||
return [
|
||||
@@ -173,6 +191,7 @@ class AiocqhttpAPIMixin:
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'approve_friend_request', source='platform', stage='accepted')
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
@@ -181,12 +200,15 @@ class AiocqhttpAPIMixin:
|
||||
) -> None:
|
||||
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
|
||||
|
||||
@diagnostics.observe('api', 'approve_group_invite', source='platform', stage='accepted')
|
||||
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
|
||||
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
@@ -21,6 +23,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
@@ -39,6 +42,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
@@ -133,6 +135,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -149,6 +152,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
|
||||
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -165,6 +169,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
raw=raw if isinstance(raw, dict) else {'result': raw},
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -188,6 +193,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.card_instance_id_dict.pop(message_id)
|
||||
|
||||
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||
async def create_message_card(self, message_id, event):
|
||||
while len(self.card_instance_id_dict) >= 1000:
|
||||
self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None)
|
||||
@@ -205,6 +211,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable-stream-reply', False))
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and action in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
@@ -260,6 +267,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
DingTalkCardCallbackHandler(self),
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: DingTalkEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||
@@ -14,6 +16,7 @@ class DingTalkAPIMixin:
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,18 +28,22 @@ class DingTalkAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
return list(self._group_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -52,14 +59,18 @@ class DingTalkAPIMixin:
|
||||
display_name=user.nickname,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return await self.bot.get_file_url(file_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
@@ -16,6 +18,7 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
|
||||
if event.conversation in {'FriendMessage', 'GroupMessage'}:
|
||||
return await DingTalkEventConverter.message_to_eba(event, bot_name)
|
||||
@@ -27,6 +30,7 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
event: DingTalkEvent,
|
||||
bot_name: str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
@@ -45,10 +47,30 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
adapter_self = self
|
||||
|
||||
class LangBotDiscordClient(discord.Client):
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_ready(self: discord.Client):
|
||||
adapter_self.bot_account_id = str(self.user.id) if self.user else ''
|
||||
await adapter_self.logger.info(f'Discord adapter running as {self.user}')
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_message(self: discord.Client, message: discord.Message):
|
||||
if self.user and message.author.id == self.user.id:
|
||||
return
|
||||
@@ -72,6 +94,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
|
||||
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
|
||||
try:
|
||||
@@ -91,16 +123,46 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_edit', (before, after), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_message_delete(self: discord.Client, message: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_delete', message, self.user.id if self.user else None
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'raw_message_delete',
|
||||
@@ -108,6 +170,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_reaction_add(
|
||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||
):
|
||||
@@ -117,6 +189,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
'reaction_add', (reaction, user), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_reaction_remove(
|
||||
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
|
||||
):
|
||||
@@ -126,6 +208,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
'reaction_remove', (reaction, user), self.user.id if self.user else None
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
if self.user and payload.user_id == self.user.id:
|
||||
return
|
||||
@@ -135,6 +227,16 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
if self.user and payload.user_id == self.user.id:
|
||||
return
|
||||
@@ -144,15 +246,55 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
self.user.id if self.user else None,
|
||||
)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_member_join(self: discord.Client, member: discord.Member):
|
||||
await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_member_remove(self: discord.Client, member: discord.Member):
|
||||
await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_guild_join(self: discord.Client, guild: discord.Guild):
|
||||
await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None)
|
||||
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_guild_remove(self: discord.Client, guild: discord.Guild):
|
||||
await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None)
|
||||
|
||||
@@ -210,6 +352,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content, files = await self.message_converter.yiri2target(message)
|
||||
channel = await self._get_channel(target_id)
|
||||
@@ -219,6 +362,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
sent = await channel.send(**kwargs)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -236,6 +380,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
sent = await message_source.source_platform_object.channel.send(**kwargs)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None):
|
||||
try:
|
||||
event = await self.event_converter.target2yiri((kind, payload), bot_user_id)
|
||||
@@ -269,6 +414,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
@@ -290,6 +436,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
"""Set up a stream context for progressive editing.
|
||||
|
||||
@@ -309,6 +456,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
}
|
||||
return True
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
@@ -15,6 +17,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
class DiscordAPIMixin:
|
||||
bot: discord.Client
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -31,6 +34,7 @@ class DiscordAPIMixin:
|
||||
return
|
||||
await message.edit(content=content)
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -41,6 +45,7 @@ class DiscordAPIMixin:
|
||||
message = await channel.fetch_message(int(message_id))
|
||||
await message.delete()
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -56,10 +61,12 @@ class DiscordAPIMixin:
|
||||
sent = await to_channel.send(content=message.content, files=files)
|
||||
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
guild = await self._get_guild(group_id)
|
||||
return DiscordEventConverter.group_from_guild(guild)
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -68,6 +75,7 @@ class DiscordAPIMixin:
|
||||
members = guild.members or [member async for member in guild.fetch_members(limit=None)]
|
||||
return [self._member_to_entity(member) for member in members]
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -77,18 +85,22 @@ class DiscordAPIMixin:
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
return self._member_to_entity(member)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id))
|
||||
return DiscordEventConverter.user_from_author(user)
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return file_id
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -102,6 +114,7 @@ class DiscordAPIMixin:
|
||||
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
||||
await member.timeout(until, reason='LangBot Omni mute_member')
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -111,6 +124,7 @@ class DiscordAPIMixin:
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.timeout(None, reason='LangBot Omni unmute_member')
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -120,6 +134,7 @@ class DiscordAPIMixin:
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.kick(reason='LangBot Omni kick_member')
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
await guild.leave()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
import discord
|
||||
@@ -16,6 +18,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None:
|
||||
if isinstance(event, discord.Message):
|
||||
return await DiscordEventConverter.message_to_eba(event)
|
||||
@@ -238,6 +241,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||
message_chain = await DiscordMessageConverter.target2yiri(message)
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
from langbot.pkg.platform.sources.kook import _decode_gateway_message
|
||||
|
||||
import asyncio
|
||||
@@ -99,6 +101,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -168,6 +171,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
self.session_id = str(data.get('session_id') or '')
|
||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
|
||||
|
||||
@diagnostics.observe('event', 'platform.receive', source='platform', stage='accepted')
|
||||
async def _handle_event(self, data: dict, sn: int):
|
||||
self.current_sn = max(self.current_sn, sn)
|
||||
|
||||
@@ -192,7 +196,9 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
if eba_event:
|
||||
self._cache_event(eba_event)
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
diagnostics.annotate(error=exc)
|
||||
diagnostics.set_outcome('failed')
|
||||
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||
@@ -14,6 +16,7 @@ class KookAPIMixin:
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -34,6 +37,7 @@ class KookAPIMixin:
|
||||
data = raw.get('data') or {}
|
||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -73,6 +77,7 @@ class KookAPIMixin:
|
||||
data = raw.get('data') or {}
|
||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -84,6 +89,7 @@ class KookAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
cached = self._group_cache.get(str(group_id))
|
||||
if cached:
|
||||
@@ -96,15 +102,18 @@ class KookAPIMixin:
|
||||
member_count=data.get('user_count'),
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
return list(self._group_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -128,6 +137,7 @@ class KookAPIMixin:
|
||||
display_name=user.nickname,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached:
|
||||
@@ -142,18 +152,22 @@ class KookAPIMixin:
|
||||
is_bot=bool(data.get('bot', False)),
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
data = {'file': file_data}
|
||||
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
|
||||
result = raw.get('data') or {}
|
||||
return str(result.get('url') or result.get('id') or '')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return file_id
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -163,6 +177,7 @@ class KookAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -172,6 +187,7 @@ class KookAPIMixin:
|
||||
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
|
||||
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -185,6 +201,7 @@ class KookAPIMixin:
|
||||
raise NotSupportedError('forward_message:message_not_cached')
|
||||
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -193,6 +210,7 @@ class KookAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('mute_member')
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -200,6 +218,7 @@ class KookAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('unmute_member')
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -207,5 +226,6 @@ class KookAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('kick_member')
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
raise NotSupportedError('leave_group')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import time
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
@@ -16,6 +18,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
|
||||
event_type = int(kook_event.get('type', 0) or 0)
|
||||
channel_type = kook_event.get('channel_type')
|
||||
@@ -55,6 +58,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
kook_event: dict, bot_account_id: str = ''
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
from langbot.pkg.platform.sources.lark import (
|
||||
LarkAdapter as LegacyLarkAdapter,
|
||||
NonBlockingLarkWSClient,
|
||||
@@ -186,6 +188,16 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self.request_app_ticket()
|
||||
|
||||
def _build_event_handler(self):
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
await self._handle_message_event(event)
|
||||
|
||||
@@ -326,6 +338,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self.request_tenant_access_token(tenant_key)
|
||||
return self.tenant_access_tokens.get(tenant_key, {}).get('token')
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -359,6 +372,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids}
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -435,6 +449,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
while len(self.pending_monitoring_msg) > 1000:
|
||||
self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
|
||||
|
||||
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||
async def create_message_card(self, message_id, event) -> bool:
|
||||
card_id = await self.create_card_id(message_id)
|
||||
content = {'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}}
|
||||
@@ -539,6 +554,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
|
||||
self.closed_streaming_cards.add(card_id)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -589,6 +605,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self.card_last_update_dict.pop(card_id, None)
|
||||
self.closed_streaming_cards.discard(card_id)
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
@@ -733,6 +750,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
return False
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
try:
|
||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest
|
||||
@@ -17,6 +19,7 @@ class LarkAPIMixin:
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -52,6 +55,7 @@ class LarkAPIMixin:
|
||||
self._message_cache[str(message_id)] = event
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
cached = self._group_cache.get(str(group_id))
|
||||
if cached:
|
||||
@@ -71,6 +75,7 @@ class LarkAPIMixin:
|
||||
self._group_cache[str(group.id)] = group
|
||||
return group
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -79,12 +84,14 @@ class LarkAPIMixin:
|
||||
user = self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id)
|
||||
return platform_entities.UserGroupMember(user=user, group_id=group_id, role=platform_entities.MemberRole.MEMBER)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached:
|
||||
return cached
|
||||
return platform_entities.User(id=user_id)
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
if str(file_id).startswith('file://'):
|
||||
return str(file_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
@@ -24,6 +26,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(
|
||||
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
||||
api_client: lark_oapi.Client,
|
||||
@@ -31,6 +34,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return await LarkEventConverter.message_to_eba(event, api_client)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
|
||||
api_client: lark_oapi.Client,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
@@ -94,6 +96,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -102,6 +105,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('send_message:official_account_requires_inbound_webhook_reply')
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -118,6 +122,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
await self.bot.set_message(source.message_id, content)
|
||||
return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True})
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -170,6 +175,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
for msg_type in ('text', 'image', 'voice', 'event'):
|
||||
self.bot.on_message(msg_type)(self._handle_native_event)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: OAEvent):
|
||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -12,6 +14,7 @@ class OfficialAccountAPIMixin:
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -23,15 +26,18 @@ class OfficialAccountAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
user = self._user_cache.get(str(user_id))
|
||||
if user is None:
|
||||
raise NotSupportedError('get_user_info:not_cached')
|
||||
return user
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -41,6 +47,7 @@ class OfficialAccountAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -49,6 +56,7 @@ class OfficialAccountAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('delete_message')
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -59,24 +67,30 @@ class OfficialAccountAPIMixin:
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('forward_message')
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raise NotSupportedError('get_group_info')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
raise NotSupportedError('get_group_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
@@ -16,6 +18,7 @@ class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConve
|
||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(self, event: OAEvent) -> platform_events.FriendMessage | None:
|
||||
eba_event = await self.target2yiri(event)
|
||||
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
|
||||
@@ -31,6 +34,7 @@ class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConve
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(self, event: OAEvent) -> platform_events.Event | None:
|
||||
if event.type in {'text', 'image', 'voice'}:
|
||||
return await self.message_to_eba(event)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
@@ -132,6 +134,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -143,6 +146,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
)
|
||||
return platform_events.MessageResult(raw={'results': raw})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -161,6 +165,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
)
|
||||
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
@@ -258,6 +263,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable-stream-reply') or self.config.get('enable_stream_reply'))
|
||||
|
||||
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = event.source_platform_object
|
||||
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
||||
@@ -277,6 +283,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
self._stream_ctx_ts[message_id] = time.time()
|
||||
return True
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -364,6 +371,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
if event is not None:
|
||||
await self._dispatch_eba_event(event)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: QQOfficialEvent):
|
||||
self.bot_account_id = self.config.get('appid', self.bot_account_id)
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
|
||||
@@ -14,6 +16,7 @@ class QQOfficialAPIMixin:
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,21 +28,25 @@ class QQOfficialAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
user = self._user_cache.get(str(user_id))
|
||||
if user is None:
|
||||
raise NotSupportedError('get_user_info:not_cached')
|
||||
return user
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
group = self._group_cache.get(str(group_id))
|
||||
if group is None:
|
||||
raise NotSupportedError('get_group_info:not_cached')
|
||||
return group
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -50,6 +57,7 @@ class QQOfficialAPIMixin:
|
||||
raise NotSupportedError('get_group_member_info:not_cached')
|
||||
return member
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -58,6 +66,7 @@ class QQOfficialAPIMixin:
|
||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -67,6 +76,7 @@ class QQOfficialAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -75,6 +85,7 @@ class QQOfficialAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('delete_message')
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -85,20 +96,26 @@ class QQOfficialAPIMixin:
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('forward_message')
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
||||
raise NotSupportedError('mute_member')
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('unmute_member')
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('kick_member')
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(self, group_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('leave_group')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import datetime
|
||||
import time
|
||||
import typing
|
||||
@@ -31,6 +33,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
self, event: QQOfficialEvent
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
@@ -65,6 +68,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event:
|
||||
if event.t in MESSAGE_EVENT_TYPES:
|
||||
return await self.message_to_eba(event)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
@@ -90,6 +92,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -100,6 +103,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
raw = await self._send_text(str(target_type), str(target_id), content)
|
||||
return platform_events.MessageResult(raw=raw)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -114,6 +118,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message))
|
||||
return platform_events.MessageResult(message_id=source.message_id, raw=raw)
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -162,6 +167,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
for msg_type in ('im', 'channel'):
|
||||
self.bot.on_message(msg_type)(self._handle_native_event)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: SlackEvent):
|
||||
try:
|
||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
|
||||
@@ -14,6 +16,7 @@ class SlackAPIMixin:
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,24 +28,29 @@ class SlackAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
user = self._user_cache.get(str(user_id))
|
||||
if user is None:
|
||||
raise NotSupportedError('get_user_info:not_cached')
|
||||
return user
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
group = self._group_cache.get(str(group_id))
|
||||
if group is None:
|
||||
raise NotSupportedError('get_group_info:not_cached')
|
||||
return group
|
||||
|
||||
@diagnostics.observe('api', 'get_group_list', source='platform', stage='accepted')
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
return list(self._group_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -51,6 +59,7 @@ class SlackAPIMixin:
|
||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -61,6 +70,7 @@ class SlackAPIMixin:
|
||||
raise NotSupportedError('get_group_member_info:not_cached')
|
||||
return member
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -70,6 +80,7 @@ class SlackAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -78,6 +89,7 @@ class SlackAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('delete_message')
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -88,8 +100,10 @@ class SlackAPIMixin:
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('forward_message')
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
@@ -19,6 +21,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
self, event: SlackEvent
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
@@ -53,6 +56,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(self, event: SlackEvent) -> platform_events.Event:
|
||||
if event.type in {'im', 'channel'}:
|
||||
return await self.message_to_eba(event)
|
||||
|
||||
@@ -6,6 +6,8 @@ Preserves all existing functionality (messaging, streaming output, markdown card
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
@@ -68,6 +70,16 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
@diagnostics.observe(
|
||||
'event',
|
||||
'platform.native_callback',
|
||||
source='platform',
|
||||
stage='convert',
|
||||
ap=lambda: getattr(logger, 'ap', None),
|
||||
fields=lambda b: {
|
||||
'workspace_uuid': getattr(getattr(logger, 'execution_context', None), 'workspace_uuid', '')
|
||||
},
|
||||
)
|
||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if (
|
||||
not update.message
|
||||
@@ -206,6 +218,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
# ---- Message Send / Reply (preserving original logic) ----
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
|
||||
@@ -240,6 +253,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
args['document'] = telegram.InputFile(doc, filename=filename)
|
||||
await self.bot.send_document(**args)
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -317,6 +331,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||
return cleaned == ''
|
||||
|
||||
@diagnostics.observe('api', 'create_message_card', source='platform', stage='accepted')
|
||||
async def create_message_card(self, message_id, event):
|
||||
assert isinstance(event.source_platform_object, Update)
|
||||
update = event.source_platform_object
|
||||
@@ -331,6 +346,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
return True
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -492,6 +508,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
# ---- Pass-through API ----
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(
|
||||
self,
|
||||
action: str,
|
||||
|
||||
@@ -5,6 +5,8 @@ Implements optional API methods defined in AbstractPlatformAdapter.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
import telegram
|
||||
@@ -25,6 +27,7 @@ class TelegramAPIMixin:
|
||||
|
||||
bot: telegram.Bot
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -52,6 +55,7 @@ class TelegramAPIMixin:
|
||||
await self.bot.edit_message_text(**args)
|
||||
return
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -61,6 +65,7 @@ class TelegramAPIMixin:
|
||||
"""Delete / recall a message."""
|
||||
await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -80,6 +85,7 @@ class TelegramAPIMixin:
|
||||
raw={'message_id': result.message_id},
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -100,6 +106,7 @@ class TelegramAPIMixin:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -134,6 +141,7 @@ class TelegramAPIMixin:
|
||||
)
|
||||
return members
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -160,6 +168,7 @@ class TelegramAPIMixin:
|
||||
display_name=member.custom_title if hasattr(member, 'custom_title') else None,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(
|
||||
self,
|
||||
user_id: typing.Union[int, str],
|
||||
@@ -172,6 +181,7 @@ class TelegramAPIMixin:
|
||||
username=chat.username,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(
|
||||
self,
|
||||
file_data: bytes,
|
||||
@@ -186,6 +196,7 @@ class TelegramAPIMixin:
|
||||
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(
|
||||
self,
|
||||
file_id: str,
|
||||
@@ -194,6 +205,7 @@ class TelegramAPIMixin:
|
||||
file = await self.bot.get_file(file_id)
|
||||
return file.file_path
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -213,6 +225,7 @@ class TelegramAPIMixin:
|
||||
kwargs['until_date'] = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=duration)
|
||||
await self.bot.restrict_chat_member(**kwargs)
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -236,6 +249,7 @@ class TelegramAPIMixin:
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -244,6 +258,7 @@ class TelegramAPIMixin:
|
||||
"""Kick a member from the group."""
|
||||
await self.bot.ban_chat_member(chat_id=group_id, user_id=user_id)
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
|
||||
@@ -5,6 +5,8 @@ Converts all Telegram Update types to unified EBA events, not just messages.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
import telegram
|
||||
@@ -54,6 +56,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(
|
||||
update: Update,
|
||||
bot: telegram.Bot,
|
||||
@@ -384,6 +387,7 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
||||
"""Convert to legacy format (FriendMessage / GroupMessage)."""
|
||||
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
@@ -90,6 +92,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -106,6 +109,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
raw_results.append(await self._send_content(user_id, agent_id, content))
|
||||
return platform_events.MessageResult(raw={'results': raw_results})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -121,6 +125,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content))
|
||||
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -174,6 +179,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
self.bot.on_message('text')(on_message)
|
||||
self.bot.on_message('image')(on_message)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: WecomEvent):
|
||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.wecom_api.api import WecomClient
|
||||
@@ -14,6 +16,7 @@ class WecomAPIMixin:
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,6 +28,7 @@ class WecomAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached is not None:
|
||||
@@ -36,24 +40,30 @@ class WecomAPIMixin:
|
||||
username=info.get('alias') or info.get('userid') or None,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raise NotSupportedError('get_group_info')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -61,6 +71,7 @@ class WecomAPIMixin:
|
||||
) -> platform_entities.UserGroupMember:
|
||||
raise NotSupportedError('get_group_member_info')
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -70,6 +81,7 @@ class WecomAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.wecom_api.api import WecomClient
|
||||
@@ -17,6 +19,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None:
|
||||
eba_event = await WecomEventConverter.target2yiri(event, bot)
|
||||
if hasattr(eba_event, 'to_legacy_event'):
|
||||
@@ -36,6 +39,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None:
|
||||
if event.type in {'text', 'image'}:
|
||||
return await WecomEventConverter.message_to_eba(event, bot)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter
|
||||
|
||||
import asyncio
|
||||
@@ -133,6 +135,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
_iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components)
|
||||
_send_media = staticmethod(LegacyWecomBotAdapter._send_media)
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -148,6 +151,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
raw = await self.bot.send_message(str(target_id), content)
|
||||
return platform_events.MessageResult(raw={'result': raw})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -169,6 +173,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
raw = await self.bot.set_message(event.message_id, content)
|
||||
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message_chunk', source='platform', stage='accepted')
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -196,6 +201,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return self.config.get('enable-stream-reply', True)
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
@@ -271,6 +277,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self.bot.on_message('event')(self._handle_native_event)
|
||||
self.bot.on_message('template_card_event')(self._handle_interaction_event)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_interaction_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event)
|
||||
@@ -279,6 +286,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -14,6 +16,7 @@ class WecomBotAPIMixin:
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,21 +28,25 @@ class WecomBotAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached is None:
|
||||
raise NotSupportedError('get_user_info:not_cached')
|
||||
return cached
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
cached = self._group_cache.get(str(group_id))
|
||||
if cached is None:
|
||||
raise NotSupportedError('get_group_info:not_cached')
|
||||
return cached
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -50,6 +57,7 @@ class WecomBotAPIMixin:
|
||||
raise NotSupportedError('get_group_member_info:not_cached')
|
||||
return cached
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -58,12 +66,15 @@ class WecomBotAPIMixin:
|
||||
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -73,6 +84,7 @@ class WecomBotAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -81,6 +93,7 @@ class WecomBotAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('delete_message')
|
||||
|
||||
@diagnostics.observe('api', 'forward_message', source='platform', stage='accepted')
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
@@ -91,14 +104,18 @@ class WecomBotAPIMixin:
|
||||
) -> platform_events.MessageResult:
|
||||
raise NotSupportedError('forward_message')
|
||||
|
||||
@diagnostics.observe('api', 'mute_member', source='platform', stage='accepted')
|
||||
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
|
||||
raise NotSupportedError('mute_member')
|
||||
|
||||
@diagnostics.observe('api', 'unmute_member', source='platform', stage='accepted')
|
||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('unmute_member')
|
||||
|
||||
@diagnostics.observe('api', 'kick_member', source='platform', stage='accepted')
|
||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('kick_member')
|
||||
|
||||
@diagnostics.observe('api', 'leave_group', source='platform', stage='accepted')
|
||||
async def leave_group(self, group_id: typing.Union[int, str]):
|
||||
raise NotSupportedError('leave_group')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
@@ -19,6 +21,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
async def yiri2target(event: platform_events.Event) -> typing.Any:
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
self, event: WecomBotEvent
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
@@ -49,6 +52,7 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
|
||||
if event.type in {'single', 'group'} and event.msgtype != 'event':
|
||||
return await self.message_to_eba(event)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
@@ -92,6 +94,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
@diagnostics.observe('api', 'send_message', source='platform', stage='accepted')
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -110,6 +113,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
)
|
||||
return platform_events.MessageResult(raw={'results': raw_results})
|
||||
|
||||
@diagnostics.observe('api', 'reply_message', source='platform', stage='accepted')
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -132,6 +136,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
)
|
||||
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
|
||||
|
||||
@diagnostics.observe('api', 'call_platform_api', source='platform', stage='accepted')
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
@@ -186,6 +191,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
for msg_type in ('text', 'image', 'file', 'voice'):
|
||||
self.bot.on_message(msg_type)(on_message)
|
||||
|
||||
@diagnostics.observe('event', 'platform.native_receive', source='platform', stage='convert')
|
||||
async def _handle_native_event(self, event: WecomCSEvent):
|
||||
self.bot_account_id = event.receiver_id or self.bot_account_id
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||
@@ -14,6 +16,7 @@ class WecomCSAPIMixin:
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
|
||||
@diagnostics.observe('api', 'get_message', source='platform', stage='accepted')
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -25,6 +28,7 @@ class WecomCSAPIMixin:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
@diagnostics.observe('api', 'get_user_info', source='platform', stage='accepted')
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached is not None:
|
||||
@@ -39,24 +43,30 @@ class WecomCSAPIMixin:
|
||||
username=info.get('external_userid') or None,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'get_friend_list', source='platform', stage='accepted')
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
@diagnostics.observe('api', 'upload_file', source='platform', stage='accepted')
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
@diagnostics.observe('api', 'get_file_url', source='platform', stage='accepted')
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_info', source='platform', stage='accepted')
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raise NotSupportedError('get_group_info')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_list', source='platform', stage='accepted')
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
@diagnostics.observe('api', 'get_group_member_info', source='platform', stage='accepted')
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
@@ -64,6 +74,7 @@ class WecomCSAPIMixin:
|
||||
) -> platform_entities.UserGroupMember:
|
||||
raise NotSupportedError('get_group_member_info')
|
||||
|
||||
@diagnostics.observe('api', 'edit_message', source='platform', stage='accepted')
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
@@ -73,6 +84,7 @@ class WecomCSAPIMixin:
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
@diagnostics.observe('api', 'delete_message', source='platform', stage='accepted')
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||
@@ -17,6 +19,7 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2legacy', source='platform', stage='convert')
|
||||
async def target2legacy(
|
||||
event: WecomCSEvent, bot: WecomCSClient | None = None
|
||||
) -> platform_events.FriendMessage | None:
|
||||
@@ -26,6 +29,7 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None:
|
||||
if event.type in {'text', 'image', 'file', 'voice'}:
|
||||
return await WecomCSEventConverter.message_to_eba(event, bot)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
@@ -368,6 +370,22 @@ class RuntimeBot:
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Record structured event routing state while preserving the human log."""
|
||||
binding = binding or {}
|
||||
diagnostics.event(
|
||||
self,
|
||||
'route',
|
||||
'route.primary',
|
||||
{
|
||||
'not_matched': 'skipped',
|
||||
'discarded': 'skipped',
|
||||
'matched': 'started',
|
||||
'delivered': 'succeeded',
|
||||
'failed': 'failed',
|
||||
}.get(status, 'unknown'),
|
||||
stage='dispatch',
|
||||
platform_event_type=event_type,
|
||||
processor_type=target_type or binding.get('target_type', ''),
|
||||
reason_code=failure_code or status,
|
||||
)
|
||||
metadata = {
|
||||
'kind': 'event_route_trace',
|
||||
'event_type': event_type,
|
||||
@@ -380,6 +398,16 @@ class RuntimeBot:
|
||||
'reason': reason or text,
|
||||
'run_id': run_id,
|
||||
}
|
||||
diagnostics.set_outcome(
|
||||
{
|
||||
'not_matched': 'skipped',
|
||||
'discarded': 'skipped',
|
||||
'matched': 'started',
|
||||
'delivered': 'succeeded',
|
||||
'failed': 'failed',
|
||||
}.get(status, 'unknown'),
|
||||
reason_code=failure_code or status,
|
||||
)
|
||||
log_method = getattr(self.logger, level, self.logger.info)
|
||||
await log_method(text, metadata=metadata)
|
||||
return metadata
|
||||
@@ -835,6 +863,7 @@ class RuntimeBot:
|
||||
processor_id=agent.get('uuid'),
|
||||
)
|
||||
|
||||
@diagnostics.observe('event', 'platform.receive', source='platform', stage='dispatch')
|
||||
async def _handle_platform_event(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
@@ -864,6 +893,7 @@ class RuntimeBot:
|
||||
if isinstance(result, BaseException):
|
||||
await self.logger.error(f'Event delivery failed: {result}')
|
||||
|
||||
@diagnostics.observe('route', 'route.subscription', source='platform', stage='dispatch')
|
||||
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
|
||||
event_type = event.type
|
||||
event_binding = {
|
||||
@@ -882,6 +912,7 @@ class RuntimeBot:
|
||||
# Resolve the installed declaration each time, including after plugin updates.
|
||||
patterns = descriptor.supported_event_patterns
|
||||
if not patterns or not self._agent_supports_event_type(patterns, event_type):
|
||||
diagnostics.set_outcome('skipped', reason_code='not_matched')
|
||||
return
|
||||
agent = {**agent, 'supported_event_patterns': patterns}
|
||||
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
|
||||
@@ -898,6 +929,7 @@ class RuntimeBot:
|
||||
text=f'Plugin processor {processor_uuid} failed: {exc}',
|
||||
)
|
||||
|
||||
@diagnostics.observe('route', 'route.dispatch', source='platform', stage='dispatch')
|
||||
async def _dispatch_eba_event_to_processor(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
@@ -1165,6 +1197,7 @@ class RuntimeBot:
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||
|
||||
@diagnostics.observe('event', 'platform.legacy_receive', source='platform', stage='dispatch')
|
||||
async def _handle_legacy_message_event(
|
||||
self,
|
||||
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
||||
@@ -1250,6 +1283,7 @@ class RuntimeBot:
|
||||
execution_context=self.execution_context,
|
||||
)
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.submit', source='platform', stage='dispatch')
|
||||
async def _handle_interaction_submission(
|
||||
self,
|
||||
event: platform_events.PlatformSpecificEvent,
|
||||
@@ -1358,6 +1392,7 @@ class RuntimeBot:
|
||||
execution_context=self.execution_context,
|
||||
)
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.resume', source='platform', stage='resume')
|
||||
async def _resume_agent_interaction(
|
||||
self,
|
||||
record: dict[str, typing.Any],
|
||||
@@ -1512,6 +1547,9 @@ class RuntimeBot:
|
||||
return
|
||||
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
||||
|
||||
from ..telemetry.diagnostic_catalog import snapshot_bot
|
||||
|
||||
snapshot_bot(self)
|
||||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||||
supported_events: list[str] = []
|
||||
if callable(get_supported_events):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..telemetry import diagnostics
|
||||
|
||||
from typing import Any, Union
|
||||
import json
|
||||
import time
|
||||
@@ -302,6 +304,7 @@ async def _validate_agent_run_session(
|
||||
if not available_apis.get(api_capability, False) and not has_admin_permission:
|
||||
return None, handler.ActionResponse.error(message=f'{api_name} access not authorized')
|
||||
|
||||
diagnostics.link_context(session.get('_diagnostic_context'))
|
||||
return session, None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# For connect to plugin runtime.
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
@@ -668,6 +670,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
}
|
||||
)
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runtime.prepare_connected_runtime', source='runtime', stage='execute')
|
||||
async def _prepare_connected_runtime(self) -> None:
|
||||
"""Handshake follow-up: pin OSS compatibility, then replay authority."""
|
||||
|
||||
@@ -853,6 +856,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
self.schedule_reconnect()
|
||||
failures = 0
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runtime.initialize', source='runtime', stage='execute')
|
||||
async def initialize(self):
|
||||
if not self.is_enable_plugin:
|
||||
self.ap.logger.info('Plugin system is disabled.')
|
||||
@@ -1705,6 +1709,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
)
|
||||
return plugin_package, latest_version
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runtime.install_plugin', source='runtime', stage='execute')
|
||||
async def install_plugin(
|
||||
self,
|
||||
install_source: PluginInstallSource,
|
||||
@@ -1820,6 +1825,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
task_context.set_current_action('plugin updated' if operation == 'upgrade' else 'plugin installed')
|
||||
task_context.metadata['progress_percent'] = 100
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runtime.upgrade_plugin', source='runtime', stage='execute')
|
||||
async def upgrade_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
@@ -1846,6 +1852,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
)
|
||||
return {}
|
||||
|
||||
@diagnostics.observe('lifecycle', 'runtime.delete_plugin', source='runtime', stage='execute')
|
||||
async def delete_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..telemetry import diagnostics
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import typing
|
||||
@@ -639,6 +641,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
avoids reserving one pooled connection across provider and network waits.
|
||||
"""
|
||||
|
||||
diagnostics.annotate(workspace_uuid=action_context.workspace_uuid)
|
||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
||||
if persistence_mgr is None:
|
||||
yield
|
||||
@@ -687,10 +690,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return
|
||||
if trusted_plugin_identity is not None:
|
||||
safe_data['caller_plugin_identity'] = trusted_plugin_identity
|
||||
async for response in _action_handler(safe_data):
|
||||
yield response
|
||||
async with contextlib.aclosing(_action_handler(safe_data)) as responses:
|
||||
async for response in responses:
|
||||
yield response
|
||||
|
||||
self.actions[action_name] = secured_stream_action
|
||||
self.actions[action_name] = diagnostics.observe(
|
||||
'api', 'host.' + action_name, source='plugin', ap=self.ap
|
||||
)(secured_stream_action)
|
||||
continue
|
||||
|
||||
async def secured_action(
|
||||
@@ -719,7 +725,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
response = await response
|
||||
return response
|
||||
|
||||
self.actions[action_name] = secured_action
|
||||
self.actions[action_name] = diagnostics.observe('api', 'host.' + action_name, source='plugin', ap=self.ap)(
|
||||
secured_action
|
||||
)
|
||||
|
||||
async def _get_plugin_setting(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import abc
|
||||
import typing
|
||||
import time
|
||||
@@ -98,6 +100,7 @@ class RuntimeProvider:
|
||||
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
|
||||
return execution_context
|
||||
|
||||
@diagnostics.observe('api', 'model.invoke_llm', source='agent')
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query: pipeline_query.Query | None,
|
||||
@@ -182,6 +185,7 @@ class RuntimeProvider:
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
|
||||
|
||||
@diagnostics.observe('api', 'model.invoke_llm_stream', source='agent')
|
||||
async def invoke_llm_stream(
|
||||
self,
|
||||
query: pipeline_query.Query | None,
|
||||
@@ -264,6 +268,7 @@ class RuntimeProvider:
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
|
||||
|
||||
@diagnostics.observe('api', 'model.invoke_embedding', source='agent')
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: RuntimeEmbeddingModel,
|
||||
@@ -331,6 +336,7 @@ class RuntimeProvider:
|
||||
except Exception as monitor_err:
|
||||
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
|
||||
|
||||
@diagnostics.observe('api', 'model.invoke_rerank', source='agent')
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: RuntimeRerankModel,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import typing
|
||||
import time
|
||||
import inspect
|
||||
@@ -473,6 +475,7 @@ class ToolManager:
|
||||
)
|
||||
return result
|
||||
|
||||
@diagnostics.observe('api', 'tool.execute', source='agent', stage='execute')
|
||||
async def execute_func_call(
|
||||
self,
|
||||
name: str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -71,6 +73,7 @@ class RAGRuntimeService:
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
return kb_uuid
|
||||
|
||||
@diagnostics.observe('api', 'knowledge.vector_upsert', source='agent')
|
||||
async def vector_upsert(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
@@ -98,6 +101,7 @@ class RAGRuntimeService:
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'knowledge.vector_search', source='agent')
|
||||
async def vector_search(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
@@ -122,6 +126,7 @@ class RAGRuntimeService:
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'knowledge.vector_delete', source='agent')
|
||||
async def vector_delete(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
@@ -158,6 +163,7 @@ class RAGRuntimeService:
|
||||
)
|
||||
return count
|
||||
|
||||
@diagnostics.observe('api', 'knowledge.vector_list', source='agent')
|
||||
async def vector_list(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
@@ -186,6 +192,7 @@ class RAGRuntimeService:
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@diagnostics.observe('api', 'knowledge.get_file_stream', source='agent')
|
||||
async def get_file_stream(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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())
|
||||
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)
|
||||
for api in spec.get('platform_specific_apis', []):
|
||||
privacy.code_value('operation', api['action'])
|
||||
result[manifest.parent.name] = {'adapter': name, 'events': events, 'apis': 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):
|
||||
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']),
|
||||
):
|
||||
try:
|
||||
supported = set(getattr(bot.adapter, method)() or [])
|
||||
except Exception:
|
||||
supported = set()
|
||||
for name in declared:
|
||||
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,
|
||||
'configured': True,
|
||||
'available': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def snapshot_bot(bot):
|
||||
try:
|
||||
_snapshot_bot(bot)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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'},
|
||||
}
|
||||
BOOLS = frozenset('stream synthetic configured available previous_session_unclean recovered supported'.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}
|
||||
@@ -0,0 +1,399 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,461 @@
|
||||
"""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 _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)
|
||||
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.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)
|
||||
elif value is None:
|
||||
set_outcome('skipped', reason_code='not_matched')
|
||||
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)
|
||||
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,
|
||||
},
|
||||
)
|
||||
@@ -416,4 +416,8 @@ space:
|
||||
# OAuth authorization page URL (user will be redirected here)
|
||||
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
||||
disable_models_service: false
|
||||
# Master OFF for usage telemetry and Beta diagnostics.
|
||||
disable_telemetry: false
|
||||
# Beta builds only: opt out of content-free quality diagnostics separately.
|
||||
# Stable, alpha and RC builds never enable this diagnostics producer.
|
||||
disable_beta_diagnostics: false
|
||||
|
||||
Reference in New Issue
Block a user