fix(monitoring): unify execution records and stabilize streaming cleanup

This commit is contained in:
Hyu
2026-10-08 03:02:37 +08:00
parent 5f0ef7e48e
commit 40a3a941a3
50 changed files with 3742 additions and 976 deletions
+19
View File
@@ -174,3 +174,22 @@ Legacy EventListener plugins remain in the Pipeline lifecycle.
`list_processor_runs` includes `created_at_ms`, `started_at_ms`, and
`finished_at_ms`: Host lifecycle times in epoch milliseconds. Use the start and finish times for elapsed processing time; select a run and call `get_processor_run_events` for its
logs and action results. These times are not internal plugin profiling data.
## Unified execution monitoring
Use `get_monitoring_executions` for the execution list and
its legacy `pipeline_ids` parameter to filter any processor kind (Agent,
Pipeline or event processor); the summary uses the same processor scope. Use
`get_monitoring_execution_detail` with `source=auto` to resolve a run,
message or event identifier outside the current list page. The detail exposes
`inputs`, `outputs` (generated content), `deliveries` (recorded platform sends),
`events`, `llm_calls`, `tool_calls`, `errors`, `related`, and `conversation` in
`pages`. Follow each section's `has_more` and `next_offset` independently.
Conversation history supplies context; historical messages without explicit
links must not be asserted to belong to the selected execution. Events without
a processor run use `source=event`. All lookups remain Workspace-scoped.
Monitoring record filters accept `mode` (`all`, `real`, `debug`) and
`execution_statuses` (normalized execution statuses). These select the owning
execution, not the individual model/tool call outcome. Calls without a recorded
execution link are excluded when an execution filter is active.
@@ -128,7 +128,33 @@ class EventLogStore:
created_at=as_naive_utc(_utc_now()),
)
session.add(event)
await session.commit()
try:
await session.commit()
except sqlalchemy.exc.IntegrityError:
await session.rollback()
existing = (
(await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)))
.scalars()
.first()
)
if existing is None or existing.workspace_id != workspace_id or existing.bot_id != bot_id:
raise
# Complete the ingress snapshot once a runner claims it. A
# second subscriber must not replace another run's identity.
for field, value in {
'run_id': run_id,
'runner_id': runner_id,
'conversation_id': conversation_id,
'thread_id': thread_id,
'actor_id': actor_id,
'actor_name': actor_name,
}.items():
if getattr(existing, field) is None and value is not None:
setattr(existing, field, value)
if input_json and ('contents' in input_json or 'attachments' in input_json):
existing.input_json = json.dumps(input_json)
existing.input_summary = input_summary
await session.commit()
return event_id
+21 -1
View File
@@ -264,6 +264,11 @@ class AgentRunOrchestrator:
context=context,
authorization=run_authorization,
)
monitoring_id = (execution_query.variables or {}).get('_monitoring_message_id')
if monitoring_id and monitoring_id != run_id:
await self.ap.monitoring_service.link_execution_message(
get_query_execution_context(execution_query), monitoring_id, run_id, event.event_id
)
await self._session_registry.register(
run_id=run_id,
runner_id=descriptor.id,
@@ -346,16 +351,25 @@ class AgentRunOrchestrator:
):
continue
journal_started = time.monotonic()
await self.journal.append_run_result(
result_dict=result_dict,
run_id=run_id,
sequence=sequence_int,
)
journal_ms = (time.monotonic() - journal_started) * 1000
# Trusted Host observers receive validated events before message-only normalization.
result_observer = (adapter_context or {}).get('_result_observer')
observer_started = time.monotonic()
if result_observer is not None:
await result_observer(result_dict)
observer_ms = (time.monotonic() - observer_started) * 1000
if journal_ms + observer_ms >= 100:
self.ap.logger.debug(
f'Slow runner event consumer run={run_id} type={result_type} '
f'journal_ms={journal_ms:.1f} observer_ms={observer_ms:.1f}'
)
if result_type == 'state.updated':
await self.journal.handle_state_updated_event(
@@ -441,9 +455,15 @@ class AgentRunOrchestrator:
terminal_status or '', 'unknown'
)
execution_error = '' if execution_outcome == 'success' else (terminal_reason or execution_outcome)
except asyncio.CancelledError:
except (asyncio.CancelledError, GeneratorExit):
execution_outcome = 'cancelled'
execution_error = 'cancelled'
await self.journal.finalize_run(
run_id=run_id,
status='cancelled',
status_reason='consumer_cancelled',
usage=terminal_usage,
)
raise
except Exception as exc:
execution_outcome = 'timeout' if self._is_deadline_exhausted(context) else 'failed'
@@ -743,10 +743,43 @@ async def execute_platform_tool(
from ...telemetry.platform import processing_mode
token = processing_mode.set(authorization.get('processor_type', 'none'))
delivery_error = None
try:
return await api_func(**normalized)
except Exception as exc:
delivery_error = str(exc)
raise
finally:
processing_mode.reset(token)
if definition.api == 'send_message' and getattr(ap, 'monitoring_service', None) is not None:
try:
import json
chain = normalized.get('message')
content = chain.model_dump(mode='json') if hasattr(chain, 'model_dump') else str(chain or '')
await ap.monitoring_service.record_message(
execution_context,
bot_id=bot_id,
bot_name=str(getattr(getattr(bot, 'bot_entity', None), 'name', bot_id)),
pipeline_id=str(authorization.get('processor_id') or ''),
pipeline_name=str(authorization.get('processor_id') or authorization.get('runner_id') or ''),
message_content=json.dumps(content, ensure_ascii=False),
session_id=str(authorization.get('conversation_id') or ''),
role='assistant',
status='error' if delivery_error else 'success',
level='error' if delivery_error else 'info',
run_id=execution_id,
parent_message_id=execution_id,
variables=json.dumps(
{
'delivery_error': delivery_error,
'target_type': normalized.get('target_type'),
'target_id': normalized.get('target_id'),
}
),
)
except Exception as exc:
ap.logger.warning(f'Failed to record platform delivery: {exc}')
finally:
reset_execution_id(execution_token)
+30 -3
View File
@@ -68,6 +68,26 @@ class AgentRunJournal:
def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]:
return [cls._sanitize_attachment_ref(attachment) for attachment in attachments]
@classmethod
def _sanitize_event_data(cls, value: typing.Any) -> typing.Any:
"""Retain arbitrary event fields without copying inline binary payloads."""
if hasattr(value, 'model_dump'):
value = value.model_dump(mode='json')
if isinstance(value, list):
return [cls._sanitize_event_data(item) for item in value]
if not isinstance(value, dict):
return value
result = {}
for key, item in value.items():
if key in ('image_base64', 'file_base64', 'base64') and item:
result[key] = None
result['content_redacted'] = True
elif key == 'attachments' and isinstance(item, list):
result[key] = [cls._sanitize_event_data(cls._sanitize_attachment_ref(ref)) for ref in item]
else:
result[key] = cls._sanitize_event_data(item)
return result
async def create_run(
self,
*,
@@ -78,6 +98,9 @@ class AgentRunJournal:
authorization: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Create the Host-owned run ledger record."""
from ...telemetry import trace
trace_state = trace.current()
runtime = context.get('runtime') if isinstance(context, dict) else {}
return await self._get_run_ledger_store().create_run(
run_id=context['run_id'],
@@ -93,17 +116,21 @@ class AgentRunJournal:
authorization=authorization,
metadata={
'event_type': event.event_type,
'ingress_event_id': trace_state.event_id if trace_state else event.event_id,
'source': event.source,
'processor_id': binding.processor_id,
'processor_type': binding.processor_type,
**(
{'input_event': event.data, 'delivery': event.delivery.model_dump(mode='json')}
{
'input_event': self._sanitize_event_data(event.data),
'delivery': event.delivery.model_dump(mode='json'),
}
if binding.processor_type == 'event_processor'
else {}
),
**(
{
**({'input_event': event.data} if event.event_type != 'message.received' else {}),
'input_event': self._sanitize_event_data(event.data),
'input': {
'text': context.get('input', {}).get('text', ''),
'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])),
@@ -260,7 +287,7 @@ class AgentRunJournal:
event_time=(
datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None
),
metadata=metadata,
metadata={**(metadata or {}), 'input_event': self._sanitize_event_data(event.data)},
)
async def write_user_transcript(
@@ -86,7 +86,14 @@ class MonitoringRouterGroup(group.RouterGroup):
async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str:
"""Full trace of a single execution (agent run or pipeline query)."""
try:
detail = await self.ap.monitoring_service.get_execution_detail(request_context, source, execution_id)
detail = await self.ap.monitoring_service.get_execution_detail(
request_context,
source,
execution_id,
section=quart.request.args.get('section'),
offset=quart.request.args.get('offset', 0),
limit=quart.request.args.get('limit', 100),
)
except ValueError as exc:
return self.fail(404, str(exc))
return self.success(data=detail)
@@ -107,6 +114,8 @@ class MonitoringRouterGroup(group.RouterGroup):
stats = await self.ap.monitoring_service.get_token_statistics(
request_context,
execution_statuses=quart.request.args.getlist('status') or None,
mode=quart.request.args.get('mode', 'all'),
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -368,6 +377,10 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_all_data(request_context: RequestContext) -> str:
"""Get all monitoring data in a single request"""
execution_filters = {
'execution_statuses': quart.request.args.getlist('status') or None,
'mode': quart.request.args.get('mode', 'all'),
}
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
@@ -382,6 +395,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get overview metrics
overview = await self.ap.monitoring_service.get_overview_metrics(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -391,6 +405,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get messages
messages, messages_total = await self.ap.monitoring_service.get_messages(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -402,6 +417,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get LLM calls
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -413,6 +429,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -424,6 +441,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -436,6 +454,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get errors
errors, errors_total = await self.ap.monitoring_service.get_errors(
request_context,
**execution_filters,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -447,6 +466,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get embedding calls
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
request_context,
**execution_filters,
start_time=start_time,
end_time=end_time,
limit=limit,
@@ -458,6 +478,7 @@ class MonitoringRouterGroup(group.RouterGroup):
'traffic': await get_traffic_series(
self.ap,
request_context,
**execution_filters,
bot_ids=bot_ids or None,
pipeline_ids=pipeline_ids or None,
start_time=start_time,
@@ -725,6 +746,8 @@ class MonitoringRouterGroup(group.RouterGroup):
stats = await self.ap.monitoring_service.get_feedback_stats(
request_context,
execution_statuses=quart.request.args.getlist('status') or None,
mode=quart.request.args.get('mode', 'all'),
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -754,6 +777,8 @@ class MonitoringRouterGroup(group.RouterGroup):
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
request_context,
execution_statuses=quart.request.args.getlist('status') or None,
mode=quart.request.args.get('mode', 'all'),
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
+23 -18
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from contextlib import aclosing
import datetime
import copy
import fnmatch
@@ -365,25 +367,28 @@ class AgentService:
origin=origin,
synthetic_event=event_type,
):
async for output in self.ap.agent_run_orchestrator.run(
event,
binding,
adapter_context={
'_query': execution_query,
'_execution_context': execution_context,
'_result_observer': observe_result,
},
):
output_text = self._provider_output_to_text(output)
if output_text:
final_text = output_text
output_items.append(
{
'kind': output.__class__.__name__,
'role': str(getattr(output, 'role', '') or ''),
'text': output_text,
}
async with aclosing(
self.ap.agent_run_orchestrator.run(
event,
binding,
adapter_context={
'_query': execution_query,
'_execution_context': execution_context,
'_result_observer': observe_result,
},
)
) as owned_stream:
async for output in owned_stream:
output_text = self._provider_output_to_text(output)
if output_text:
final_text = output_text
output_items.append(
{
'kind': output.__class__.__name__,
'role': str(getattr(output, 'role', '') or ''),
'text': output_text,
}
)
return {
'event_id': event_id,
+408 -150
View File
@@ -14,9 +14,11 @@ from ....entity.persistence import agent as persistence_agent
from ....entity.persistence import monitoring as persistence_monitoring
from ....entity.persistence import agent_run as persistence_agent_run
from ....entity.persistence import agent_interaction as persistence_agent_interaction
from ....entity.persistence.event_log import EventLog
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
from .monitoring_execution_details import ExecutionDetailsMixin
_DEFAULT_MONITORING_PAGE_ROWS = 1000
@@ -131,7 +133,7 @@ def _workspace_transaction(method):
return wrapped
class MonitoringService:
class MonitoringService(ExecutionDetailsMixin):
"""Monitoring service"""
ap: app.Application
@@ -535,9 +537,17 @@ class MonitoringService:
runner_name: str | None = None,
variables: str | None = None,
role: str = 'user',
event_id: str | None = None,
run_id: str | None = None,
parent_message_id: str | None = None,
) -> str:
"""Record a message"""
workspace_uuid = self._require_write_context(context)
from ....telemetry import trace
trace_state = trace.current()
event_id = event_id or (trace_state.event_id if trace_state else None)
run_id = run_id or trace.current_run() or None
message_id = str(uuid.uuid4())
message_content = self._sanitize_message_content(message_content)
message_data = {
@@ -558,6 +568,9 @@ class MonitoringService:
'runner_name': runner_name,
'variables': variables,
'role': role,
'event_id': event_id,
'run_id': run_id,
'parent_message_id': parent_message_id,
}
await self.ap.persistence_mgr.execute_async(
@@ -879,6 +892,8 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
"""Get overview metrics"""
workspace_uuid = require_workspace_uuid(context)
@@ -888,15 +903,47 @@ class MonitoringService:
embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
message_conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode
)
)
llm_conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
)
)
session_conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode
)
)
embedding_conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
if pipeline_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
message_conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
)
)
llm_conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
)
)
session_conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
)
)
if start_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
@@ -973,23 +1020,109 @@ class MonitoringService:
# ========== Execution view ==========
@staticmethod
def _debug_run_condition(model):
return sqlalchemy.or_(
model.binding_id.like('debug:%'),
model.conversation_id.like('debug:%'),
model.bot_id == 'websocket-proxy-bot',
)
@staticmethod
def _debug_pipeline_condition(model):
# Dashboard/API pipeline debug uses this reserved proxy identity.
# Real Web Page bots have their own bot UUID, even on the same adapter.
return sqlalchemy.func.coalesce(model.bot_id == 'websocket-proxy-bot', False)
def _monitoring_execution_condition(self, model, workspace_uuid, statuses=None, mode='all'):
"""Filter telemetry by its owning execution, not by the call's outcome."""
if not statuses and mode == 'all':
return sqlalchemy.true()
Run = persistence_agent_run.AgentRun
Message = persistence_monitoring.MonitoringMessage
roots = sqlalchemy.orm.aliased(Message)
run_conditions = [Run.workspace_id == workspace_uuid]
if statuses:
run_conditions.append(Run.status.in_(self._raw_agent_statuses(statuses)))
debug = self._debug_run_condition(Run)
if mode != 'all':
run_conditions.append(debug if mode == 'debug' else sqlalchemy.not_(sqlalchemy.func.coalesce(debug, False)))
runs = sqlalchemy.select(Run.run_id).where(*run_conditions)
root_conditions = [roots.workspace_uuid == workspace_uuid, roots.role != 'assistant']
if statuses:
root_conditions.append(roots.status.in_(self._raw_pipeline_statuses(statuses)))
if mode != 'all':
root_debug = self._debug_pipeline_condition(roots)
root_conditions.append(root_debug if mode == 'debug' else sqlalchemy.not_(root_debug))
root_ids = sqlalchemy.select(roots.id).where(*root_conditions)
linked = sqlalchemy.or_(
Message.id.in_(root_ids), Message.parent_message_id.in_(root_ids), Message.run_id.in_(runs)
)
message_ids = sqlalchemy.select(Message.id).where(Message.workspace_uuid == workspace_uuid, linked)
if model is Message:
return linked
if hasattr(model, 'message_id'):
return sqlalchemy.or_(model.message_id.in_(runs), model.message_id.in_(message_ids))
if model is persistence_monitoring.MonitoringSession:
return sqlalchemy.or_(
model.session_id.in_(sqlalchemy.select(roots.session_id).where(*root_conditions)),
model.session_id.in_(sqlalchemy.select(Run.conversation_id).where(*run_conditions)),
)
return sqlalchemy.false()
def _processor_runs(self, workspace_uuid, processor_ids):
Run = persistence_agent_run.AgentRun
return sqlalchemy.select(Run.run_id).where(
Run.workspace_id == workspace_uuid,
sqlalchemy.func.coalesce(self._execution_json_text(Run.metadata_json, 'processor_id'), Run.agent_id).in_(
processor_ids
),
)
def _monitoring_processor_condition(self, model, workspace_uuid, processor_ids):
direct = model.pipeline_id.in_(processor_ids)
if hasattr(model, 'message_id'):
Message = persistence_monitoring.MonitoringMessage
return sqlalchemy.or_(
direct,
model.message_id.in_(self._processor_runs(workspace_uuid, processor_ids)),
model.message_id.in_(
sqlalchemy.select(Message.id).where(
Message.workspace_uuid == workspace_uuid, Message.pipeline_id.in_(processor_ids)
)
),
)
return direct
def _agent_run_conditions(
self,
workspace_uuid: str,
*,
bot_ids: list[str] | None = None,
agent_ids: list[str] | None = None,
processor_ids: list[str] | None = None,
statuses: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
debug: bool | None = None,
) -> list:
AgentRun = persistence_agent_run.AgentRun
conditions = [AgentRun.workspace_id == workspace_uuid]
conditions = [
AgentRun.workspace_id == workspace_uuid,
~sqlalchemy.exists(
sqlalchemy.select(persistence_monitoring.MonitoringMessage.id).where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.run_id == AgentRun.run_id,
persistence_monitoring.MonitoringMessage.role != 'assistant',
)
),
]
if bot_ids:
conditions.append(AgentRun.bot_id.in_(bot_ids))
if agent_ids:
conditions.append(AgentRun.agent_id.in_(agent_ids))
if processor_ids:
conditions.append(AgentRun.run_id.in_(self._processor_runs(workspace_uuid, processor_ids)))
if statuses is not None:
conditions.append(AgentRun.status.in_(statuses) if statuses else sqlalchemy.false())
if start_time:
@@ -997,11 +1130,10 @@ class MonitoringService:
if end_time:
conditions.append(AgentRun.created_at <= end_time)
if debug is not None:
debug_condition = sqlalchemy.or_(
AgentRun.binding_id.like('debug:%'),
AgentRun.conversation_id.like('debug:%'),
debug_condition = self._debug_run_condition(AgentRun)
conditions.append(
debug_condition if debug else sqlalchemy.not_(sqlalchemy.func.coalesce(debug_condition, False))
)
conditions.append(debug_condition if debug else sqlalchemy.not_(debug_condition))
return conditions
def _pipeline_execution_conditions(
@@ -1013,6 +1145,7 @@ class MonitoringService:
statuses: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
debug: bool | None = None,
) -> list:
MonitoringMessage = persistence_monitoring.MonitoringMessage
conditions = [
@@ -1023,13 +1156,16 @@ class MonitoringService:
if bot_ids:
conditions.append(MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(MonitoringMessage.pipeline_id.in_(pipeline_ids))
conditions.append(self._monitoring_processor_condition(MonitoringMessage, workspace_uuid, pipeline_ids))
if statuses is not None:
conditions.append(MonitoringMessage.status.in_(statuses) if statuses else sqlalchemy.false())
if start_time:
conditions.append(MonitoringMessage.timestamp >= start_time)
if end_time:
conditions.append(MonitoringMessage.timestamp <= end_time)
if debug is not None:
debug_condition = self._debug_pipeline_condition(MonitoringMessage)
conditions.append(debug_condition if debug else sqlalchemy.not_(debug_condition))
return conditions
@staticmethod
@@ -1079,7 +1215,10 @@ class MonitoringService:
A run stores the Agent it executed as a uuid; the list has to name the
processor, so the names are resolved for the whole page at once.
"""
agent_ids = {row.agent_id for row in rows if row.agent_id}
agent_ids = {self._deserialize_json(row.metadata_json).get('processor_id') or row.agent_id for row in rows} - {
None,
'',
}
if not agent_ids:
return {}
Agent = persistence_agent.Agent
@@ -1101,7 +1240,11 @@ class MonitoringService:
created_at_ms = _epoch_ms(row.created_at)
started_at_ms = _epoch_ms(row.started_at)
finished_at_ms = _epoch_ms(row.finished_at)
is_debug = str(row.binding_id or '').startswith('debug:') or str(row.conversation_id or '').startswith('debug:')
is_debug = (
str(row.binding_id or '').startswith('debug:')
or str(row.conversation_id or '').startswith('debug:')
or row.bot_id == 'websocket-proxy-bot'
)
return {
'source': 'agent',
'id': row.run_id,
@@ -1109,11 +1252,11 @@ class MonitoringService:
'status': row.status,
'status_group': _status_group(row.status, 'agent'),
'title': metadata.get('event_type') or row.queue_name or row.runner_id,
'target_kind': 'agent' if row.agent_id else 'processor',
'target_id': row.agent_id or row.binding_id,
'target_kind': metadata.get('processor_type') or ('agent' if row.agent_id else 'processor'),
'target_id': metadata.get('processor_id') or row.agent_id or row.binding_id,
'target_name': metadata.get('agent_name')
or metadata.get('target_name')
or (agent_names or {}).get(row.agent_id or ''),
or (agent_names or {}).get(metadata.get('processor_id') or row.agent_id or ''),
'bot_id': row.bot_id,
'bot_name': metadata.get('bot_name'),
'pipeline_id': None,
@@ -1130,6 +1273,8 @@ class MonitoringService:
'queue_name': row.queue_name,
'debug': is_debug,
'has_error': row.status in {'failed', 'timeout'},
'status_reason': row.status_reason,
'input_preview': _message_preview(json.dumps(metadata.get('input') or {}, ensure_ascii=False), 200),
}
def _serialize_pipeline_execution(self, row) -> dict:
@@ -1137,7 +1282,7 @@ class MonitoringService:
return {
'source': 'pipeline',
'id': row.id,
'event_id': None,
'event_id': row.event_id,
'status': row.status,
'status_group': _status_group(row.status, 'pipeline'),
'title': _message_preview(row.message_content),
@@ -1158,8 +1303,9 @@ class MonitoringService:
'usage': None,
'cost': None,
'queue_name': None,
'debug': False,
'debug': row.bot_id == 'websocket-proxy-bot',
'has_error': row.status == 'error',
'input_preview': _message_preview(row.message_content),
'platform': row.platform,
'user_id': row.user_id,
'user_name': row.user_name,
@@ -1173,13 +1319,20 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
LLMCall = persistence_monitoring.MonitoringLLMCall
conditions = [LLMCall.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids))
if start_time:
conditions.append(LLMCall.timestamp >= start_time)
if end_time:
@@ -1207,10 +1360,18 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
AgentRun = persistence_agent_run.AgentRun
run_conditions = self._agent_run_conditions(
workspace_uuid, bot_ids=bot_ids, start_time=start_time, end_time=end_time
workspace_uuid,
bot_ids=bot_ids,
processor_ids=pipeline_ids,
start_time=start_time,
end_time=end_time,
statuses=self._raw_agent_statuses(statuses),
debug=True if mode == 'debug' else False if mode == 'real' else None,
)
run_status_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(AgentRun.status, sqlalchemy.func.count(AgentRun.id))
@@ -1224,10 +1385,7 @@ class MonitoringService:
sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where(
sqlalchemy.and_(
*run_conditions,
sqlalchemy.or_(
AgentRun.binding_id.like('debug:%'),
AgentRun.conversation_id.like('debug:%'),
),
self._debug_run_condition(AgentRun),
)
)
)
@@ -1252,7 +1410,13 @@ class MonitoringService:
durations = sorted((row[1] - row[0]).total_seconds() * 1000 for row in timing_result.all() if row[0] and row[1])
pipeline_conditions = self._pipeline_execution_conditions(
workspace_uuid, bot_ids=bot_ids, pipeline_ids=pipeline_ids, start_time=start_time, end_time=end_time
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
statuses=self._raw_pipeline_statuses(statuses),
start_time=start_time,
end_time=end_time,
debug=True if mode == 'debug' else False if mode == 'real' else None,
)
pipeline_status_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
@@ -1264,6 +1428,12 @@ class MonitoringService:
)
pipeline_counts = {row[0]: row[1] for row in pipeline_status_result.all()}
pipeline_total = sum(pipeline_counts.values())
pipeline_debug_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(
*pipeline_conditions, self._debug_pipeline_condition(persistence_monitoring.MonitoringMessage)
)
)
debug_count += pipeline_debug_result.scalar() or 0
# Pending interactions are gated by the same bot/window scope as the runs
# so "waiting input" cannot drift from the rest of the card.
@@ -1273,10 +1443,21 @@ class MonitoringService:
]
if bot_ids:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.bot_id.in_(bot_ids))
if pipeline_ids:
waiting_conditions.append(
persistence_agent_interaction.AgentInteraction.run_id.in_(
self._processor_runs(workspace_uuid, pipeline_ids)
)
)
if start_time:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at >= start_time)
if end_time:
waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at <= end_time)
waiting_conditions.append(
persistence_agent_interaction.AgentInteraction.run_id.in_(
sqlalchemy.select(AgentRun.run_id).where(*run_conditions)
)
)
waiting_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_agent_interaction.AgentInteraction.id)).where(
sqlalchemy.and_(*waiting_conditions)
@@ -1334,71 +1515,102 @@ class MonitoringService:
"""List executions from both object types (agent runs and pipeline queries)."""
workspace_uuid = require_workspace_uuid(context)
limit, offset = self.normalize_page_window(limit, offset)
fetch_limit = min(limit + offset, self._configured_query_limit('page_rows', 1000, 5000))
if source not in {'all', 'agent', 'pipeline'}:
if source not in {'all', 'agent', 'pipeline', 'event'}:
source = 'all'
if mode not in {'all', 'real', 'debug'}:
mode = 'all'
debug = None if mode == 'all' else (mode == 'debug')
include_agent = source in {'all', 'agent'} and (not pipeline_ids or bool(agent_ids))
include_pipeline = source in {'all', 'pipeline'} and (not agent_ids or bool(pipeline_ids))
items: list[dict] = []
total = 0
if include_agent:
run_conditions = self._agent_run_conditions(
debug = None if mode == 'all' else mode == 'debug'
branches = []
AgentRun = persistence_agent_run.AgentRun
Message = persistence_monitoring.MonitoringMessage
if source in {'all', 'agent'}:
conditions = self._agent_run_conditions(
workspace_uuid,
bot_ids=bot_ids,
agent_ids=agent_ids,
processor_ids=pipeline_ids,
statuses=self._raw_agent_statuses(statuses),
start_time=start_time,
end_time=end_time,
debug=debug,
)
AgentRun = persistence_agent_run.AgentRun
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(AgentRun)
.where(sqlalchemy.and_(*run_conditions))
.order_by(AgentRun.created_at.desc(), AgentRun.id.desc())
.limit(fetch_limit)
branches.append(
sqlalchemy.select(
sqlalchemy.literal('agent').label('source'),
AgentRun.run_id.label('id'),
AgentRun.created_at.label('time'),
).where(*conditions)
)
rows = self._model_rows(result)
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where(sqlalchemy.and_(*run_conditions))
)
total += count_result.scalar() or 0
agent_names = await self._agent_names(workspace_uuid, rows)
items.extend(self._serialize_agent_execution(row, agent_names=agent_names) for row in rows)
if include_pipeline and (debug is None or debug is False):
message_conditions = self._pipeline_execution_conditions(
if source in {'all', 'pipeline'} and (not agent_ids or pipeline_ids):
conditions = self._pipeline_execution_conditions(
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
statuses=self._raw_pipeline_statuses(statuses),
start_time=start_time,
end_time=end_time,
debug=debug,
)
MonitoringMessage = persistence_monitoring.MonitoringMessage
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringMessage)
.where(sqlalchemy.and_(*message_conditions))
.order_by(MonitoringMessage.timestamp.desc())
.limit(fetch_limit)
branches.append(
sqlalchemy.select(
sqlalchemy.literal('pipeline').label('source'),
Message.id.label('id'),
Message.timestamp.label('time'),
).where(*conditions)
)
rows = self._model_rows(result)
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(MonitoringMessage.id)).where(
sqlalchemy.and_(*message_conditions)
if source in {'all', 'event'} and mode != 'debug' and not pipeline_ids and not agent_ids:
conditions = self._unhandled_event_conditions(workspace_uuid, bot_ids, start_time, end_time)
if statuses:
conditions.append(self._execution_json_text(EventLog.metadata_json, 'status').in_(statuses))
branches.append(
sqlalchemy.select(
sqlalchemy.literal('event').label('source'),
EventLog.event_id.label('id'),
EventLog.created_at.label('time'),
).where(*conditions)
)
total = 0
page = []
if branches:
combined = sqlalchemy.union_all(*branches).subquery()
count = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count()).select_from(combined)
)
total = count.scalar() or 0
refs = (
await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(combined)
.order_by(combined.c.time.desc(), combined.c.source, combined.c.id)
.offset(offset)
.limit(limit)
)
)
total += count_result.scalar() or 0
items.extend(self._serialize_pipeline_execution(row) for row in rows)
items.sort(key=lambda item: item.get('created_at_ms') or 0, reverse=True)
page = items[offset : offset + limit]
).all()
by_key = {}
for kind, model, identity_column in [
('agent', AgentRun, AgentRun.run_id),
('pipeline', Message, Message.id),
('event', EventLog, EventLog.event_id),
]:
ids = [r.id for r in refs if r.source == kind]
if not ids:
continue
workspace_column = model.workspace_uuid if kind == 'pipeline' else model.workspace_id
records = await self._execution_rows(
workspace_uuid,
sqlalchemy.select(model).where(workspace_column == workspace_uuid, identity_column.in_(ids)),
)
names = await self._agent_names(workspace_uuid, records) if kind == 'agent' else {}
for record in records:
item = (
self._serialize_agent_execution(record, names)
if kind == 'agent'
else self._serialize_pipeline_execution(record)
if kind == 'pipeline'
else self._serialize_event_execution(record)
)
by_key[(kind, item['id'])] = item
page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key]
await self.enrich_execution_rows(workspace_uuid, page)
return {
'items': page,
'total': total,
@@ -1408,6 +1620,8 @@ class MonitoringService:
'summary': {
'executions': await self._get_execution_summary(
workspace_uuid,
statuses=statuses,
mode=mode,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
start_time=start_time,
@@ -1415,6 +1629,8 @@ class MonitoringService:
),
'tokens': await self._get_token_coverage(
workspace_uuid,
execution_statuses=statuses,
mode=mode,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
start_time=start_time,
@@ -1423,75 +1639,6 @@ class MonitoringService:
},
}
async def get_execution_detail(
self,
context: TenantContext,
source: str,
execution_id: str,
) -> dict:
"""Full trace of a single execution, keyed by object type."""
workspace_uuid = require_workspace_uuid(context)
if source == 'agent':
from ....agent.runner.run_ledger_store import RunLedgerStore
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
run = await store.get_run(execution_id)
if run is None or run.get('workspace_id') != workspace_uuid:
raise ValueError('Execution not found')
items, next_cursor, _, has_more = await store.page_run_events(run_id=execution_id, limit=200)
return {
'source': 'agent',
'run': run,
'events': items,
'has_more': has_more,
'next_cursor': next_cursor,
}
if source == 'pipeline':
MonitoringMessage = persistence_monitoring.MonitoringMessage
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringMessage).where(
sqlalchemy.and_(
MonitoringMessage.id == execution_id,
MonitoringMessage.workspace_uuid == workspace_uuid,
)
)
)
rows = self._model_rows(result)
if not rows:
raise ValueError('Execution not found')
serialized = self.ap.persistence_mgr.serialize_model(MonitoringMessage, rows[0])
async def _related(model, order_column):
related = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(model)
.where(
sqlalchemy.and_(
model.workspace_uuid == workspace_uuid,
model.message_id == execution_id,
)
)
.order_by(order_column.asc())
)
return [self.ap.persistence_mgr.serialize_model(model, row) for row in self._model_rows(related)]
return {
'source': 'pipeline',
'message': serialized,
'llm_calls': await _related(
persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.timestamp
),
'tool_calls': await _related(
persistence_monitoring.MonitoringToolCall, persistence_monitoring.MonitoringToolCall.timestamp
),
'errors': await _related(
persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.timestamp
),
}
raise ValueError('Unknown execution source')
async def get_token_statistics(
self,
context: TenantContext,
@@ -1500,6 +1647,8 @@ class MonitoringService:
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
bucket: str = 'hour',
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
"""Get detailed token usage statistics for production observability.
@@ -1518,10 +1667,15 @@ class MonitoringService:
bucket = 'hour'
conditions = [LLMCall.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids))
if start_time:
conditions.append(LLMCall.timestamp >= start_time)
if end_time:
@@ -1702,16 +1856,27 @@ class MonitoringService:
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get messages with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
)
)
if session_ids:
conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids))
if start_time:
@@ -1757,16 +1922,27 @@ class MonitoringService:
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get LLM calls with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
if end_time:
@@ -1812,16 +1988,27 @@ class MonitoringService:
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringToolCall, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringToolCall, workspace_uuid, pipeline_ids
)
)
if session_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
if start_time:
@@ -1865,12 +2052,19 @@ class MonitoringService:
knowledge_base_id: str | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get embedding calls with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
if end_time:
@@ -1919,16 +2113,27 @@ class MonitoringService:
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get sessions with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
@@ -1983,16 +2188,27 @@ class MonitoringService:
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get errors with filters"""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringError, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
if end_time:
@@ -2458,7 +2674,11 @@ class MonitoringService:
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
if end_time:
@@ -2514,7 +2734,11 @@ class MonitoringService:
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
if end_time:
@@ -2621,7 +2845,11 @@ class MonitoringService:
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
if end_time:
@@ -2672,7 +2900,11 @@ class MonitoringService:
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
@@ -2825,6 +3057,8 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
"""Get feedback statistics.
@@ -2834,10 +3068,19 @@ class MonitoringService:
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
@@ -2918,16 +3161,27 @@ class MonitoringService:
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
limit, offset = self.normalize_page_window(limit, offset)
workspace_uuid = require_workspace_uuid(context)
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
conditions.append(
self._monitoring_execution_condition(
persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode
)
)
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
)
)
if feedback_type is not None:
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
if start_time:
@@ -2980,7 +3234,11 @@ class MonitoringService:
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
conditions.append(
self._monitoring_processor_condition(
persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids
)
)
if start_time:
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
if end_time:
@@ -0,0 +1,580 @@
"""Tenant-scoped execution inputs, outputs, related runs and conversation history."""
from __future__ import annotations
import datetime
import json
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from ....entity.persistence.agent_run import AgentRun, AgentRunEvent
from ....entity.persistence.event_log import EventLog
from ....entity.persistence.transcript import Transcript
from ....entity.persistence import monitoring as models
from .tenant import require_workspace_uuid
def epoch(value):
if value is None:
return None
return int(value.replace(tzinfo=datetime.timezone.utc).timestamp() * 1000)
def parsed(value, fallback=None):
try:
return json.loads(value) if value else fallback
except (TypeError, ValueError):
return fallback
def event_content(content, event_data):
"""Keep event-specific properties alongside the normalized message input."""
if not isinstance(event_data, dict) or not event_data:
return content
if not isinstance(content, dict):
return event_data if not content else {**event_data, 'input': content}
merged = dict(event_data)
conflicts = {}
for key, value in content.items():
if key not in merged:
merged[key] = value
elif merged[key] != value and value is not None and value != '' and value != [] and value != {}:
conflicts[key] = value
if conflicts:
# Never silently overwrite a custom field that shares an input name.
return {'event': merged, 'input': conflicts}
return merged
def event_item(row):
metadata = parsed(row.metadata_json, {})
return {
'id': row.event_id,
'event_type': row.event_type,
'source': row.source,
'timestamp_ms': epoch(row.event_time or row.created_at),
'actor_id': row.actor_id,
'actor_name': row.actor_name,
'conversation_id': row.conversation_id,
'thread_id': row.thread_id,
'content': event_content(parsed(row.input_json, None) or row.input_summary, metadata.get('input_event')),
'metadata': metadata,
}
def message_item(row):
return {
'id': row.id,
'role': row.role or 'user',
'timestamp_ms': epoch(row.timestamp),
'content': parsed(row.message_content, None) or row.message_content,
'actor_id': row.user_id,
'actor_name': row.user_name,
'status': row.status,
'event_id': row.event_id,
'run_id': row.run_id,
'origin': 'delivery' if row.role == 'assistant' else 'input',
'delivery': parsed(row.variables, {}) if row.role == 'assistant' else None,
}
def transcript_item(row):
return {
'id': row.transcript_id,
'role': row.role,
'timestamp_ms': epoch(row.created_at),
'content': parsed(row.content_json, None) or row.content,
'attachments': parsed(row.attachment_refs_json, []),
'event_id': row.event_id,
'run_id': row.run_id,
'origin': 'generated' if row.role == 'assistant' else 'input',
}
class ExecutionDetailsMixin:
def _execution_json_text(self, column, key):
# SQLite JSON functions accept TEXT directly; PostgreSQL operators
# require an explicit JSON cast for the existing text-backed journals.
if self.ap.persistence_mgr.get_db_engine().dialect.name == 'sqlite':
expression = sa.type_coerce(column, sa.JSON)
else:
expression = sa.cast(column, sa.JSON)
return expression[key].as_string()
async def link_execution_message(self, context, message_id, run_id, event_id):
workspace = self._require_write_context(context)
statement = (
sa.update(models.MonitoringMessage)
.where(
models.MonitoringMessage.workspace_uuid == workspace,
models.MonitoringMessage.id == message_id,
)
.values(run_id=run_id, event_id=sa.func.coalesce(models.MonitoringMessage.event_id, event_id))
)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(workspace) as uow:
await uow.session.execute(statement)
else:
await self.ap.persistence_mgr.execute_async(statement)
async def record_ingress_event(
self,
context,
*,
event_id,
event_type,
bot_id,
bot_name,
platform,
payload,
actor_id=None,
actor_name=None,
status='running',
routes=None,
):
"""Persist entry facts even when routing never creates a processor run."""
from ....agent.runner.event_log_store import EventLogStore
from .monitoring import _message_preview
workspace = self._require_write_context(context)
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
# Reuse the message sanitizer (including inline-media handling) instead
# of storing an unbounded adapter object in the event journal.
content = parsed(self._sanitize_message_content(json.dumps(payload, ensure_ascii=False, default=str)), {})
summary = _message_preview(json.dumps(content, ensure_ascii=False), 1000) if content else event_type
await store.append_event(
event_id=event_id,
event_type=event_type,
source='platform',
workspace_id=workspace,
bot_id=bot_id,
actor_id=actor_id,
actor_name=actor_name,
actor_type='user' if actor_id else 'system',
input_summary=summary,
input_json=content,
metadata={'status': status, 'bot_name': bot_name, 'platform': platform},
)
if routes is not None:
safe_routes = []
for route in routes:
if isinstance(route, BaseException):
safe_routes.append({'status': 'failed', 'reason': str(route)})
elif isinstance(route, dict):
safe_routes.append(
{
key: route.get(key)
for key in (
'status',
'reason',
'target_type',
'target_uuid',
'run_id',
'failure_code',
)
}
)
metadata = {'status': status, 'bot_name': bot_name, 'platform': platform, 'routes': safe_routes}
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
await session.execute(
sa.update(EventLog)
.where(
EventLog.workspace_id == workspace,
EventLog.event_id == event_id,
)
.values(metadata_json=json.dumps(metadata, ensure_ascii=False))
)
await session.commit()
async def _execution_query(self, workspace, statement):
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if callable(tenant_uow):
async with tenant_uow(workspace) as uow:
return await uow.session.execute(statement)
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
return await session.execute(statement)
async def _execution_rows(self, workspace, statement):
return list((await self._execution_query(workspace, statement)).scalars().all())
async def _execution_page(self, workspace, statement, convert, offset, limit):
rows = await self._execution_rows(workspace, statement.offset(offset).limit(limit + 1))
return {
'items': [convert(row) for row in rows[:limit]],
'has_more': len(rows) > limit,
'next_offset': offset + min(len(rows), limit),
}
def _unhandled_event_conditions(self, workspace, bot_ids=None, start_time=None, end_time=None):
# Absorbed steering events belong to an existing run, too.
conditions = [
EventLog.workspace_id == workspace,
~sa.exists(
sa.select(AgentRun.id).where(
AgentRun.workspace_id == workspace,
sa.or_(AgentRun.event_id == EventLog.event_id, AgentRun.run_id == EventLog.run_id),
)
),
~sa.exists(
sa.select(models.MonitoringMessage.id).where(
models.MonitoringMessage.workspace_uuid == workspace,
models.MonitoringMessage.event_id == EventLog.event_id,
)
),
]
if bot_ids:
conditions.append(EventLog.bot_id.in_(bot_ids))
if start_time:
conditions.append(EventLog.created_at >= start_time)
if end_time:
conditions.append(EventLog.created_at <= end_time)
return conditions
def _serialize_event_execution(self, row):
metadata = parsed(row.metadata_json, {})
status = metadata.get('status', 'ignored')
return {
'source': 'event',
'id': row.event_id,
'event_id': row.event_id,
'status': status,
'status_group': status,
'title': row.event_type,
'input_preview': row.input_summary or '',
'target_kind': 'event',
'target_id': None,
'target_name': None,
'bot_id': row.bot_id,
'bot_name': metadata.get('bot_name'),
'platform': metadata.get('platform') or row.source,
'user_id': row.actor_id,
'user_name': row.actor_name,
'conversation_id': row.conversation_id,
'session_id': row.conversation_id,
'created_at_ms': epoch(row.created_at),
'started_at_ms': None,
'finished_at_ms': None,
'duration_ms': None,
'usage': None,
'cost': None,
'queue_name': None,
'pipeline_id': None,
'pipeline_name': None,
'runner_id': None,
'debug': row.source == 'debug',
'has_error': status == 'failed',
'status_reason': metadata.get('reason'),
}
async def enrich_execution_rows(self, workspace, items):
ids = {item['event_id'] for item in items if item.get('event_id')}
if not ids:
return
events = await self._execution_rows(
workspace,
sa.select(EventLog).where(
EventLog.workspace_id == workspace,
EventLog.event_id.in_(ids),
),
)
by_id = {row.event_id: row for row in events}
for item in items:
event = by_id.get(item.get('event_id'))
if event:
meta = parsed(event.metadata_json, {})
item.update(
event_type=event.event_type,
input_preview=event.input_summary or item.get('input_preview') or '',
user_id=event.actor_id,
user_name=event.actor_name,
platform=meta.get('platform') or event.source,
bot_name=item.get('bot_name') or meta.get('bot_name'),
)
async def get_execution_detail(self, context, source, execution_id, *, section=None, offset=0, limit=100):
workspace = require_workspace_uuid(context)
limit, offset = self.normalize_page_window(limit, offset)
limit = min(limit, 100)
sections = {
'inputs',
'outputs',
'deliveries',
'conversation',
'related',
'events',
'llm_calls',
'tool_calls',
'errors',
}
if section is not None and section not in sections:
raise ValueError('Unknown execution section')
run = message = event = None
if source in {'agent', 'auto'}:
rows = await self._execution_rows(
workspace,
sa.select(AgentRun).where(
AgentRun.workspace_id == workspace,
AgentRun.run_id == execution_id,
),
)
run = rows[0] if rows else None
if run is None and source in {'pipeline', 'auto'}:
rows = await self._execution_rows(
workspace,
sa.select(models.MonitoringMessage).where(
models.MonitoringMessage.workspace_uuid == workspace,
models.MonitoringMessage.id == execution_id,
),
)
message = rows[0] if rows else None
if message and message.parent_message_id:
return await self.get_execution_detail(
context, 'auto', message.parent_message_id, section=section, offset=offset, limit=limit
)
if run is None and message is None and source in {'event', 'auto'}:
rows = await self._execution_rows(
workspace,
sa.select(EventLog).where(
EventLog.workspace_id == workspace,
EventLog.event_id == execution_id,
),
)
event = rows[0] if rows else None
if run is None and message is None and event is None:
raise ValueError('Execution not found')
if message and message.run_id:
linked = await self._execution_rows(
workspace,
sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == message.run_id),
)
if linked:
linked_run = linked[0]
else:
linked_run = None
else:
linked_run = None
if run:
names = await self._agent_names(workspace, [run])
row = self._serialize_agent_execution(run, names)
event_id, bot, conversation, thread = run.event_id, run.bot_id, run.conversation_id, run.thread_id
run_id = run.run_id
elif message:
row = self._serialize_pipeline_execution(message)
event_id, bot, conversation, thread = message.event_id, message.bot_id, message.session_id, None
run_id = message.run_id
if linked_run:
row['duration_ms'] = (
int((linked_run.finished_at - linked_run.started_at).total_seconds() * 1000)
if linked_run.started_at and linked_run.finished_at
else None
)
row['usage'] = parsed(linked_run.usage_json)
row['status_reason'] = linked_run.status_reason
else:
row = self._serialize_event_execution(event)
event_id, bot, conversation, thread = event.event_id, event.bot_id, event.conversation_id, event.thread_id
run_id = event.run_id
await self.enrich_execution_rows(workspace, [row])
ledger = run or linked_run
ingress_id = parsed(ledger.metadata_json, {}).get('ingress_event_id') if ledger else None
event_conditions = [
EventLog.workspace_id == workspace,
sa.or_(
EventLog.event_id == ingress_id if ingress_id else sa.false(),
EventLog.event_id == ledger.event_id if ledger else sa.false(),
EventLog.event_id == event_id if event_id else sa.false(),
EventLog.run_id == run_id if run_id else sa.false(),
),
]
event_ids = sa.select(EventLog.event_id).where(*event_conditions)
# All joins use explicit identities. Session/time matching is reserved
# for the visibly separate conversation history section.
message_scope = [models.MonitoringMessage.workspace_uuid == workspace]
related_message = sa.or_(
models.MonitoringMessage.id == message.id if message else sa.false(),
models.MonitoringMessage.parent_message_id == (message.id if message else run_id)
if message or run_id
else sa.false(),
models.MonitoringMessage.run_id == run_id if run_id else sa.false(),
)
transcript_scope = [Transcript.workspace_id == workspace, Transcript.bot_id == bot]
transcripts = sa.select(Transcript).where(
*transcript_scope,
sa.or_(
Transcript.run_id == run_id if run_id else sa.false(),
sa.and_(Transcript.role == 'user', Transcript.event_id.in_(event_ids)),
),
)
call_ids = sa.select(models.MonitoringMessage.id).where(*message_scope, related_message)
statements = {
'inputs': (sa.select(EventLog).where(*event_conditions).order_by(EventLog.id.asc()), event_item),
'outputs': (transcripts.where(Transcript.role != 'user').order_by(Transcript.seq.asc()), transcript_item),
'deliveries': (
sa.select(models.MonitoringMessage)
.where(*message_scope, related_message, models.MonitoringMessage.role == 'assistant')
.order_by(models.MonitoringMessage.timestamp.asc(), models.MonitoringMessage.id.asc()),
message_item,
),
'events': (
sa.select(AgentRunEvent)
.where(AgentRunEvent.run_id == run_id if run_id else sa.false())
.order_by(AgentRunEvent.sequence.asc()),
lambda r: {
'id': r.id,
'sequence': r.sequence,
'type': r.type,
'data': parsed(r.data_json, {}),
'timestamp_ms': epoch(r.created_at),
},
),
}
for name, model in [
('llm_calls', models.MonitoringLLMCall),
('tool_calls', models.MonitoringToolCall),
('errors', models.MonitoringError),
]:
statements[name] = (
sa.select(model)
.where(
model.workspace_uuid == workspace,
sa.or_(
model.message_id.in_(call_ids),
model.message_id == run_id if run_id else sa.false(),
),
)
.order_by(model.timestamp.asc(), model.id.asc()),
lambda r, model=model: self.ap.persistence_mgr.serialize_model(model, r),
)
if message:
statements['conversation'] = (
sa.select(models.MonitoringMessage)
.where(
*message_scope,
models.MonitoringMessage.bot_id == bot,
models.MonitoringMessage.session_id == conversation,
)
.order_by(models.MonitoringMessage.timestamp.desc(), models.MonitoringMessage.id.desc()),
message_item,
)
else:
statements['conversation'] = (
sa.select(Transcript)
.where(
*transcript_scope,
Transcript.conversation_id == conversation if conversation else sa.false(),
Transcript.thread_id == thread,
)
.order_by(Transcript.seq.desc()),
transcript_item,
)
pages = {}
for name, (query, convert) in statements.items():
if section is None or section == name:
pages[name] = await self._execution_page(workspace, query, convert, offset, limit)
if run:
meta = parsed(run.metadata_json, {})
for item in pages.get('inputs', {}).get('items', []):
# Older event logs omitted data, but the root run may retain it.
# Do not apply the root snapshot to later steering events.
if item['id'] == run.event_id and not item.get('metadata', {}).get('input_event'):
item['content'] = event_content(item['content'], meta.get('input_event'))
if message and (section is None or section == 'inputs'):
# The original rich message is authoritative even when the event
# log is absent or only retained a compact event summary.
if offset == 0:
pages['inputs']['items'].insert(0, message_item(message))
if (
run
and not pages.get('inputs', {}).get('items')
and offset == 0
and (section is None or section == 'inputs')
):
meta = parsed(run.metadata_json, {})
content = event_content(meta.get('input'), meta.get('input_event'))
if content:
pages['inputs']['items'] = [
{'id': run.event_id or run.run_id, 'content': content, 'timestamp_ms': epoch(run.created_at)}
]
if section is None or section == 'related':
# Union of ledger executions and legacy pipeline roots. SQL paging
# avoids silently dropping siblings on highly fanned-out events.
related_runs = sa.select(
sa.literal('agent').label('source'), AgentRun.run_id.label('id'), AgentRun.created_at.label('time')
).where(
AgentRun.workspace_id == workspace,
sa.or_(
AgentRun.event_id.in_(event_ids),
self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id').in_(event_ids),
),
AgentRun.run_id != (run_id or ''),
~sa.exists().where(
models.MonitoringMessage.workspace_uuid == workspace,
models.MonitoringMessage.run_id == AgentRun.run_id,
models.MonitoringMessage.role != 'assistant',
),
)
related_messages = sa.select(
sa.literal('pipeline'), models.MonitoringMessage.id, models.MonitoringMessage.timestamp
).where(
*message_scope,
models.MonitoringMessage.event_id.in_(event_ids),
models.MonitoringMessage.role != 'assistant',
models.MonitoringMessage.id != (message.id if message else ''),
)
union = sa.union_all(related_runs, related_messages).subquery()
refs = (
await self._execution_query(
workspace,
sa.select(union).order_by(union.c.time, union.c.source, union.c.id).offset(offset).limit(limit + 1),
)
).all()
related = []
for ref in refs[:limit]:
if ref.source == 'agent':
records = await self._execution_rows(
workspace,
sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == ref.id),
)
related.append(
self._serialize_agent_execution(records[0], await self._agent_names(workspace, records))
)
else:
records = await self._execution_rows(
workspace,
sa.select(models.MonitoringMessage).where(
*message_scope, models.MonitoringMessage.id == ref.id
),
)
related.append(self._serialize_pipeline_execution(records[0]))
pages['related'] = {
'items': related,
'has_more': len(refs) > limit,
'next_offset': offset + min(len(refs), limit),
}
result = {
'source': row['source'],
'row': row,
'pages': pages,
'legacy_context': bool(message and not message.event_id),
}
if run:
# Do not expose runtime authorization, leases or claim tokens.
result['run'] = {'run_id': run.run_id, 'status': run.status, 'status_reason': run.status_reason}
if message:
result['message'] = self.ap.persistence_mgr.serialize_model(models.MonitoringMessage, message)
# Backwards-compatible fields for existing API consumers.
for key in ('events', 'llm_calls', 'tool_calls', 'errors'):
if key in pages:
result[key] = pages[key]['items']
result['has_more'] = pages.get('events', {}).get('has_more', False)
result['next_cursor'] = pages.get('events', {}).get('next_offset')
return result
@@ -24,9 +24,14 @@ async def get_traffic_series(
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
execution_statuses: list[str] | None = None,
mode: str = 'all',
) -> dict:
"""Count all matching records in UTC buckets, returning at most 1000 points."""
workspace_uuid = require_workspace_uuid(context)
from .monitoring import MonitoringService
service = MonitoringService(ap)
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
@@ -40,10 +45,11 @@ async def get_traffic_series(
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
conditions = [model.workspace_uuid == workspace_uuid]
conditions.append(service._monitoring_execution_condition(model, workspace_uuid, execution_statuses, mode))
if bot_ids:
conditions.append(model.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(model.pipeline_id.in_(pipeline_ids))
conditions.append(service._monitoring_processor_condition(model, workspace_uuid, pipeline_ids))
if start_time is not None:
conditions.append(timestamp >= start_time)
if end_time is not None:
+48 -2
View File
@@ -165,7 +165,7 @@ class LangBotMCPServer:
return _dump(ap.box_service.get_recent_errors(context))
@mcp.tool(
description='Read bounded Workspace runtime records. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, knowledge_base_id, user_query, is_active as supported by the record kind.'
description='Read bounded Workspace runtime records. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.'
)
async def get_monitoring_records(
kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'],
@@ -176,7 +176,7 @@ class LangBotMCPServer:
import datetime
context = _authorized(Permission.RESOURCE_VIEW)
allowed = {'start_time', 'end_time'}
allowed = {'start_time', 'end_time', 'mode', 'execution_statuses'}
if kind != 'embedding_calls':
allowed |= {'bot_ids', 'pipeline_ids'}
if kind in {'messages', 'tool_calls'}:
@@ -207,6 +207,52 @@ class LangBotMCPServer:
return _dump(await ap.monitoring_service.get_message_details(context, identifier))
return _dump(await ap.monitoring_service.get_session_analysis(context, identifier))
@mcp.tool(
description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.'
)
async def get_monitoring_executions(
limit: int = 50,
offset: int = 0,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
statuses: list[str] | None = None,
mode: typing.Literal['all', 'real', 'debug'] = 'all',
) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(
await ap.monitoring_service.get_executions(
context,
limit=limit,
offset=offset,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
statuses=statuses,
mode=mode,
)
)
@mcp.tool(
description='Read execution inputs, generated outputs, delivery records, calls, errors, related executions and conversation history. Each section is independently paginated; follow pages[section].next_offset when has_more is true.'
)
async def get_monitoring_execution_detail(
source: typing.Literal['auto', 'agent', 'pipeline', 'event'],
identifier: str,
section: str | None = None,
offset: int = 0,
limit: int = 100,
) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(
await ap.monitoring_service.get_execution_detail(
context,
source,
identifier,
section=section,
offset=offset,
limit=limit,
)
)
# ----- Bots ---------------------------------------------------- #
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
async def list_bots() -> str:
@@ -29,6 +29,9 @@ class MonitoringMessage(Base):
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
parent_message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
__table_args__ = (
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
@@ -0,0 +1,36 @@
"""Link monitoring inputs and deliveries to events and runs."""
from alembic import op
import sqlalchemy as sa
revision = '0034_monitoring_execution_links'
down_revision = '0033_operation_traceability'
branch_labels = None
depends_on = None
def upgrade():
inspector = sa.inspect(op.get_bind())
if not inspector.has_table('monitoring_messages'):
return
columns = {c['name'] for c in inspector.get_columns('monitoring_messages')}
indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')}
for name in ('event_id', 'run_id', 'parent_message_id'):
if name not in columns:
op.add_column('monitoring_messages', sa.Column(name, sa.String(255), nullable=True))
index = f'ix_monitoring_messages_{name}'
if index not in indexes:
op.create_index(index, 'monitoring_messages', [name])
def downgrade():
inspector = sa.inspect(op.get_bind())
if not inspector.has_table('monitoring_messages'):
return
columns = {c['name'] for c in inspector.get_columns('monitoring_messages')}
indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')}
for name in ('parent_message_id', 'run_id', 'event_id'):
if f'ix_monitoring_messages_{name}' in indexes:
op.drop_index(f'ix_monitoring_messages_{name}', table_name='monitoring_messages')
if name in columns:
op.drop_column('monitoring_messages', name)
+9 -2
View File
@@ -483,9 +483,16 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
and source.name in {'config', 'stages', 'extensions_preferences'}
)
if not pipeline_json_cas:
monitoring_metadata = (
type(element.type) is sqlalchemy.JSON
and isinstance(source, sqlalchemy.Column)
and type(source.type) is sqlalchemy.Text
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
and source.name == 'metadata_json'
)
if not (pipeline_json_cas or monitoring_metadata):
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
'TenantUnitOfWork only allows trusted pgvector, Pipeline JSON CAS and monitoring metadata casts'
)
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
@@ -296,6 +296,7 @@ class MonitoringHelper:
user_name=sender_name,
runner_name=runner_name,
role='assistant',
parent_message_id=(query.variables or {}).get('_monitoring_message_id'),
)
except Exception as e:
ap.logger.error(f'Failed to record query response: {e}')
+35
View File
@@ -931,6 +931,32 @@ class RuntimeBot:
node=node,
)
async def _persist_monitoring_ingress(self, event, adapter, execution_id, status='running', routes=None):
service = getattr(getattr(self, 'ap', None), 'monitoring_service', None)
if service is None or not execution_id:
return
try:
sender = getattr(event, 'sender', None)
await service.record_ingress_event(
self.execution_context,
event_id=execution_id,
event_type=getattr(event, 'type', None) or event.__class__.__name__,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name,
platform=getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
payload={
key: value
for key, value in self._safe_model_dump(event).items()
if key != 'source_platform_object' and not key.startswith('_')
},
actor_id=self._get_entity_id(sender),
actor_name=self._get_entity_name(sender),
status=status,
routes=routes,
)
except Exception as exc:
await self.logger.warning(f'Failed to persist monitoring ingress: {exc}')
async def _handle_platform_event_body(
self,
event: platform_events.EBAEvent,
@@ -941,6 +967,7 @@ class RuntimeBot:
results: list[typing.Any] = []
try:
await self._record_adapter_event(event, adapter)
await self._persist_monitoring_ingress(event, adapter, execution_id)
primary = (
self._handle_interaction_submission(event, adapter, execution_id)
@@ -961,6 +988,7 @@ class RuntimeBot:
results = await asyncio.gather(*tasks, return_exceptions=True)
except BaseException as exc:
self._record_platform_event(event, adapter, 'failed', f'{type(exc).__name__}: {exc}', node)
await self._persist_monitoring_ingress(event, adapter, execution_id, 'failed', [exc])
raise
# Recorded after handling so the inbound stage carries the real outcome
@@ -973,6 +1001,13 @@ class RuntimeBot:
except Exception:
pass
self._record_platform_event(event, adapter, outcome, error_detail, node)
await self._persist_monitoring_ingress(
event,
adapter,
execution_id,
{'success': 'completed', 'skipped': 'ignored'}.get(outcome, 'failed'),
results,
)
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid, execution_id=None):
event_type = event.type
+20 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from contextlib import aclosing
import asyncio
import inspect
import typing
@@ -1582,22 +1584,25 @@ class RuntimeConnectionHandler(handler.Handler):
remove_think = _resolve_remove_think(data, query)
effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else []
async for chunk in llm_model.provider.invoke_llm_stream(
query=query,
model=llm_model,
messages=messages_obj,
funcs=effective_funcs,
extra_args=effective_extra_args,
remove_think=remove_think,
execution_context=execution_context,
):
if chunk is None:
continue
yield handler.ActionResponse.success(
data={
'chunk': chunk.model_dump(),
},
async with aclosing(
llm_model.provider.invoke_llm_stream(
query=query,
model=llm_model,
messages=messages_obj,
funcs=effective_funcs,
extra_args=effective_extra_args,
remove_think=remove_think,
execution_context=execution_context,
)
) as owned_stream:
async for chunk in owned_stream:
if chunk is None:
continue
yield handler.ActionResponse.success(
data={
'chunk': chunk.model_dump(),
},
)
usage = _pop_query_llm_usage(query)
if usage is not None:
yield handler.ActionResponse.success(
+19 -9
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from contextlib import aclosing
import abc
import asyncio
import typing
import time
@@ -204,15 +207,18 @@ class RuntimeProvider:
try:
# Stream the response
async for chunk in self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
):
yield chunk
async with aclosing(
self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
)
) as owned_stream:
async for chunk in owned_stream:
yield chunk
# Extract usage from stream if available (stored by LiteLLM requester)
if query:
if query.variables is None:
@@ -223,6 +229,10 @@ class RuntimeProvider:
input_tokens = usage_info.get('prompt_tokens', 0)
output_tokens = usage_info.get('completion_tokens', 0)
del query.variables[STREAM_USAGE_QUERY_VARIABLE]
except (asyncio.CancelledError, GeneratorExit):
status = 'error'
error_message = 'Streaming generation cancelled by consumer'
raise
except Exception as e:
status = 'error'
error_message = str(e)
@@ -3,6 +3,7 @@
from __future__ import annotations
import typing
import asyncio
import litellm
from litellm import acompletion, aembedding, arerank
@@ -1245,6 +1246,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
reasoning_started = False
reasoning_closed = False
thinking_blocks_state: list[dict[str, typing.Any]] = []
response = None
try:
response = await acompletion(**args)
@@ -1372,6 +1374,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
except Exception as e:
self._handle_litellm_error(e)
finally:
# Closing a downstream async generator must release the provider's
# HTTP stream immediately, including cancellation while at yield.
close = getattr(response, 'aclose', None)
if callable(close):
try:
await asyncio.wait_for(close(), timeout=5.0)
except Exception as exc:
self.ap.logger.debug(f'Failed to close LLM stream: {type(exc).__name__}')
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,
@@ -80,6 +80,7 @@ class FakePluginConnector:
self.results = results or []
self.error = error
self.delay = delay
self.delay_entered = asyncio.Event()
self.calls: list[dict] = []
self.contexts: list[dict] = []
self.sessions_during_run: list[dict | None] = []
@@ -100,6 +101,7 @@ class FakePluginConnector:
for result in self.results:
if self.delay:
self.delay_entered.set()
await asyncio.sleep(self.delay)
yield result
@@ -600,6 +602,35 @@ async def test_orchestrator_stops_after_cancel_request(clean_agent_state):
assert run['status_reason'] == 'user stopped'
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel_while_waiting', [False, True])
async def test_consumer_exit_finalizes_cancelled_ledger(clean_agent_state, cancel_while_waiting):
connector = FakePluginConnector(
results=[
{'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'first'}}},
{'type': 'run.completed', 'data': {'finish_reason': 'stop'}},
]
)
orchestrator = AgentRunOrchestrator(FakeApplication(connector, clean_agent_state), FakeRegistry(make_descriptor()))
plan = orchestrator.query_bridge.build_plan(make_query())
stream = orchestrator.run(plan.event, plan.binding, adapter_context={'_execution_context': TEST_CONTEXT})
await anext(stream)
if cancel_while_waiting:
connector.delay = 10
pending = asyncio.create_task(anext(stream))
# Cancel the provider wait, not an arbitrary journal DB operation.
await asyncio.wait_for(connector.delay_entered.wait(), timeout=2)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
else:
await stream.aclose()
run = await RunLedgerStore(clean_agent_state).get_run(connector.contexts[0]['run_id'])
assert run['status'] == 'cancelled'
assert run['status_reason'] == 'consumer_cancelled'
assert await get_session_registry().list_active_runs() == []
@pytest.mark.asyncio
async def test_orchestrator_does_not_package_query_messages_into_context(clean_agent_state):
"""Host should not build an agent working-context window from query.messages."""
+16 -4
View File
@@ -52,7 +52,19 @@ async def test_agent_run_snapshot_records_identity_and_redacts_inline_attachment
assert raw_input['contents'][0]['image_base64'] == 'large-image-data'
assert raw_input['attachments'][0]['content'] == 'large-file-data'
if event_type != 'message.received':
assert saved['metadata']['input_event'] == event.data
else:
assert 'input_event' not in saved['metadata']
assert saved['metadata']['input_event'] == event.data
def test_event_snapshot_preserves_custom_values_and_redacts_nested_binary():
data = {
'member': {'id': 'u1', 'active': False},
'count': 0,
'custom': {'contents': [{'type': 'image_base64', 'image_base64': 'secret'}]},
'attachments': [{'name': 'file.txt', 'content': 'secret'}],
}
saved = AgentRunJournal._sanitize_event_data(data)
assert saved['member'] == data['member']
assert saved['count'] == 0
assert saved['custom']['contents'][0]['image_base64'] is None
assert saved['attachments'][0]['content'] is None
assert data['custom']['contents'][0]['image_base64'] == 'secret'
@@ -23,6 +23,73 @@ from langbot.pkg.entity.persistence.base import Base
WORKSPACE = 'workspace-1'
@pytest.mark.asyncio
@pytest.mark.parametrize('has_log', [False, True])
async def test_event_properties_survive_detail_snapshot_fallback(engine, service, has_log):
import json
from langbot.pkg.entity.persistence.event_log import EventLog
data = {'member': {'id': 'u1', 'active': False}, 'count': 0, 'custom': {'reason': 'joined'}}
async with AsyncSession(engine) as session:
session.add(
persistence_agent_run.AgentRun(
run_id='custom-run',
event_id='custom-event',
workspace_id=WORKSPACE,
runner_id='runner',
status='completed',
created_at=_dt(10),
metadata_json=json.dumps(
{
'event_type': 'group.member_joined',
'input_event': data,
'input': {'text': '', 'contents': [], 'attachments': []},
}
),
)
)
if has_log:
session.add(
EventLog(
event_id='custom-event',
event_type='group.member_joined',
source='webui',
workspace_id=WORKSPACE,
run_id='custom-run',
input_json='{"text":"","contents":[]}',
created_at=_dt(10),
)
)
session.add(
EventLog(
event_id='later-event',
event_type='message.received',
source='webui',
workspace_id=WORKSPACE,
run_id='custom-run',
input_json='{"text":"later"}',
created_at=_dt(10, 1),
)
)
await session.commit()
detail = await service.get_execution_detail(WORKSPACE, 'agent', 'custom-run')
items = detail['pages']['inputs']['items']
assert items[0]['content']['member'] == data['member']
assert items[0]['content']['count'] == 0
assert items[0]['content']['custom'] == data['custom']
if has_log:
assert items[1]['content'] == {'text': 'later'}
def test_event_content_preserves_conflicting_custom_input_fields():
from langbot.pkg.api.http.service.monitoring_execution_details import event_content
assert event_content({'text': 'normalized'}, {'text': {'custom': False}}) == {
'event': {'text': {'custom': False}},
'input': {'text': 'normalized'},
}
class FakePersistenceManager:
"""Runs statements against a real in-memory SQLite engine."""
@@ -33,6 +100,11 @@ class FakePersistenceManager:
def get_db_engine(self):
return self._engine
def tenant_uow(self, workspace_uuid):
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
return TenantUnitOfWork(self._engine, workspace_uuid)
async def execute_async(self, statement):
# Match PersistenceManager's connection-level result shape.
async with self._engine.connect() as connection:
@@ -348,3 +420,319 @@ async def test_execution_detail_for_agent_and_pipeline(engine, service):
with pytest.raises(ValueError):
await service.get_execution_detail(WORKSPACE, 'pipeline', 'missing-message')
@pytest.mark.asyncio
async def test_explicit_deliveries_context_and_tenant_isolation(engine, service):
import sqlalchemy as sa
await _seed(engine)
async with AsyncSession(engine) as session:
await session.execute(
sa.update(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.id == 'msg-assistant')
.values(parent_message_id='msg-user')
)
await session.commit()
detail = await service.get_execution_detail(WORKSPACE, 'auto', 'msg-assistant')
assert detail['row']['id'] == 'msg-user'
assert [m['id'] for m in detail['pages']['deliveries']['items']] == ['msg-assistant']
assert detail['pages']['inputs']['items'][0]['id'] == 'msg-user'
assert detail['pages']['conversation']['items']
with pytest.raises(ValueError, match='not found'):
await service.get_execution_detail('another-workspace', 'auto', 'msg-assistant')
@pytest.mark.asyncio
async def test_legacy_replies_are_context_not_guessed_deliveries(engine, service):
await _seed(engine)
detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
assert detail['legacy_context'] is True
assert detail['pages']['deliveries']['items'] == []
assert 'msg-assistant' in [m['id'] for m in detail['pages']['conversation']['items']]
@pytest.mark.asyncio
async def test_fanout_steering_orphans_and_paged_trace(engine, service):
import json
import sqlalchemy as sa
from langbot.pkg.entity.persistence.event_log import EventLog
from langbot.pkg.entity.persistence.transcript import Transcript
await _seed(engine)
async with AsyncSession(engine) as session:
await session.execute(
sa.update(persistence_agent_run.AgentRun)
.where(persistence_agent_run.AgentRun.run_id == 'run-done')
.values(
conversation_id='shared',
thread_id='thread-1',
metadata_json=json.dumps({'processor_type': 'event_processor'}),
)
)
session.add_all(
[
EventLog(
event_id='evt-1',
event_type='message.received',
source='platform',
workspace_id=WORKSPACE,
bot_id='bot-1',
input_json='{"text":"original input"}',
actor_name='Alice',
created_at=_dt(10),
),
EventLog(
event_id='steer',
event_type='message.received',
source='platform',
workspace_id=WORKSPACE,
bot_id='bot-1',
run_id='run-done',
input_json='{"text":"additional input"}',
created_at=_dt(10, 1),
),
EventLog(
event_id='unrouted',
event_type='group.member.joined',
source='platform',
workspace_id=WORKSPACE,
bot_id='bot-1',
metadata_json='{"status":"ignored"}',
created_at=_dt(10, 7),
),
persistence_agent_run.AgentRun(
run_id='sibling',
event_id='evt-1',
workspace_id=WORKSPACE,
bot_id='bot-1',
runner_id='plugin',
status='completed',
created_at=_dt(10),
),
Transcript(
transcript_id='output',
event_id='generated',
conversation_id='shared',
thread_id='thread-1',
workspace_id=WORKSPACE,
bot_id='bot-1',
role='assistant',
content='generated only',
run_id='run-done',
seq=1,
),
Transcript(
transcript_id='other-thread',
event_id='other',
conversation_id='shared',
thread_id='thread-2',
workspace_id=WORKSPACE,
bot_id='bot-1',
role='user',
content='private other thread',
seq=2,
),
]
)
session.add_all(
[
persistence_agent_run.AgentRunEvent(run_id='run-done', sequence=i, type='message.delta', data_json='{}')
for i in range(2, 206)
]
)
await session.commit()
result = await service.get_executions(WORKSPACE)
assert 'unrouted' in [r['id'] for r in result['items']]
assert 'steer' not in [r['id'] for r in result['items']]
ignored = await service.get_executions(WORKSPACE, statuses=['ignored'])
assert 'unrouted' in [r['id'] for r in ignored['items']]
detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done')
assert detail['row']['target_kind'] == 'event_processor'
assert detail['row']['user_name'] == 'Alice'
assert [i['id'] for i in detail['pages']['inputs']['items']] == ['evt-1', 'steer']
assert detail['pages']['outputs']['items'][0]['content'] == 'generated only'
assert [i['id'] for i in detail['pages']['related']['items']] == ['sibling']
assert 'other-thread' not in [i['id'] for i in detail['pages']['conversation']['items']]
assert len(detail['pages']['events']['items']) == 100
assert detail['pages']['events']['has_more']
final = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done', section='events', offset=200)
assert len(final['pages']['events']['items']) == 5
assert not final['pages']['events']['has_more']
orphan = await service.get_execution_detail(WORKSPACE, 'event', 'unrouted')
assert orphan['pages']['deliveries']['items'] == []
assert orphan['pages']['conversation']['items'] == []
@pytest.mark.asyncio
async def test_pipeline_runner_is_not_counted_twice(engine, service):
import sqlalchemy as sa
await _seed(engine)
async with AsyncSession(engine) as session:
await session.execute(
sa.update(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
.values(run_id='run-done')
)
await session.commit()
result = await service.get_executions(WORKSPACE)
assert result['total'] == 4
assert result['summary']['executions']['total'] == 4
assert 'run-done' not in [r['id'] for r in result['items']]
detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
assert detail['row']['duration_ms'] == 2000
assert detail['events'][0]['type'] == 'run.completed'
@pytest.mark.asyncio
async def test_event_log_fanout_is_idempotent_and_cannot_cross_tenants(engine):
from langbot.pkg.agent.runner.event_log_store import EventLogStore
store = EventLogStore(engine)
await store.append_event('event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot')
await store.append_event(
'event-id',
'message.received',
'platform',
workspace_id=WORKSPACE,
bot_id='bot',
run_id='first-run',
input_json={'text': 'input', 'attachments': []},
)
await store.append_event(
'event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot', run_id='second-run'
)
event = await store.get_event('event-id')
assert event['run_id'] == 'first-run'
assert event['input_json']['text'] == 'input'
with pytest.raises(Exception):
await store.append_event('event-id', 'message.received', 'platform', workspace_id='other', bot_id='bot')
@pytest.mark.asyncio
async def test_processor_filter_scopes_each_kind_and_legacy_calls(engine, service):
import json
await _seed(engine)
async with AsyncSession(engine) as session:
session.add(
persistence_agent_run.AgentRun(
run_id='event-run',
workspace_id=WORKSPACE,
runner_id='event-runner',
status='completed',
created_at=_dt(11),
metadata_json=json.dumps({'processor_type': 'event_processor', 'processor_id': 'event-processor-1'}),
)
)
session.add(
persistence_monitoring.MonitoringLLMCall(
id='event-call',
bot_id='bot-1',
bot_name='Bot One',
pipeline_name='Event Processor',
session_id='session-event',
input_tokens=6,
output_tokens=6,
duration=1,
timestamp=_dt(11),
workspace_uuid=WORKSPACE,
model_name='model',
message_id='event-run',
pipeline_id='',
status='success',
total_tokens=12,
)
)
await session.commit()
for processor, expected in [
('agent-1', {'run-done', 'run-debug'}),
('pipeline-1', {'msg-user', 'msg-discarded'}),
('event-processor-1', {'event-run'}),
('missing', set()),
]:
result = await service.get_executions(WORKSPACE, pipeline_ids=[processor])
assert {row['id'] for row in result['items']} == expected
assert result['total'] == result['summary']['executions']['total'] == len(expected)
calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['event-processor-1'])
assert total == 1
assert [row['id'] for row in calls] == ['event-call']
other = await service.get_executions('another-workspace', pipeline_ids=['event-processor-1'])
assert other['total'] == 0
@pytest.mark.asyncio
async def test_execution_filters_also_scope_cards_calls_and_traffic(engine, service):
import sqlalchemy as sa
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
await _seed(engine)
async with AsyncSession(engine) as session:
await session.execute(
sa.update(persistence_monitoring.MonitoringLLMCall)
.where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1')
.values(message_id='run-failed', pipeline_id='')
)
await session.commit()
failed = await service.get_executions(WORKSPACE, statuses=['failed'])
assert failed['total'] == failed['summary']['executions']['total'] == 1
assert failed['summary']['executions']['waiting'] == 0
calls, total = await service.get_llm_calls(WORKSPACE, execution_statuses=['failed'])
assert total == 1 and calls[0]['id'] == 'llm-1'
# A successful model call may belong to a failed execution.
assert calls[0]['status'] == 'success'
overview = await service.get_overview_metrics(WORKSPACE, execution_statuses=['failed'])
assert overview['llm_calls'] == 1 and overview['total_messages'] == 0
traffic = await get_traffic_series(service.ap, WORKSPACE, execution_statuses=['failed'])
assert sum(p['llm_calls'] for p in traffic['points']) == 1
assert sum(p['messages'] for p in traffic['points']) == 0
debug = await service.get_executions(WORKSPACE, mode='debug')
assert debug['total'] == debug['summary']['executions']['total'] == 1
real = await service.get_executions(WORKSPACE, mode='real')
assert real['total'] == real['summary']['executions']['total'] == 4
traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug')
assert sum(p['messages'] for p in traffic['points']) == 0
@pytest.mark.asyncio
async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(engine, service):
import sqlalchemy as sa
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
await _seed(engine)
async with AsyncSession(engine) as session:
await session.execute(
sa.update(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
.values(bot_id='websocket-proxy-bot')
)
await session.execute(
sa.update(persistence_monitoring.MonitoringLLMCall)
.where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1')
.values(message_id='msg-user', pipeline_id='pipeline-1')
)
await session.execute(
sa.update(persistence_agent_run.AgentRun)
.where(persistence_agent_run.AgentRun.run_id == 'run-done')
.values(bot_id='websocket-proxy-bot')
)
await session.commit()
debug = await service.get_executions(WORKSPACE, mode='debug')
ids = {r['id'] for r in debug['items']}
assert ids == {'msg-user', 'run-done', 'run-debug'}
assert all(r['debug'] for r in debug['items'])
assert debug['summary']['executions']['total'] == 3
assert debug['summary']['executions']['debug'] == 3
real = await service.get_executions(WORKSPACE, mode='real')
assert not ids.intersection(r['id'] for r in real['items'])
assert real['summary']['executions']['debug'] == 0
filtered = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug')
assert [r['id'] for r in filtered['items']] == ['msg-user']
calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug')
assert total == 1 and calls[0]['id'] == 'llm-1'
real_rows, real_calls = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='real')
assert real_calls == 1 and real_rows[0]['id'] == 'llm-2'
traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug', pipeline_ids=['pipeline-1'])
assert sum(p['llm_calls'] for p in traffic['points']) == 1
@@ -980,6 +980,18 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
_validate_scoped_statement_call((statement,), {})
async def test_monitoring_json_casts_are_limited_to_journal_metadata() -> None:
from langbot.pkg.entity.persistence.agent_run import AgentRun
from langbot.pkg.entity.persistence.event_log import EventLog
for column in (AgentRun.metadata_json, EventLog.metadata_json):
statement = sa.select(sa.cast(column, sa.JSON)['status'].as_string())
_validate_scoped_statement_call((statement,), {})
for column in (AgentRun.authorization_json, EventLog.input_json, sa.literal('{}')):
with pytest.raises(ScopedSessionTransactionError, match='casts'):
_validate_scoped_statement_call((sa.select(sa.cast(column, sa.JSON)),), {})
async def test_scoped_session_executes_sqlite_strftime() -> None:
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
@@ -53,6 +53,37 @@ class MockRuntimeRerankModel:
self.provider.token_mgr.get_token = Mock(return_value=api_key)
@pytest.mark.asyncio
async def test_closing_consumer_closes_provider_http_stream():
import asyncio
from types import SimpleNamespace
app = Mock()
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=app, config={})
closed = asyncio.Event()
async def provider():
try:
yield SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'first'}),
finish_reason=None,
)
]
)
await asyncio.Event().wait()
finally:
closed.set()
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: provider())):
output = requester.invoke_llm_stream(query=None, model=MockRuntimeModel(), messages=[])
assert (await anext(output)).content == 'first'
await output.aclose()
assert closed.is_set()
class TestBuildLiteLLMModelName:
"""Test _build_litellm_model_name method"""
@@ -77,6 +77,8 @@ interface TokenStatistics {
}
interface TokenMonitoringProps {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
@@ -103,6 +105,8 @@ const TOOLTIP_STYLE: React.CSSProperties = {
};
export default function TokenMonitoring({
mode,
statusGroup,
botIds,
pipelineIds,
startTime,
@@ -123,6 +127,9 @@ export default function TokenMonitoring({
setError(null);
try {
const result = await httpClient.getTokenStatistics({
mode,
status:
statusGroup && statusGroup !== 'all' ? [statusGroup] : undefined,
botId: botIds,
pipelineId: pipelineIds,
startTime,
@@ -136,7 +143,16 @@ export default function TokenMonitoring({
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [botIdsKey, pipelineIdsKey, startTime, endTime, bucket, refreshKey]);
}, [
mode,
statusGroup,
botIdsKey,
pipelineIdsKey,
startTime,
endTime,
bucket,
refreshKey,
]);
useEffect(() => {
fetchStats();
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ExternalLink } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import {
Sheet,
SheetContent,
@@ -13,14 +13,14 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { backendClient } from '@/app/infra/http';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import type {
ExecutionCallRow,
ExecutionDetail,
ExecutionDetailItem,
ExecutionRow,
ExecutionSection,
} from '@/app/infra/entities/api/monitoring-executions';
import AgentExecutionTrace from '@/app/home/agents/components/AgentExecutionTrace';
import { ExecutionValue as Value } from './ExecutionValue';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { formatDateTime } from '../../utils/dateUtils';
import {
@@ -28,329 +28,409 @@ import {
executionProcessorLabel,
executionStatusLabel,
executionStatusVariant,
executionTargetLabel,
} from './ExecutionTable';
interface ExecutionDetailSheetProps {
row: ExecutionRow | null;
onClose: () => void;
}
export type ExecutionSelection = Pick<ExecutionRow, 'id'> & {
source: ExecutionRow['source'] | 'auto';
};
/** Walk parsed JSON, collecting every `"text"` value (OpenAI-style content). */
function collectText(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map(collectText).filter(Boolean).join(' ');
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.text === 'string') return record.text;
return Object.values(record).map(collectText).filter(Boolean).join(' ');
}
return '';
}
function messagePreview(raw: string | null | undefined): string {
if (!raw) return '';
try {
return collectText(JSON.parse(raw)).trim() || raw;
} catch {
return raw;
}
}
function callTitle(call: ExecutionCallRow, index: number): string {
const candidates = [
call.model_name,
call.model,
call.name,
call.tool_name,
call.function,
call.error_type,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate) return candidate;
}
return typeof call.id === 'string' && call.id ? call.id : `#${index + 1}`;
}
const mainSections: ExecutionSection[] = ['inputs', 'outputs', 'deliveries'];
const secondarySections: ExecutionSection[] = [
'related',
'conversation',
'llm_calls',
'tool_calls',
'errors',
'events',
];
export default function ExecutionDetailSheet({
row,
onClose,
}: ExecutionDetailSheetProps) {
onSelect,
}: {
row: ExecutionSelection | null;
onClose: () => void;
onSelect: (row: ExecutionSelection) => void;
}) {
const { t } = useTranslation();
const workspace = useCurrentWorkspace()?.workspace.uuid;
const [detail, setDetail] = useState<ExecutionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const requestRef = useRef(0);
const [sectionError, setSectionError] = useState<ExecutionSection | null>(
null,
);
const [busy, setBusy] = useState<ExecutionSection | null>(null);
const [refresh, setRefresh] = useState(0);
const request = useRef(0);
const identity = `${workspace}:${row?.source}:${row?.id}`;
const [loadedIdentity, setLoadedIdentity] = useState('');
const visible = loadedIdentity === identity ? detail : null;
const id = row?.id;
const source = row?.source;
useEffect(() => {
if (!row) {
setDetail(null);
setError(false);
const version = ++request.current;
setDetail(null);
setError(false);
setSectionError(null);
setBusy(null);
if (!id || !source) {
setLoading(false);
return;
}
const requestId = requestRef.current + 1;
requestRef.current = requestId;
setLoading(true);
setError(false);
setDetail(null);
backendClient
.getExecutionDetail(row.source, row.id)
.getExecutionDetail(source, id)
.then((result) => {
if (requestRef.current !== requestId) return;
if (version !== request.current) return;
setDetail(result);
setLoadedIdentity(identity);
})
.catch(() => {
if (requestRef.current !== requestId) return;
setError(true);
if (version === request.current) setError(true);
})
.finally(() => {
if (requestRef.current === requestId) setLoading(false);
});
}, [row]);
const isAgent =
detail != null ? detail.source === 'agent' : row?.source === 'agent';
const metadata: { label: string; value: string }[] = [];
if (row) {
metadata.push({
label: t('monitoring.execution.detail.executionId'),
value: row.id,
});
if (row.event_id) {
metadata.push({
label: t('monitoring.execution.detail.eventId'),
value: row.event_id,
if (version === request.current) setLoading(false);
});
return () => {
request.current += 1;
};
}, [id, source, workspace, identity, refresh]);
const status = visible?.row.status_group;
useEffect(() => {
if (!['running', 'queued'].includes(status ?? '')) return;
// Do not discard paged history with a background refresh.
if (
Object.values(visible?.pages ?? {}).some((page) => page.next_offset > 100)
)
return;
const timer = setInterval(() => setRefresh((value) => value + 1), 5000);
return () => clearInterval(timer);
}, [status, visible]);
const loadMore = async (section: ExecutionSection) => {
if (!visible || !row || busy) return;
const version = request.current;
setBusy(section);
setSectionError(null);
try {
const result = await backendClient.getExecutionDetail(
row.source,
row.id,
{ section, offset: visible.pages[section]?.next_offset ?? 0 },
);
if (version !== request.current) return;
const next = result.pages[section];
if (next)
setDetail(
(previous) =>
previous && {
...previous,
pages: {
...previous.pages,
[section]: {
...next,
items: [
...(previous.pages[section]?.items ?? []),
...next.items,
],
},
},
},
);
} catch {
if (version === request.current) setSectionError(section);
} finally {
if (version === request.current) setBusy(null);
}
if (row.runner_id) {
metadata.push({
label: t('monitoring.execution.detail.runner'),
value: row.runner_id,
});
};
const renderItem = (section: ExecutionSection, item: ExecutionDetailItem) => {
if (section === 'related') {
const related = item as unknown as ExecutionRow;
return (
<Button
variant="outline"
className="h-auto w-full justify-between whitespace-normal py-3 text-left"
onClick={() => onSelect(related)}
>
<span>
{executionProcessorLabel(related)} ·{' '}
{executionKindLabel(related, t)}
</span>
<Badge variant={executionStatusVariant(related.status_group)}>
{executionStatusLabel(related, t)}
</Badge>
</Button>
);
}
if (row.queue_name) {
metadata.push({
label: t('monitoring.execution.detail.queue'),
value: row.queue_name,
});
}
if (row.created_at_ms != null) {
metadata.push({
label: t('monitoring.execution.detail.created'),
value: formatDateTime(new Date(row.created_at_ms)),
});
}
if (row.duration_ms != null) {
metadata.push({
label: t('monitoring.execution.detail.duration'),
value: formatRunDuration(row.duration_ms),
});
}
if (isAgent && row.usage?.total_tokens != null) {
metadata.push({
label: t('monitoring.execution.detail.usage'),
value: row.usage.total_tokens.toLocaleString(),
});
}
}
const objectHref = row
? row.target_kind === 'agent' && row.target_id
? `/home/agents?id=${encodeURIComponent(row.target_id)}`
: row.target_kind === 'pipeline' && row.target_id
? `/home/pipelines?id=${encodeURIComponent(row.target_id)}`
const content = [
'inputs',
'outputs',
'deliveries',
'conversation',
].includes(section);
const title = content
? [
item.actor_name || item.actor_id,
item.event_type,
item.role &&
t(`monitoring.unified.roles.${item.role}`, {
defaultValue: item.role,
}),
]
.filter(Boolean)
.join(' · ')
: String(
item.model_name ||
item.tool_name ||
item.error_type ||
item.type ||
item.id,
);
return (
<div className="min-w-0 space-y-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="break-all">{title}</span>
<span>
{item.timestamp_ms
? formatDateTime(new Date(item.timestamp_ms))
: typeof item.timestamp === 'string'
? item.timestamp
: ''}
</span>
{item.status && (
<Badge
variant={executionStatusVariant(
['error', 'failed'].includes(item.status)
? 'failed'
: item.status,
)}
>
{item.status}
</Badge>
)}
</div>
{content ? (
<>
<Value value={item.content} />
<Value value={item.attachments} />
{item.origin === 'generated' && section === 'conversation' && (
<Badge variant="outline">{t('monitoring.unified.outputs')}</Badge>
)}
{item.delivery?.delivery_error ? (
<p className="text-sm text-destructive">
{String(item.delivery.delivery_error)}
</p>
) : null}
{Array.isArray(item.metadata?.routes) && (
<Value value={item.metadata.routes} />
)}
</>
) : (
<>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{typeof item.duration === 'number' && (
<span>{formatRunDuration(item.duration)}</span>
)}
{typeof item.total_tokens === 'number' && (
<span>{item.total_tokens.toLocaleString()} Token</span>
)}
</div>
<Value value={item.error_message} />
<details>
<summary className="cursor-pointer text-xs text-muted-foreground">
{t('monitoring.unified.payload')}
</summary>
<div className="mt-2">
<Value value={section === 'events' ? item.data : item} />
</div>
</details>
</>
)}
</div>
);
};
const renderSection = (section: ExecutionSection, primary = false) => {
const page = visible?.pages[section];
if (
!page ||
(!primary && page.items.length === 0 && section !== 'conversation')
)
return null;
const body = (
<div className="mt-3 space-y-3">
{section === 'conversation' && (
<p className="text-xs text-muted-foreground">
{t('monitoring.unified.contextHint')}
</p>
)}
{page.items.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('monitoring.unified.noRecords')}
</p>
)}
{page.items.map((item, i) => (
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
))}
{sectionError === section && (
<p role="alert" className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{page.has_more && (
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => loadMore(section)}
>
{t('monitoring.unified.loadMore')}
</Button>
)}
</div>
);
const title = `${t(`monitoring.unified.${section}`)} (${page.items.length}${page.has_more ? '+' : ''})`;
return primary ? (
<section key={section}>
<h3 className="text-sm font-semibold">{title}</h3>
{body}
</section>
) : (
<details key={section} className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{title}
</summary>
{body}
</details>
);
};
const record = visible?.row;
const href = record?.target_id
? record.target_kind === 'pipeline'
? `/home/pipelines?id=${encodeURIComponent(record.target_id)}`
: ['agent', 'event_processor', 'processor'].includes(record.target_kind)
? `/home/agents?id=${encodeURIComponent(record.target_id)}`
: null
: null;
const renderCalls = (title: string, calls: ExecutionCallRow[]) => (
<div className="space-y-2">
<h3 className="text-sm font-medium">{title}</h3>
{calls.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.detail.noEvents')}
</p>
) : (
<div className="space-y-1">
{calls.map((call, index) => {
const duration =
typeof call.duration === 'number' ? call.duration : null;
return (
<div
key={`${title}-${call.id}-${index}`}
className="flex items-start justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{callTitle(call, index)}</div>
{call.error_message && (
<div className="truncate text-xs text-muted-foreground">
{call.error_message}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{call.status && (
<Badge
variant={executionStatusVariant(
String(call.status).toLowerCase(),
)}
>
{call.status}
</Badge>
)}
<span className="w-16 text-right tabular-nums text-muted-foreground">
{duration == null ? '—' : formatRunDuration(duration)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
return (
<Sheet
open={row != null}
open={row !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent side="right" className="w-full p-0 sm:max-w-2xl">
<SheetDescription className="sr-only">
{t('monitoring.execution.detail.a11yDescription')}
</SheetDescription>
<SheetContent side="right" className="w-full p-0 sm:max-w-3xl">
<SheetHeader className="sr-only">
<SheetTitle>{t('monitoring.execution.detail.title')}</SheetTitle>
<SheetDescription>
{t('monitoring.execution.detail.a11yDescription')}
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-full">
<div className="space-y-6 p-6">
{row && (
<div className="space-y-5 p-5 sm:p-6">
<div className="flex items-start justify-between gap-3 pr-8">
<h2 className="text-lg font-semibold">
{record
? record.source === 'event'
? t('monitoring.unified.unhandled')
: executionProcessorLabel(record)
: t('monitoring.execution.detail.title')}
</h2>
<Button
variant="ghost"
size="icon"
disabled={loading}
aria-label={t('monitoring.refreshData')}
onClick={() => setRefresh((value) => value + 1)}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{loading && <Skeleton className="h-36 w-full" />}
{error && (
<p role="alert" className="text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{record && (
<>
<SheetHeader className="space-y-3">
<SheetTitle className="pr-8 text-left">
{executionProcessorLabel(row)}
</SheetTitle>
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.triggeredBy', {
trigger: executionTargetLabel(row, t),
})}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">
{executionKindLabel(row, t)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={executionStatusVariant(record.status_group)}>
{executionStatusLabel(record, t)}
</Badge>
<Badge variant="outline">
{executionKindLabel(record, t)}
</Badge>
{record.debug && (
<Badge variant="secondary">
{t('monitoring.execution.debug')}
</Badge>
<Badge variant={executionStatusVariant(row.status_group)}>
{executionStatusLabel(row, t)}
</Badge>
{row.debug && (
<Badge variant="secondary">
{t('monitoring.execution.debug')}
</Badge>
)}
{objectHref && (
<Button
asChild
size="sm"
variant="outline"
className="ml-auto h-7"
>
<Link to={objectHref}>
<ExternalLink className="mr-1 h-3.5 w-3.5" />
{t('monitoring.execution.detail.openObject')}
</Link>
</Button>
)}
</div>
</SheetHeader>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{metadata.map((item) => (
<div key={item.label} className="min-w-0 space-y-0.5">
<div className="text-xs text-muted-foreground">
{item.label}
</div>
<div className="truncate text-sm font-medium">
{item.value}
</div>
</div>
))}
)}
{href && (
<Button asChild variant="outline" size="sm">
<Link to={href}>
{t('monitoring.execution.detail.openObject')}
</Link>
</Button>
)}
</div>
<Separator />
{isAgent ? (
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t('monitoring.execution.detail.timeline')}
</h3>
{loading && (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
)}
{!loading && error && (
<p className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{!loading &&
!error &&
detail?.source === 'agent' &&
(detail.events.length > 0 ? (
<AgentExecutionTrace
events={detail.events}
finished={row.status_group !== 'running'}
toolLabels={{}}
/>
) : (
<p className="text-sm text-muted-foreground">
{t('monitoring.execution.detail.noEvents')}
</p>
))}
</div>
) : (
<div className="space-y-5">
{loading && (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
)}
{!loading && error && (
<p className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
)}
{!loading && !error && detail?.source === 'pipeline' && (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t('monitoring.execution.detail.query')}
</h3>
<p className="whitespace-pre-wrap break-words rounded-md border bg-muted/40 px-3 py-2 text-sm">
{messagePreview(detail.message.message_content) ||
'—'}
</p>
</div>
{renderCalls(
t('monitoring.execution.detail.modelCalls'),
detail.llm_calls,
)}
{renderCalls(
t('monitoring.execution.detail.toolCalls'),
detail.tool_calls,
)}
{renderCalls(
t('monitoring.execution.detail.errors'),
detail.errors,
)}
</>
)}
</div>
<p className="text-sm text-muted-foreground">
{[
record.bot_name,
record.platform,
record.user_name || record.user_id,
]
.filter(Boolean)
.join(' · ')}
</p>
{record.status_reason && (
<p className="rounded-md bg-muted p-3 text-sm">
{record.status_reason}
</p>
)}
{mainSections.map((section) => renderSection(section, true))}
{secondarySections.map((section) => renderSection(section))}
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm">
{t('monitoring.unified.metadata')}
</summary>
<dl className="mt-3 space-y-2 text-xs">
{[
[t('monitoring.execution.detail.executionId'), record.id],
[
t('monitoring.execution.detail.eventId'),
record.event_id,
],
[
t('monitoring.unified.conversation'),
record.conversation_id || record.session_id,
],
[
t('monitoring.execution.detail.runner'),
record.runner_id,
],
[
t('monitoring.execution.detail.created'),
record.created_at_ms
? formatDateTime(new Date(record.created_at_ms))
: null,
],
[
t('monitoring.execution.detail.duration'),
record.duration_ms != null
? formatRunDuration(record.duration_ms)
: null,
],
]
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="break-all">{value}</dd>
</div>
))}
</dl>
</details>
</>
)}
</div>
@@ -45,17 +45,26 @@ export function executionStatusVariant(
return 'outline';
}
const KIND_GROUPS = ['agent', 'pipeline', 'processor'];
const KIND_GROUPS = [
'agent',
'pipeline',
'processor',
'event_processor',
'event',
];
export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
if (KIND_GROUPS.includes(row.target_kind)) {
if (row.target_kind === 'event_processor')
return t('monitoring.execution.kind.processor');
if (row.target_kind === 'event') return t('monitoring.unified.unhandled');
return t(`monitoring.execution.kind.${row.target_kind}`);
}
return row.target_kind;
}
export function executionTargetLabel(row: ExecutionRow, t: TFunction): string {
if (row.target_kind === 'agent') {
if (row.source !== 'pipeline') {
return eventPatternLabel(row.title, t);
}
const title = row.title ?? '';
@@ -85,6 +94,7 @@ export default function ExecutionTable({
<TableRow>
<TableHead>{t('monitoring.execution.columns.time')}</TableHead>
<TableHead>{t('monitoring.execution.columns.target')}</TableHead>
<TableHead>{t('monitoring.unified.inputs')}</TableHead>
<TableHead>{t('monitoring.execution.columns.status')}</TableHead>
<TableHead className="text-right">
{t('monitoring.execution.columns.duration')}
@@ -127,7 +137,9 @@ export default function ExecutionTable({
{executionKindLabel(row, t)}
</Badge>
<span className="truncate font-medium">
{executionProcessorLabel(row)}
{row.source === 'event'
? t('monitoring.unified.unhandled')
: executionProcessorLabel(row)}
</span>
</div>
<span className="truncate text-xs text-muted-foreground">
@@ -137,6 +149,16 @@ export default function ExecutionTable({
</span>
</div>
</TableCell>
<TableCell className="max-w-xs">
<div className="line-clamp-2 break-words text-sm">
{row.input_preview || '—'}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{[row.bot_name, row.user_name || row.user_id]
.filter(Boolean)
.join(' · ')}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={executionStatusVariant(row.status_group)}>
@@ -0,0 +1,178 @@
import { MessageContentRenderer } from '../MessageContentRenderer';
// Keep arbitrary event properties, including false and zero. Empty transport
// fields are not messages. This also applies to historical serialized payloads.
export function hasExecutionValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.some(hasExecutionValue);
if (typeof value === 'object')
return Object.values(value).some(hasExecutionValue);
return true;
}
function textProjection(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(textProjection).join('');
if (value && typeof value === 'object') {
const item = value as Record<string, unknown>;
if (item.type === 'text' || item.type === 'Plain')
return typeof item.text === 'string' ? item.text : '';
}
return '';
}
const messageTypes = new Set([
'Plain',
'At',
'AtAll',
'Image',
'File',
'Voice',
'Quote',
]);
const componentFields: Record<string, string[]> = {
Plain: ['text'],
At: ['target', 'display'],
AtAll: [],
Image: ['url', 'base64', 'imageId'],
File: ['name', 'id', 'url', 'size'],
Voice: ['url', 'base64', 'voiceId', 'length'],
Quote: ['origin'],
};
export function ExecutionValue({ value }: { value: unknown }) {
if (!hasExecutionValue(value)) return null;
if (typeof value === 'string') {
// Do not reinterpret ordinary strings such as "false", "0", or quoted
// user text. Only historical structured JSON needs decoding.
if (/^\s*[\[{]/.test(value)) {
try {
return <ExecutionValue value={JSON.parse(value)} />;
} catch {
/* Text. */
}
}
return (
<pre className="whitespace-pre-wrap break-words font-sans text-sm [overflow-wrap:anywhere]">
{value}
</pre>
);
}
if (Array.isArray(value))
return (
<div className="space-y-2">
{value.map((item, index) => (
<ExecutionValue key={index} value={item} />
))}
</div>
);
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.type === 'string' && messageTypes.has(record.type)) {
const fields = ['type', ...componentFields[record.type]];
return (
<>
<MessageContentRenderer
content={JSON.stringify([record])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(([key]) => !fields.includes(key)),
)}
/>
</>
);
}
if (record.type === 'text') {
const rest = Object.fromEntries(
Object.entries(record).filter(
([key, item]) =>
key !== 'type' && key !== 'text' && hasExecutionValue(item),
),
);
return (
<>
<ExecutionValue value={record.text} />
<ExecutionValue value={rest} />
</>
);
}
if (['image', 'image_url', 'image_base64'].includes(String(record.type))) {
const image = record.image_url;
const url =
typeof image === 'string'
? image
: image && typeof image === 'object'
? (image as { url?: string }).url
: typeof record.image_base64 === 'string'
? record.image_base64.startsWith('data:')
? record.image_base64
: `data:image/png;base64,${record.image_base64}`
: null;
if (url)
return (
<>
<MessageContentRenderer
content={JSON.stringify([{ type: 'Image', url }])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(
([key]) =>
!['type', 'image_url', 'image_base64'].includes(key),
),
)}
/>
</>
);
}
if (['file', 'file_url', 'file_base64'].includes(String(record.type))) {
const name = record.file_name ?? record.name;
return (
<>
<MessageContentRenderer
content={JSON.stringify([{ type: 'File', name }])}
maxLines={12}
/>
<ExecutionValue
value={Object.fromEntries(
Object.entries(record).filter(
([key]) =>
!['type', 'file_name', 'name', 'file_base64'].includes(key),
),
)}
/>
</>
);
}
// Only equal text projections are aliases. Never deduplicate distinct
// records, or hide different text and structured content from one event.
const bodyKeys = ['chain', 'message_chain', 'contents', 'content'];
const bodyKey = bodyKeys.find((key) => hasExecutionValue(record[key]));
const body = bodyKey ? record[bodyKey] : undefined;
const duplicateText =
bodyKey &&
typeof record.text === 'string' &&
record.text === textProjection(body);
const entries = Object.entries(record).filter(
([key, item]) =>
hasExecutionValue(item) && !(key === 'text' && duplicateText),
);
return (
<div className="space-y-2">
{entries.map(([key, item]) => (
<div key={key}>
{!['text', ...bodyKeys, 'attachments'].includes(key) && (
<div className="text-xs text-muted-foreground">{key}</div>
)}
<ExecutionValue value={item} />
</div>
))}
</div>
);
}
return <span className="text-sm">{String(value)}</span>;
}
@@ -8,12 +8,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { backendClient } from '@/app/infra/http';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { TimeRangeOption } from '../../types/monitoring';
import type {
ExecutionModeFilter,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
interface MonitoringFiltersProps {
selectedBots: string[];
@@ -22,8 +19,6 @@ interface MonitoringFiltersProps {
onBotsChange: (bots: string[]) => void;
onPipelinesChange: (pipelines: string[]) => void;
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
source?: ExecutionSourceFilter;
onSourceChange?: (value: ExecutionSourceFilter) => void;
mode?: ExecutionModeFilter;
onModeChange?: (value: ExecutionModeFilter) => void;
statusGroup?: string;
@@ -39,14 +34,25 @@ const STATUS_GROUP_OPTIONS = [
'ignored',
];
const STATUS_COLORS: Record<string, string> = {
completed: 'text-emerald-700 dark:text-emerald-400',
failed: 'text-red-600 dark:text-red-400',
running: 'text-blue-600 dark:text-blue-400',
queued: 'text-amber-700 dark:text-amber-400',
cancelled: 'text-slate-600 dark:text-slate-400',
ignored: 'text-muted-foreground',
};
interface Bot {
uuid: string;
name: string;
iconURL: string;
}
interface Pipeline {
interface Processor {
uuid: string;
name: string;
emoji?: string;
}
export default function MonitoringFilters({
@@ -56,21 +62,22 @@ export default function MonitoringFilters({
onBotsChange,
onPipelinesChange,
onTimeRangeChange,
source,
onSourceChange,
mode,
onModeChange,
statusGroup,
onStatusGroupChange,
}: MonitoringFiltersProps) {
const { t } = useTranslation();
const workspace = useCurrentWorkspace()?.workspace.uuid;
const [bots, setBots] = useState<Bot[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [processors, setProcessors] = useState<Processor[]>([]);
const [loadingBots, setLoadingBots] = useState(false);
const [loadingPipelines, setLoadingPipelines] = useState(false);
const [loadingProcessors, setLoadingProcessors] = useState(false);
// Fetch bots list
useEffect(() => {
let active = true;
setBots([]);
const fetchBots = async () => {
setLoadingBots(true);
try {
@@ -78,41 +85,57 @@ export default function MonitoringFilters({
// Filter out bots without uuid and map to local Bot interface
const validBots = (response.bots || [])
.filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)
.map((bot) => ({ uuid: bot.uuid, name: bot.name }));
setBots(validBots);
.map((bot) => ({
uuid: bot.uuid,
name: bot.name,
iconURL: backendClient.getAdapterIconURL(bot.adapter),
}));
if (active) setBots(validBots);
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoadingBots(false);
if (active) setLoadingBots(false);
}
};
fetchBots();
}, []);
return () => {
active = false;
};
}, [workspace]);
// Fetch pipelines list
// Fetch all processor kinds from the same catalog as the sidebar
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true);
let active = true;
setProcessors([]);
const fetchProcessors = async () => {
setLoadingProcessors(true);
try {
const response = await backendClient.getPipelines();
// Filter out pipelines without uuid and map to local Pipeline interface
const validPipelines = (response.pipelines || [])
const response = await backendClient.getAgents();
// Preserve the custom icon displayed to the left of the sidebar name.
const validProcessors = (response.agents || [])
.filter(
(pipeline): pipeline is typeof pipeline & { uuid: string } =>
!!pipeline.uuid,
)
.map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));
setPipelines(validPipelines);
.map((pipeline) => ({
uuid: pipeline.uuid,
name: pipeline.name,
emoji: pipeline.emoji,
}));
if (active) setProcessors(validProcessors);
} catch (error) {
console.error('Failed to fetch pipelines:', error);
console.error('Failed to fetch processors:', error);
} finally {
setLoadingPipelines(false);
if (active) setLoadingProcessors(false);
}
};
fetchPipelines();
}, []);
fetchProcessors();
return () => {
active = false;
};
}, [workspace]);
const handleBotChange = (value: string) => {
if (value === 'all') {
@@ -135,41 +158,104 @@ export default function MonitoringFilters({
};
return (
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-6">
{/* Source Filter */}
{onSourceChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.execution.filters.source')}
</Label>
<Select
value={source ?? 'all'}
onValueChange={(value) =>
onSourceChange(value as ExecutionSourceFilter)
}
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{/* Bot Filter */}
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-bot"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.bot')}
</Label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger
id="monitoring-filter-bot"
className="h-9 w-full min-w-0"
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.execution.filters.sourceAll')}
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
<span className="flex min-w-0 items-center gap-2">
<img
src={bot.iconURL}
alt=""
className="size-4 shrink-0 rounded"
/>
<span className="truncate">{bot.name}</span>
</span>
</SelectItem>
<SelectItem value="agent">
{t('monitoring.execution.filters.sourceAgent')}
))}
</SelectContent>
</Select>
</div>
{/* Processor Filter */}
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-processor"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.processor')}
</Label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingProcessors}
>
<SelectTrigger
id="monitoring-filter-processor"
className="h-9 w-full min-w-0"
>
<SelectValue
placeholder={
loadingProcessors
? t('monitoring.filters.loading')
: t('monitoring.filters.selectProcessor')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allProcessors')}
</SelectItem>
{processors.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
<span className="flex min-w-0 items-center gap-2">
{pipeline.emoji && (
<span className="shrink-0 text-sm" aria-hidden="true">
{pipeline.emoji}
</span>
)}
<span className="truncate">{pipeline.name}</span>
</span>
</SelectItem>
<SelectItem value="pipeline">
{t('monitoring.execution.filters.sourcePipeline')}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
))}
</SelectContent>
</Select>
</div>
{/* Mode Filter */}
{onModeChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-mode"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.execution.filters.mode')}
</Label>
<Select
@@ -178,7 +264,10 @@ export default function MonitoringFilters({
onModeChange(value as ExecutionModeFilter)
}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectTrigger
id="monitoring-filter-mode"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -198,15 +287,21 @@ export default function MonitoringFilters({
{/* Status Filter */}
{onStatusGroupChange && (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-status"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.execution.filters.status')}
</Label>
<Select
value={statusGroup ?? 'all'}
onValueChange={(value) => onStatusGroupChange(value)}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectTrigger
id="monitoring-filter-status"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -215,7 +310,15 @@ export default function MonitoringFilters({
</SelectItem>
{STATUS_GROUP_OPTIONS.map((group) => (
<SelectItem key={group} value={group}>
{t(`monitoring.execution.status.${group}`)}
<span
className={`flex items-center gap-2 ${STATUS_COLORS[group]}`}
>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full bg-current"
/>
{t(`monitoring.execution.status.${group}`)}
</span>
</SelectItem>
))}
</SelectContent>
@@ -223,77 +326,19 @@ export default function MonitoringFilters({
</div>
)}
{/* Bot Filter */}
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.bot')}
</Label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Pipeline Filter */}
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.pipeline')}
</Label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingPipelines}
>
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingPipelines
? t('monitoring.filters.loading')
: t('monitoring.filters.selectPipeline')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allPipelines')}
</SelectItem>
{pipelines.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
{pipeline.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Time Range Filter */}
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 text-sm font-medium sm:w-auto sm:whitespace-nowrap">
<div className="min-w-0 space-y-1.5">
<Label
htmlFor="monitoring-filter-time"
className="text-xs font-medium text-muted-foreground"
>
{t('monitoring.filters.timeRange')}
</Label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="h-9 w-full sm:w-[150px]">
<SelectTrigger
id="monitoring-filter-time"
className="h-9 w-full min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next';
import {
Activity,
CheckCircle2,
Info,
Timer,
TriangleAlert,
Users,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ExecutionSummary } from '@/app/infra/entities/api/monitoring-executions';
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
import { MetricCard } from './MetricCard';
@@ -63,7 +56,7 @@ export default function ExecutionOverviewCards({
>
{!!overview?.debug && (
<div className="text-xs text-muted-foreground">
{t('monitoring.execution.cards.hiddenDebug', {
{t('monitoring.execution.cards.debugRuns', {
count: overview.debug,
})}
</div>
@@ -72,6 +65,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.successRate')}
description={t('monitoring.execution.cards.successRateHint')}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="#3b82f6"
loading={loading}
@@ -82,24 +76,6 @@ export default function ExecutionOverviewCards({
completed: (overview?.completed ?? 0).toLocaleString(),
denominator: (overview?.denominator ?? 0).toLocaleString(),
})}
action={
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label={t('monitoring.execution.cards.successRate')}
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{t('monitoring.execution.cards.successRateHint')}
</TooltipContent>
</Tooltip>
}
/>
<MetricCard
@@ -142,6 +118,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.latency')}
description={t('monitoring.execution.cards.latencyHint')}
icon={<Timer className="h-4 w-4" />}
accent="#f59e0b"
loading={loading}
@@ -175,6 +152,7 @@ export default function ExecutionOverviewCards({
<MetricCard
label={t('monitoring.execution.cards.tokens')}
description={t('monitoring.execution.cards.tokensHint')}
icon={<Zap className="h-4 w-4" />}
accent="#10b981"
loading={loading}
@@ -4,10 +4,18 @@ import React from 'react';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export interface MetricCardProps {
/** Small label, sitting next to the icon. */
label: string;
description?: string;
/** The card's headline value; a node so it can carry a badge. */
value?: React.ReactNode;
/** Icon of the tinted tile on the leading edge. */
@@ -32,6 +40,7 @@ export interface MetricCardProps {
*/
export function MetricCard({
label,
description,
value,
icon,
hint,
@@ -56,9 +65,28 @@ export function MetricCard({
{icon}
</span>
) : null}
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
{description && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={label}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="max-w-[min(20rem,calc(100vw-2rem))] text-sm leading-relaxed"
>
{description}
</TooltipContent>
</Tooltip>
)}
</div>
{action}
</div>
@@ -4,6 +4,8 @@ import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
import { parseUTCTimestamp } from '../utils/dateUtils';
interface UseFeedbackDataParams {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
@@ -60,6 +62,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
const fetchStats = useCallback(async () => {
try {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
if (params.statusGroup && params.statusGroup !== 'all')
queryParams.append('status', params.statusGroup);
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
@@ -99,7 +104,14 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
} catch (err) {
console.error('Failed to fetch feedback stats:', err);
}
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
}, [
params.mode,
params.statusGroup,
params.botIds,
params.pipelineIds,
params.startTime,
params.endTime,
]);
const fetchFeedback = useCallback(async () => {
setLoading(true);
@@ -107,6 +119,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
try {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
if (params.statusGroup && params.statusGroup !== 'all')
queryParams.append('status', params.statusGroup);
if (params.botIds) {
params.botIds.forEach((id) => queryParams.append('botId', id));
}
@@ -54,6 +54,11 @@ export function useMonitoringData(filterState: FilterState) {
);
const response = await backendClient.getMonitoringData({
mode: filterState.mode,
status:
filterState.statusGroup && filterState.statusGroup !== 'all'
? [filterState.statusGroup]
: undefined,
botId:
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -390,6 +395,8 @@ export function useMonitoringData(filterState: FilterState) {
if (isCurrent()) setLoading(false);
}
}, [
filterState.mode,
filterState.statusGroup,
filterState.timeRange,
filterState.customDateRange,
filterState.selectedBots,
@@ -408,6 +415,8 @@ export function useMonitoringData(filterState: FilterState) {
}, [
selectedBotsStr,
selectedPipelinesStr,
filterState.mode,
filterState.statusGroup,
filterState.timeRange,
customDateRangeStr,
workspaceUuid,
@@ -1,65 +1,128 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentWorkspace } from '@/app/infra/http';
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
import { getPresetDateRange } from '../utils/dateUtils';
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
/**
* Custom hook for managing monitoring filters
*/
export function useMonitoringFilters() {
const [searchParams] = useSearchParams();
const defaults = (): FilterState => ({
selectedBots: [],
selectedPipelines: [],
timeRange: 'last24Hours',
customDateRange: null,
mode: 'all',
statusGroup: 'all',
});
const timeRanges = [
'lastHour',
'last6Hours',
'last24Hours',
'last7Days',
'last30Days',
'custom',
];
const statuses = [
'all',
'completed',
'failed',
'running',
'queued',
'cancelled',
'ignored',
];
// Initialize filters from URL params
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
const botId = searchParams.get('botId');
return botId ? [botId] : [];
});
const [selectedPipelines, setSelectedPipelines] = useState<string[]>(() => {
const pipelineId = searchParams.get('pipelineId');
return pipelineId ? [pipelineId] : [];
});
const [timeRange, setTimeRange] = useState<TimeRangeOption>('last24Hours');
const [customDateRange, setCustomDateRange] = useState<DateRange | null>(
null,
);
// Get the active date range (either preset or custom)
const getActiveDateRange = (): DateRange | null => {
if (timeRange === 'custom' && customDateRange) {
return customDateRange;
function restore(key: string): FilterState {
const state = defaults();
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (!saved || typeof saved !== 'object') return state;
for (const name of ['selectedBots', 'selectedPipelines'] as const) {
if (Array.isArray(saved[name]))
state[name] = saved[name].filter(
(id: unknown) => typeof id === 'string' && id.length > 0,
);
}
return getPresetDateRange(timeRange);
};
if (timeRanges.includes(saved.timeRange)) state.timeRange = saved.timeRange;
if (['all', 'real', 'debug'].includes(saved.mode)) state.mode = saved.mode;
if (statuses.includes(saved.statusGroup))
state.statusGroup = saved.statusGroup;
if (saved.customDateRange) {
const from = new Date(saved.customDateRange.from);
const to = new Date(saved.customDateRange.to);
if (
Number.isFinite(from.getTime()) &&
Number.isFinite(to.getTime()) &&
from <= to
)
state.customDateRange = { from, to };
}
if (state.timeRange === 'custom' && !state.customDateRange)
state.timeRange = 'last24Hours';
} catch {
// Invalid or unavailable browser storage must not prevent monitoring.
}
return state;
}
// Reset all filters
/** The page is keyed by Workspace so saved resource IDs never cross scopes. */
export function useMonitoringFilters() {
const workspace = useCurrentWorkspace()?.workspace.uuid;
const key = `monitoring.filters.v1:${workspace ?? 'pending'}`;
const [searchParams, setSearchParams] = useSearchParams();
const [filterState, setFilters] = useState<FilterState>(() => {
const state = workspace ? restore(key) : defaults();
// Explicit links take precedence over saved resource filters.
if (searchParams.has('botId'))
state.selectedBots = searchParams.getAll('botId').filter(Boolean);
if (searchParams.has('pipelineId'))
state.selectedPipelines = searchParams
.getAll('pipelineId')
.filter(Boolean);
return state;
});
useEffect(() => {
if (!workspace) return;
try {
localStorage.setItem(key, JSON.stringify(filterState));
} catch {
/* Storage may be disabled. */
}
}, [key, workspace, filterState]);
const update = (value: Partial<FilterState>) =>
setFilters((current) => ({ ...current, ...value }));
const clearLinkedFilter = (name: string) => {
if (!searchParams.has(name)) return;
const next = new URLSearchParams(searchParams);
next.delete(name);
setSearchParams(next, { replace: true });
};
const resetFilters = () => {
setSelectedBots([]);
setSelectedPipelines([]);
setTimeRange('last24Hours');
setCustomDateRange(null);
setFilters(defaults());
const next = new URLSearchParams(searchParams);
next.delete('botId');
next.delete('pipelineId');
setSearchParams(next, { replace: true });
};
// Get the current filter state
const filterState: FilterState = {
selectedBots,
selectedPipelines,
timeRange,
customDateRange,
};
return {
selectedBots,
setSelectedBots,
selectedPipelines,
setSelectedPipelines,
timeRange,
setTimeRange,
customDateRange,
setCustomDateRange,
getActiveDateRange,
resetFilters,
...filterState,
filterState,
setSelectedBots: (selectedBots: string[]) => {
update({ selectedBots });
clearLinkedFilter('botId');
},
setSelectedPipelines: (selectedPipelines: string[]) => {
update({ selectedPipelines });
clearLinkedFilter('pipelineId');
},
setTimeRange: (timeRange: TimeRangeOption) => update({ timeRange }),
setCustomDateRange: (customDateRange: DateRange | null) =>
update({ customDateRange }),
setExecutionMode: (mode: ExecutionModeFilter) => update({ mode }),
setExecutionStatus: (statusGroup: string) => update({ statusGroup }),
getActiveDateRange: () =>
filterState.timeRange === 'custom'
? filterState.customDateRange
: getPresetDateRange(filterState.timeRange),
resetFilters,
};
}
+67 -134
View File
@@ -16,7 +16,7 @@ import {
ChevronDown,
ExternalLink,
RefreshCw,
MessageSquare,
RotateCcw,
Sparkles,
CheckCircle2,
Activity,
@@ -30,20 +30,14 @@ import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData';
import { useFeedbackData } from './hooks/useFeedbackData';
import { ConversationTurnList } from './components/ConversationTurnList';
import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import ExecutionOverviewCards from './components/overview-cards/ExecutionOverviewCards';
import ExecutionTable from './components/executions/ExecutionTable';
import ExecutionDetailSheet from './components/executions/ExecutionDetailSheet';
import ExecutionDetailSheet, {
type ExecutionSelection,
} from './components/executions/ExecutionDetailSheet';
import { useExecutions } from './hooks/useExecutions';
import type {
ExecutionModeFilter,
ExecutionRow,
ExecutionSourceFilter,
} from '@/app/infra/entities/api/monitoring-executions';
import { buildConversationTurns } from './utils/conversationTurns';
import { resolveCallTarget } from './utils/callLinks';
import { cn } from '@/lib/utils';
import { resolveMonitoringWindow } from './utils/dateUtils';
import { LoadingPage } from '@/components/ui/loading-spinner';
@@ -54,20 +48,25 @@ function MonitoringPageContent() {
const currentWorkspace = useCurrentWorkspace();
const canExport =
currentWorkspace?.permissions.includes('data.export') ?? false;
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
useMonitoringFilters();
const { data, loading, error, refetch } = useMonitoringData(filterState);
// Unified execution view (agent runs + pipeline queries). Its filters are
// independent of the legacy message/model tabs.
const [executionSource, setExecutionSource] =
useState<ExecutionSourceFilter>('all');
const [executionMode, setExecutionMode] =
useState<ExecutionModeFilter>('all');
const [executionStatus, setExecutionStatus] = useState<string>('all');
const {
filterState,
setSelectedBots,
setSelectedPipelines,
setTimeRange,
setExecutionMode,
setExecutionStatus,
resetFilters,
} = useMonitoringFilters();
const executionMode = filterState.mode ?? 'all';
const executionStatus = filterState.statusGroup ?? 'all';
const { data, loading, error, refetch } = useMonitoringData({
...filterState,
mode: executionMode,
statusGroup: executionStatus,
});
const [executionOffset, setExecutionOffset] = useState(0);
const [selectedExecution, setSelectedExecution] =
useState<ExecutionRow | null>(null);
useState<ExecutionSelection | null>(null);
const EXECUTIONS_PAGE_SIZE = 50;
const {
@@ -79,7 +78,7 @@ function MonitoringPageContent() {
selectedBots: filterState.selectedBots,
selectedPipelines: filterState.selectedPipelines,
selectedAgents: [],
source: executionSource,
source: 'all',
mode: executionMode,
statusGroup: executionStatus,
timeRange: filterState.timeRange,
@@ -92,7 +91,6 @@ function MonitoringPageContent() {
useEffect(() => {
setExecutionOffset(0);
}, [
executionSource,
executionMode,
executionStatus,
filterState.selectedBots,
@@ -121,6 +119,8 @@ function MonitoringPageContent() {
stats: feedbackStats,
loading: feedbackLoading,
} = useFeedbackData({
mode: executionMode,
statusGroup: executionStatus,
botIds:
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -141,44 +141,16 @@ function MonitoringPageContent() {
setFeedbackRefreshKey((k) => k + 1);
}, [refetch, refetchExecutions]);
const conversationTurns = useMemo(
() =>
buildConversationTurns(
data?.messages || [],
data?.llmCalls || [],
data?.errors || [],
data?.toolCalls || [],
),
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
);
// State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
// State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('executions');
// Ids the message list actually holds: a call may name a record that the
// current window does not contain, and a dead link is worse than no link.
const messageIds = useMemo(
() => new Set(data?.messages.map((message) => message.id) ?? []),
[data?.messages],
);
// Function to jump to a message record
const jumpToMessage = (messageId: string) => {
setActiveTab('messages');
setTimeout(() => {
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
setExpandedTurnId(turn?.id ?? messageId);
}, 100);
};
const toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
// Resolve the owning record server-side, including records outside this page.
const jumpToMessage = (identifier: string) => {
setActiveTab('executions');
setSelectedExecution({ source: 'auto', id: identifier });
};
const toggleErrorExpand = (errorId: string) => {
@@ -194,7 +166,7 @@ function MonitoringPageContent() {
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<Card className="flex flex-col gap-3 p-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<Card className="flex flex-col gap-4 p-4 xl:flex-row xl:items-end">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
@@ -202,20 +174,22 @@ function MonitoringPageContent() {
onBotsChange={setSelectedBots}
onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange}
source={executionSource}
onSourceChange={setExecutionSource}
mode={executionMode}
onModeChange={setExecutionMode}
statusGroup={executionStatus}
onStatusGroupChange={setExecutionStatus}
/>
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-3 xl:border-l xl:border-t-0 xl:pl-4 xl:pt-0 [&_button]:h-9">
<Button variant="outline" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('monitoring.filters.reset')}
</Button>
{canExport && <ExportDropdown filterState={filterState} />}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
className="flex-1 shadow-sm sm:flex-shrink-0 sm:flex-none"
className="shadow-sm"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
@@ -258,14 +232,6 @@ function MonitoringPageContent() {
className="text-sm text-muted-foreground space-y-1"
role="status"
>
{data.totalCount.messages > data.messages.length && (
<p>
{t('monitoring.partialMessages', {
shown: data.messages.length,
total: data.totalCount.messages,
})}
</p>
)}
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
data.modelCalls.length && (
<p>
@@ -308,9 +274,7 @@ function MonitoringPageContent() {
<TabsTrigger value="executions" className="px-3 py-2 sm:px-6">
{t('monitoring.execution.tabs.executions')}
</TabsTrigger>
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.messages')}
</TabsTrigger>
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
@@ -413,30 +377,6 @@ function MonitoringPageContent() {
)}
</TabsContent>
<TabsContent
value="messages"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<div>
{loading && <TabState loading rows={6} />}
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<TabState
icon={<MessageSquare className="h-12 w-12" />}
title={t('monitoring.messageList.noMessages')}
/>
)}
</div>
</TabsContent>
<TabsContent
value="modelCalls"
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
@@ -453,19 +393,9 @@ function MonitoringPageContent() {
// A recorded call names the record it belongs to, so
// the whole card opens it; without a match the card
// stays static instead of pretending to be a link.
const target = resolveCallTarget(
call,
executionResult?.items ?? [],
messageIds,
);
const target = call.messageId;
const openTarget = () => {
if (!target) return;
if (target.kind === 'execution') {
setActiveTab('executions');
setSelectedExecution(target.row);
return;
}
jumpToMessage(target.id);
if (target) jumpToMessage(target);
};
return (
<Card
@@ -504,31 +434,30 @@ function MonitoringPageContent() {
{/* The stored id is the owning record: a
Pipeline's message, or an Agent run's id
that the card itself opens. */}
{call.messageId &&
messageIds.has(call.messageId) && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(event) => {
// The card itself opens the call's
// record; this button is the more
// specific message target.
event.stopPropagation();
jumpToMessage(call.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
</div>
)}
{call.messageId && (
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(event) => {
// The card itself opens the call's
// record; this button is the more
// specific message target.
event.stopPropagation();
jumpToMessage(call.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
</div>
)}
<div className="flex items-center gap-2 mb-2">
{/* Model Type Badge */}
<Badge
@@ -715,6 +644,8 @@ function MonitoringPageContent() {
className="m-0 min-h-0 flex-1 overflow-y-auto p-3 sm:p-6"
>
<TokenMonitoring
mode={executionMode}
statusGroup={executionStatus}
botIds={
filterState.selectedBots.length > 0
? filterState.selectedBots
@@ -934,6 +865,7 @@ function MonitoringPageContent() {
{/* Execution trace drawer */}
<ExecutionDetailSheet
row={selectedExecution}
onSelect={setSelectedExecution}
onClose={() => setSelectedExecution(null)}
/>
</div>
@@ -941,9 +873,10 @@ function MonitoringPageContent() {
}
export default function MonitoringPage() {
const workspace = useCurrentWorkspace()?.workspace.uuid;
return (
<Suspense fallback={<LoadingPage />}>
<MonitoringPageContent />
<MonitoringPageContent key={workspace ?? 'pending'} />
</Suspense>
);
}
@@ -164,6 +164,8 @@ export interface OverviewMetrics {
}
export interface FilterState {
mode?: 'all' | 'real' | 'debug';
statusGroup?: string;
selectedBots: string[];
selectedPipelines: string[];
timeRange: TimeRangeOption;
@@ -1,6 +1,4 @@
import type { ProcessorRun, ProcessorRunEvent } from './index';
export type ExecutionSource = 'agent' | 'pipeline';
export type ExecutionSource = 'agent' | 'pipeline' | 'event';
export type ExecutionSourceFilter = 'all' | ExecutionSource;
export type ExecutionModeFilter = 'all' | 'real' | 'debug';
@@ -24,6 +22,9 @@ export interface ExecutionRow {
| 'ignored'
| string;
title: string;
input_preview?: string;
event_type?: string;
status_reason?: string | null;
target_kind: 'agent' | 'pipeline' | 'processor' | string;
target_id: string | null;
target_name: string | null;
@@ -117,20 +118,48 @@ export interface ExecutionCallRow {
[key: string]: unknown;
}
export interface AgentExecutionDetail {
source: 'agent';
run: ProcessorRun;
events: ProcessorRunEvent[];
export type ExecutionSection =
| 'inputs'
| 'outputs'
| 'deliveries'
| 'conversation'
| 'related'
| 'events'
| 'llm_calls'
| 'tool_calls'
| 'errors';
export interface ExecutionDetailItem {
id: string | number;
timestamp_ms?: number | null;
role?: string;
origin?: string;
event_type?: string;
actor_name?: string;
actor_id?: string;
content?: unknown;
attachments?: unknown[];
status?: string;
type?: string;
sequence?: number;
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
delivery?: Record<string, unknown>;
[key: string]: unknown;
}
export interface ExecutionDetailPage {
items: ExecutionDetailItem[];
has_more: boolean;
next_cursor: number | null;
next_offset: number;
}
export interface PipelineExecutionDetail {
source: 'pipeline';
message: PipelineMessageRow;
llm_calls: ExecutionCallRow[];
tool_calls: ExecutionCallRow[];
errors: ExecutionCallRow[];
export interface ExecutionDetail {
source: ExecutionSource;
row: ExecutionRow;
pages: Partial<Record<ExecutionSection, ExecutionDetailPage>>;
legacy_context: boolean;
}
export type ExecutionDetail = AgentExecutionDetail | PipelineExecutionDetail;
export type AgentExecutionDetail = ExecutionDetail & { source: 'agent' };
export type PipelineExecutionDetail = ExecutionDetail & { source: 'pipeline' };
+14 -2
View File
@@ -1980,6 +1980,8 @@ export class BackendClient extends BaseHttpClient {
// ============ Monitoring API ============
public getMonitoringData(params: {
mode?: ExecutionModeFilter;
status?: string[];
botId?: string[];
pipelineId?: string[];
startTime?: string;
@@ -2103,6 +2105,8 @@ export class BackendClient extends BaseHttpClient {
};
}> {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
params.status?.forEach((status) => queryParams.append('status', status));
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
@@ -2170,11 +2174,15 @@ export class BackendClient extends BaseHttpClient {
}
public getExecutionDetail(
source: ExecutionSource,
source: ExecutionSource | 'auto',
executionId: string,
options: { section?: string; offset?: number; limit?: number } = {},
): Promise<ExecutionDetail> {
const params = new URLSearchParams(
Object.entries(options).map(([key, value]) => [key, String(value)]),
);
return this.get(
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}`,
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}?${params}`,
);
}
@@ -2207,6 +2215,8 @@ export class BackendClient extends BaseHttpClient {
}
public getTokenStatistics(params: {
mode?: ExecutionModeFilter;
status?: string[];
botId?: string[];
pipelineId?: string[];
startTime?: string;
@@ -2247,6 +2257,8 @@ export class BackendClient extends BaseHttpClient {
bucket: string;
}> {
const queryParams = new URLSearchParams();
if (params.mode) queryParams.append('mode', params.mode);
params.status?.forEach((status) => queryParams.append('status', status));
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
+36 -5
View File
@@ -2456,6 +2456,30 @@ const enUS = {
},
},
monitoring: {
unified: {
inputs: 'Event / input',
outputs: 'Generated results',
deliveries: 'Delivery records',
conversation: 'Conversation context',
related: 'Related executions',
events: 'Execution events',
llm_calls: 'Model calls',
tool_calls: 'Tool calls',
errors: 'Errors',
payload: 'Details',
metadata: 'Technical details',
loadMore: 'Load more',
noRecords: 'No records',
contextHint:
'Conversation context is not necessarily part of this execution.',
unhandled: 'No processor run',
roles: {
user: 'User',
assistant: 'Assistant',
system: 'System',
tool: 'Tool',
},
},
execution: {
title: 'Executions',
subtitle:
@@ -2499,24 +2523,28 @@ const enUS = {
loading: 'Loading executions...',
triggeredBy: 'Triggered by {{trigger}}',
cards: {
latencyHint:
'P50/P95: about 50%/95% of runs finish within this time. Based on recent completed Agent runs, excluding queue time.',
tokensHint:
'Only recorded usage is included. Totals may be incomplete when models do not return usage.',
totalExecutions: 'Executions',
bySource: '{{agent}} agent · {{pipeline}} pipeline',
successRate: 'Execution Success Rate',
successRateHint:
'Completed ÷ (completed + failed). Ignored events and in-flight runs are excluded from the denominator.',
denominator: '{{completed}} of {{denominator}} settled',
'Successes ÷ (successes + failures). In-progress, ignored and cancelled runs are excluded.',
denominator: '{{completed}} succeeded out of {{denominator}} finished',
running: 'Running',
queued: 'Queued',
waiting: 'Waiting input',
failed: 'Failed',
ignored: 'Ignored',
latency: 'Duration P50 / P95',
latency: 'Execution time',
p50: 'P50',
p95: 'P95',
sample: 'sample of {{count}} runs',
sample: 'Based on {{count}} runs',
tokens: 'Tokens',
coverage: 'usage on {{withUsage}}/{{calls}} calls',
hiddenDebug: '{{count}} debug runs hidden',
debugRuns: 'Includes {{count}} debug runs',
},
detail: {
title: 'Execution',
@@ -2553,6 +2581,9 @@ const enUS = {
pipeline: 'Pipeline',
allBots: 'All Bots',
selectBot: 'Select Bot',
processor: 'Processor',
allProcessors: 'All Processors',
selectProcessor: 'Select Processor',
allPipelines: 'All Pipelines',
selectPipeline: 'Select Pipeline',
loading: 'Loading...',
+34 -3
View File
@@ -2508,6 +2508,30 @@ const esES = {
},
},
monitoring: {
unified: {
inputs: 'Evento y entrada',
outputs: 'Resultados generados',
deliveries: 'Registros de envío',
conversation: 'Contexto de conversación',
related: 'Ejecuciones relacionadas',
events: 'Eventos de ejecución',
llm_calls: 'Llamadas al modelo',
tool_calls: 'Llamadas a herramientas',
errors: 'Errores',
payload: 'Detalles',
metadata: 'Información técnica',
loadMore: 'Cargar más',
noRecords: 'Sin registros',
contextHint:
'Otros mensajes no pertenecen necesariamente a esta ejecución.',
unhandled: 'Sin ejecución',
roles: {
user: 'Usuario',
assistant: 'Asistente',
system: 'Sistema',
tool: 'Herramienta',
},
},
execution: {
title: 'Ejecuciones',
subtitle:
@@ -2551,24 +2575,28 @@ const esES = {
loading: 'Cargando ejecuciones...',
triggeredBy: 'Activada por {{trigger}}',
cards: {
latencyHint:
'P50/P95: cerca del 50%/95% termina dentro de este tiempo. Solo ejecuciones recientes de Agent completadas, sin espera en cola.',
tokensHint:
'Solo se suma el uso registrado. El total puede estar incompleto si el modelo no devuelve el uso.',
totalExecutions: 'Ejecuciones',
bySource: '{{agent}} de agentes · {{pipeline}} de pipelines',
successRate: 'Tasa de éxito de ejecución',
successRateHint:
'Completadas ÷ (completadas + fallidas). Se excluyen del denominador los eventos ignorados y las ejecuciones en curso.',
'Éxitos ÷ (éxitos + fallos). Se excluyen ejecuciones en curso, ignoradas y canceladas.',
denominator: '{{completed}} de {{denominator}} finalizadas',
running: 'En curso',
queued: 'En cola',
waiting: 'Esperando entrada',
failed: 'Fallida',
ignored: 'Ignorada',
latency: 'Duración P50 / P95',
latency: 'Tiempo de ejecución',
p50: 'P50',
p95: 'P95',
sample: 'muestra de {{count}} ejecuciones',
tokens: 'Tokens',
coverage: 'uso registrado en {{withUsage}}/{{calls}} llamadas',
hiddenDebug: '{{count}} ejecuciones de depuración ocultas',
debugRuns: 'Incluye {{count}} ejecuciones de depuración',
},
detail: {
title: 'Ejecución',
@@ -2606,6 +2634,9 @@ const esES = {
pipeline: 'Pipeline',
allBots: 'Todos los Bots',
selectBot: 'Seleccionar Bot',
processor: 'Procesador',
allProcessors: 'Todos los procesadores',
selectProcessor: 'Seleccionar procesador',
allPipelines: 'Todos los Pipelines',
selectPipeline: 'Seleccionar Pipeline',
loading: 'Cargando...',
+33 -3
View File
@@ -2472,6 +2472,29 @@ const jaJP = {
},
},
monitoring: {
unified: {
inputs: 'イベントと入力',
outputs: '生成結果',
deliveries: '送信記録',
conversation: '会話の前後関係',
related: '関連する実行',
events: '実行イベント',
llm_calls: 'モデル呼び出し',
tool_calls: 'ツール呼び出し',
errors: 'エラー',
payload: '詳細',
metadata: '技術情報',
loadMore: 'さらに読み込む',
noRecords: '記録なし',
contextHint: '会話内の他のメッセージがこの実行に属するとは限りません。',
unhandled: '処理なし',
roles: {
user: 'ユーザー',
assistant: 'アシスタント',
system: 'システム',
tool: 'ツール',
},
},
execution: {
title: '実行履歴',
subtitle:
@@ -2515,24 +2538,28 @@ const jaJP = {
loading: '実行履歴を読み込み中...',
triggeredBy: '{{trigger}} により開始',
cards: {
latencyHint:
'P50/P95:実行の約 50%/95% がこの時間内に完了します。最近の完了済み Agent 実行が対象で、キュー待ちは除きます。',
tokensHint:
'記録済みの使用量のみ集計します。モデルが使用量を返さない場合、合計は不完全になります。',
totalExecutions: '実行履歴',
bySource: 'エージェント {{agent}} 件 · パイプライン {{pipeline}} 件',
successRate: '実行成功率',
successRateHint:
'完了 ÷(完了+失敗)。無視されたイベントと実行中の処理は分母に含まれません。',
'成功数 ÷(成功数 + 失敗数)。実行中・無視・キャンセルは除きます。',
denominator: '終了済み {{denominator}} 件中 {{completed}} 件が完了',
running: '実行中',
queued: '待機中',
waiting: '入力待ち',
failed: '失敗',
ignored: '無視',
latency: '所要時間 P50 / P95',
latency: '実行時間',
p50: 'P50',
p95: 'P95',
sample: '{{count}} 件の実行を集計',
tokens: 'トークン',
coverage: '{{calls}} 回中 {{withUsage}} 回で使用量を取得',
hiddenDebug: 'デバッグ実行 {{count}} 件を非表示',
debugRuns: 'うちデバッグ実行 {{count}} 件',
},
detail: {
title: '実行',
@@ -2570,6 +2597,9 @@ const jaJP = {
pipeline: 'パイプライン',
allBots: 'すべてのボット',
selectBot: 'ボットを選択',
processor: 'プロセッサー',
allProcessors: 'すべてのプロセッサー',
selectProcessor: 'プロセッサーを選択',
allPipelines: 'すべてのパイプライン',
selectPipeline: 'パイプラインを選択',
loading: '読み込み中...',
+33 -3
View File
@@ -2480,6 +2480,29 @@ const ruRU = {
},
},
monitoring: {
unified: {
inputs: 'Событие и ввод',
outputs: 'Сгенерированные результаты',
deliveries: 'Записи отправки',
conversation: 'Контекст диалога',
related: 'Связанные запуски',
events: 'События выполнения',
llm_calls: 'Вызовы модели',
tool_calls: 'Вызовы инструментов',
errors: 'Ошибки',
payload: 'Подробности',
metadata: 'Технические сведения',
loadMore: 'Загрузить ещё',
noRecords: 'Нет записей',
contextHint: 'Другие сообщения могут не относиться к этому запуску.',
unhandled: 'Обработчик не запущен',
roles: {
user: 'Пользователь',
assistant: 'Ассистент',
system: 'Система',
tool: 'Инструмент',
},
},
execution: {
title: 'Выполнения',
subtitle:
@@ -2523,24 +2546,28 @@ const ruRU = {
loading: 'Загрузка выполнений...',
triggeredBy: 'Запущено: {{trigger}}',
cards: {
latencyHint:
'P50/P95: около 50%/95% запусков завершаются за это время. Только недавние завершённые запуски Agent, без ожидания в очереди.',
tokensHint:
'Учитывается только записанный расход. Итог может быть неполным, если модель не возвращает расход.',
totalExecutions: 'Выполнения',
bySource: 'Агенты: {{agent}} · Конвейеры: {{pipeline}}',
successRate: 'Доля успешных выполнений',
successRateHint:
'Завершённые ÷ (завершённые + неудачные). Пропущенные события и незавершённые выполнения не входят в знаменатель.',
'Успешные ÷ (успешные + неудачные). Текущие, пропущенные и отменённые запуски исключены.',
denominator: 'Успешно: {{completed}} из {{denominator}} завершившихся',
running: 'Выполняется',
queued: 'В очереди',
waiting: 'Ожидание ввода',
failed: 'Ошибка',
ignored: 'Пропущено',
latency: 'Длительность P50 / P95',
latency: 'Время выполнения',
p50: 'P50',
p95: 'P95',
sample: 'выборка: {{count}} выполнений',
tokens: 'Токены',
coverage: 'расход указан для {{withUsage}}/{{calls}} вызовов',
hiddenDebug: 'Скрыто отладочных выполнений: {{count}}',
debugRuns: 'В том числе отладочных выполнений: {{count}}',
},
detail: {
title: 'Выполнение',
@@ -2578,6 +2605,9 @@ const ruRU = {
pipeline: 'Конвейер',
allBots: 'Все боты',
selectBot: 'Выберите бота',
processor: 'Обработчик',
allProcessors: 'Все обработчики',
selectProcessor: 'Выберите обработчик',
allPipelines: 'Все конвейеры',
selectPipeline: 'Выберите конвейер',
loading: 'Загрузка...',
+33 -3
View File
@@ -2407,6 +2407,29 @@ const thTH = {
},
},
monitoring: {
unified: {
inputs: 'เหตุการณ์และข้อมูลเข้า',
outputs: 'ผลลัพธ์ที่สร้าง',
deliveries: 'บันทึกการส่ง',
conversation: 'บริบทการสนทนา',
related: 'การทำงานที่เกี่ยวข้อง',
events: 'เหตุการณ์การทำงาน',
llm_calls: 'การเรียกโมเดล',
tool_calls: 'การเรียกเครื่องมือ',
errors: 'ข้อผิดพลาด',
payload: 'รายละเอียด',
metadata: 'ข้อมูลทางเทคนิค',
loadMore: 'โหลดเพิ่มเติม',
noRecords: 'ไม่มีบันทึก',
contextHint: 'ข้อความอื่นอาจไม่ใช่ส่วนหนึ่งของการทำงานนี้',
unhandled: 'ไม่ได้เริ่มตัวประมวลผล',
roles: {
user: 'ผู้ใช้',
assistant: 'ผู้ช่วย',
system: 'ระบบ',
tool: 'เครื่องมือ',
},
},
execution: {
title: 'การทำงาน',
subtitle:
@@ -2450,11 +2473,15 @@ const thTH = {
loading: 'กำลังโหลดการทำงาน...',
triggeredBy: 'เรียกใช้โดย {{trigger}}',
cards: {
latencyHint:
'P50/P95: ประมาณ 50%/95% ทำงานเสร็จภายในเวลานี้ ใช้เฉพาะ Agent ที่เสร็จล่าสุด ไม่รวมเวลารอคิว',
tokensHint:
'รวมเฉพาะการใช้ที่บันทึกไว้ ผลรวมอาจไม่ครบหากโมเดลไม่ส่งข้อมูลการใช้กลับมา',
totalExecutions: 'การทำงาน',
bySource: 'เอเจนต์ {{agent}} · ไปป์ไลน์ {{pipeline}}',
successRate: 'อัตราการทำงานสำเร็จ',
successRateHint:
'เสร็จสิ้น ÷ (เสร็จสิ้น + ล้มเหลว) ไม่นับเหตุการณ์ที่ละเว้นและการทำงานที่ยังไม่สิ้นสุดในตัวหาร',
'สำเร็จ ÷ (สำเร็จ + ล้มเหลว) ไม่รวมงานที่กำลังทำงาน ถูกละเว้น หรือยกเลิก',
denominator:
'สำเร็จ {{completed}} จาก {{denominator}} รายการที่สิ้นสุดแล้ว',
running: 'กำลังทำงาน',
@@ -2462,13 +2489,13 @@ const thTH = {
waiting: 'รอการป้อนข้อมูล',
failed: 'ล้มเหลว',
ignored: 'ละเว้นแล้ว',
latency: 'ระยะเวลา P50 / P95',
latency: 'เวลาทำงาน',
p50: 'P50',
p95: 'P95',
sample: 'ตัวอย่างการทำงาน {{count}} ครั้ง',
tokens: 'โทเคน',
coverage: 'มีข้อมูลการใช้งาน {{withUsage}}/{{calls}} ครั้ง',
hiddenDebug: 'ซ่อนการทำงานดีบัก {{count}} ครั้ง',
debugRuns: 'รวมการทำงานดีบัก {{count}} ครั้ง',
},
detail: {
title: 'การทำงาน',
@@ -2505,6 +2532,9 @@ const thTH = {
pipeline: 'Pipeline',
allBots: 'Bot ทั้งหมด',
selectBot: 'เลือก Bot',
processor: 'ตัวประมวลผล',
allProcessors: 'ตัวประมวลผลทั้งหมด',
selectProcessor: 'เลือกตัวประมวลผล',
allPipelines: 'Pipeline ทั้งหมด',
selectPipeline: 'เลือก Pipeline',
loading: 'กำลังโหลด...',
+33 -3
View File
@@ -2442,6 +2442,29 @@ const viVN = {
},
},
monitoring: {
unified: {
inputs: 'Sự kiện và đầu vào',
outputs: 'Kết quả tạo ra',
deliveries: 'Bản ghi gửi',
conversation: 'Ngữ cảnh hội thoại',
related: 'Lượt chạy liên quan',
events: 'Sự kiện thực thi',
llm_calls: 'Lần gọi mô hình',
tool_calls: 'Lần gọi công cụ',
errors: 'Lỗi',
payload: 'Chi tiết',
metadata: 'Thông tin kỹ thuật',
loadMore: 'Tải thêm',
noRecords: 'Chưa có bản ghi',
contextHint: 'Các tin nhắn khác không nhất thiết thuộc lượt chạy này.',
unhandled: 'Chưa chạy bộ xử lý',
roles: {
user: 'Người dùng',
assistant: 'Trợ lý',
system: 'Hệ thống',
tool: 'Công cụ',
},
},
execution: {
title: 'Lượt thực thi',
subtitle:
@@ -2485,11 +2508,15 @@ const viVN = {
loading: 'Đang tải lượt thực thi...',
triggeredBy: 'Được kích hoạt bởi {{trigger}}',
cards: {
latencyHint:
'P50/P95: khoảng 50%/95% lượt chạy hoàn tất trong thời gian này. Chỉ tính Agent đã hoàn tất gần đây, không gồm thời gian xếp hàng.',
tokensHint:
'Chỉ cộng mức dùng đã ghi. Tổng có thể thiếu nếu mô hình không trả về dữ liệu sử dụng.',
totalExecutions: 'Lượt thực thi',
bySource: 'Tác nhân {{agent}} · Pipeline {{pipeline}}',
successRate: 'Tỷ lệ thực thi thành công',
successRateHint:
'Hoàn thành ÷ (hoàn thành + thất bại). Không tính sự kiện bị bỏ qua và lượt chưa kết thúc vào mẫu số.',
'Thành công ÷ (thành công + thất bại). Không tính lượt đang chạy, bỏ qua hoặc đã hủy.',
denominator:
'Thành công {{completed}} trong {{denominator}} lượt đã kết thúc',
running: 'Đang chạy',
@@ -2497,13 +2524,13 @@ const viVN = {
waiting: 'Đang chờ nhập liệu',
failed: 'Thất bại',
ignored: 'Đã bỏ qua',
latency: 'Thời lượng P50 / P95',
latency: 'Thời gian thực thi',
p50: 'P50',
p95: 'P95',
sample: 'Mẫu gồm {{count}} lượt thực thi',
tokens: 'Token',
coverage: 'Có dữ liệu sử dụng cho {{withUsage}}/{{calls}} lệnh gọi',
hiddenDebug: 'Đã ẩn {{count}} lượt gỡ lỗi',
debugRuns: 'Bao gồm {{count}} lượt gỡ lỗi',
},
detail: {
title: 'Lượt thực thi',
@@ -2540,6 +2567,9 @@ const viVN = {
pipeline: 'Pipeline',
allBots: 'Tất cả Bot',
selectBot: 'Chọn Bot',
processor: 'Bộ xử lý',
allProcessors: 'Tất cả bộ xử lý',
selectProcessor: 'Chọn bộ xử lý',
allPipelines: 'Tất cả Pipeline',
selectPipeline: 'Chọn Pipeline',
loading: 'Đang tải...',
+36 -6
View File
@@ -2308,6 +2308,29 @@ const zhHans = {
},
},
monitoring: {
unified: {
inputs: '事件与输入',
outputs: '生成结果',
deliveries: '发送记录',
conversation: '会话上下文',
related: '关联执行',
events: '执行事件',
llm_calls: '模型调用',
tool_calls: '工具调用',
errors: '错误',
payload: '查看详情',
metadata: '技术信息',
loadMore: '加载更多',
noRecords: '暂无记录',
contextHint: '会话中的其他消息不一定属于本次执行。',
unhandled: '未启动处理器',
roles: {
user: '用户',
assistant: '助手',
system: '系统',
tool: '工具',
},
},
execution: {
title: '执行记录',
subtitle: '每条入站事件及其触发的执行,覆盖 Agent 与 Pipeline。',
@@ -2350,24 +2373,28 @@ const zhHans = {
loading: '加载执行记录中...',
triggeredBy: '触发:{{trigger}}',
cards: {
latencyHint:
'P50/P95:约 50%/95% 的执行能在此时间内完成。仅统计最近已完成的 Agent 执行,不含排队时间。',
tokensHint:
'仅汇总已记录的用量;部分模型未返回用量时,总数可能不完整。',
totalExecutions: '执行总数',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '执行成功率',
successRateHint:
'已完成 ÷(已完成 + 失败)。已忽略事件与执行中的任务不计入分母。',
denominator: '{{completed}} / {{denominator}} 已结束',
'成功次数 ÷(成功 + 失败)。不计进行中、已忽略和已取消的执行。',
denominator: '{{denominator}} 次已结束,{{completed}} 次成功',
running: '运行中',
queued: '排队中',
waiting: '等待交互',
waiting: '等待输入',
failed: '失败',
ignored: '已忽略',
latency: '耗时 P50 / P95',
latency: '执行耗时',
p50: 'P50',
p95: 'P95',
sample: '样本 {{count}} 次',
sample: '根据 {{count}} 次执行计算',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次调用有用量',
hiddenDebug: '已隐藏 {{count}} 条调试运行',
debugRuns: '其中 {{count}} 条为调试执行',
},
detail: {
title: '执行详情',
@@ -2404,6 +2431,9 @@ const zhHans = {
pipeline: '流水线',
allBots: '全部机器人',
selectBot: '选择机器人',
processor: '处理器',
allProcessors: '全部处理器',
selectProcessor: '选择处理器',
allPipelines: '全部流水线',
selectPipeline: '选择流水线',
loading: '加载中...',
+33 -3
View File
@@ -2309,6 +2309,29 @@ const zhHant = {
},
},
monitoring: {
unified: {
inputs: '事件與輸入',
outputs: '生成結果',
deliveries: '傳送記錄',
conversation: '對話上下文',
related: '關聯執行',
events: '執行事件',
llm_calls: '模型呼叫',
tool_calls: '工具呼叫',
errors: '錯誤',
payload: '查看詳情',
metadata: '技術資訊',
loadMore: '載入更多',
noRecords: '暫無記錄',
contextHint: '對話中的其他訊息不一定屬於本次執行。',
unhandled: '未啟動處理器',
roles: {
user: '使用者',
assistant: '助手',
system: '系統',
tool: '工具',
},
},
execution: {
title: '執行記錄',
subtitle: '每條入站事件及其觸發的執行,涵蓋 Agent 與 Pipeline。',
@@ -2351,24 +2374,28 @@ const zhHant = {
triggeredBy: '觸發:{{trigger}}',
loading: '載入執行記錄中...',
cards: {
latencyHint:
'P50/P95:約 50%/95% 的執行能在此時間內完成。僅統計最近已完成的 Agent 執行,不含排隊時間。',
tokensHint:
'僅加總已記錄的用量;部分模型未回傳用量時,總數可能不完整。',
totalExecutions: '執行總數',
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
successRate: '執行成功率',
successRateHint:
'已完成 ÷(已完成 + 失敗)。已忽略事件與執行中的任務不計入分母。',
'成功次數 ÷(成功 + 失敗)。不計進行中、已忽略及已取消的執行。',
denominator: '{{completed}} / {{denominator}} 已結束',
running: '執行中',
queued: '排隊中',
waiting: '等待互動',
failed: '失敗',
ignored: '已忽略',
latency: '耗時 P50 / P95',
latency: '執行耗時',
p50: 'P50',
p95: 'P95',
sample: '樣本 {{count}} 次',
tokens: 'Token',
coverage: '{{withUsage}}/{{calls}} 次呼叫有用量',
hiddenDebug: '已隱藏 {{count}} 筆偵錯執行',
debugRuns: '其中 {{count}} 筆為偵錯執行',
},
detail: {
title: '執行詳情',
@@ -2405,6 +2432,9 @@ const zhHant = {
pipeline: '流水線',
allBots: '全部機器人',
selectBot: '選擇機器人',
processor: '處理器',
allProcessors: '全部處理器',
selectProcessor: '選擇處理器',
allPipelines: '全部流水線',
selectPipeline: '選擇流水線',
loading: '載入中...',
+55 -25
View File
@@ -269,7 +269,7 @@ function executionText(raw: unknown): string {
}
/** Derive the unified execution list from a mocked monitoring payload. */
function buildExecutionList(monitoringData: unknown) {
export function buildExecutionList(monitoringData: unknown) {
const payload = monitoringData as
| { messages?: RawMonitoringMessage[] }
| undefined;
@@ -284,6 +284,7 @@ function buildExecutionList(monitoringData: unknown) {
status,
status_group: executionStatusGroup(status, 'pipeline'),
title: executionText(message.message_content),
input_preview: executionText(message.message_content),
target_kind: 'pipeline' as const,
target_id: (message.pipeline_id as string) ?? null,
target_name: (message.pipeline_name as string) ?? null,
@@ -1289,39 +1290,68 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
}
const executionDetail = path.match(
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline)\/(.+)$/,
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline|event|auto)\/(.+)$/,
);
if (executionDetail) {
const [, source, executionId] = executionDetail;
if (source === 'agent') {
return fulfillJson(route, {
source: 'agent',
run: { run_id: executionId, status: 'completed', created_at: 0 },
events: [],
has_more: false,
next_cursor: null,
});
}
const [, , executionId] = executionDetail;
const payload = state.monitoringData as {
messages?: RawMonitoringMessage[];
llmCalls?: RawMonitoringMessage[];
toolCalls?: RawMonitoringMessage[];
errors?: RawMonitoringMessage[];
};
const relatedToExecution = (rows: RawMonitoringMessage[] | undefined) =>
(rows ?? []).filter((row) => row.message_id === executionId);
const row = buildExecutionList(payload).items.find(
(item) => item.id === executionId,
);
if (!row)
return route.fulfill({
status: 404,
json: { code: 404, msg: 'Execution not found' },
});
const root = (payload.messages ?? []).find(
(item) => item.id === executionId,
)!;
const content = (item: RawMonitoringMessage) => ({
...item,
content: item.message_content,
timestamp_ms: Date.parse(String(item.timestamp)),
origin: item.role === 'assistant' ? 'delivery' : 'input',
});
const pages = Object.fromEntries(
Object.entries({
inputs: [content(root)],
outputs: [],
deliveries: (payload.messages ?? [])
.filter((item) => item.parent_message_id === executionId)
.map(content),
conversation: (payload.messages ?? [])
.filter(
(item) =>
item.session_id === root.session_id &&
item.bot_id === root.bot_id,
)
.map(content),
related: [],
events: [],
llm_calls: (payload.llmCalls ?? []).filter(
(item) => item.message_id === executionId,
),
tool_calls: (payload.toolCalls ?? []).filter(
(item) => item.message_id === executionId,
),
errors: (payload.errors ?? []).filter(
(item) => item.message_id === executionId,
),
}).map(([key, items]) => [
key,
{ items, next_offset: items.length, has_more: false },
]),
);
return fulfillJson(route, {
source: 'pipeline',
message: (payload.messages ?? []).find(
(row) => row.id === executionId,
) ?? {
id: executionId,
status: 'success',
message_content: '',
},
llm_calls: relatedToExecution(payload.llmCalls),
tool_calls: relatedToExecution(payload.toolCalls),
errors: relatedToExecution(payload.errors),
source: row.source,
row,
pages,
legacy_context: true,
});
}
+1 -1
View File
@@ -69,7 +69,7 @@ test.describe('authenticated app shell', () => {
).toBeVisible();
await expect(
page.getByRole('tab', { name: 'Message Records' }),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByRole('tab', { name: 'Token Monitoring' }),
).toBeVisible();
+462
View File
@@ -0,0 +1,462 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
buildExecutionList,
} from './fixtures/langbot-api';
const monitoring = {
messages: [
{
id: 'input-one',
role: 'user',
message_content: 'Open this execution',
timestamp: '2026-07-02T10:00:00Z',
bot_id: 'bot',
pipeline_id: 'pipe',
pipeline_name: 'Processor A',
},
],
};
const row = buildExecutionList(monitoring).items[0];
const pageOf = (
items: unknown[],
has_more = false,
next_offset = items.length,
) => ({ items, has_more, next_offset });
test('renders typed and custom events without duplicate text or empty attachment messages', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoring,
});
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
source: 'pipeline',
row,
pages: {
inputs: pageOf([
{
id: 'message',
content: {
text: '你好~',
contents: [{ type: 'text', text: '你好~', image_url: null }],
attachments: [],
},
},
{
id: 'member',
event_type: 'group.member_banned',
content: {
member: { id: 'member-42', nickname: 'Alice' },
group: { id: 'group-9' },
duration: 0,
permanent: false,
custom: { reason: 'Moderation reason' },
},
},
{
id: 'edited',
content: {
message_chain: [{ type: 'Plain', text: 'Typed chain text' }],
editor: { id: 'editor-7' },
new_content: [{ type: 'Plain', text: 'Updated text' }],
},
},
{
id: 'custom',
content: {
type: 'CustomEvent',
text: 'Distinct text',
content: 'Different content',
properties: { nested: ['custom-value'] },
},
},
{
id: 'same-again',
content: {
text: '你好~',
contents: [{ type: 'text', text: '你好~' }],
},
},
{
id: 'image',
content: {
contents: [
{
type: 'image_url',
image_url: {
url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
},
},
],
},
},
]),
outputs: pageOf([
{
id: 'assistant',
content: {
content: 'Agent answer',
tool_calls: [
{
id: 'tool-1',
function: {
name: 'event_reply',
arguments: '{"text":"sent"}',
},
},
],
},
attachments: [],
},
]),
deliveries: pageOf([]),
},
},
},
});
});
await page.goto('/home/monitoring');
await page
.getByRole('button')
.filter({ hasText: 'Open this execution' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('你好~', { exact: true })).toHaveCount(2);
await expect(sheet.getByText('[Empty message]', { exact: true })).toHaveCount(
0,
);
for (const text of [
'Agent answer',
'member-42',
'Alice',
'group-9',
'Moderation reason',
'0',
'false',
'Typed chain text',
'editor-7',
'Updated text',
'Distinct text',
'Different content',
'custom-value',
'event_reply',
]) {
await expect(sheet.getByText(text, { exact: true })).toBeVisible();
}
await expect(
sheet.getByRole('img', { name: 'Message attachment' }),
).toBeVisible();
});
test('pages a long trace without dropping inputs and opens related executions', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoring,
});
const requests: string[] = [];
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
const url = new URL(route.request().url());
requests.push(url.search);
const sibling = url.pathname.endsWith('/sibling');
const more = url.searchParams.get('section') === 'events';
await route.fulfill({
json: {
code: 0,
data: {
source: 'pipeline',
row: sibling
? { ...row, id: 'sibling', target_name: 'Processor B' }
: row,
pages: more
? {
events: pageOf(
[
{
id: 101,
sequence: 101,
type: 'run.completed',
data: { final: 'Last trace event' },
},
],
false,
101,
),
}
: {
inputs: pageOf([
{
id: 'in',
content: sibling ? 'Sibling input' : 'Original input',
},
]),
outputs: pageOf([
{ id: 'out', content: 'Generated but not sent' },
]),
deliveries: pageOf([]),
events: pageOf(
[{ id: 1, sequence: 1, type: 'run.started', data: {} }],
true,
100,
),
related: pageOf(
sibling
? []
: [{ ...row, id: 'sibling', target_name: 'Processor B' }],
),
},
},
},
});
});
await page.goto('/home/monitoring');
await page
.getByRole('button')
.filter({ hasText: 'Open this execution' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('Generated but not sent')).toBeVisible();
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
await sheet.getByText('Execution events (1+)', { exact: true }).click();
await sheet.getByRole('button', { name: 'Load more' }).click();
await expect(sheet.getByText('run.completed', { exact: true })).toBeVisible();
await expect(sheet.getByText('Original input')).toBeVisible();
expect(
requests.some(
(query) =>
query.includes('section=events') && query.includes('offset=100'),
),
).toBe(true);
await sheet.getByText('Related executions (1)', { exact: true }).click();
await sheet.getByRole('button', { name: /Processor B/ }).click();
await expect(sheet.getByText('Sibling input')).toBeVisible();
await expect(sheet.getByText('Original input')).toHaveCount(0);
});
test('shows non-message events without fabricating a reply or a processor', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const event = {
...row,
source: 'event',
id: 'event-only',
event_id: 'event-only',
target_id: null,
target_kind: 'event',
target_name: null,
title: 'group.member.joined',
input_preview: 'Member joined',
status_group: 'ignored',
status: 'ignored',
};
await page.route('**/api/v1/monitoring/executions?*', (route) =>
route.fulfill({
json: {
code: 0,
data: { ...buildExecutionList(monitoring), items: [event] },
},
}),
);
await page.route('**/api/v1/monitoring/executions/event/*?*', (route) =>
route.fulfill({
json: {
code: 0,
data: {
source: 'event',
row: event,
pages: {
inputs: pageOf([
{
id: 'event-only',
event_type: 'group.member.joined',
content: { member_id: 'member-42' },
metadata: {
routes: [
{ status: 'not_matched', reason: 'No matching route' },
],
},
},
]),
outputs: pageOf([]),
deliveries: pageOf([]),
},
},
},
}),
);
await page.goto('/home/monitoring');
await page.getByRole('button').filter({ hasText: 'Member joined' }).click();
const sheet = page.getByRole('dialog');
await expect(
sheet.getByRole('heading', { name: 'No processor run' }),
).toBeVisible();
await expect(sheet.getByText(/member-42/)).toBeVisible();
await expect(sheet.getByText(/No matching route/)).toBeVisible();
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
});
test('filters all three processor kinds with the sidebar icons', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const processors = [
{ uuid: 'agent-one', name: 'Agent One', kind: 'agent', emoji: '🤖' },
{
uuid: 'pipeline-one',
name: 'Pipeline One',
kind: 'pipeline',
emoji: '⚙️',
},
{
uuid: 'event-one',
name: 'Event One',
kind: 'event_processor',
emoji: '🧩',
},
];
await page.route('**/api/v1/agents', (route) =>
route.fulfill({ json: { code: 0, data: { agents: processors } } }),
);
await page.goto('/home/monitoring');
const filter = page.getByRole('combobox', { name: 'Processor', exact: true });
for (const processor of processors) {
await filter.click();
const option = page.getByRole('option', {
name: processor.name,
exact: true,
});
await expect(
option.getByText(processor.emoji, { exact: true }),
).toBeVisible();
const request = page.waitForRequest((req) => {
const url = new URL(req.url());
return (
url.pathname.endsWith('/monitoring/executions') &&
url.searchParams.get('pipelineId') === processor.uuid
);
});
await option.click();
await request;
await expect(filter).toContainText(processor.name);
await expect(
filter.getByText(processor.emoji, { exact: true }),
).toBeVisible();
}
await filter.click();
await page
.getByRole('option', { name: 'All Processors', exact: true })
.click();
await expect(filter).toContainText('All Processors');
});
test('shares mode and status filters with dashboard charts and records', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/monitoring');
for (const [id, option, parameter, value] of [
['monitoring-filter-mode', 'Debug only', 'mode', 'debug'],
['monitoring-filter-status', 'Failed', 'status', 'failed'],
]) {
await page.locator(`#${id}`).click();
const requests = ['/monitoring/data', '/monitoring/executions'].map(
(path) =>
page.waitForRequest((req) => {
const url = new URL(req.url());
return (
url.pathname.endsWith(path) &&
url.searchParams.get(parameter) === value
);
}),
);
await page.getByRole('option', { name: option, exact: true }).click();
await Promise.all(requests);
}
});
test('remembers all filters on reload and persists resetting them', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/bots', (route) =>
route.fulfill({
json: {
code: 0,
data: {
bots: [{ uuid: 'bot-one', name: 'Saved Bot', adapter: 'aiocqhttp' }],
},
},
}),
);
await page.route('**/api/v1/agents', (route) =>
route.fulfill({
json: {
code: 0,
data: {
agents: [
{
uuid: 'processor-one',
name: 'Saved Processor',
kind: 'agent',
emoji: '🤖',
},
],
},
},
}),
);
await page.goto('/home/monitoring');
const selections = [
['bot', 'Saved Bot'],
['processor', 'Saved Processor'],
['mode', 'Debug only'],
['status', 'Failed'],
['time', 'Last 7 days'],
];
for (const [id, label] of selections) {
await page.locator(`#monitoring-filter-${id}`).click();
await page.getByRole('option', { name: label, exact: true }).click();
}
await page.reload();
for (const [id, label] of selections)
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
await page
.getByRole('button', { name: 'Reset Filters', exact: true })
.click();
await page.reload();
for (const [id, label] of [
['bot', 'All Bots'],
['processor', 'All Processors'],
['mode', 'Live + debug'],
['status', 'All statuses'],
['time', 'Last 24 hours'],
]) {
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
}
});
test('reset removes linked resource filters from the URL', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto(
'/home/monitoring?botId=linked-bot&pipelineId=linked-processor',
);
await page
.getByRole('button', { name: 'Reset Filters', exact: true })
.click();
await expect(page).toHaveURL(/\/home\/monitoring$/);
await page.reload();
await expect(page.locator('#monitoring-filter-bot')).toContainText(
'All Bots',
);
await expect(page.locator('#monitoring-filter-processor')).toContainText(
'All Processors',
);
});
+53 -59
View File
@@ -1,6 +1,9 @@
import { expect, test, Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import {
installLangBotApiMocks,
buildExecutionList,
} from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
import {
ErrorLog,
@@ -254,7 +257,15 @@ function rawMonitoringData() {
success_rate: 100,
active_sessions: 2,
},
messages: scenario.messages.map(rawMessage),
messages: scenario.messages.map((item) => ({
...rawMessage(item),
parent_message_id:
item.role === 'assistant'
? item.id === 'agent-assistant-4'
? 'agent-user-2'
: 'agent-user-1'
: undefined,
})),
llmCalls: scenario.llmCalls.map(rawLlmCall),
toolCalls: scenario.toolCalls.map(rawToolCall),
embeddingCalls: [],
@@ -273,8 +284,13 @@ function rawMonitoringData() {
async function respond(route: Route, label: string) {
const data = rawMonitoringData();
data.messages = [rawMessage(message(label, 'user', 10, label))];
await route.fulfill({ json: { code: 0, data } });
data.messages = [
{
...rawMessage(message(label, 'user', 10, label)),
parent_message_id: undefined,
},
];
await route.fulfill({ json: { code: 0, data: buildExecutionList(data) } });
}
test.describe('monitoring request contracts', () => {
@@ -283,7 +299,7 @@ test.describe('monitoring request contracts', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/data?*', async (route) => {
await page.route('**/api/v1/monitoring/executions?*', async (route) => {
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
@@ -298,15 +314,14 @@ test.describe('monitoring request contracts', () => {
else await respond(route, 'Recovered monitoring');
});
await page.goto('/home/monitoring');
await expect(page.getByRole('alert')).toContainText(
'Failed to load monitoring data',
);
await expect(
page.getByText('Failed to load execution trace'),
).toBeVisible();
await expect(page.getByText('No message records')).toHaveCount(0);
failing = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(
page.getByText('Recovered monitoring', { exact: true }),
page.getByText('Recovered monitoring', { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
@@ -316,7 +331,7 @@ test.describe('monitoring request contracts', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const pending: Route[] = [];
await page.route('**/api/v1/monitoring/data?*', (route) => {
await page.route('**/api/v1/monitoring/executions?*', (route) => {
pending.push(route);
});
await page.goto('/home/monitoring');
@@ -326,9 +341,8 @@ test.describe('monitoring request contracts', () => {
await expect.poll(() => pending.length).toBe(3);
await respond(pending[2], 'Latest filter data');
// The message-turn view now lives behind its own tab.
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(
page.getByText('Latest filter data', { exact: true }),
page.getByText('Latest filter data', { exact: true }).first(),
).toBeVisible();
await respond(pending[0], 'Obsolete filter data');
await respond(pending[1], 'Obsolete filter data');
@@ -339,7 +353,7 @@ test.describe('monitoring request contracts', () => {
),
);
await expect(
page.getByText('Latest filter data', { exact: true }),
page.getByText('Latest filter data', { exact: true }).first(),
).toBeVisible();
await page
.getByRole('button', { name: 'Refresh Data', exact: true })
@@ -353,14 +367,14 @@ test.describe('monitoring request contracts', () => {
await expect.poll(() => pending.length).toBe(5);
await respond(pending[4], 'Current result');
await expect(
page.getByText('Current result', { exact: true }),
page.getByText('Current result', { exact: true }).first(),
).toBeVisible();
await pending[3].fulfill({
status: 500,
json: { code: 500, msg: 'old failure' },
});
await expect(
page.getByText('Current result', { exact: true }),
page.getByText('Current result', { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
@@ -389,7 +403,7 @@ test.describe('monitoring request contracts', () => {
page.getByText(
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByText('Traffic range truncated. Choose a shorter time range.'),
).toBeVisible();
@@ -592,57 +606,37 @@ test.describe('monitoring conversation turn grouping', () => {
expect(turns[0].totalToolDuration).toBe(45);
});
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
test('replaces the messages tab with full execution inputs, deliveries and calls', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await page.getByRole('tab', { name: 'Message Records' }).click();
await expect(page.getByText('3 conversation turns')).toBeVisible();
await expect(
page.getByText('Standalone question with no reply'),
).toBeVisible();
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
await expect(page.getByText('Need deployment plan')).toBeVisible();
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('3 LLM')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
await expect(page.getByText('790 tokens')).toBeVisible();
await expect(page.getByText('1 errors')).toBeVisible();
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
await expect(page.getByText('Rollback plan ready')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Need deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Conversation Trace')).toBeVisible();
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
page.getByRole('tab', { name: 'Message Records' }),
).toHaveCount(0);
await page
.getByRole('button')
.filter({ hasText: 'Need deployment plan' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('Event / input (1)')).toBeVisible();
await expect(
page.getByText('Final answer: deployment plan ready'),
sheet.getByText('Need deployment plan', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
await expect(page.getByText('In: 300')).toBeVisible();
await expect(page.getByText('Out: 90')).toBeVisible();
await expect(page.getByText('Total: 390')).toBeVisible();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await expect(page.getByText('Result')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments').first()).toBeVisible();
await expect(page.getByText('Result').first()).toBeVisible();
await expect(page.getByText('Tool retry failed')).toBeVisible();
await expect(sheet.getByText('Delivery records (3)')).toBeVisible();
await expect(
sheet.getByText('Final answer: deployment plan ready').first(),
).toBeVisible();
await sheet.getByText('Model calls (3)', { exact: true }).click();
await expect(sheet.getByText('gpt-5.5').first()).toBeVisible();
await sheet.getByText('Tool calls (2)', { exact: true }).click();
await expect(sheet.getByText('repo_search', { exact: true })).toBeVisible();
await sheet.getByText('Errors (1)', { exact: true }).click();
await expect(sheet.getByText('Tool retry failed').first()).toBeVisible();
await sheet.getByText('Conversation context (6)', { exact: true }).click();
await expect(sheet.getByText('Rollback plan ready')).toBeVisible();
});
});