From 68f59c75d853a3d3360e21783e8c0f5e9ead23b6 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:03:29 +0800 Subject: [PATCH] feat: improve message tracing and add bounded SQLite history (#10515) * feat: expand message tracing and redesign trace explorer * feat: add trace metadata chips and preserve response ownership * feat: show full-width tracing list with optional details * style: remove gray trace tab card * feat: unify trace toolbar controls and use Vuetify datatable * style: use native Vuetify trace table rows * fix: prevent trace platform header wrapping * feat: persist message traces in bounded SQLite storage * fix: expose trace settings and preserve long prompt attributes * docs: simplify trace storage setting hints * feat: add trace history cleanup and disk usage accounting * fix: finish event traces and fold Agent execution chains * feat: fold long trace messages and consolidate persona metadata * fix: hide redundant adapter event labels in traces * refactor: remove redundant persisted span metadata * feat: paginate trace history with Vuetify table controls * style: reduce trace table text size * style: increase trace table text to 13px * style: flatten trace status chips and narrow platform column * feat: show independent Agent and command trace types * feat: show cumulative Agent token usage in trace details * style: abbreviate trace token counts with k and M * style: reduce trace table header height * feat: show group and private chat trace types * fix: include month and day in trace timestamps * style: use system trace fonts and blue running status * style: separate trace origin and sender metadata rows * fix: trace successful proactive message tool deliveries * feat: show model persona and tool counts on Agent headings * style: prefix trace plugin chips with puzzle icons * style: add icons and spacing to Agent metadata chips * style: remove execution mode prefix from trace chips * refactor: centralize response tracing without source attribution * style: hide module metadata in trace details * refactor: omit redundant module metadata from plugin traces * refactor: remove obsolete module filtering from trace UI * feat: expose trace recording toggle in system settings * docs: simplify trace recording setting description * feat: retain traces only for command responses and Agent calls * fix: revert trace admission filtering and expand writer queue * refactor: adopt OpenTelemetry SDK and simplify trace tests * refactor: separate event and trace execution contexts * fix: include OpenTelemetry SDK in runtime requirements * refactor: dispatch session waiters in the core pipeline Preserve access and safety checks before waiter dispatch and limit built-in group context handlers to group messages. * test: remove trace dashboard tests * fix: recover orphaned traces and enforce minimum storage budget * fix: increase trace queue byte budgets and remove frame count limit --- astrbot/builtin_stars/astrbot/main.py | 21 +- astrbot/core/__init__.py | 1 - astrbot/core/astr_main_agent.py | 11 +- astrbot/core/config/default.py | 42 +- astrbot/core/core_lifecycle.py | 8 +- astrbot/core/cron/events.py | 13 +- astrbot/core/cron/manager.py | 220 ++- astrbot/core/event_bus.py | 3 +- astrbot/core/log.py | 174 +- astrbot/core/pipeline/context_utils.py | 48 +- .../process_stage/method/agent_request.py | 31 +- .../method/agent_sub_stages/internal.py | 1 + .../process_stage/method/star_request.py | 51 +- astrbot/core/pipeline/process_stage/stage.py | 10 + astrbot/core/pipeline/respond/stage.py | 63 +- astrbot/core/pipeline/scheduler.py | 28 +- astrbot/core/pipeline/waking_check/stage.py | 10 + astrbot/core/platform/astr_message_event.py | 66 +- .../platform/sources/webchat/webchat_event.py | 2 +- .../sources/wecom_ai_bot/wecomai_event.py | 8 +- astrbot/core/star/context.py | 25 +- astrbot/core/utils/migra_helper.py | 29 +- astrbot/core/utils/storage_cleaner.py | 80 +- astrbot/core/utils/telemetry.py | 112 ++ astrbot/core/utils/trace.py | 461 ++++- astrbot/core/utils/trace_store.py | 665 +++++++ astrbot/dashboard/api/logs.py | 47 +- astrbot/dashboard/schemas.py | 2 +- astrbot/dashboard/services/log_service.py | 40 + astrbot/dashboard/services/plugin_service.py | 11 +- changelogs/unreleased.md | 83 + .../src/api/generated/openapi-v1/sdk.gen.ts | 24 +- .../src/api/generated/openapi-v1/types.gen.ts | 37 +- dashboard/src/api/v1.ts | 8 +- .../mdi-subset/materialdesignicons-subset.css | 6 +- .../materialdesignicons-webfont-subset.woff | Bin 18568 -> 18656 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 14964 -> 15032 bytes .../components/shared/StorageCleanupPanel.vue | 23 +- .../src/components/shared/TraceDisplayer.vue | 1696 +++++++++++------ .../components/shared/TraceMessagePreview.vue | 50 + .../en-US/features/config-metadata.json | 22 +- .../i18n/locales/en-US/features/settings.json | 17 +- .../i18n/locales/en-US/features/trace.json | 131 +- .../ja-JP/features/config-metadata.json | 22 +- .../i18n/locales/ja-JP/features/settings.json | 17 +- .../i18n/locales/ja-JP/features/trace.json | 127 +- .../ru-RU/features/config-metadata.json | 24 +- .../i18n/locales/ru-RU/features/settings.json | 17 +- .../i18n/locales/ru-RU/features/trace.json | 127 +- .../zh-CN/features/config-metadata.json | 22 +- .../i18n/locales/zh-CN/features/settings.json | 17 +- .../i18n/locales/zh-CN/features/trace.json | 131 +- dashboard/src/views/Settings.vue | 7 +- dashboard/src/views/TracePage.vue | 150 +- docs/en/dev/astrbot-config.md | 25 +- docs/en/dev/star/guides/session-control.md | 2 + docs/en/use/images/trace-timeline-en.jpg | Bin 0 -> 49131 bytes docs/en/use/webui.md | 46 +- docs/zh/dev/astrbot-config.md | 25 +- docs/zh/dev/star/guides/session-control.md | 2 + docs/zh/use/images/trace-timeline-zh.jpg | Bin 0 -> 46649 bytes docs/zh/use/webui.md | 46 +- openspec/openapi-v1.yaml | 62 +- pyproject.toml | 2 + requirements.txt | 2 + tests/test_fastapi_v1_dashboard.py | 115 ++ tests/unit/test_astr_main_agent.py | 6 + tests/unit/test_astr_message_event.py | 298 ++- tests/unit/test_event_bus.py | 2 +- tests/unit/test_event_execution.py | 346 ++++ tests/unit/test_message_tools.py | 23 +- tests/unit/test_plugin_handler_trace.py | 138 ++ tests/unit/test_session_waiter_pipeline.py | 122 ++ tests/unit/test_trace_queue_capacity.py | 62 + tests/unit/test_trace_store.py | 784 ++++++++ 75 files changed, 6082 insertions(+), 1067 deletions(-) create mode 100644 astrbot/core/utils/telemetry.py create mode 100644 astrbot/core/utils/trace_store.py create mode 100644 dashboard/src/components/shared/TraceMessagePreview.vue create mode 100644 docs/en/use/images/trace-timeline-en.jpg create mode 100644 docs/zh/use/images/trace-timeline-zh.jpg create mode 100644 tests/unit/test_event_execution.py create mode 100644 tests/unit/test_plugin_handler_trace.py create mode 100644 tests/unit/test_session_waiter_pipeline.py create mode 100644 tests/unit/test_trace_queue_capacity.py create mode 100644 tests/unit/test_trace_store.py diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 76b3942ca0a..9afdcfd61e9 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -12,10 +12,7 @@ from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.message_type import MessageType from astrbot.core.utils.session_waiter import ( - FILTERS, - USER_SESSIONS, SessionController, - SessionWaiter, session_waiter, ) @@ -41,15 +38,6 @@ def __init__(self, context: star.Context) -> None: except BaseException as e: logger.error(f"group chat context init failed: {e}") - @filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize) - async def handle_session_control_agent(self, event: AstrMessageEvent) -> None: - """会话控制代理""" - for session_filter in FILTERS: - session_id = session_filter.filter(event) - if session_id in USER_SESSIONS: - await SessionWaiter.trigger(session_id, event) - event.stop_event() - @filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1) async def handle_empty_mention(self, event: AstrMessageEvent): """处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。""" @@ -147,8 +135,8 @@ def group_context_enabled(self, event: AstrMessageEvent): or group_context_settings["active_reply"]["enable"] ) - @filter.platform_adapter_type( - filter.PlatformAdapterType.ALL, + @filter.event_message_type( + filter.EventMessageType.GROUP_MESSAGE, priority=maxsize - 2, ) async def persist_group_message(self, event: AstrMessageEvent) -> None: @@ -193,7 +181,10 @@ async def persist_group_message(self, event: AstrMessageEvent) -> None: except Exception: logger.exception("Failed to persist an incoming group message.") - @filter.platform_adapter_type(filter.PlatformAdapterType.ALL) + @filter.event_message_type( + filter.EventMessageType.GROUP_MESSAGE, + priority=maxsize - 2, + ) async def on_message(self, event: AstrMessageEvent): """群聊上下文感知""" message_components = _iter_message_components(event) diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index 1919081dcb7..17e7db6b9f5 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -35,7 +35,6 @@ """**Deprecated**. For plugin developers, please use `self.context.html_renderer` instead.""" logger = LogManager.GetLogger(log_name="astrbot") LogManager.configure_logger(logger, astrbot_config) -LogManager.configure_trace_logger(astrbot_config) db_helper = SQLiteDatabase(DB_PATH) sp = SharedPreferences(db_helper=db_helper) file_token_service = FileTokenService() diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index c6ce20fc623..59183b8a613 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -571,6 +571,7 @@ async def _ensure_persona_and_skills( event: AstrMessageEvent, ) -> None: """Ensure persona and skills are applied to the request's system prompt or user prompt.""" + event.set_extra("_trace_persona_id", None) if req.system_prompt is None: req.system_prompt = "" @@ -592,6 +593,8 @@ async def _ensure_persona_and_skills( provider_settings=cfg, ) + event.set_extra("_trace_persona_id", persona_id) + set_persona_custom_error_message_on_event( event, extract_persona_custom_error_message_from_persona(persona) ) @@ -727,14 +730,6 @@ async def _ensure_persona_and_skills( ).strip() if router_prompt: req.system_prompt += f"\n{router_prompt}\n" - try: - event.trace.record( - "sel_persona", - persona_id=persona_id, - persona_toolset=persona_toolset.names(), - ) - except Exception: - pass async def _request_img_caption( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index df696ecc531..80ed2d5010c 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -309,9 +309,9 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "log_file_max_mb": 20, "temp_dir_max_size": 1024, "trace_enable": False, - "trace_log_enable": False, - "trace_log_path": "logs/astrbot.trace.log", - "trace_log_max_mb": 20, + "trace_db_path": "logs/traces.db", + "trace_db_max_mb": 80, + "trace_retention_days": 7, "pip_install_arg": "", "pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/", "persona": [], # deprecated @@ -3236,15 +3236,10 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, "temp_dir_max_size": {"type": "int"}, - "trace_log_enable": {"type": "bool"}, - "trace_log_path": { - "type": "string", - "condition": {"trace_log_enable": True}, - }, - "trace_log_max_mb": { - "type": "int", - "condition": {"trace_log_enable": True}, - }, + "trace_enable": {"type": "bool"}, + "trace_db_path": {"type": "string"}, + "trace_db_max_mb": {"type": "int"}, + "trace_retention_days": {"type": "int"}, "t2i_strategy": { "type": "string", "options": ["remote", "local"], @@ -4851,20 +4846,25 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "type": "int", "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。", }, - "trace_log_enable": { - "description": "启用 Trace 文件日志", + "trace_enable": { + "description": "启用追踪记录", "type": "bool", - "hint": "将 Trace 事件写入独立文件(不影响控制台输出)。", + "hint": "追踪用户的每次指令和 AI 调用。", }, - "trace_log_path": { - "description": "Trace 日志文件路径", + "trace_db_path": { + "description": "追踪数据库路径", "type": "string", - "hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。", + "hint": "相对路径以 data 目录为基准,例如 logs/traces.db;支持绝对路径。", }, - "trace_log_max_mb": { - "description": "Trace 日志大小上限 (MB)", + "trace_db_max_mb": { + "description": "追踪数据库容量预算 (MB)", "type": "int", - "hint": "超过大小后自动轮转,默认 20MB。", + "hint": "默认 80 MB,最小 32 MB,低于最小值时按 32 MB 生效。超出后自动清除旧数据。", + }, + "trace_retention_days": { + "description": "追踪保留天数", + "type": "int", + "hint": "默认 7 天,期限或容量先达到者触发清理。", }, "pip_install_arg": { "description": "pip 安装额外参数", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 31b56c90c96..8d0e19a1a34 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -165,10 +165,8 @@ async def initialize(self) -> None: LogManager.configure_logger( logger, self.astrbot_config, override_level="DEBUG" ) - LogManager.configure_trace_logger(self.astrbot_config) else: LogManager.configure_logger(logger, self.astrbot_config) - LogManager.configure_trace_logger(self.astrbot_config) await self.db.initialize() if sp.db_helper is self.db: @@ -420,6 +418,9 @@ async def stop(self) -> None: logger.error(f"任务 {task.get_name()} 发生错误: {e}") # 释放数据库引擎连接池 + from astrbot.core.utils.trace_store import close_trace_store + + await asyncio.to_thread(close_trace_store) try: await self.db.engine.dispose() except Exception as e: @@ -431,6 +432,9 @@ async def restart(self) -> None: await self.provider_manager.terminate() await self.platform_manager.terminate() await self.kb_manager.terminate() + from astrbot.core.utils.trace_store import close_trace_store + + await asyncio.to_thread(close_trace_store) if sp.db_helper is self.db: await sp.close() self.dashboard_shutdown_event.set() diff --git a/astrbot/core/cron/events.py b/astrbot/core/cron/events.py index 34445e552e7..7fbfec660e1 100644 --- a/astrbot/core/cron/events.py +++ b/astrbot/core/cron/events.py @@ -4,7 +4,10 @@ from astrbot.core.message.components import Plain from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.astr_message_event import ( + AstrMessageEvent, + streaming_response_event, +) from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember from astrbot.core.platform.message_session import MessageSession from astrbot.core.platform.message_type import MessageType @@ -56,7 +59,13 @@ def __init__( async def send(self, message: MessageChain) -> None: if message is None: return - if not await self.context_obj.send_message(self.session, message): + # The outer event.send records delivery; suppress its nested public send. + token = streaming_response_event.set(self) + try: + sent = await self.context_obj.send_message(self.session, message) + finally: + streaming_response_event.reset(token) + if not sent: return await super().send(message) diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index e5f49e0e53b..c5baae3ff5a 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -17,11 +17,14 @@ from astrbot.core.cron.events import CronMessageEvent from astrbot.core.db import BaseDatabase from astrbot.core.db.po import CronJob +from astrbot.core.log import log_context +from astrbot.core.platform.astr_message_event import event_context from astrbot.core.platform.message_session import MessageSession from astrbot.core.platform.message_type import MessageType from astrbot.core.provider.entites import ProviderRequest from astrbot.core.utils.config_number import coerce_int_config from astrbot.core.utils.history_saver import persist_agent_history +from astrbot.core.utils.trace import trace_execution if TYPE_CHECKING: from astrbot.core.star.context import Context @@ -460,114 +463,123 @@ async def _woke_main_agent( message_type=session.message_type, ) - # judge user's role - umo = cron_event.unified_msg_origin - cfg = self.ctx.get_config(umo=umo) - enabled_plugins_name = cfg.get("plugin_set", ["*"]) - cron_event.plugins_name = ( - None if enabled_plugins_name == ["*"] else enabled_plugins_name - ) - cron_payload = extras.get("cron_payload", {}) if extras else {} - sender_id = cron_payload.get("sender_id") - admin_ids = cfg.get("admins_id", []) - if admin_ids: - cron_event.role = "admin" if sender_id in admin_ids else "member" - if cron_payload.get("origin", "tool") == "api": - cron_event.role = "admin" - - provider_settings = cfg.get("provider_settings", {}) or {} - persona_config = ( - cfg.get("agent_runner", {}).get("config", {}).get("persona", {}) - ) - tool_call_timeout = ( - cfg.get("agent_runner", {}) - .get("config", {}) - .get("misc", {}) - .get("tool_call_timeout", 120) - ) - agent_max_step = coerce_int_config( - cfg.get("agent_runner", {}) - .get("config", {}) - .get("misc", {}) - .get("max_steps", 128), - default=128, - min_value=1, - field_name="agent_runner.config.misc.max_steps", - ) - config = MainAgentBuildConfig( - tool_call_timeout=tool_call_timeout, - fallback_provider_ids=cfg.get("agent_runner", {}) - .get("config", {}) - .get("model", {}) - .get("fallback_provider_ids", []), - **resolve_context_compression_config( - cfg.get("agent_runner", {}).get("config", {}).get("compression", {}) - ), - llm_safety_mode=persona_config.get("safety_mode", True), - safety_mode_strategy=persona_config.get( - "safety_mode_strategy", "system_prompt" - ), - streaming_response=False, - computer_use_runtime=provider_settings.get("computer_use_runtime", "none"), - sandbox_cfg=provider_settings.get("sandbox", {}), - provider_settings=provider_settings, - ) - req = ProviderRequest() - conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx) - req.conversation = conv - req.contexts = json.loads(conv.history) - cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False) - req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT - # Keep per-run metadata after history so it does not invalidate its cache. - req.prompt = PROACTIVE_AGENT_CRON_WOKE_USER_PROMPT.format(cron_job=cron_job_str) - if delivery_session_str: - if not req.func_tool: - req.func_tool = ToolSet() - req.func_tool.add_tool( - self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool) + with ( + log_context(cron_event.get_platform_id(), cron_event.get_platform_name()), + event_context(cron_event), + trace_execution(cron_event.trace), + ): + # judge user's role + umo = cron_event.unified_msg_origin + cfg = self.ctx.get_config(umo=umo) + enabled_plugins_name = cfg.get("plugin_set", ["*"]) + cron_event.plugins_name = ( + None if enabled_plugins_name == ["*"] else enabled_plugins_name ) - - result = await build_main_agent( - event=cron_event, plugin_context=self.ctx, config=config, req=req - ) - if not result: - raise RuntimeError("Failed to build main agent for cron job.") - - runner = result.agent_runner - async for _ in runner.step_until_done(agent_max_step): - # agent will send message to user via using tools - pass - llm_resp = runner.get_final_llm_resp() - if runner.state == AgentState.ERROR: - # The run failed (e.g. malformed function call at max steps) but - # no exception escapes the runner; without this the job was - # recorded as completed with last_error=NULL and the user saw - # only intermediate messages (#9980). - detail = ( - f": {llm_resp.completion_text}" - if llm_resp and llm_resp.completion_text - else "" + cron_payload = extras.get("cron_payload", {}) if extras else {} + sender_id = cron_payload.get("sender_id") + admin_ids = cfg.get("admins_id", []) + if admin_ids: + cron_event.role = "admin" if sender_id in admin_ids else "member" + if cron_payload.get("origin", "tool") == "api": + cron_event.role = "admin" + + provider_settings = cfg.get("provider_settings", {}) or {} + persona_config = ( + cfg.get("agent_runner", {}).get("config", {}).get("persona", {}) ) - raise RuntimeError(f"Cron agent run ended in ERROR state{detail}") - cron_meta = extras.get("cron_job", {}) if extras else {} - summary_note = ( - f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} " - f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, " - ) - if llm_resp and llm_resp.role == "assistant": - summary_note += ( - f"I finished this job, here is the result: {llm_resp.completion_text}" + tool_call_timeout = ( + cfg.get("agent_runner", {}) + .get("config", {}) + .get("misc", {}) + .get("tool_call_timeout", 120) + ) + agent_max_step = coerce_int_config( + cfg.get("agent_runner", {}) + .get("config", {}) + .get("misc", {}) + .get("max_steps", 128), + default=128, + min_value=1, + field_name="agent_runner.config.misc.max_steps", ) + config = MainAgentBuildConfig( + tool_call_timeout=tool_call_timeout, + fallback_provider_ids=cfg.get("agent_runner", {}) + .get("config", {}) + .get("model", {}) + .get("fallback_provider_ids", []), + **resolve_context_compression_config( + cfg.get("agent_runner", {}).get("config", {}).get("compression", {}) + ), + llm_safety_mode=persona_config.get("safety_mode", True), + safety_mode_strategy=persona_config.get( + "safety_mode_strategy", "system_prompt" + ), + streaming_response=False, + computer_use_runtime=provider_settings.get( + "computer_use_runtime", "none" + ), + sandbox_cfg=provider_settings.get("sandbox", {}), + provider_settings=provider_settings, + ) + req = ProviderRequest() + conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx) + req.conversation = conv + req.contexts = json.loads(conv.history) + cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False) + req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT + # Keep per-run metadata after history so it does not invalidate its cache. + req.prompt = PROACTIVE_AGENT_CRON_WOKE_USER_PROMPT.format( + cron_job=cron_job_str + ) + if delivery_session_str: + if not req.func_tool: + req.func_tool = ToolSet() + req.func_tool.add_tool( + self.ctx.get_llm_tool_manager().get_builtin_tool( + SendMessageToUserTool + ) + ) - await persist_agent_history( - self.ctx.conversation_manager, - event=cron_event, - req=req, - summary_note=summary_note, - ) - if not llm_resp: - logger.warning("Cron job agent got no response") - return + result = await build_main_agent( + event=cron_event, plugin_context=self.ctx, config=config, req=req + ) + if not result: + raise RuntimeError("Failed to build main agent for cron job.") + + runner = result.agent_runner + async for _ in runner.step_until_done(agent_max_step): + # agent will send message to user via using tools + pass + llm_resp = runner.get_final_llm_resp() + if runner.state == AgentState.ERROR: + # The run failed (e.g. malformed function call at max steps) but + # no exception escapes the runner; without this the job was + # recorded as completed with last_error=NULL and the user saw + # only intermediate messages (#9980). + detail = ( + f": {llm_resp.completion_text}" + if llm_resp and llm_resp.completion_text + else "" + ) + raise RuntimeError(f"Cron agent run ended in ERROR state{detail}") + cron_meta = extras.get("cron_job", {}) if extras else {} + summary_note = ( + f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} " + f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, " + ) + if llm_resp and llm_resp.role == "assistant": + summary_note += f"I finished this job, here is the result: {llm_resp.completion_text}" + + await persist_agent_history( + self.ctx.conversation_manager, + event=cron_event, + req=req, + summary_note=summary_note, + ) + if not llm_resp: + logger.warning("Cron job agent got no response") + return __all__ = ["CronJobManager"] diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index df2deeba78c..3119f8b333b 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -56,7 +56,8 @@ async def dispatch(self) -> None: conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin) conf_id = conf_info["id"] conf_name = conf_info.get("name") or conf_id - self._print_event(event, conf_name) + with event.trace.context(): + self._print_event(event, conf_name) scheduler = self.pipeline_scheduler_mapping.get(conf_id) if not scheduler: logger.error( diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 01b29fd54f6..75653660282 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -6,6 +6,7 @@ import os import sys import tempfile +import threading import time from asyncio import Queue from collections import deque @@ -17,12 +18,17 @@ from typing import TYPE_CHECKING from loguru import logger as _raw_loguru_logger +from opentelemetry.trace import format_span_id, format_trace_id, get_current_span from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import ( get_astrbot_config_path, get_astrbot_data_path, ) +from astrbot.core.utils.telemetry import ( + bounded_attributes, + bounded_text, +) CACHED_SIZE = 500 @@ -115,6 +121,12 @@ def filter(self, record: logging.LogRecord) -> bool: record.platform_type = getattr(record, "platform_type", None) or ( context.platform_type if context_is_active else None ) + span = get_current_span() + # Copied task contexts can still reference a span after its execution ends. + if span.is_recording(): + trace_context = span.get_span_context() + record.trace_id = format_trace_id(trace_context.trace_id) + record.span_id = format_span_id(trace_context.span_id) return True @@ -176,6 +188,14 @@ def _patch_record(record: "Record") -> None: extra.setdefault("plugin_name", None) extra.setdefault("platform_id", None) extra.setdefault("platform_type", None) + extra.setdefault( + "trace_tag", + ( + f" [trace_id={extra['trace_id']} span_id={extra.get('span_id', '')}]" + if extra.get("trace_id") + else "" + ), + ) _loguru = _raw_loguru_logger.patch(_patch_record) @@ -208,6 +228,8 @@ def emit(self, record: logging.LogRecord) -> None: "plugin_name": getattr(record, "plugin_name", None), "platform_id": getattr(record, "platform_id", None), "platform_type": getattr(record, "platform_type", None), + "trace_id": getattr(record, "trace_id", None), + "span_id": getattr(record, "span_id", None), } _loguru.bind(**payload).opt(exception=record.exc_info).log( @@ -222,22 +244,70 @@ class LogBroker: def __init__(self) -> None: self.log_cache = deque(maxlen=CACHED_SIZE) self.subscribers: list[Queue] = [] + self._subscriber_lock = threading.Lock() + self._subscriber_loops: dict[Queue, asyncio.AbstractEventLoop | None] = {} + self._pending_callbacks: dict[Queue, int] = {} def register(self) -> Queue: q = Queue(maxsize=CACHED_SIZE + 10) - self.subscribers.append(q) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + with self._subscriber_lock: + self.subscribers.append(q) + self._subscriber_loops[q] = loop + self._pending_callbacks[q] = 0 return q def unregister(self, q: Queue) -> None: - self.subscribers.remove(q) + with self._subscriber_lock: + self.subscribers.remove(q) + self._subscriber_loops.pop(q, None) + self._pending_callbacks.pop(q, None) def publish(self, log_entry: dict) -> None: - self.log_cache.append(log_entry) - for q in self.subscribers: + if log_entry.get("type") != "trace": + self.log_cache.append(log_entry) + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + current_loop = None + for q in tuple(self.subscribers): + owner = self._subscriber_loops.get(q) + if owner is None or owner is current_loop: + try: + q.put_nowait(log_entry) + except asyncio.QueueFull: + pass + continue + # Reserve space before scheduling: queued callbacks must also be + # bounded when a background writer reports errors to a slow loop. + with self._subscriber_lock: + if ( + q not in self._pending_callbacks + or q.qsize() + self._pending_callbacks[q] >= q.maxsize + or owner.is_closed() + ): + continue + self._pending_callbacks[q] += 1 + + def enqueue(queue=q, entry=log_entry): + with self._subscriber_lock: + if queue not in self._pending_callbacks: + return + self._pending_callbacks[queue] -= 1 + try: + queue.put_nowait(entry) + except asyncio.QueueFull: + pass + try: - q.put_nowait(log_entry) - except asyncio.QueueFull: - pass + owner.call_soon_threadsafe(enqueue) + except RuntimeError: + with self._subscriber_lock: + if q in self._pending_callbacks: + self._pending_callbacks[q] -= 1 class LogQueueHandler(logging.Handler): @@ -253,7 +323,42 @@ def emit(self, record: logging.LogRecord) -> None: { "level": record.levelname, "time": time.time(), - "data": log_entry, + "data": bounded_text(log_entry), + "timestamp": str(int(record.created * 10**9)), + "observed_timestamp": str(time.time_ns()), + "severity_text": record.levelname, + "severity_number": {10: 5, 20: 9, 30: 13, 40: 17, 50: 21}.get( + record.levelno, 0 + ), + "body": bounded_text(record.getMessage()), + **( + { + "trace_id": record.trace_id, + "span_id": record.span_id, + "trace_flags": 1, + } + if getattr(record, "trace_id", None) + else {} + ), + "attributes": bounded_attributes( + { + "astrbot.category": getattr(record, "category", None) + or "system", + "astrbot.source": getattr(record, "source", "core"), + "astrbot.plugin_name": getattr(record, "plugin_name", None), + "astrbot.platform_id": getattr(record, "platform_id", None), + "astrbot.platform_type": getattr(record, "platform_type", None), + "code.file.path": record.pathname, + "code.line.number": record.lineno, + } + ), + "resource": { + "attributes": { + "service.name": "astrbot", + "service.version": VERSION, + } + }, + "instrumentation_scope": {"name": record.name}, "category": getattr(record, "category", None) or "system", "source": getattr(record, "source", "core"), "plugin_name": getattr(record, "plugin_name", None), @@ -270,7 +375,6 @@ class LogManager: _configured = False _console_sink_id: int | None = None _file_sink_id: int | None = None - _trace_sink_id: int | None = None _plugin_logger_names: set[str] = set() _plugin_level_overrides: dict[str, str] | None = None _log_broker: "LogBroker | None" = None @@ -524,25 +628,12 @@ def _add_file_sink( level: int, max_mb: int | None, backup_count: int, - trace: bool, ) -> int: os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None retention = ( backup_count if rotation and backup_count and backup_count > 0 else None ) - if trace: - return _loguru.add( - file_path, - level="INFO", - format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}", - encoding="utf-8", - rotation=rotation, - retention=retention, - enqueue=True, - filter=lambda record: record["extra"].get("is_trace", False), - ) - logging_level_name = logging.getLevelName(level) if isinstance(logging_level_name, int): logging_level_name = "INFO" @@ -552,7 +643,7 @@ def _add_file_sink( format=( "[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} " "[{extra[short_levelname]}]{extra[astrbot_version_tag]} " - "[{extra[source_file]}:{extra[source_line]}]: {message}" + "[{extra[source_file]}:{extra[source_line]}]{extra[trace_tag]}: {message}" ), encoding="utf-8", rotation=rotation, @@ -636,43 +727,6 @@ def configure_logger( level=logger.level, max_mb=max_mb, backup_count=3, - trace=False, ) except Exception as e: logger.error(f"Failed to add file sink: {e}") - - @classmethod - def configure_trace_logger(cls, config: dict | None) -> None: - if not config: - return - - enable = bool( - config.get("trace_log_enable") - or (config.get("log_file", {}) or {}).get("trace_enable", False) - ) - path = config.get("trace_log_path") - max_mb = config.get("trace_log_max_mb") - if "log_file" in config: - legacy = config.get("log_file") or {} - path = path or legacy.get("trace_path") - max_mb = max_mb or legacy.get("trace_max_mb") - - trace_logger = logging.getLogger("astrbot.trace") - cls._ensure_logger_enricher_filter(trace_logger) - cls._ensure_logger_intercept_handler(trace_logger) - trace_logger.setLevel(logging.INFO) - trace_logger.propagate = False - - cls._remove_sink(cls._trace_sink_id) - cls._trace_sink_id = None - - if not enable: - return - - cls._trace_sink_id = cls._add_file_sink( - file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"), - level=logging.INFO, - max_mb=max_mb, - backup_count=3, - trace=True, - ) diff --git a/astrbot/core/pipeline/context_utils.py b/astrbot/core/pipeline/context_utils.py index 5497bbf9746..7fbefe9c615 100644 --- a/astrbot/core/pipeline/context_utils.py +++ b/astrbot/core/pipeline/context_utils.py @@ -93,19 +93,61 @@ async def call_event_hook( plugins_name=event.plugins_name, ) for handler in handlers: + plugin = star_map.get(handler.handler_module_path) + plugin_name = plugin.name if plugin else handler.handler_module_path + plugin_display_name = plugin.display_name if plugin else None + handler_category = ( + "llm_tool" if hook_type == EventType.OnCallingFuncToolEvent else "hook" + ) + event.trace.record( + "handler_start", + kind="plugin_handler", + status="running", + plugin=plugin_name, + plugin_display_name=plugin_display_name or plugin_name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + hook=hook_type.name, + ) try: assert inspect.iscoroutinefunction(handler.handler) logger.debug( - f"hook({hook_type.name}) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + f"hook({hook_type.name}) -> {plugin_name} - {handler.handler_name}", ) await handler.handler(event, *args, **kwargs) except BaseException: logger.error(traceback.format_exc()) + event.trace.record( + "handler_end", + kind="plugin_handler", + status="error", + plugin=plugin_name, + plugin_display_name=plugin_display_name or plugin_name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + hook=hook_type.name, + ) + else: + event.trace.record( + "handler_end", + kind="plugin_handler", + status="success", + plugin=plugin_name, + plugin_display_name=plugin_display_name or plugin_name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + hook=hook_type.name, + ) if event.is_stopped(): logger.info( - f"{star_map[handler.handler_module_path].name} - " - f"{handler.handler_name} stopped event propagation.", + f"{plugin_name} - {handler.handler_name} stopped event propagation.", ) return True diff --git a/astrbot/core/pipeline/process_stage/method/agent_request.py b/astrbot/core/pipeline/process_stage/method/agent_request.py index 260c77a4101..c50be62406b 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_request.py +++ b/astrbot/core/pipeline/process_stage/method/agent_request.py @@ -49,5 +49,32 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None, None]: ) return - async for resp in self.agent_sub_stage.process(event, self.prov_wake_prefix): - yield resp + agent_config = self.config.get("agent_runner", {}) + event.trace.record( + "agent_start", + kind="agent", + status="running", + runner_type=agent_config.get("runner_type", "local"), + provider_wake_prefix=self.prov_wake_prefix, + ) + try: + async for resp in self.agent_sub_stage.process( + event, self.prov_wake_prefix + ): + yield resp + except BaseException as exc: + event.trace.record( + "agent_end", + kind="agent", + status="error", + error=str(exc), + runner_type=agent_config.get("runner_type", "local"), + ) + raise + else: + event.trace.record( + "agent_end", + kind="agent", + status="success", + runner_type=agent_config.get("runner_type", "local"), + ) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index b805bd51d0b..e5c705ce7eb 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -332,6 +332,7 @@ async def process( event.trace.record( "astr_agent_prepare", + persona_id=event.get_extra("_trace_persona_id"), system_prompt=req.system_prompt, tools=req.func_tool.names() if req.func_tool else [], stream=streaming_response, diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 3adcddc0775..4d97aefa2c7 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -7,11 +7,14 @@ from astrbot.core import logger from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.star.filter.command import CommandFilter +from astrbot.core.star.filter.command_group import CommandGroupFilter +from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, StarHandlerMetadata from ...context import PipelineContext, call_event_hook, call_handler -from ..stage import Stage +from ...stage import Stage class StarRequestSubStage(Stage): @@ -44,10 +47,44 @@ async def process( ) continue logger.debug(f"plugin -> {md.name} - {handler.handler_name}") + # Match the component categories used by the plugin detail page. + handler_category = ( + "command" + if any( + isinstance( + event_filter, CommandFilter | CommandGroupFilter | RegexFilter + ) + for event_filter in handler.event_filters + ) + else "listener" + ) + event.trace.record( + "handler_start", + kind="plugin_handler", + status="running", + plugin=md.name or handler.handler_module_path, + plugin_display_name=md.display_name or md.name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + priority=handler.extras_configs.get("priority", 0), + ) try: wrapper = call_handler(event, handler.handler, **params) async for ret in wrapper: yield ret + event.trace.record( + "handler_end", + kind="plugin_handler", + status="success", + plugin=md.name or handler.handler_module_path, + plugin_display_name=md.display_name or md.name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + ) if event.is_stopped(): break event.clear_result() # 清除上一个 handler 的结果 @@ -55,6 +92,18 @@ async def process( traceback_text = traceback.format_exc() logger.error(traceback_text) logger.error(f"Star {handler.handler_full_name} handle error: {e}") + event.trace.record( + "handler_end", + kind="plugin_handler", + status="error", + plugin=md.name or handler.handler_module_path, + plugin_display_name=md.display_name or md.name, + handler=handler.handler_name, + handler_full_name=handler.handler_full_name, + handler_type=handler.event_type.name, + handler_category=handler_category, + error=str(e), + ) await call_event_hook( event, diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index 076f7f12ac2..4298c5d570a 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -3,6 +3,7 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ProviderRequest from astrbot.core.star.star_handler import StarHandlerMetadata +from astrbot.core.utils.session_waiter import FILTERS, USER_SESSIONS, SessionWaiter from ..context import PipelineContext from ..stage import Stage, register_stage @@ -30,6 +31,15 @@ async def process( event: AstrMessageEvent, ) -> None | AsyncGenerator[None, None]: """处理事件""" + # Dispatch after access/safety checks and preprocessing, before plugins. + for session_filter in tuple(FILTERS): + session_id = session_filter.filter(event) + session = USER_SESSIONS.get(session_id) + if session and not session.session_controller.future.done(): + await SessionWaiter.trigger(session_id, event) + event.stop_event() + return + activated_handlers: list[StarHandlerMetadata] = event.get_extra( "activated_handlers", ) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 15af89c3d52..fa90ec70e41 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -7,9 +7,13 @@ from astrbot.core import logger from astrbot.core.message.components import BaseMessageComponent, ComponentType from astrbot.core.message.message_event_result import MessageChain, ResultContentType -from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.astr_message_event import ( + AstrMessageEvent, + streaming_response_event, +) from astrbot.core.star.star_handler import EventType from astrbot.core.utils.path_util import path_Mapping +from astrbot.core.utils.telemetry import bounded_text from ..context import PipelineContext, call_event_hook from ..stage import Stage, register_stage @@ -234,7 +238,6 @@ async def process( f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}", extra={"category": "user_chat"}, ) - if result.result_content_type == ResultContentType.STREAMING_RESULT: if result.async_stream is None: logger.warning("async_stream is empty; skipping delivery.") @@ -248,7 +251,61 @@ async def process( == "realtime_segmenting" ) logger.info(f"Applying streaming output ({event.get_platform_id()}).") - await event.send_streaming(result.async_stream, realtime_segmenting) + stream = result.async_stream + stream_outline: list[str] = [] + outline_bytes = 0 + outline_truncated = False + + async def traced_stream() -> AsyncGenerator[MessageChain, None]: + """Collect response outlines as the platform consumes the stream. + + Yields: + Original message chunks for platform delivery. + """ + nonlocal outline_bytes, outline_truncated + iterator = stream.__aiter__() + while True: + # Tool/plugin sends during generation are independent responses. + generation_token = streaming_response_event.set(None) + try: + chain = await iterator.__anext__() + except StopAsyncIteration: + break + finally: + streaming_response_event.reset(generation_token) + if chain.type not in { + "break", + "reasoning", + "audio_chunk", + "tool_call", + "agent_stats", + }: + fragment = event._outline_chain(chain.chain) + remaining = 4096 - outline_bytes + if remaining >= 16: + preview = bounded_text(fragment, remaining) + stream_outline.append(preview) + outline_bytes += len(preview.encode()) + outline_truncated |= preview != fragment + elif fragment: + outline_truncated = True + yield chain + + # Adapter chunks and protocol closing frames share one final trace. + stream_token = streaming_response_event.set(event) + try: + await event.send_streaming(traced_stream(), realtime_segmenting) + finally: + streaming_response_event.reset(stream_token) + outline = "".join(stream_outline) + if outline.strip(): + event.trace.record( + "response_sent", + kind="response", + message=outline, + streaming=True, + **({"truncated": True} if outline_truncated else {}), + ) return if len(result.chain) > 0: # 检查路径映射 diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index a7ddf6cd45f..45b04ca5586 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -4,11 +4,12 @@ from astrbot.core import logger from astrbot.core.log import log_context from astrbot.core.platform import AstrMessageEvent +from astrbot.core.platform.astr_message_event import event_context from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEvent from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( WecomAIBotMessageEvent, ) -from astrbot.core.utils.active_event_registry import active_event_registry +from astrbot.core.utils.trace import trace_execution from .bootstrap import ensure_builtin_stages_registered from .context import PipelineContext @@ -87,16 +88,15 @@ async def execute(self, event: AstrMessageEvent) -> None: event (AstrMessageEvent): 事件对象 """ - active_event_registry.register(event) - with log_context(event.get_platform_id(), event.get_platform_name()): - try: - await self._process_stages(event) - - # Send an empty message for platform-specific completion handling. - if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): - await event.send(None) - - logger.debug("pipeline execution completed.") - finally: - event.cleanup_temporary_local_files() - active_event_registry.unregister(event) + with ( + log_context(event.get_platform_id(), event.get_platform_name()), + event_context(event), + trace_execution(event.trace), + ): + await self._process_stages(event) + + # Send the closing frame before the event is marked complete. + if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): + await event.send(None) + + logger.debug("pipeline execution completed.") diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 7008ad48272..31487a468c5 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -9,6 +9,7 @@ from astrbot.core.star.session_plugin_manager import SessionPluginManager from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry +from astrbot.core.utils.session_waiter import FILTERS, USER_SESSIONS from ..context import PipelineContext from ..stage import Stage, register_stage @@ -236,6 +237,15 @@ async def process( event.set_extra("activated_handlers", activated_handlers) event.set_extra("handlers_parsed_params", handlers_parsed_params) + # Admit follow-up input without invoking callbacks before access checks. + if not is_wake: + for session_filter in tuple(FILTERS): + session = USER_SESSIONS.get(session_filter.filter(event)) + if session and not session.session_controller.future.done(): + is_wake = True + event.is_wake = True + break + if is_wake: self._umo_auto_name_recorder.schedule(event) else: diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 0c8ad246e00..3b700a7c9c7 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -4,7 +4,9 @@ import os import re import uuid -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Iterator +from contextlib import contextmanager +from contextvars import ContextVar from time import time from typing import Any @@ -26,6 +28,7 @@ from astrbot.core.message.message_event_result import MessageChain, MessageEventResult from astrbot.core.platform.message_type import MessageType from astrbot.core.provider.entities import ProviderRequest +from astrbot.core.utils.active_event_registry import active_event_registry from astrbot.core.utils.metrics import Metric from astrbot.core.utils.trace import TraceSpan @@ -33,6 +36,45 @@ from .message_session import MessageSesion, MessageSession # noqa from .platform_metadata import PlatformMetadata +streaming_response_event: ContextVar["AstrMessageEvent | None"] = ContextVar( + "streaming_response_event", default=None +) +current_message_event: ContextVar["AstrMessageEvent | None"] = ContextVar( + "current_message_event", default=None +) + + +@contextmanager +def event_context(event: "AstrMessageEvent") -> Iterator[None]: + """Bind the current event and clean up event-owned resources. + + Logging and tracing scopes are owned separately by the framework entry + point. Nested scopes do not unregister the event or clean up files early. + + Args: + event: Event whose outermost scope owns registration and cleanup. + + Yields: + Control while the event is active. + """ + if event._execution_active: + yield + return + event._execution_active = True + event_token = current_message_event.set(event) + active_event_registry.register(event) + try: + yield + finally: + try: + event.cleanup_temporary_local_files() + finally: + try: + active_event_registry.unregister(event) + finally: + event._execution_active = False + current_message_event.reset(event_token) + class AstrMessageEvent(abc.ABC): def __init__( @@ -88,6 +130,14 @@ def __init__( """用于记录事件处理的 TraceSpan 对象""" self.span = self.trace """事件级 TraceSpan(别名: span)""" + self.trace.record( + "event_received", + kind="message", + platform_id=platform_meta.id, + platform_name=platform_meta.name, + message_type=self.get_message_type().value, + message=self.get_message_outline(), + ) self._has_send_oper = False """在此次事件中是否有过至少一次发送消息的操作""" @@ -101,6 +151,7 @@ def __init__( # back_compability self.platform = platform_meta + self._execution_active = False @property def unified_msg_origin(self) -> str: @@ -487,6 +538,19 @@ async def send(self, message: MessageChain) -> None: message (MessageChain): 消息链,具体使用方式请参考文档。 """ + if ( + message is not None + and message.chain + and streaming_response_event.get() is not self + ): + self.trace.record( + "response_sent", + kind="response", + message=self._outline_chain(message.chain), + component_types=[str(component.type) for component in message.chain], + component_count=len(message.chain), + message_type=message.type, + ) # Leverage BLAKE2 hash function to generate a non-reversible hash of the sender ID for privacy. hash_obj = hashlib.blake2b(self.get_sender_id().encode("utf-8"), digest_size=16) sid = str(uuid.UUID(bytes=hash_obj.digest())) diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 11c2d35128d..0812877abd2 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -177,7 +177,7 @@ async def send(self, message: MessageChain | None) -> None: }, ) await WebChatMessageEvent._send(message_id, message, session_id=self.session_id) - await super().send(MessageChain([])) + await super().send(message if message is not None else MessageChain([])) async def send_typing(self) -> None: """Emit a run-start signal before an independent LLM request.""" diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py index 74d120f5f3b..f4ebb5ce100 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py @@ -170,7 +170,7 @@ async def send(self, message: MessageChain | None) -> None: ): if self.only_use_webhook_url_to_send and self.webhook_client and message: await self.webhook_client.send_message_chain(message) - await super().send(MessageChain([])) + await super().send(message) return if self.webhook_client and message: @@ -191,13 +191,13 @@ async def send(self, message: MessageChain | None) -> None: }, }, ) - await super().send(MessageChain([])) + await super().send(message) return if self.only_use_webhook_url_to_send and self.webhook_client and message: await self.webhook_client.send_message_chain(message) await self._mark_stream_complete(stream_id) - await super().send(MessageChain([])) + await super().send(message) return if self.webhook_client and message: @@ -212,7 +212,7 @@ async def send(self, message: MessageChain | None) -> None: self.queue_mgr, suppress_unsupported_log=self.webhook_client is not None, ) - await super().send(MessageChain([])) + await super().send(message) async def send_streaming(self, generator, use_fallback=False) -> None: """流式发送消息,参考webchat的send_streaming设计""" diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index e7a98ea87be..f547daf618d 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -20,7 +20,12 @@ from astrbot.core.message.message_event_result import MessageChain from astrbot.core.persona_mgr import PersonaManager from astrbot.core.platform import Platform -from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion +from astrbot.core.platform.astr_message_event import ( + AstrMessageEvent, + MessageSesion, + current_message_event, + streaming_response_event, +) from astrbot.core.platform.message_type import MessageType from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType @@ -677,6 +682,24 @@ async def send_message( for platform in self.platform_manager.platform_insts: if platform.meta().id == session.platform_name: await platform.send_by_session(session, message_chain) + event = current_message_event.get() + if ( + event is not None + and event._execution_active + and message_chain.chain + and streaming_response_event.get() is not event + ): + event.trace.record( + "response_sent", + kind="response", + message=event._outline_chain(message_chain.chain), + component_types=[ + str(component.type) for component in message_chain.chain + ], + component_count=len(message_chain.chain), + message_type=message_chain.type, + target_umo=str(session), + ) settings = self.get_config(umo=str(session)).get( "provider_ltm_settings", {}, diff --git a/astrbot/core/utils/migra_helper.py b/astrbot/core/utils/migra_helper.py index 1fd8dd91b03..67c32ae7ac1 100644 --- a/astrbot/core/utils/migra_helper.py +++ b/astrbot/core/utils/migra_helper.py @@ -312,6 +312,33 @@ def migrate_config_on_load(config: dict[str, Any], config_path: Path) -> bool: Returns: Whether the configuration changed. """ + changed = False + legacy = config.get("log_file") + legacy = legacy if isinstance(legacy, dict) else {} + old_keys = ("trace_log_enable", "trace_log_path", "trace_log_max_mb") + if any(key in config for key in old_keys) or any( + key in legacy for key in ("trace_enable", "trace_path", "trace_max_mb") + ): + old_size = config.get("trace_log_max_mb", legacy.get("trace_max_mb", 20)) + if "trace_db_max_mb" not in config: + config["trace_db_max_mb"] = ( + old_size * 4 + if isinstance(old_size, int) + and not isinstance(old_size, bool) + and old_size > 0 + else 80 + ) + config.setdefault("trace_db_path", "logs/traces.db") + config.setdefault("trace_retention_days", 7) + for key in old_keys: + config.pop(key, None) + for key in ("trace_enable", "trace_path", "trace_max_mb"): + legacy.pop(key, None) + logger.warning( + "Trace file settings migrated to SQLite in %s. Recording now persists when trace_enable is on; old files are untouched and not imported.", + config_path, + ) + changed = True fallback_config = None resolved_path = config_path.resolve() profile_root = Path(get_astrbot_config_path()).resolve() @@ -328,7 +355,7 @@ def migrate_config_on_load(config: dict[str, Any], config_path: Path) -> bool: resolved_path, exc, ) - changed = _migrate_agent_runner_config(config, fallback_config) + changed = _migrate_agent_runner_config(config, fallback_config) or changed config_version = config.get("config_version") if not isinstance(config_version, int) or config_version < 5: provider_settings = config.get("provider_settings", {}) diff --git a/astrbot/core/utils/storage_cleaner.py b/astrbot/core/utils/storage_cleaner.py index 134071dce9e..3f42ad0f8bd 100644 --- a/astrbot/core/utils/storage_cleaner.py +++ b/astrbot/core/utils/storage_cleaner.py @@ -18,7 +18,8 @@ class LogFileConfig: class StorageCleaner: TARGET_LOGS = "logs" TARGET_CACHE = "cache" - VALID_TARGETS = {TARGET_LOGS, TARGET_CACHE, "all"} + TARGET_TRACES = "traces" + VALID_TARGETS = {TARGET_LOGS, TARGET_CACHE, TARGET_TRACES, "all"} def __init__( self, @@ -34,10 +35,14 @@ def __init__( def get_status(self) -> dict: logs = self._build_status(self.TARGET_LOGS) cache = self._build_status(self.TARGET_CACHE) + traces = self._build_status(self.TARGET_TRACES) return { self.TARGET_LOGS: logs, self.TARGET_CACHE: cache, - "total_bytes": logs["size_bytes"] + cache["size_bytes"], + self.TARGET_TRACES: traces, + "total_bytes": logs["size_bytes"] + + cache["size_bytes"] + + traces["size_bytes"], } def cleanup(self, target: str = "all") -> dict: @@ -46,7 +51,7 @@ def cleanup(self, target: str = "all") -> dict: raise ValueError(f"Unsupported cleanup target: {target}") targets = ( - [self.TARGET_LOGS, self.TARGET_CACHE] + [self.TARGET_TRACES, self.TARGET_LOGS, self.TARGET_CACHE] if normalized_target == "all" else [normalized_target] ) @@ -57,6 +62,7 @@ def cleanup(self, target: str = "all") -> dict: "deleted_files": 0, "truncated_files": 0, "failed_files": 0, + "removed_traces": 0, } for target_name in targets: @@ -81,6 +87,15 @@ def _build_status(self, target: str) -> dict: elif target == self.TARGET_CACHE: files = self._collect_cache_files() primary_path = self._temp_dir + elif target == self.TARGET_TRACES: + primary_path = self._resolve_log_path( + self._get_optional_str("trace_db_path"), + default_relative_path="logs/traces.db", + ) + files = { + Path(str(primary_path) + suffix) + for suffix in ("", "-wal", "-shm", "-journal") + } else: raise ValueError(f"Unsupported cleanup target: {target}") @@ -93,6 +108,27 @@ def _build_status(self, target: str) -> dict: } def _cleanup_target(self, target: str) -> dict: + if target == self.TARGET_TRACES: + from astrbot.core.utils.trace_store import get_trace_store + + before = self._build_status(target) + removed_traces = 0 + if before["size_bytes"]: + store = get_trace_store(self._config) + if store.path.resolve() != Path(before["path"]).resolve(): + raise ValueError( + "Restart AstrBot after changing the trace database path before cleaning history." + ) + removed_traces = store.clear_history()["removed_traces"] + after = self._build_status(target) + return { + "removed_bytes": max(0, before["size_bytes"] - after["size_bytes"]), + "removed_traces": removed_traces, + "processed_files": 0, + "deleted_files": 0, + "truncated_files": 0, + "failed_files": 0, + } if target == self.TARGET_LOGS: files = self._collect_log_files() active_log_files = self._active_log_files() @@ -145,6 +181,7 @@ def _cleanup_target(self, target: str) -> dict: return { "removed_bytes": removed_bytes, + "removed_traces": 0, "processed_files": deleted_files + truncated_files, "deleted_files": deleted_files, "truncated_files": truncated_files, @@ -155,7 +192,32 @@ def _collect_log_files(self) -> set[Path]: files = set(self._iter_files(self._data_dir / "logs")) for log_path in self._configured_log_paths(): files.update(self._iter_log_family_files(log_path)) - return files + trace_path = self._resolve_log_path( + self._get_optional_str("trace_db_path"), + default_relative_path="logs/traces.db", + ).resolve() + protected = { + Path(str(trace_path) + suffix) + for suffix in ("", "-wal", "-shm", "-journal") + } + candidates = set() + for path in files: + if ( + path.resolve() in protected + or path.suffix in {".db", ".sqlite", ".sqlite3"} + or path.name.endswith(("-wal", "-shm", "-journal")) + ): + continue + try: + # Protect custom database names, including an old active path + # after a configuration edit that has not restarted the core. + with path.open("rb") as stream: + if stream.read(16) == b"SQLite format 3\x00": + continue + except OSError: + continue + candidates.add(path) + return candidates def _collect_cache_files(self) -> set[Path]: files = set(self._iter_files(self._temp_dir)) @@ -168,7 +230,15 @@ def _collect_cache_files(self) -> set[Path]: if extra_file.is_file(): files.add(extra_file) - return files + trace_path = self._resolve_log_path( + self._get_optional_str("trace_db_path"), + default_relative_path="logs/traces.db", + ) + protected = { + Path(str(trace_path) + suffix).resolve() + for suffix in ("", "-wal", "-shm", "-journal") + } + return {path for path in files if path.resolve() not in protected} def _log_file_configs(self) -> list[LogFileConfig]: return [ diff --git a/astrbot/core/utils/telemetry.py b/astrbot/core/utils/telemetry.py new file mode 100644 index 00000000000..a812b2cab55 --- /dev/null +++ b/astrbot/core/utils/telemetry.py @@ -0,0 +1,112 @@ +"""Bounded application values shared by OpenTelemetry spans and logs.""" + +import json +from itertools import islice +from typing import Any + +MAX_ATTRIBUTE_BYTES = 16 * 1024 +MAX_BODY_BYTES = 16 * 1024 + + +def bounded_text(value: str, limit: int = MAX_BODY_BYTES) -> str: + """Bound text by UTF-8 bytes without splitting a character. + + Args: + value: Original text. + limit: Maximum encoded length. + + Returns: + Original text or text ending in a truncation marker. + """ + encoded = value[:limit].encode("utf-8") + if len(value) <= limit and len(encoded) <= limit: + return value + return ( + encoded[: max(0, limit - 16)].decode("utf-8", errors="ignore") + " [truncated]" + ) + + +def bounded_attributes( + fields: dict[str, Any], *, max_bytes: int = MAX_ATTRIBUTE_BYTES +) -> dict[str, Any]: + """Convert application fields to bounded OTel attribute values. + + Nested objects are represented as JSON strings, not non-standard attribute + objects. A shared byte budget also bounds wide and deeply nested inputs. + + Args: + fields: Application attributes with their final names. + max_bytes: Shared attribute budget, not a per-field text limit. + + Returns: + Primitive attributes with an optional truncation marker. + """ + remaining = max_bytes - 64 + truncated = False + result = {} + for key, value in fields.items(): + key = bounded_text(str(key), 128) + if value is None: + continue + if ( + isinstance(value, (list, tuple)) + and len(value) <= 128 + and (not value or type(value[0]) in (str, bool, int, float)) + and all(type(item) is type(value[0]) for item in value) + ): + original = value + value = [ + bounded_text(item, 256) if isinstance(item, str) else item + for item in value + ] + truncated |= value != list(original) + elif not isinstance(value, (str, bool, int, float)): + # Bound containers before serialization, including nested values. + def compact(item: Any, depth: int = 0) -> Any: + """Bound nested values before serialization.""" + nonlocal truncated + if isinstance(item, str): + text = bounded_text(item, 2048) + truncated |= text != item + return text + if depth >= 3: + truncated = True + return bounded_text(str(type(item).__name__), 128) + if isinstance(item, dict): + truncated |= len(item) > 16 + return { + str(k)[:128]: compact(v, depth + 1) + for k, v in islice(item.items(), 16) + } + if isinstance(item, (list, tuple)): + truncated |= len(item) > 16 + return [compact(v, depth + 1) for v in item[:16]] + return ( + item + if isinstance(item, (bool, int, float)) or item is None + else bounded_text(str(item), 2048) + ) + + try: + value = json.dumps(compact(value), ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError): + truncated = True + continue + if isinstance(value, str): + original = value + value = bounded_text(value, max(16, remaining - len(key) - 16)) + truncated |= value != original + try: + cost = len( + json.dumps({key: value}, ensure_ascii=False, allow_nan=False).encode() + ) + except ValueError: + continue + if cost > remaining: + result["astrbot.truncated"] = True + break + result[key] = value + remaining -= cost + if truncated: + result["astrbot.truncated"] = True + return result diff --git a/astrbot/core/utils/trace.py b/astrbot/core/utils/trace.py index 7b095dbc019..c6de89ac061 100644 --- a/astrbot/core/utils/trace.py +++ b/astrbot/core/utils/trace.py @@ -1,15 +1,51 @@ +"""SDK-managed traces bridged to local SQLite and live JSON updates.""" + import json -import logging +import secrets import time -import uuid +from collections.abc import Iterator +from contextlib import contextmanager from typing import Any -from astrbot import logger -from astrbot.core import LogManager, astrbot_config +from opentelemetry import context as otel_context +from opentelemetry import trace as otel_trace +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import Span, SpanLimits, TracerProvider +from opentelemetry.sdk.trace.sampling import ALWAYS_ON +from opentelemetry.trace import Status, StatusCode, format_span_id, format_trace_id + +from astrbot import __version__, logger +from astrbot.core import astrbot_config from astrbot.core.log import LogQueueHandler +from astrbot.core.utils.telemetry import ( + bounded_attributes, + bounded_text, +) +from astrbot.core.utils.trace_store import get_trace_store _cached_log_broker = None -_trace_logger = None +MAX_TRACE_BYTES = 1024 * 1024 +MAX_SPANS = 128 +MAX_EVENTS = 256 + +# Keep local recording independent of global/plugin providers and exporters. +# Application byte budgets bound attributes without SDK truncation. +_provider = TracerProvider( + resource=Resource({"service.name": "astrbot", "service.version": __version__}), + sampler=ALWAYS_ON, + span_limits=SpanLimits( + max_attributes=SpanLimits.UNSET, + max_span_attributes=SpanLimits.UNSET, + max_event_attributes=SpanLimits.UNSET, + max_link_attributes=SpanLimits.UNSET, + max_attribute_length=SpanLimits.UNSET, + max_span_attribute_length=SpanLimits.UNSET, + max_events=MAX_EVENTS, + max_links=0, + ), + shutdown_on_exit=False, +) +_tracer = _provider.get_tracer("astrbot.trace", __version__) def _get_log_broker(): @@ -23,18 +59,9 @@ def _get_log_broker(): return None -def _get_trace_logger(): - global _trace_logger - if _trace_logger is not None: - return _trace_logger - - # 按配置初始化 trace 文件日志 - LogManager.configure_trace_logger(astrbot_config) - _trace_logger = logging.getLogger("astrbot.trace") - return _trace_logger - - class TraceSpan: + """Own one message trace and its bounded operation spans and events.""" + def __init__( self, name: str, @@ -42,36 +69,386 @@ def __init__( sender_name: str | None = None, message_outline: str | None = None, ) -> None: - self.span_id = str(uuid.uuid4()) - self.name = name - self.umo = umo - self.sender_name = sender_name - self.message_outline = message_outline + self.name = bounded_text(name, 256) + self.umo = bounded_text(umo or "", 1024) + self.sender_name = bounded_text(sender_name or "", 256) + self.message_outline = bounded_text(message_outline or "", 4096) self.started_at = time.time() + self._start_ns = time.time_ns() + self._root = _tracer.start_span( + self.name, + context=otel_context.Context(), + start_time=self._start_ns, + attributes={"astrbot.kind": "message"}, + ) + root_context = self._root.get_span_context() + self.trace_id = format_trace_id(root_context.trace_id) + self.span_id = format_span_id(root_context.span_id) + self._started = False + self._finished = False + self._execution_active = False + self._bytes = 0 + self._events = 0 + self._span_count = 1 + self._pending: dict[str, tuple[Span, Any, str]] = {} + self._plugins: set[str] = set() + self._summary = { + "trace_id": self.trace_id, + "root_span_id": self.span_id, + "name": self.name, + "start_time_unix_nano": str(self._start_ns), + "end_time_unix_nano": None, + "state": "running", + "has_errors": False, + "has_models": False, + "has_plugins": False, + "has_commands": False, + "span_count": 1, + "truncated": False, + "attributes": bounded_attributes( + { + "astrbot.umo": self.umo, + "astrbot.sender_name": self.sender_name, + "astrbot.message_outline": self.message_outline, + } + ), + "resource": {"attributes": dict(_provider.resource.attributes)}, + } - def record(self, action: str, **fields: Any) -> None: - # Check if trace recording is enabled - if not astrbot_config.get("trace_enable", True): + @contextmanager + def context(self) -> Iterator[None]: + """Bind the message root without correlating unrecorded messages. + + Yields: + Control while this root's IDs are active. + """ + if not self._root.is_recording() or ( + not astrbot_config.get("trace_enable", False) and not self._started + ): + yield return + with otel_trace.use_span( + self._root, record_exception=False, set_status_on_exception=False + ): + yield + + def _emit( + self, + *, + span: Span | None = None, + event: dict | None = None, + closing: bool = False, + ) -> None: + """Queue a bounded snapshot and separately notify live subscribers. + Args: + span: Standard span update. + event: Standard event associated with a span. + closing: Whether completion must survive the trace payload limit. + """ payload = { "type": "trace", - "level": "TRACE", - "time": time.time(), - "span_id": self.span_id, - "name": self.name, - "umo": self.umo, - "sender_name": self.sender_name, - "message_outline": self.message_outline, - "action": action, - "fields": fields, + "schema_version": 3, + "trace": self._summary, + "time_unix_nano": str(time.time_ns()), } - log_broker = _get_log_broker() - if log_broker: - log_broker.publish(payload) - else: - logger.info(f"[trace] {payload}") - - trace_logger = _get_trace_logger() - if trace_logger and trace_logger.handlers: - trace_logger.info(json.dumps(payload, ensure_ascii=False)) + self._summary["updated_time_unix_nano"] = payload["time_unix_nano"] + if span is not None: + status = {"code": span.status.status_code.value} + if span.status.description: + status["message"] = span.status.description + payload["span"] = { + "trace_id": format_trace_id(span.context.trace_id), + "span_id": format_span_id(span.context.span_id), + "parent_span_id": format_span_id(span.parent.span_id) + if span.parent + else None, + "name": span.name, + # SDK SpanKind is zero-based; the existing JSON uses OTel wire values. + "kind": span.kind.value + 1, + "start_time_unix_nano": str(span.start_time), + "end_time_unix_nano": str(span.end_time) + if span.end_time is not None + else None, + "status": status, + "attributes": dict(span.attributes or {}), + } + if event is not None: + payload["event"] = event + size = len(json.dumps(payload, ensure_ascii=False).encode()) + if self._bytes + size > MAX_TRACE_BYTES: + self._summary["truncated"] = True + self._bytes = MAX_TRACE_BYTES + if not closing: + return + if span is not None: + payload["span"]["attributes"] = bounded_attributes( + { + key: bounded_text(value, 256) + if isinstance(value, str) + else value + for key, value in payload["span"]["attributes"].items() + if key + in { + "astrbot.kind", + "astrbot.plugin", + "astrbot.handler", + "astrbot.handler_full_name", + "astrbot.handler_category", + "astrbot.handler_type", + "astrbot.runner_type", + } + } + ) + self._bytes += size + try: + accepted = get_trace_store(astrbot_config).append(payload, owner=self) + except Exception: + accepted = False + logger.error("Failed to queue trace persistence.", exc_info=True) + # Persistence acknowledgements are not implied by an SSE notification. + live = json.loads( + json.dumps({**payload, "storage_accepted": accepted}, ensure_ascii=False) + ) + broker = _get_log_broker() + if broker: + broker.publish(live) + + def record(self, action: str, **fields: Any) -> None: + """Record a span transition or event using bounded primitive attributes. + + Args: + action: Existing instrumentation action. + **fields: AstrBot-specific metadata mapped to namespaced attributes. + """ + if self._finished or not self._root.is_recording(): + return + enabled = bool(astrbot_config.get("trace_enable", False)) + if not enabled and not self._started: + return + now = time.time_ns() + attrs = bounded_attributes( + { + "astrbot." + key: value + for key, value in fields.items() + if key not in {"action", "end_action", "duration_ms"} + }, + max_bytes=MAX_TRACE_BYTES, + ) + if attrs.get("astrbot.truncated"): + self._summary["truncated"] = True + if fields.get("status") == "error": + self._summary["has_errors"] = True + if action == "event_received": + self._summary["attributes"].update( + {key: value for key, value in attrs.items() if key != "astrbot.message"} + ) + self._root.set_attributes(attrs) + if not self._started: + self._started = True + self._emit(span=self._root) + if action == "event_received": + return + + current = otel_trace.get_current_span() + if ( + not current.is_recording() + or current.get_span_context().trace_id != self._root.context.trace_id + ): + current = self._root + parent = format_span_id(current.get_span_context().span_id) + starts = { + "handler_start": "handler_end", + "agent_start": "agent_end", + "agent_tool_call": "agent_tool_result", + } + if action in starts: + if ( + not enabled + or self._span_count >= MAX_SPANS + or self._bytes >= MAX_TRACE_BYTES + ): + self._summary["truncated"] = True + return + call_kind = { + "handler_start": "plugin_handler", + "agent_start": "agent", + "agent_tool_call": "tool", + }[action] + span = _tracer.start_span( + bounded_text( + str( + fields.get("handler_full_name") + or fields.get("tool_name") + or call_kind + ), + 256, + ), + context=otel_trace.set_span_in_context(current), + start_time=now, + attributes={ + **attrs, + "astrbot.kind": call_kind, + }, + ) + span_id = format_span_id(span.context.span_id) + token = otel_context.attach(otel_trace.set_span_in_context(span)) + self._pending[span_id] = (span, token, starts[action]) + self._span_count += 1 + self._summary["span_count"] = self._span_count + if action == "handler_start": + self._summary["has_plugins"] = True + if fields.get("handler_category") == "command": + self._summary["has_commands"] = True + if len(self._plugins) < MAX_SPANS: + self._plugins.add( + bounded_text(str(fields.get("plugin") or ""), 256) + ) + if len(self._plugins) < MAX_SPANS: + self._plugins.add( + bounded_text( + str( + fields.get("plugin_display_name") + or fields.get("plugin") + or "" + ), + 256, + ) + ) + self._summary["attributes"]["astrbot.plugins"] = bounded_text( + ", ".join(sorted(self._plugins)), 4096 + ) + if action == "agent_start": + self._summary["has_models"] = True + self._emit(span=span) + return + + if action in starts.values() or action == "pipeline_end": + target = parent + while target in self._pending: + span, _, end_action = self._pending[target] + if action == "pipeline_end" or ( + end_action == action + and ( + action != "handler_end" + or span.attributes.get("astrbot.handler_full_name") + == fields.get("handler_full_name") + ) + ): + break + target = format_span_id(span.parent.span_id) + if action != "pipeline_end" and target not in self._pending: + return + # Close abandoned descendants before their ancestor. Their copied + # contexts are invalidated even when an operation is cancelled. + while parent in self._pending: + span, token, _ = self._pending.pop(parent) + is_target = parent == target and action != "pipeline_end" + if is_target: + span.set_attributes( + bounded_attributes( + {**span.attributes, **attrs}, + max_bytes=MAX_TRACE_BYTES, + ) + ) + failed = fields.get("status") == "error" + span.set_status( + Status( + StatusCode.ERROR if failed else StatusCode.OK, + bounded_text(str(fields["error"]), 1024) + if failed and fields.get("error") + else None, + ) + ) + else: + span.set_status( + Status( + StatusCode.ERROR, "Parent operation ended before this span." + ) + ) + self._summary["has_errors"] = True + span.end(end_time=now) + otel_context.detach(token) + parent = format_span_id(span.parent.span_id) + self._emit(span=span, closing=True) + if is_target: + break + if action != "pipeline_end": + return + for span, _, _ in self._pending.values(): + span.set_status( + Status( + StatusCode.ERROR, + "Message ended before this concurrent operation.", + ) + ) + span.end(end_time=now) + self._summary["has_errors"] = True + self._emit(span=span, closing=True) + self._pending.clear() + self._summary["state"] = "completed" + self._summary["end_time_unix_nano"] = str(now) + self._root.set_attributes(attrs) + failed = fields.get("status") == "error" + self._root.set_status( + Status( + StatusCode.ERROR if failed else StatusCode.OK, + bounded_text(str(fields["error"]), 1024) + if failed and fields.get("error") + else None, + ) + ) + self._root.end(end_time=now) + self._finished = True + self._emit(span=self._root, closing=True) + return + + if not enabled or self._events >= MAX_EVENTS or self._bytes >= MAX_TRACE_BYTES: + self._summary["truncated"] = True + return + self._events += 1 + current.add_event(action, attributes=attrs, timestamp=now) + self._emit( + event={ + "event_id": secrets.token_hex(8), + "trace_id": self.trace_id, + "span_id": parent, + "name": action, + "time_unix_nano": str(now), + "attributes": attrs, + } + ) + + +@contextmanager +def trace_execution(trace: TraceSpan) -> Iterator[None]: + """Bind and finish a trace independently of message-event resources. + + Args: + trace: Trace whose outermost execution scope owns completion. + + Yields: + Control while the trace's root span is bound. + + Raises: + BaseException: Any execution failure or cancellation, unchanged. + """ + if trace._execution_active or trace._finished: + yield + return + with trace.context(): + trace._execution_active = True + status = "success" + error = None + try: + yield + except BaseException as exc: + status = "error" + error = str(exc) or type(exc).__name__ + raise + finally: + try: + trace.record("pipeline_end", status=status, error=error) + finally: + trace._finished = True + trace._execution_active = False diff --git a/astrbot/core/utils/trace_store.py b/astrbot/core/utils/trace_store.py new file mode 100644 index 00000000000..63eb2bfec00 --- /dev/null +++ b/astrbot/core/utils/trace_store.py @@ -0,0 +1,665 @@ +"""Independent, bounded SQLite storage for local trace history.""" + +from __future__ import annotations + +import atexit +import json +import logging +import sqlite3 +import threading +import time +from collections.abc import Mapping +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from contextlib import closing +from pathlib import Path +from queue import Empty, Full, Queue +from typing import Any +from weakref import WeakValueDictionary + +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +logger = logging.getLogger("astrbot") +QUEUE_BYTES = 128 * 1024 * 1024 +MAX_WAL_BYTES = 4 * 1024 * 1024 +MAX_FRAME_BYTES = 2 * 1024 * 1024 + + +class TraceStore: + """Store span updates and events using one bounded background writer.""" + + def __init__(self, path: Path, config: Mapping[str, Any]) -> None: + self.path = path + self.config = config + self.queue: Queue = Queue() + self._lock = threading.Lock() + self._pending_bytes = 0 + self._pending_traces: dict[str, int] = {} + self._active_traces: WeakValueDictionary[str, Any] = WeakValueDictionary() + self._stop = threading.Event() + self._ready = threading.Event() + self._failed = False + self.dropped = 0 + self.last_error: str | None = None + self._last_warning = 0.0 + self._thread = threading.Thread( + target=self._run, name="trace-sqlite", daemon=True + ) + self._thread.start() + + def append(self, payload: dict, *, owner: Any = None) -> bool: + """Queue an immutable snapshot without blocking message processing. + + Args: + payload: A trace summary with a span update or event. + owner: Live trace object, held weakly until execution finishes. + + Returns: + Whether the bounded queue accepted the snapshot. + """ + encoded = json.dumps( + payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ) + size = len(encoded.encode()) + trace_id = payload.get("trace", {}).get("trace_id") + with self._lock: + if owner is not None: + if owner._finished: + self._active_traces.pop(trace_id, None) + else: + self._active_traces[trace_id] = owner + if ( + self._stop.is_set() + or self._failed + or size > MAX_FRAME_BYTES + or self._pending_bytes + size > QUEUE_BYTES + ): + accepted = False + else: + try: + self.queue.put_nowait((encoded, size)) + self._pending_bytes += size + self._pending_traces[trace_id] = ( + self._pending_traces.get(trace_id, 0) + 1 + ) + accepted = True + except Full: + accepted = False + if not accepted: + self.dropped += 1 + if not accepted and time.monotonic() - self._last_warning > 30: + self._last_warning = time.monotonic() + logger.warning( + "Trace persistence rejected a record: queue/capacity unavailable (dropped=%s).", + self.dropped, + ) + return accepted + + def flush(self, timeout: float = 5) -> bool: + """Wait for accepted snapshots to commit. + + Args: + timeout: Maximum seconds to wait. + + Returns: + Whether the barrier committed without a storage error. + """ + if self._failed or self._stop.is_set(): + return False + done = threading.Event() + try: + self.queue.put((done, 0), timeout=timeout) + except Full: + return False + return done.wait(timeout) and self.last_error is None + + def close(self) -> None: + """Drain the writer and close its SQLite connection.""" + self._stop.set() + self._thread.join(timeout=5) + if self._thread.is_alive(): + logger.error( + "Trace database writer did not finish within the shutdown deadline." + ) + + def clear_history(self) -> dict: + """Clear finished history using the same queue as trace writes. + + Running traces are retained so their later updates remain complete. + + Returns: + The number of removed message traces. + + Raises: + RuntimeError: If the writer is unavailable or cleanup times out. + """ + if not self._ready.wait(5) or self._failed or self._stop.is_set(): + raise RuntimeError(self.last_error or "Trace writer is unavailable.") + result = Future() + try: + self.queue.put((result, 0), timeout=5) + except Full as exc: + raise RuntimeError("Trace cleanup queue is full.") from exc + try: + return result.result(timeout=15) + except FutureTimeoutError as exc: + result.cancel() + raise RuntimeError( + "Trace cleanup timed out; check storage status." + ) from exc + + def _interrupt_inactive(self, connection: sqlite3.Connection) -> None: + """Close orphaned records without interrupting live or queued traces. + + Args: + connection: Writer-owned connection. + """ + with self._lock: + protected = set(self._pending_traces) + protected.update( + trace_id + for trace_id, owner in self._active_traces.items() + if not owner._finished + ) + # A temporary table avoids SQLite's parameter limit at high concurrency. + connection.execute( + "CREATE TEMP TABLE IF NOT EXISTS protected_traces(trace_id TEXT PRIMARY KEY)" + ) + connection.execute("DELETE FROM protected_traces") + connection.executemany( + "INSERT INTO protected_traces VALUES (?)", ((item,) for item in protected) + ) + connection.execute("""UPDATE traces SET state='interrupted', has_errors=1, + data=json_set(data, '$.state', 'interrupted', '$.has_errors', json('true'), + '$.end_time_unix_nano', CAST(updated_ns AS TEXT)) + WHERE state='running' AND trace_id NOT IN (SELECT trace_id FROM protected_traces)""") + connection.execute("""UPDATE spans SET data=json_set(data, + '$.end_time_unix_nano', CAST((SELECT updated_ns FROM traces WHERE traces.trace_id=spans.trace_id) AS TEXT), + '$.status.code', 2, '$.status.message', 'Execution ended before persistence completed') + WHERE trace_id IN (SELECT trace_id FROM traces WHERE state='interrupted') + AND json_extract(data, '$.end_time_unix_nano') IS NULL""") + + def _prune(self, connection: sqlite3.Connection, force: bool = False) -> None: + """Evict complete traces as units and reclaim reusable database pages. + + Args: + connection: Writer-owned connection. + force: Whether to free additional space after a full-disk error. + """ + self._interrupt_inactive(connection) + max_mb = self.config.get("trace_db_max_mb", 80) + max_mb = ( + max(32, max_mb) + if isinstance(max_mb, int) and not isinstance(max_mb, bool) and max_mb > 0 + else 80 + ) + days = self.config.get("trace_retention_days", 7) + days = ( + days + if isinstance(days, int) and not isinstance(days, bool) and days > 0 + else 7 + ) + connection.execute( + "DELETE FROM traces WHERE state != 'running' AND updated_ns < ?", + (time.time_ns() - days * 86400 * 10**9,), + ) + logical = sum( + connection.execute( + f"SELECT COALESCE(SUM(size_bytes), 0) FROM {table}" + ).fetchone()[0] + for table in ("traces", "spans", "events") + ) + physical = connection.execute("PRAGMA page_count").fetchone()[0] * 4096 + free = connection.execute("PRAGMA freelist_count").fetchone()[0] * 4096 + budget = max_mb * 1024 * 1024 + if force or logical > budget * 0.75 or physical - free > budget * 0.85: + target = max(0, logical - max(budget // 4, logical - int(budget * 0.6))) + while logical > target: + oldest = connection.execute( + "SELECT trace_id FROM traces WHERE state != 'running' ORDER BY start_ns, trace_id LIMIT 32" + ).fetchall() + if not oldest: + break + connection.executemany("DELETE FROM traces WHERE trace_id = ?", oldest) + logical = sum( + connection.execute( + f"SELECT COALESCE(SUM(size_bytes), 0) FROM {table}" + ).fetchone()[0] + for table in ("traces", "spans", "events") + ) + connection.commit() + connection.execute("PRAGMA incremental_vacuum(256)") + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") + connection.execute(f"PRAGMA max_page_count={max(256, budget // 4096)}") + + def _write(self, connection: sqlite3.Connection, batch: list[dict]) -> None: + """Upsert bounded snapshots in one transaction. + + Args: + connection: Writer-owned connection. + batch: Snapshots to persist atomically. + """ + with connection: + for payload in batch: + summary = payload["trace"] + trace_id = summary["trace_id"] + data = json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + attrs = summary["attributes"] + search = " ".join( + str(attrs.get("astrbot." + key, "")) + for key in ("umo", "sender_name", "message_outline", "plugins") + ) + connection.execute( + """INSERT INTO traces VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trace_id) DO UPDATE SET + updated_ns=excluded.updated_ns, state=excluded.state, + has_errors=MAX(traces.has_errors, excluded.has_errors), + has_models=MAX(traces.has_models, excluded.has_models), + has_plugins=MAX(traces.has_plugins, excluded.has_plugins), + search=excluded.search, data=excluded.data, size_bytes=excluded.size_bytes + WHERE excluded.updated_ns >= traces.updated_ns""", + ( + trace_id, + int(summary["start_time_unix_nano"]), + int(payload["time_unix_nano"]), + summary["state"], + summary["has_errors"], + summary["has_models"], + summary["has_plugins"], + search, + data, + len(data.encode()), + summary["root_span_id"], + ), + ) + if "span" in payload: + span = payload["span"] + data = json.dumps(span, ensure_ascii=False, separators=(",", ":")) + connection.execute( + """INSERT INTO spans VALUES (?, ?, ?, ?, ?) + ON CONFLICT(span_id) DO UPDATE SET data=excluded.data, size_bytes=excluded.size_bytes + WHERE json_extract(spans.data, '$.end_time_unix_nano') IS NULL + OR json_extract(excluded.data, '$.end_time_unix_nano') IS NOT NULL""", + ( + span["span_id"], + trace_id, + span["parent_span_id"], + data, + len(data.encode()), + ), + ) + if "event" in payload: + event = payload["event"] + data = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + connection.execute( + "INSERT OR IGNORE INTO events VALUES (?, ?, ?, ?, ?, ?)", + ( + event["event_id"], + trace_id, + event["span_id"], + int(event["time_unix_nano"]), + data, + len(data.encode()), + ), + ) + + def _run(self) -> None: + """Own the connection, batch writes, and perform bounded retention.""" + connection = None + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=1) + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + if tables and "trace_meta" not in tables: + raise ValueError( + "Trace path contains a different database; choose a separate path." + ) + application_id = connection.execute("PRAGMA application_id").fetchone()[0] + if application_id not in (0, 0x41535452): + raise ValueError("Trace path belongs to a different application.") + if "trace_meta" in tables: + if ( + connection.execute("SELECT version FROM trace_meta").fetchone()[0] + != 1 + ): + raise ValueError("Unsupported trace database schema version.") + connection.execute("PRAGMA application_id=1095980114") + connection.execute("PRAGMA page_size=4096") + connection.execute("PRAGMA auto_vacuum=INCREMENTAL") + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA journal_size_limit=4194304") + connection.execute("PRAGMA wal_autocheckpoint=128") + connection.executescript(""" + CREATE TABLE IF NOT EXISTS trace_meta(version INTEGER NOT NULL); + INSERT INTO trace_meta SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM trace_meta); + CREATE TABLE IF NOT EXISTS traces( + trace_id TEXT PRIMARY KEY, start_ns INTEGER NOT NULL, + updated_ns INTEGER NOT NULL, state TEXT NOT NULL, + has_errors INTEGER NOT NULL, has_models INTEGER NOT NULL, + has_plugins INTEGER NOT NULL, search TEXT NOT NULL, + data TEXT NOT NULL, size_bytes INTEGER NOT NULL, + root_span_id TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS traces_time ON traces(start_ns DESC, trace_id DESC); + CREATE INDEX IF NOT EXISTS traces_errors ON traces(has_errors, start_ns DESC); + CREATE INDEX IF NOT EXISTS traces_models ON traces(has_models, start_ns DESC); + CREATE INDEX IF NOT EXISTS traces_plugins ON traces(has_plugins, start_ns DESC); + CREATE TABLE IF NOT EXISTS spans( + span_id TEXT PRIMARY KEY, trace_id TEXT NOT NULL REFERENCES traces(trace_id) ON DELETE CASCADE, + parent_span_id TEXT, data TEXT NOT NULL, size_bytes INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS spans_trace ON spans(trace_id); + CREATE TABLE IF NOT EXISTS events( + event_id TEXT PRIMARY KEY, trace_id TEXT NOT NULL REFERENCES traces(trace_id) ON DELETE CASCADE, + span_id TEXT NOT NULL, time_ns INTEGER NOT NULL, + data TEXT NOT NULL, size_bytes INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id, time_ns); + """) + if connection.execute("SELECT version FROM trace_meta").fetchone()[0] != 1: + raise ValueError("Unsupported trace database schema version.") + # A process restart cannot resume spans from a previous process. + self._interrupt_inactive(connection) + connection.commit() + self._prune(connection) + self._ready.set() + last_prune = time.monotonic() + while not self._stop.is_set() or not self.queue.empty(): + try: + first = self.queue.get(timeout=0.1) + except Empty: + if time.monotonic() - last_prune >= 60: + self._prune(connection) + last_prune = time.monotonic() + continue + items = [first] + for _ in range(31): + # Cleanup is a barrier: never batch later writes before it. + if isinstance(items[-1][0], Future): + break + try: + items.append(self.queue.get_nowait()) + except Empty: + break + batch = [json.loads(item) for item, _ in items if isinstance(item, str)] + written = False + try: + wal = Path(str(self.path) + "-wal") + if wal.exists() and wal.stat().st_size > MAX_WAL_BYTES: + self._prune(connection) + if wal.exists() and wal.stat().st_size > MAX_WAL_BYTES: + raise sqlite3.OperationalError( + "Trace WAL cannot checkpoint within its capacity budget." + ) + try: + self._write(connection, batch) + except sqlite3.OperationalError as exc: + # A lock or I/O error must not trigger early eviction. + if "database or disk is full" not in str(exc).lower(): + raise + self._prune(connection, force=True) + self._write(connection, batch) + written = True + # All frames in this batch have committed. Later queued frames + # still protect their trace from orphan reconciliation. + with self._lock: + for payload in batch: + trace_id = payload["trace"]["trace_id"] + self._pending_traces[trace_id] -= 1 + if not self._pending_traces[trace_id]: + del self._pending_traces[trace_id] + if batch: + self.last_error = None + if time.monotonic() - last_prune >= 10: + self._prune(connection) + last_prune = time.monotonic() + command = items[-1][0] + if ( + isinstance(command, Future) + and command.set_running_or_notify_cancel() + ): + with connection: + self._interrupt_inactive(connection) + removed = connection.execute( + "DELETE FROM traces WHERE state != 'running'" + ).rowcount + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") + connection.execute("VACUUM") + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") + self.last_error = None + command.set_result({"removed_traces": removed}) + except Exception as exc: + self.last_error = str(exc) + self.dropped += len(batch) if not written else 0 + if not written: + with self._lock: + for payload in batch: + trace_id = payload["trace"]["trace_id"] + self._pending_traces[trace_id] -= 1 + if not self._pending_traces[trace_id]: + del self._pending_traces[trace_id] + logger.error("Trace database write failed: %s", exc) + for item, _ in items: + if isinstance(item, Future) and not item.done(): + item.set_exception(exc) + finally: + for item, size in items: + with self._lock: + self._pending_bytes -= size + if isinstance(item, threading.Event): + item.set() + self.queue.task_done() + except Exception as exc: + self._failed = True + self.last_error = str(exc) + logger.error("Trace database initialization failed: %s", exc) + while True: + try: + item, size = self.queue.get_nowait() + except Empty: + break + with self._lock: + self._pending_bytes -= size + self.dropped += int(size > 0) + if isinstance(item, str): + trace_id = json.loads(item)["trace"]["trace_id"] + self._pending_traces[trace_id] -= 1 + if not self._pending_traces[trace_id]: + del self._pending_traces[trace_id] + if isinstance(item, threading.Event): + item.set() + elif isinstance(item, Future) and not item.done(): + item.set_exception(RuntimeError(self.last_error)) + self.queue.task_done() + finally: + self._ready.set() + if connection is not None: + connection.close() + + def list_traces( + self, + *, + limit: int = 50, + page: int = 1, + cursor: str | None = None, + query: str = "", + errors: bool = False, + models: bool = False, + plugins: bool = False, + ) -> dict: + """Read one numbered or keyset page, never all stored span bodies. + + Args: + limit: Maximum summaries, capped at 100. + page: One-based page number; mutually exclusive with a cursor. + cursor: Previous page's timestamp and trace ID. + query: Message, session, sender, or plugin search. + errors: Require an error. + models: Require an Agent call. + plugins: Require a plugin call. + + Returns: + Summaries, filtered count, next cursor, and storage health. + + Raises: + RuntimeError: If initialization failed. + ValueError: If the page or cursor is invalid. + """ + if not self._ready.wait(5) or self._failed: + raise RuntimeError(self.last_error or "Trace database is not ready.") + limit = min(100, max(1, limit)) + if page < 1 or (cursor and page != 1): + raise ValueError("Invalid trace page.") + conditions, values = [], [] + for key, enabled in ( + ("has_errors", errors), + ("has_models", models), + ("has_plugins", plugins), + ): + if enabled: + conditions.append(key + "=1") + if query: + conditions.append("search LIKE ? ESCAPE '\\'") + values.append( + "%" + + query[:256] + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + + "%" + ) + where = " WHERE " + " AND ".join(conditions) if conditions else "" + with closing(sqlite3.connect(self.path, timeout=1)) as connection: + connection.execute("BEGIN") + total = connection.execute( + "SELECT COUNT(*) FROM traces" + where, values + ).fetchone()[0] + page = min(page, max(1, (total + limit - 1) // limit)) + if cursor: + stamp, trace_id = cursor.split(":", 1) + if len(trace_id) != 32 or any( + c not in "0123456789abcdef" for c in trace_id + ): + raise ValueError("Invalid trace cursor.") + conditions.append("(start_ns < ? OR (start_ns = ? AND trace_id < ?))") + values.extend((int(stamp), int(stamp), trace_id)) + where = " WHERE " + " AND ".join(conditions) if conditions else "" + rows = connection.execute( + "SELECT data, start_ns, trace_id FROM traces" + + where + + " ORDER BY start_ns DESC, trace_id DESC LIMIT ? OFFSET ?", + [*values, limit + 1, (page - 1) * limit], + ).fetchall() + summaries = [json.loads(row[0]) for row in rows[:limit]] + # Older summaries predate command flags; derive only those flags + # from this page's indexed spans without loading full trace detail. + legacy = { + summary["trace_id"]: summary + for summary in summaries + if "has_commands" not in summary + } + if legacy: + placeholders = ",".join("?" for _ in legacy) + for trace_id, data in connection.execute( + f"SELECT trace_id, data FROM spans WHERE trace_id IN ({placeholders})", + list(legacy), + ): + if ( + json.loads(data) + .get("attributes", {}) + .get("astrbot.handler_category") + == "command" + ): + legacy[trace_id]["has_commands"] = True + for summary in legacy.values(): + summary.setdefault("has_commands", False) + return { + "traces": summaries, + "total": total, + "page": page, + "cursor": f"{rows[limit - 1][1]}:{rows[limit - 1][2]}" + if len(rows) > limit + else None, + "storage": {"dropped": self.dropped, "error": self.last_error}, + } + + def get_trace(self, trace_id: str) -> dict | None: + """Read one bounded trace and its spans/events. + + Args: + trace_id: ID of the requested trace. + + Returns: + Complete detail, or None when evicted or absent. + + Raises: + RuntimeError: If storage initialization failed. + """ + if not self._ready.wait(5) or self._failed: + raise RuntimeError(self.last_error or "Trace database is not ready.") + connection = sqlite3.connect(self.path, timeout=1) + try: + connection.execute("BEGIN") + row = connection.execute( + "SELECT data FROM traces WHERE trace_id=?", (trace_id,) + ).fetchone() + if not row: + return None + spans = [ + json.loads(item[0]) + for item in connection.execute( + "SELECT data FROM spans WHERE trace_id=? LIMIT 128", (trace_id,) + ) + ] + events = [ + json.loads(item[0]) + for item in connection.execute( + "SELECT data FROM events WHERE trace_id=? ORDER BY time_ns, event_id LIMIT 256", + (trace_id,), + ) + ] + by_id = {span["span_id"]: span for span in spans} + for event in events: + owner = by_id.get(event["span_id"]) + if owner is not None: + owner.setdefault("events", []).append(event) + return {"trace": json.loads(row[0]), "spans": spans} + finally: + connection.close() + + +_store: TraceStore | None = None +_store_lock = threading.Lock() + + +def get_trace_store(config: Mapping[str, Any] | None = None) -> TraceStore: + """Get the process-wide dedicated writer. + + Args: + config: Global configuration, or None to resolve it lazily. + + Returns: + The local trace store. + """ + global _store + with _store_lock: + if _store is None: + if config is None: + from astrbot.core import astrbot_config + + config = astrbot_config + path = Path(config.get("trace_db_path") or "logs/traces.db") + if not path.is_absolute(): + path = Path(get_astrbot_data_path()) / path + _store = TraceStore(path.resolve(), config) + atexit.register(_store.close) + return _store + + +def close_trace_store() -> None: + """Flush an initialized trace store without creating a new one.""" + if _store is not None: + _store.close() diff --git a/astrbot/dashboard/api/logs.py b/astrbot/dashboard/api/logs.py index de5e6b5729e..c8b97506166 100644 --- a/astrbot/dashboard/api/logs.py +++ b/astrbot/dashboard/api/logs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Header, Request +from fastapi import APIRouter, Depends, Header, Path, Query, Request from fastapi.responses import StreamingResponse from astrbot.dashboard.responses import ApiError, ok @@ -88,6 +88,51 @@ async def get_trace_settings( return _get_trace_settings(service) +@router.get("/trace/history") +async def list_traces( + limit: int = Query(default=50, ge=1, le=100), + page: int = Query(default=1, ge=1), + cursor: str | None = Query(default=None, max_length=64), + query: str = Query(default="", max_length=256), + errors: bool = False, + models: bool = False, + plugins: bool = False, + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + """Return a page of persisted trace summaries.""" + try: + return ok( + await service.get_trace_history( + limit=limit, + page=page, + cursor=cursor, + query=query, + errors=errors, + models=models, + plugins=plugins, + ) + ) + except LogServiceError as exc: + _raise_log_error(exc) + + +@router.get("/trace/{trace_id}") +async def get_trace_detail( + trace_id: str = Path(pattern="^[0-9a-f]{32}$"), + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + """Return one trace's canonical spans and events.""" + try: + detail = await service.get_trace_detail(trace_id) + except LogServiceError as exc: + _raise_log_error(exc) + if detail is None: + raise ApiError("Trace not found or already evicted.", status_code=404) + return ok(detail) + + @router.put("/trace/settings") async def update_trace_settings( payload: TraceSettingsRequest, diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index d181762fabe..0cbb871fba5 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -204,7 +204,7 @@ class TraceSettingsRequest(BaseModel): class StorageCleanupRequest(BaseModel): - target: str = "all" + target: Literal["logs", "cache", "traces", "all"] = "all" class GhProxyTestRequest(BaseModel): diff --git a/astrbot/dashboard/services/log_service.py b/astrbot/dashboard/services/log_service.py index 52cbcc05d82..cf923e53eac 100644 --- a/astrbot/dashboard/services/log_service.py +++ b/astrbot/dashboard/services/log_service.py @@ -7,6 +7,7 @@ from astrbot.core import LogBroker, logger from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.utils.trace_store import get_trace_store class LogServiceError(Exception): @@ -76,6 +77,45 @@ def get_trace_settings(self) -> dict: logger.error(f"获取 Trace 设置失败: {exc}") raise LogServiceError(f"获取 Trace 设置失败: {exc}") from exc + async def get_trace_history(self, **filters) -> dict: + """Read a bounded database page. + + Args: + **filters: Validated page/cursor, keyword, and feature filters. + + Returns: + Trace summaries and pagination metadata. + + Raises: + LogServiceError: If the cursor or database is unavailable. + """ + try: + store = get_trace_store(self.config) + return await asyncio.to_thread(store.list_traces, **filters) + except Exception as exc: + logger.error("Failed to query trace history: %s", exc) + raise LogServiceError(str(exc)) from exc + + async def get_trace_detail(self, trace_id: str) -> dict | None: + """Read the spans and events of a single trace. + + Args: + trace_id: Validated trace identifier. + + Returns: + Trace detail, or None if evicted. + + Raises: + LogServiceError: If database access fails. + """ + try: + return await asyncio.to_thread( + get_trace_store(self.config).get_trace, trace_id + ) + except Exception as exc: + logger.error("Failed to query trace detail: %s", exc) + raise LogServiceError(str(exc)) from exc + def update_trace_settings(self, payload: dict | None) -> str: try: if payload is None: diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py index 4d6906b7393..765984fa7f7 100644 --- a/astrbot/dashboard/services/plugin_service.py +++ b/astrbot/dashboard/services/plugin_service.py @@ -864,6 +864,10 @@ def _build_command_filter_component( fallback_desc, ), } + handler_md = getattr(command_filter, "handler_md", None) + if handler_md: + component["handler_full_name"] = handler_md.handler_full_name + component["handler_name"] = handler_md.handler_name return self._wrap_command_component(parts[:-1], component) def _build_command_group_component( @@ -907,10 +911,15 @@ def _build_command_group_child( component["subcommands"] = subcommands return component - return { + component = { "name": command_filter.command_name, "description": self._get_command_description(command_filter), } + handler_md = getattr(command_filter, "handler_md", None) + if handler_md: + component["handler_full_name"] = handler_md.handler_full_name + component["handler_name"] = handler_md.handler_name + return component @staticmethod def _wrap_command_component(parent_names: list[str], component: dict) -> dict: diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 79e701b844d..ae1c1a30c18 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -1 +1,84 @@ # Unreleased + +- Move session-waiter dispatch from the built-in plugin's `handle_session_control_agent` handler into the core pipeline. Admit matching follow-up input without a mention or wake prefix, and dispatch after access/safety checks and preprocessing but before ordinary plugin handlers or the Agent. Preserve the existing `@session_waiter` API. + +- Bound the trace writer queue by 128 MiB with a 2 MiB per-frame limit and no frame-count cap, while keeping the WAL threshold unchanged. Recover orphaned running records and enforce a minimum SQLite capacity budget of 32 MB. + +- Expose Enable Trace Recording under Settings → General → Logs, backed by the same trace_enable setting as the trace toolbar's start/pause button. + +- Stop recording redundant plugin handler module metadata without rewriting or filtering historical data. + +- Remove response-source bookkeeping and System/Plugin sender chips. Record successful proactive sends in Context.send_message rather than the send-message tool, correlate them with the active event, and deduplicate nested event forwarding. Preserve final-only streaming summaries and independent tool deliveries; do not create traces for unscoped sends or rewrite historical data. + +- Remove the execution-mode prefix from Agent chips, retaining only the configured runner's display name. + +- Prefix Agent model, persona, and tool-call chips with box, heart, and wrench icons; use a consistent 5px icon-to-label gap for trace metadata chips, including plugin names. + +- Add a puzzle icon before plugin names in grouped and individual trace plugin chips, retaining timeline plugin logos. + +- Add model and persona ID chips to Agent execution headings, show provider IDs on model hover, and count actual tool calls separately for each Agent while folded or expanded. + +- Record successful send_message_to_user deliveries as response events on the originating trace, including message-chain outlines and destination UMO. Do not record failed sends as delivered responses; leave cron Agent instrumentation unchanged. + +- Present trace-detail UMO and sender on separate icon-and-value rows instead of a dot-separated line, preserving UMO alias tooltips. + +- Use native system fonts throughout trace text and tooltips instead of Outfit, without changing text sizing or icon fonts. Change running status chips and detail badges from amber to blue. + +- Include month and day in trace timestamps (MM-DD HH:mm:ss.SSS), including detail timestamps and timeline tooltips. + +- Add independent Group chat / Private chat chips to the trace Type column, using summary message types with a legacy UMO fallback and leaving unknown types unlabeled. + +- Reduce trace table header height to 40px using Vuetify's native header-height variable; preserve body row and pager sizing. + +- Show the Tokens card total with two-decimal k/M notation while keeping precise counts in its tooltip. + +- Add an Agent-only Tokens statistics card with input/output/cache breakdown, latest-snapshot deduplication, and an unavailable-state placeholder instead of a misleading zero. + +- Add Type before UMO in the trace table, with independent blue Agent and green Command chips. Persist command flags in summaries and derive legacy flags for the current page; use explicit colors because the generic Vuetify color pack is disabled. + +- Use flat status chips in the trace table and narrow the platform column to 80px, retaining a non-wrapping header. + +- Set trace table header and cell text to 13px without changing timeline text or table density. + +- Load more → Vuetify server-side datatable pagination: support page navigation and 25/50/100 rows, reset on search/filter changes, and retain only the current page. Keep first-page live refresh without inserting into historical pages. Remove borders from the three trace statistics blocks while retaining their subtle backgrounds. + +- Remove persisted trace action/end-action and redundant duration attributes. Keep transition matching internal, set call kinds explicitly, retain handler types, and derive UI call completion and durations from canonical span timestamps/status. Preserve reads of legacy history. + +- Hide the redundant AdapterMessageEvent type label on plugin listener nodes; keep handler names, category chips, and hook event types. + +- Remove the standalone Select persona trace node and include its resolved persona ID in Agent request prepared details. Fold long trace titles and received/sent text previews with explicit expansion, without truncating recorded content. + +- Log & Cache Cleanup → Log, Cache & Trace Cleanup: include trace database/sidecar usage, add Clear Trace Data, and include finished trace history in Clean All. Confirm irreversible removal, retain running traces, and compact through the existing database writer without unlinking live database files. + +## Logs + +- Add structured source, plugin, platform, and category metadata for in-memory log filtering. +- Logs: top-row filters → the Filters sidebar, with combined level/source/plugin/platform/keyword filtering and an option to hide user-chat logs. +- Core source label → System; plugin selectors prefer display names and show IDs as subtitles. +- Auto-scroll switch → automatic bottom following, preserving the reading position when viewing older logs. +- Add 8–16px font controls (12px default, 2px steps) alongside fullscreen and Pip installation. + +## Trace + +- Finish message and Cron traces through independent `trace_execution()` scopes at framework entry points, after awaited processing, delivery, and history saving. Compose logging separately; keep current-event binding, active-event registration, and temporary-file cleanup in `event_context(event)`. Close failures and cancellations without swallowing them; nested scopes do not finish traces early. + +- Plugin handler triggered → Call plugin: compact the trace timeline with short titles, neutral category/metadata chips, platform IDs on a secondary line, relative times with exact timestamps on hover, and a consistent heading/chevron interaction instead of repeated View details rows. +- Start Agent → Agent execution: collapse descendants using actual span ancestry, retain responses on the main line and error indicators on collapsed headers, and preserve chronological ordering for concurrent/interleaved branches. Folding does not alter trace/span/call counts. + +- Expose SQLite trace settings under Settings → General → Logs, replacing the obsolete JSONL settings whitelist. +- Remove the per-field 4 KB text truncation limit for trace attributes while retaining whole-trace, writer-queue, and storage budgets. + +- Expand message traces to include plugin handlers and their categories, event hooks, Agent requests, response outlines, and completion status. +- Trace table with expandable message rows → full-width tracing list with an opt-in execution timeline inspector. Columns: status chip, platform logo, timestamp, live duration, message, UMO, sender, and Spans. +- Add a Filters sidebar with combinable Has errors / Has model calls / Has plugin calls conditions; counts refer to message traces, not internal steps. +- Prefer session aliases and plugin display names with hover details; show platform/plugin logos and lazily load handler descriptions from plugin details. +- Collapse consecutive same-type timeline nodes with stacked icons and visible error counts. +- Record only the final streaming-response summary, excluding adapter chunks and protocol closing frames while retaining independent tool/plugin replies. +- Replace the in-memory trace record cache and JSONL sink with dedicated SQLite history at `data/logs/traces.db`, using bounded asynchronous writes, whole-trace retention, and a default 80 MB capacity target / 7-day retention. Preserve legacy files without importing or appending them. +- Use the OpenTelemetry API and SDK to manage trace/span IDs, parent relationships, timestamps, status, attributes, events, and active context, and correlate structured logs with the active span. Preserve local SQLite and JSON API/SSE without OTLP transport or external export. +- Migrate `trace_log_enable/path/max_mb` to `trace_db_path/max_mb` and `trace_retention_days`; keep `trace_enable`. New database settings take precedence. Protect SQLite files and sidecars from log cleanup. +- Show execution mode, plugin names, and response ownership as timeline chips. Reuse profile runner labels and classify built-in Agent replies as system/core while preserving plugin senders. +- Remove the outer gray trace card and tinted list shell, using a flat tab layout with toolbar/table separators in both themes. +- Top hint and separate recording switch → a toolbar Start/Pause/Connecting button. Filters, recording, and refresh share gray icon-and-text styling and 36px input height; search fills available width and includes the message count in its placeholder. +- Use Vuetify datatable with native rows, typography, spacing, and hover styling for the trace list; retain fixed headers, stable trace row IDs, keyboard activation, and explicit detail selection. Read history in keyset pages, cap browser summaries at 300, and lazily fetch only the selected trace's detail. Spans now counts actual spans, excluding events. +- Shorten the Chinese duration column label and prevent the platform column header from wrapping with a minimum width. diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 0d7c6ad9173..1bb3510359b 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios'; -import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, InitFileUploadData, InitFileUploadError, InitFileUploadResponse, UploadFileChunkData, UploadFileChunkError, UploadFileChunkResponse, CompleteFileUploadData, CompleteFileUploadError, CompleteFileUploadResponse, AbortFileUploadData, AbortFileUploadError, AbortFileUploadResponse, StatusFileUploadData, StatusFileUploadError, StatusFileUploadResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginViewsByIdData, ListPluginViewsByIdError, ListPluginViewsByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginViewByIdData, GetPluginViewByIdError, GetPluginViewByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, ListPluginViewsData, ListPluginViewsError, ListPluginViewsResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginViewData, GetPluginViewError, GetPluginViewResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginViewTokenAssetData, GetPluginViewTokenAssetError, GetPluginViewTokenAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, StatusBackupUploadData, StatusBackupUploadError, StatusBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; +import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, InitFileUploadData, InitFileUploadError, InitFileUploadResponse, UploadFileChunkData, UploadFileChunkError, UploadFileChunkResponse, CompleteFileUploadData, CompleteFileUploadError, CompleteFileUploadResponse, AbortFileUploadData, AbortFileUploadError, AbortFileUploadResponse, StatusFileUploadData, StatusFileUploadError, StatusFileUploadResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginViewsByIdData, ListPluginViewsByIdError, ListPluginViewsByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginViewByIdData, GetPluginViewByIdError, GetPluginViewByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, ListPluginViewsData, ListPluginViewsError, ListPluginViewsResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginViewData, GetPluginViewError, GetPluginViewResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginViewTokenAssetData, GetPluginViewTokenAssetError, GetPluginViewTokenAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, StatusBackupUploadData, StatusBackupUploadError, StatusBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, ListTracesData, ListTracesError, ListTracesResponse, GetTraceDetailData, GetTraceDetailError, GetTraceDetailResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -2815,6 +2815,7 @@ export const getStartTime = (options?: Opt /** * Get storage status + * Returns log, cache, and trace database usage (including trace sidecars), plus total bytes. */ export const getStorageStatus = (options?: OptionsLegacyParser) => { return (options?.client ?? client).get({ @@ -2825,6 +2826,7 @@ export const getStorageStatus = (options?: /** * Clean storage + * Clears logs, cache, finished trace history, or all three. Running traces are retained. */ export const cleanupStorage = (options?: OptionsLegacyParser) => { return (options?.client ?? client).post({ @@ -3123,6 +3125,26 @@ export const getLogHistory = (options?: Op }); }; +/** + * List persisted trace summaries + */ +export const listTraces = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/trace/history' + }); +}; + +/** + * Get persisted trace spans and events + */ +export const getTraceDetail = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/trace/{trace_id}' + }); +}; + /** * Get trace settings */ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index d8d99e9be26..243c2f8866e 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -690,6 +690,12 @@ export type SkillUploadRequest = { overwrite?: boolean; }; +export type StorageCleanupRequest = { + target?: 'logs' | 'cache' | 'traces' | 'all'; +}; + +export type target = 'logs' | 'cache' | 'traces' | 'all'; + export type SuccessEnvelope = { status: "ok"; message?: string; @@ -3480,7 +3486,7 @@ export type GetStorageStatusResponse = (SuccessEnvelope); export type GetStorageStatusError = unknown; export type CleanupStorageData = { - body?: DynamicConfig; + body?: StorageCleanupRequest; }; export type CleanupStorageResponse = (SuccessEnvelope); @@ -3725,6 +3731,35 @@ export type GetLogHistoryResponse = (string); export type GetLogHistoryError = unknown; +export type ListTracesData = { + query?: { + /** + * Legacy keyset cursor; use page for numbered pagination. + */ + cursor?: string; + errors?: boolean; + limit?: number; + models?: boolean; + page?: number; + plugins?: boolean; + query?: string; + }; +}; + +export type ListTracesResponse = (SuccessEnvelope); + +export type ListTracesError = unknown; + +export type GetTraceDetailData = { + path: { + trace_id: string; + }; +}; + +export type GetTraceDetailResponse = (SuccessEnvelope); + +export type GetTraceDetailError = (unknown); + export type GetTraceSettingsResponse = (SuccessEnvelope); export type GetTraceSettingsError = unknown; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 685e9b61d47..1911204217c 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -688,6 +688,12 @@ export const apiKeyApi = { }; export const traceApi = { + history(query?: { limit?: number; page?: number; cursor?: string; query?: string; errors?: boolean; models?: boolean; plugins?: boolean }) { + return typed(openApiV1.listTraces({ query })); + }, + detail(traceId: string) { + return typed(openApiV1.getTraceDetail({ path: { trace_id: traceId } })); + }, getSettings() { return typed(openApiV1.getTraceSettings()); }, @@ -1855,7 +1861,7 @@ export const statsApi = { storage() { return typed(openApiV1.getStorageStatus()); }, - cleanupStorage(target?: string) { + cleanupStorage(target: 'logs' | 'cache' | 'traces' | 'all' = 'all') { return typed( openApiV1.cleanupStorage({ body: target ? { target } : undefined, diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 2be4a9970a3..53a9a9b6a22 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 267 icons */ +/* Auto-generated MDI subset – 268 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -328,6 +328,10 @@ content: "\F1640"; } +.mdi-database-outline::before { + content: "\F1632"; +} + .mdi-database-refresh-outline::before { content: "\F1634"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 0d34c1b90e391cb03753fa97619eb3f90d9c5644..f6428f9eec4e8b53cbefa41c97a80c677008e47d 100644 GIT binary patch delta 16087 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 dtYpw-00AZ-<^nXk}pl07aMp001BW001Nd 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 C$5K21 diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index bd2b65aa58711fc29862ea72e9957eb0427fb4d7..85d7c680542b0f41807a401614693568b48edcfe 100644 GIT binary patch literal 15032 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 OT%?1GlM!ayO8@{UB0VYq literal 14964 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 diff --git a/dashboard/src/components/shared/StorageCleanupPanel.vue b/dashboard/src/components/shared/StorageCleanupPanel.vue index 9ecc8683c2d..79d46ba911d 100644 --- a/dashboard/src/components/shared/StorageCleanupPanel.vue +++ b/dashboard/src/components/shared/StorageCleanupPanel.vue @@ -76,6 +76,12 @@ const storageStatus = ref({ path: '', exists: false }, + traces: { + size_bytes: 0, + file_count: 0, + path: '', + exists: false + }, total_bytes: 0 }); @@ -116,6 +122,12 @@ const storageCards = computed(() => [ buttonText: tm('system.cleanup.targets.logs.button'), icon: 'mdi-file-document-outline', sizeBytes: storageStatus.value.logs?.size_bytes || 0 + }, + { + key: 'traces', + buttonText: tm('system.cleanup.targets.traces.button'), + icon: 'mdi-database-outline', + sizeBytes: storageStatus.value.traces?.size_bytes || 0 } ]); @@ -134,7 +146,9 @@ const loadStorageStatus = async () => { const cleanupStorage = async (target) => { const confirmed = await askForConfirmation( - tm('system.cleanup.confirm', { target: tm(`system.cleanup.targetNames.${target}`) }), + ['traces', 'all'].includes(target) + ? tm('system.cleanup.confirmWithTraces', { target: tm(`system.cleanup.targetNames.${target}`) }) + : tm('system.cleanup.confirm', { target: tm(`system.cleanup.targetNames.${target}`) }), confirmDialog ); if (!confirmed) return; @@ -150,9 +164,12 @@ const cleanupStorage = async (target) => { const cleanupData = res.data.data || {}; storageStatus.value = cleanupData.status || storageStatus.value; showToast( - tm('system.cleanup.messages.cleanupSuccess', { + tm(target === 'traces' ? 'system.cleanup.messages.cleanupTracesSuccess' + : cleanupData.removed_traces ? 'system.cleanup.messages.cleanupWithTracesSuccess' + : 'system.cleanup.messages.cleanupSuccess', { size: formatBytes(cleanupData.removed_bytes || 0), - count: cleanupData.processed_files || 0 + count: target === 'traces' ? cleanupData.removed_traces || 0 : cleanupData.processed_files || 0, + traces: cleanupData.removed_traces || 0 }) ); } catch (error) { diff --git a/dashboard/src/components/shared/TraceDisplayer.vue b/dashboard/src/components/shared/TraceDisplayer.vue index 74154f54de9..dea6425e600 100644 --- a/dashboard/src/components/shared/TraceDisplayer.vue +++ b/dashboard/src/components/shared/TraceDisplayer.vue @@ -1,644 +1,1200 @@ +const { tm: configTm } = useModuleI18n("features/config"); +const props = defineProps({ + recordingEnabled: { type: Boolean, default: true }, + recordingBusy: { type: Boolean, default: false }, +}); +const emit = defineEmits(["toggle-recording"]); +const maxItems = 300; +const events = ref([]); +const selectedSpanId = ref(null); +const searchText = ref(""); +const filtersOpen = ref(false); +const selectedFilters = ref([]); +const loading = ref(true); +const historyBusy = ref(false); +const detailBusy = ref(false); +const historyError = ref(""); +const detailError = ref(""); +const totalRecords = ref(0); +const historyPage = ref(1); +const pageSize = ref(50); +const storageWarning = ref(false); +let metadataLoaded = false; +let historyRequest = 0; +let detailRequest = 0; +let queryTimer = null; +let historyRefreshTimer = null; +const clockNow = ref(Date.now() / 1000); +const durationTimer = ref(null); +const connected = ref(false); +const recordingLabel = computed(() => !props.recordingEnabled ? tm("controls.start") : connected.value ? tm("controls.pauseButton") : tm("connecting")); +const eventSource = ref(null); +const retryTimer = ref(null); +const retryAttempts = ref(0); +const lastEventId = ref(null); +const maxRetryAttempts = 10; +const baseRetryDelay = 1000; +const eventIndex = new Map(); +const umoNames = ref({}); +const plugins = ref({}); +const failedPluginLogos = reactive(new Set()); +const failedPlatformLogos = reactive(new Set()); +const handlerDetails = reactive({}); +const expandedGroups = reactive(new Set()); +const expandedAgents = reactive(new Set()); +const expandedDetails = reactive(new Set()); - +const actionLabels = { + event_received: "eventReceived", + pipeline_start: "pipelineStart", + pipeline_end: "pipelineEnd", + stage_start: "stageStart", + stage_end: "stageEnd", + handler_start: "handlerStart", + handler_end: "handlerEnd", + agent_start: "agentStart", + agent_end: "agentEnd", + astr_agent_prepare: "agentPrepare", + astr_agent_complete: "agentComplete", + agent_tool_call: "agentToolCall", + agent_tool_result: "agentToolResult", + response_ready: "responseReady", + response_sent: "responseSent", +}; - - - diff --git a/dashboard/src/components/shared/TraceMessagePreview.vue b/dashboard/src/components/shared/TraceMessagePreview.vue new file mode 100644 index 00000000000..db0b6e663c4 --- /dev/null +++ b/dashboard/src/components/shared/TraceMessagePreview.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index eb53e7d87fb..8ac0955e3e6 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1290,17 +1290,21 @@ "description": "Temp Directory Size Limit (MB)", "hint": "Limits total size of data/temp in MB. The system checks every 10 minutes, and when exceeded, deletes oldest files first to release about 30% of current size." }, - "trace_log_enable": { - "description": "Enable Trace File Logging", - "hint": "Write trace events to a separate file (does not change console output)." + "trace_enable": { + "description": "Enable Trace Recording", + "hint": "Trace each user command and AI call." }, - "trace_log_path": { - "description": "Trace Log File Path", - "hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.trace.log; absolute paths are supported." + "trace_db_path": { + "description": "Trace Database Path", + "hint": "Relative paths start from the data directory, for example logs/traces.db. Absolute paths are also supported." }, - "trace_log_max_mb": { - "description": "Trace Log Max Size (MB)", - "hint": "Rotate when exceeding this size; default 20MB." + "trace_db_max_mb": { + "description": "Trace Database Capacity Budget (MB)", + "hint": "Default: 80 MB. Values below 32 MB use 32 MB. Old data is automatically cleared when this size is exceeded." + }, + "trace_retention_days": { + "description": "Trace Retention Days", + "hint": "Default: 7 days. Old data is cleared when either the retention period or capacity is reached." }, "pip_install_arg": { "description": "Additional pip Installation Arguments", diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index aaa55276924..0f6bbb95a38 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -113,7 +113,7 @@ "button": "Backup Manager" }, "cleanup": { - "title": "Log & Cache Cleanup", + "title": "Log, Cache & Trace Cleanup", "subtitle": "Review disk usage for logs and caches, then clean them from the UI without shell commands.", "refresh": "Refresh Usage", "clean": "Clean", @@ -127,7 +127,8 @@ "targetNames": { "cache": "cache", "logs": "logs", - "all": "logs and cache" + "all": "logs, cache, and trace data", + "traces": "trace data" }, "targets": { "cache": { @@ -139,13 +140,21 @@ "title": "Logs", "subtitle": "Remove rotated logs and truncate the current active log files.", "button": "Clean Logs" + }, + "traces": { + "title": "Traces", + "subtitle": "Clear historical traces while keeping running traces.", + "button": "Clear Trace Data" } }, "messages": { "statusFailed": "Failed to load storage usage", "cleanupSuccess": "Cleared {count} files and freed {size}", - "cleanupFailed": "Cleanup failed" - } + "cleanupFailed": "Cleanup failed", + "cleanupTracesSuccess": "Cleared {count} traces and freed {size}", + "cleanupWithTracesSuccess": "Cleared {count} files and {traces} traces, freeing {size}" + }, + "confirmWithTraces": "Clean {target} now? Historical traces cannot be recovered. Running traces are retained." } }, "backup": { diff --git a/dashboard/src/i18n/locales/en-US/features/trace.json b/dashboard/src/i18n/locales/en-US/features/trace.json index c393246a8bb..f0fd6cc9ccf 100644 --- a/dashboard/src/i18n/locales/en-US/features/trace.json +++ b/dashboard/src/i18n/locales/en-US/features/trace.json @@ -4,7 +4,7 @@ "enabled": "Auto-scroll: On", "disabled": "Auto-scroll: Off" }, - "hint": "Currently only recording partial model call paths from AstrBot main Agent. More coverage will be added.", + "hint": "Inspect the in-memory execution chain for each message: plugin handlers, Agent steps, tools, and responses.", "toggleLabel": "Record traces", "recording": "Recording", "paused": "Paused", @@ -17,5 +17,132 @@ "expand": "Expand", "collapse": "Collapse", "showMore": "Show more", - "empty": "No trace data yet" + "empty": "No trace data yet", + "search": "Search messages, sessions, or plugins in {count} records", + "clearSearch": "Clear search", + "live": "Live", + "connecting": "Connecting", + "refresh": "Refresh", + "records": "records", + "traceList": "Trace list", + "loading": "Loading traces…", + "untitled": "Untitled message", + "steps": "steps", + "duration": "Duration", + "handlers": "Plugin handlers", + "responses": "Responses", + "details": "View details", + "selectTrace": "Select a trace to inspect the complete execution chain", + "closeDetails": "Close details", + "pagination": { + "perPage": "Rows per page", + "range": "{0}–{1} of {2}" + }, + "messagePreview": { + "expand": "Show full text", + "collapse": "Collapse" + }, + "compact": { + "pluginHandler": "Call plugin", + "agentExecution": "Agent execution", + "rawData": "Raw data", + "toggleAgent": "{action} the Agent execution chain" + }, + "history": { + "loadFailed": "Failed to load trace history. Please retry.", + "detailFailed": "Failed to load trace details; the trace may have been evicted. Please retry.", + "storageWarning": "Some traces could not be persisted. Check database errors or capacity limits.", + "loadMore": "Load more", + "limitHint": "Showing up to {count} traces. Refine your search.", + "truncated": "This trace exceeded content or node limits; some data was truncated." + }, + "controls": { + "start": "Start", + "pauseButton": "Pause", + "pause": "Pause trace recording", + "resume": "Start trace recording", + "loadFailed": "Failed to load trace settings. Refresh to retry.", + "updateFailed": "Failed to change trace recording. Please retry." + }, + "tokens": { + "title": "Tokens", + "breakdown": "Input: {input} (cache read: {cached})\nOutput: {output}", + "unavailable": "No reported token usage available" + }, + "classification": { + "agent": "Agent", + "command": "Command", + "group": "Group chat", + "private": "Private chat" + }, + "columns": { + "category": "Type", + "duration": "Duration", + "status": "Status", + "platform": "Platform", + "timestamp": "Timestamp", + "message": "Message", + "sender": "Sender", + "spansHint": "Actual spans including the message root, excluding events. Collapsing does not change the count." + }, + "chips": { + "provider": "Provider: {id}", + "persona": "Persona: {id}", + "toolCalls": "{count} tool calls" + }, + "groups": { + "handlerStart": "Multiple plugin handlers triggered", + "agentStart": "Multiple Agents started", + "agentToolCall": "Multiple tools called", + "agentToolResult": "Multiple tool results", + "responseReady": "Multiple responses ready", + "responseSent": "Multiple responses sent", + "repeated": "Repeated: {label}", + "count": "{count} records", + "errors": "{count} errors" + }, + "filters": { + "button": "Filters", + "title": "Filters", + "reset": "Reset", + "close": "Close filters", + "errors": "Has errors", + "models": "Has model calls", + "plugins": "Has plugin calls", + "hint": "Select multiple conditions to require all of them. No selection shows all traces." + }, + "status": { + "error": "Error", + "complete": "Complete", + "live": "In progress" + }, + "handlerDescription": { + "loading": "Loading handler description…", + "loadFailed": "Failed to load. Hover again to retry.", + "empty": "No handler description available" + }, + "handlerCategories": { + "command": "Command/Command group", + "listener": "Event listener", + "hook": "Event hook", + "llm_tool": "LLM tool" + }, + "actions": { + "selectPersona": "Select persona", + "eventReceived": "Message received", + "pipelineStart": "Pipeline started", + "pipelineEnd": "Processing complete", + "stageStart": "Stage started", + "stageEnd": "Stage complete", + "handlerStart": "Plugin handler triggered", + "handlerEnd": "Handler complete", + "agentStart": "Agent started", + "agentEnd": "Agent complete", + "agentPrepare": "Agent request prepared", + "agentComplete": "Model request complete", + "agentToolCall": "Tool called", + "agentToolResult": "Tool returned", + "responseReady": "Response ready", + "responseSent": "Response sent" + } } diff --git a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json index 033ddb6d371..3aef08be48b 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json @@ -1391,17 +1391,21 @@ "description": "一時ディレクトリの最大サイズ(MB)", "hint": "data/temp ディレクトリの合計サイズを MB 単位で制限します。システムは 10 分ごとに確認し、上限を超えると更新日時の古いファイルから削除して、現在のサイズの約 30% を解放します。" }, - "trace_log_enable": { - "description": "Trace ファイルログを有効化", - "hint": "Trace イベントを個別のファイルへ書き込みます(コンソール出力には影響しません)。" + "trace_enable": { + "description": "トレース記録を有効化", + "hint": "ユーザーの各コマンドと AI 呼び出しを追跡します。" }, - "trace_log_path": { - "description": "Trace ログファイルのパス", - "hint": "相対パスは data ディレクトリを基準とします(例: logs/astrbot.trace.log)。絶対パスにも対応しています。" + "trace_db_path": { + "description": "トレースDBのパス", + "hint": "相対パスは data ディレクトリが基準です(例:logs/traces.db)。絶対パスも使用できます。" }, - "trace_log_max_mb": { - "description": "Trace ログの最大サイズ(MB)", - "hint": "サイズを超えると自動的にローテーションします。デフォルトは 20MB です。" + "trace_db_max_mb": { + "description": "トレースDB容量 (MB)", + "hint": "既定は 80 MB。32 MB 未満の値は 32 MB として適用されます。超えると古いデータを自動的に削除します。" + }, + "trace_retention_days": { + "description": "トレース保持日数", + "hint": "既定は 7 日。保持期間または容量のどちらかに達すると古いデータを削除します。" }, "pip_install_arg": { "description": "pip インストールの追加引数", diff --git a/dashboard/src/i18n/locales/ja-JP/features/settings.json b/dashboard/src/i18n/locales/ja-JP/features/settings.json index 384e0ebfb4a..53ab164eecb 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/settings.json +++ b/dashboard/src/i18n/locales/ja-JP/features/settings.json @@ -113,7 +113,7 @@ "button": "バックアップ管理" }, "cleanup": { - "title": "ログとキャッシュの消去", + "title": "ログ・キャッシュ・トレースの消去", "subtitle": "ログとキャッシュの使用量を確認し、まとめて消去します。", "refresh": "使用量を再読み込み", "clean": "消去", @@ -127,7 +127,8 @@ "targetNames": { "cache": "キャッシュ", "logs": "ログ", - "all": "ログとキャッシュ" + "all": "ログ、キャッシュ、トレースデータ", + "traces": "トレースデータ" }, "targets": { "cache": { @@ -139,13 +140,21 @@ "title": "ログ", "subtitle": "過去のログを削除し、現在書き込み中のログファイルを空にします。", "button": "ログを消去" + }, + "traces": { + "title": "トレース", + "subtitle": "履歴を削除します。実行中のトレースは保持します。", + "button": "トレースデータを消去" } }, "messages": { "statusFailed": "ストレージ使用量の読み込みに失敗しました", "cleanupSuccess": "{count} ファイルを消去し、{size} を解放しました", - "cleanupFailed": "消去に失敗しました" - } + "cleanupFailed": "消去に失敗しました", + "cleanupTracesSuccess": "{count} 件のトレースを消去し、{size} を解放しました", + "cleanupWithTracesSuccess": "{count} ファイルと {traces} 件のトレースを消去し、{size} を解放しました" + }, + "confirmWithTraces": "{target}を消去しますか?過去のトレースは復元できません。実行中のトレースは保持します。" } }, "backup": { diff --git a/dashboard/src/i18n/locales/ja-JP/features/trace.json b/dashboard/src/i18n/locales/ja-JP/features/trace.json index 0c4522fe571..b51d58b6976 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/trace.json +++ b/dashboard/src/i18n/locales/ja-JP/features/trace.json @@ -4,7 +4,7 @@ "enabled": "自動スクロール:オン", "disabled": "自動スクロール:オフ" }, - "hint": "現在は AstrBot のメインエージェントによる一部のモデル呼び出し経路のみ記録しています。今後、記録範囲を拡充します。", + "hint": "メッセージごとのプラグイン Handler、Agent、ツール、レスポンスの実行チェーンをメモリ上で確認できます。", "toggleLabel": "トレースを記録", "recording": "記録中", "paused": "一時停止中", @@ -17,5 +17,128 @@ "expand": "展開", "collapse": "折りたたむ", "showMore": "さらに表示", - "empty": "トレースデータはありません" + "empty": "トレースデータはありません", + "search": "{count} 件の記録からメッセージ、セッション、プラグインを検索", + "clearSearch": "検索をクリア", + "live": "ライブ", + "connecting": "接続中", + "refresh": "更新", + "records": "レコード", + "traceList": "トレース一覧", + "loading": "トレースを読み込み中…", + "untitled": "名前のないメッセージ", + "steps": "ステップ", + "duration": "所要時間", + "handlers": "プラグインハンドラー", + "responses": "レスポンス", + "details": "詳細を表示", + "selectTrace": "トレースを選択すると実行チェーンを確認できます", + "closeDetails": "詳細を閉じる", + "pagination": { + "perPage": "ページあたりの件数", + "range": "{2} 件中 {0}–{1} 件" + }, + "messagePreview": { + "expand": "全文を表示", + "collapse": "折りたたむ" + }, + "compact": { + "pluginHandler": "プラグイン呼び出し", + "agentExecution": "Agent 実行", + "rawData": "元データ", + "toggleAgent": "Agent の実行チェーンを{action}" + }, + "history": { + "loadFailed": "トレース履歴を読み込めません。再試行してください。", + "detailFailed": "詳細を読み込めません。削除済みの可能性があります。再試行してください。", + "storageWarning": "一部のトレースを保存できませんでした。DB エラーまたは容量を確認してください。", + "loadMore": "もっと読み込む", + "limitHint": "最大 {count} 件表示。検索条件を絞ってください。", + "truncated": "内容またはノード数が上限を超え、一部が省略されました。" + }, + "controls": { + "start": "開始", + "pauseButton": "一時停止", + "pause": "トレース記録を一時停止", + "resume": "トレース記録を開始", + "loadFailed": "トレース設定の取得に失敗しました。ページを更新してください。", + "updateFailed": "トレース記録の切り替えに失敗しました。再試行してください。" + }, + "tokens": { + "title": "Tokens", + "breakdown": "入力: {input} (キャッシュ読込: {cached})\n出力: {output}", + "unavailable": "報告されたトークン使用量はありません" + }, + "classification": { + "agent": "Agent", + "command": "コマンド", + "group": "グループチャット", + "private": "個人チャット" + }, + "columns": { + "category": "種類", + "duration": "実行時間", + "status": "状態", + "platform": "プラットフォーム", + "timestamp": "タイムスタンプ", + "message": "メッセージ", + "sender": "送信者", + "spansHint": "メッセージのルートを含む実際の Span 数。イベントは含まず、折りたたんでも変わりません。" + }, + "chips": { + "provider": "プロバイダー: {id}", + "persona": "ペルソナ: {id}", + "toolCalls": "ツール呼び出し {count} 回" + }, + "groups": { + "handlerStart": "複数のプラグインハンドラーを実行", + "agentStart": "複数の Agent を開始", + "agentToolCall": "複数のツールを呼び出し", + "agentToolResult": "複数のツール結果", + "responseReady": "複数の応答を準備", + "responseSent": "複数の応答を送信", + "repeated": "繰り返し: {label}", + "count": "{count} 件", + "errors": "エラー {count} 件" + }, + "filters": { + "button": "フィルター", + "title": "フィルター", + "reset": "リセット", + "close": "フィルターを閉じる", + "errors": "エラーあり", + "models": "モデル呼び出しあり", + "plugins": "プラグイン呼び出しあり", + "hint": "複数選択するとすべての条件を満たす追跡を表示します。未選択ではすべて表示します。" + }, + "status": { "error": "エラー", "complete": "完了", "live": "実行中" }, + "handlerDescription": { + "loading": "ハンドラーの説明を読み込み中…", + "loadFailed": "読み込みに失敗しました。再度ホバーしてお試しください。", + "empty": "ハンドラーの説明はありません" + }, + "handlerCategories": { + "command": "コマンド/コマンドグループ", + "listener": "イベントリスナー", + "hook": "イベントフック", + "llm_tool": "LLM ツール" + }, + "actions": { + "selectPersona": "人格を選択", + "eventReceived": "メッセージ受信", + "pipelineStart": "パイプライン開始", + "pipelineEnd": "処理完了", + "stageStart": "ステージ開始", + "stageEnd": "ステージ完了", + "handlerStart": "Handler 呼び出し", + "handlerEnd": "Handler 完了", + "agentStart": "Agent 開始", + "agentEnd": "Agent 完了", + "agentPrepare": "Agent リクエスト準備", + "agentComplete": "モデルリクエスト完了", + "agentToolCall": "ツール呼び出し", + "agentToolResult": "ツール応答", + "responseReady": "レスポンス準備完了", + "responseSent": "レスポンス送信" + } } diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 600ad8f7dac..4e8d9741598 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1395,17 +1395,21 @@ "description": "Лимит размера временной директории (МБ)", "hint": "Лимит временной папки (МБ). Система проверяет каждые 10 минут и удаляет старое при переполнении." }, - "trace_log_enable": { - "description": "Включить логирование трассировки", - "hint": "Записывать трассировку в отдельный файл." + "trace_enable": { + "description": "Включить запись трассировок", + "hint": "Отслеживать каждую команду пользователя и вызов ИИ." }, - "trace_log_path": { - "description": "Путь к файлу логов трассировки", - "hint": "Относительные пути определяются относительно директории данных, например, logs/astrbot.trace.log; абсолютные пути также поддерживаются." + "trace_db_path": { + "description": "Путь базы трассировок", + "hint": "Относительные пути считаются от каталога data, например logs/traces.db. Абсолютные пути также поддерживаются." }, - "trace_log_max_mb": { - "description": "Макс. размер файла логов трассировки (МБ)", - "hint": "Ротация при достижении размера. По умолчанию 20МБ." + "trace_db_max_mb": { + "description": "Бюджет базы трассировок (МБ)", + "hint": "По умолчанию 80 МБ. Значения меньше 32 МБ применяются как 32 МБ. При превышении этого размера старые данные удаляются автоматически." + }, + "trace_retention_days": { + "description": "Хранение трассировок (дни)", + "hint": "По умолчанию 7 дней. Старые данные удаляются при достижении срока хранения или объёма." }, "pip_install_arg": { "description": "Дополнительные аргументы установки pip", @@ -2134,4 +2138,4 @@ "helpMiddle": "или", "helpSuffix": "." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index 408ace4b372..a3b76243b22 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -113,7 +113,7 @@ "button": "Управление бэкапами" }, "cleanup": { - "title": "Очистка логов и кэша", + "title": "Очистка логов, кэша и трассировок", "subtitle": "Показывает текущий размер логов и кэша и позволяет очистить их прямо из WebUI.", "refresh": "Обновить", "clean": "Очистить", @@ -127,7 +127,8 @@ "targetNames": { "cache": "кэш", "logs": "логи", - "all": "логи и кэш" + "all": "логи, кэш и трассировки", + "traces": "трассировки" }, "targets": { "cache": { @@ -139,13 +140,21 @@ "title": "Логи", "subtitle": "Удаляет старые файлы логов и очищает текущие активные логи.", "button": "Очистить логи" + }, + "traces": { + "title": "Трассировки", + "subtitle": "Удаляет историю, сохраняя выполняющиеся трассы.", + "button": "Очистить трассировки" } }, "messages": { "statusFailed": "Не удалось получить размер хранилища", "cleanupSuccess": "Очищено {count} файлов, освобождено {size}", - "cleanupFailed": "Ошибка очистки" - } + "cleanupFailed": "Ошибка очистки", + "cleanupTracesSuccess": "Очищено {count} трассировок, освобождено {size}", + "cleanupWithTracesSuccess": "Очищено {count} файлов и {traces} трассировок, освобождено {size}" + }, + "confirmWithTraces": "Очистить {target}? Историю трассировок нельзя восстановить. Выполняющиеся трассы сохраняются." } }, "backup": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/trace.json b/dashboard/src/i18n/locales/ru-RU/features/trace.json index 4fd3a07367d..0f3d50db72b 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/trace.json +++ b/dashboard/src/i18n/locales/ru-RU/features/trace.json @@ -4,7 +4,7 @@ "enabled": "Автопрокрутка: ВКЛ", "disabled": "Автопрокрутка: ВЫКЛ" }, - "hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.", + "hint": "Показывает цепочку выполнения сообщения в памяти: обработчики плагинов, Agent, инструменты и ответы.", "toggleLabel": "Записывать трассировки", "recording": "Запись...", "paused": "Пауза", @@ -17,5 +17,128 @@ "expand": "Развернуть", "collapse": "Свернуть", "showMore": "Показать ещё", - "empty": "Данных трассировки пока нет" + "empty": "Данных трассировки пока нет", + "search": "Поиск сообщений, сессий и плагинов в {count} записях", + "clearSearch": "Очистить поиск", + "live": "В эфире", + "connecting": "Подключение", + "refresh": "Обновить", + "records": "записей", + "traceList": "Список трассировок", + "loading": "Загрузка трассировок…", + "untitled": "Сообщение без названия", + "steps": "шагов", + "duration": "Длительность", + "handlers": "Обработчики плагинов", + "responses": "Ответы", + "details": "Подробности", + "selectTrace": "Выберите трассировку, чтобы увидеть цепочку выполнения", + "closeDetails": "Закрыть подробности", + "pagination": { + "perPage": "Строк на странице", + "range": "{0}–{1} из {2}" + }, + "messagePreview": { + "expand": "Показать весь текст", + "collapse": "Свернуть" + }, + "compact": { + "pluginHandler": "Вызов плагина", + "agentExecution": "Выполнение Agent", + "rawData": "Исходные данные", + "toggleAgent": "{action} цепочку выполнения Agent" + }, + "history": { + "loadFailed": "Не удалось загрузить историю трасс. Повторите попытку.", + "detailFailed": "Не удалось загрузить детали; трасса могла быть удалена. Повторите попытку.", + "storageWarning": "Часть трасс не сохранена. Проверьте ошибки БД и лимиты.", + "loadMore": "Загрузить ещё", + "limitHint": "Показано до {count} трасс. Уточните поиск.", + "truncated": "Превышен лимит содержимого или узлов; часть данных усечена." + }, + "controls": { + "start": "Запустить", + "pauseButton": "Пауза", + "pause": "Приостановить запись трассировок", + "resume": "Начать запись трассировок", + "loadFailed": "Не удалось загрузить настройки трассировки. Обновите страницу.", + "updateFailed": "Не удалось изменить запись трассировок. Повторите попытку." + }, + "tokens": { + "title": "Tokens", + "breakdown": "Вход: {input} (чтение кэша: {cached})\nВыход: {output}", + "unavailable": "Нет данных об использовании токенов" + }, + "classification": { + "agent": "Agent", + "command": "Команда", + "group": "Групповой чат", + "private": "Личный чат" + }, + "columns": { + "category": "Тип", + "duration": "Длительность", + "status": "Статус", + "platform": "Платформа", + "timestamp": "Время", + "message": "Сообщение", + "sender": "Отправитель", + "spansHint": "Число Span, включая корневой Span сообщения, без событий. Сворачивание не меняет число." + }, + "chips": { + "provider": "Провайдер: {id}", + "persona": "Персона: {id}", + "toolCalls": "Вызовов инструментов: {count}" + }, + "groups": { + "handlerStart": "Вызваны несколько обработчиков плагинов", + "agentStart": "Запущены несколько Agent", + "agentToolCall": "Вызваны несколько инструментов", + "agentToolResult": "Несколько результатов инструментов", + "responseReady": "Подготовлены несколько ответов", + "responseSent": "Отправлены несколько ответов", + "repeated": "Повтор: {label}", + "count": "Записей: {count}", + "errors": "Ошибок: {count}" + }, + "filters": { + "button": "Фильтры", + "title": "Фильтры", + "reset": "Сбросить", + "close": "Закрыть фильтры", + "errors": "Есть ошибки", + "models": "Есть вызовы модели", + "plugins": "Есть вызовы плагинов", + "hint": "Выбранные условия должны выполняться одновременно. Без выбора показаны все трассировки." + }, + "status": { "error": "Ошибка", "complete": "Завершено", "live": "Выполняется" }, + "handlerDescription": { + "loading": "Загрузка описания обработчика…", + "loadFailed": "Не удалось загрузить. Наведите курсор снова.", + "empty": "Описание обработчика отсутствует" + }, + "handlerCategories": { + "command": "Команда/Группа команд", + "listener": "Обработчик событий", + "hook": "Хук событий", + "llm_tool": "LLM-инструмент" + }, + "actions": { + "selectPersona": "Выбор персоны", + "eventReceived": "Сообщение получено", + "pipelineStart": "Конвейер запущен", + "pipelineEnd": "Обработка завершена", + "stageStart": "Этап запущен", + "stageEnd": "Этап завершён", + "handlerStart": "Вызов обработчика", + "handlerEnd": "Обработчик завершён", + "agentStart": "Agent запущен", + "agentEnd": "Agent завершён", + "agentPrepare": "Запрос Agent подготовлен", + "agentComplete": "Запрос модели завершён", + "agentToolCall": "Вызов инструмента", + "agentToolResult": "Ответ инструмента", + "responseReady": "Ответ подготовлен", + "responseSent": "Ответ отправлен" + } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index df25180bc4d..f3a52e4a723 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1311,17 +1311,21 @@ "description": "临时目录大小上限 (MB)", "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。" }, - "trace_log_enable": { - "description": "启用 Trace 文件日志", - "hint": "将 Trace 事件写入独立文件(不影响控制台输出)。" + "trace_enable": { + "description": "启用追踪记录", + "hint": "追踪用户的每次指令和 AI 调用。" }, - "trace_log_path": { - "description": "Trace 日志文件路径", - "hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。" + "trace_db_path": { + "description": "追踪数据库路径", + "hint": "相对路径以 data 目录为基准,例如 logs/traces.db;支持绝对路径。" }, - "trace_log_max_mb": { - "description": "Trace 日志大小上限 (MB)", - "hint": "超过大小后自动轮转,默认 20MB。" + "trace_db_max_mb": { + "description": "追踪数据库容量预算 (MB)", + "hint": "默认 80 MB,最小 32 MB,低于最小值时按 32 MB 生效。超出后自动清除旧数据。" + }, + "trace_retention_days": { + "description": "追踪保留天数", + "hint": "默认 7 天,期限或容量先达到者触发清理。" }, "pip_install_arg": { "description": "pip 安装额外参数", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 276bd895125..5d043d30ef6 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -113,7 +113,7 @@ "button": "备份管理" }, "cleanup": { - "title": "日志与缓存清理", + "title": "日志、缓存与追踪清理", "subtitle": "查看当前日志和缓存占用,并一键清理。", "refresh": "刷新占用", "clean": "清理", @@ -127,7 +127,8 @@ "targetNames": { "cache": "缓存", "logs": "日志", - "all": "日志和缓存" + "all": "日志、缓存和追踪数据", + "traces": "追踪数据" }, "targets": { "cache": { @@ -139,13 +140,21 @@ "title": "日志", "subtitle": "清理历史日志,并清空当前正在写入的日志文件内容。", "button": "清理日志" + }, + "traces": { + "title": "追踪", + "subtitle": "清除历史追踪数据,保留运行中的追踪。", + "button": "清理追踪数据" } }, "messages": { "statusFailed": "加载存储占用失败", "cleanupSuccess": "已清理 {count} 个文件,释放 {size}", - "cleanupFailed": "清理失败" - } + "cleanupFailed": "清理失败", + "cleanupTracesSuccess": "已清理 {count} 条追踪,释放 {size}", + "cleanupWithTracesSuccess": "已清理 {count} 个文件和 {traces} 条追踪,释放 {size}" + }, + "confirmWithTraces": "确定要清理 {target} 吗?历史追踪数据将无法恢复,运行中的追踪会保留。" } }, "backup": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/trace.json b/dashboard/src/i18n/locales/zh-CN/features/trace.json index 2612203e5c2..e6e85653046 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/trace.json +++ b/dashboard/src/i18n/locales/zh-CN/features/trace.json @@ -4,7 +4,7 @@ "enabled": "自动滚动:开", "disabled": "自动滚动:关" }, - "hint": "当前仅记录部分 AstrBot 主 Agent 的模型调用路径,后续会不断完善。", + "hint": "记录一次消息唤醒后的完整执行链:插件 Handler、Agent、工具调用与响应。数据仅保留在内存中。", "toggleLabel": "记录追踪", "recording": "记录中", "paused": "已暂停", @@ -17,5 +17,132 @@ "expand": "展开", "collapse": "收起", "showMore": "显示更多", - "empty": "暂无追踪数据" + "empty": "暂无追踪数据", + "search": "在 {count} 条记录中搜索消息、会话、插件", + "clearSearch": "清除搜索", + "live": "实时", + "connecting": "连接中", + "refresh": "刷新", + "records": "条记录", + "traceList": "追踪列表", + "loading": "加载追踪数据…", + "untitled": "未命名消息", + "steps": "步骤", + "duration": "耗时", + "handlers": "插件处理器", + "responses": "响应", + "details": "查看详情", + "selectTrace": "选择一条追踪查看完整执行链", + "closeDetails": "关闭详情", + "pagination": { + "perPage": "每页条数", + "range": "第 {0}–{1} 条,共 {2} 条" + }, + "messagePreview": { + "expand": "展开全文", + "collapse": "收起" + }, + "compact": { + "pluginHandler": "调用插件", + "agentExecution": "Agent 执行", + "rawData": "原始数据", + "toggleAgent": "{action} Agent 内部执行链" + }, + "history": { + "loadFailed": "读取追踪历史失败,请重试", + "detailFailed": "读取追踪详情失败;记录可能已被清理,请重试", + "storageWarning": "部分追踪未能持久化,请检查数据库写入错误或容量限制。", + "loadMore": "加载更多", + "limitHint": "当前最多展示 {count} 条,请缩小搜索范围。", + "truncated": "此追踪内容或节点数量超过限制,部分数据已截断。" + }, + "controls": { + "start": "启动", + "pauseButton": "暂停", + "pause": "暂停追踪记录", + "resume": "开始追踪记录", + "loadFailed": "读取追踪设置失败,请刷新页面重试", + "updateFailed": "切换追踪记录失败,请重试" + }, + "tokens": { + "title": "Tokens", + "breakdown": "输入: {input} (缓存读: {cached})\n输出: {output}", + "unavailable": "暂无已上报的 Token 用量" + }, + "classification": { + "agent": "Agent", + "command": "指令", + "group": "群聊", + "private": "私聊" + }, + "columns": { + "category": "类型", + "duration": "时长", + "status": "状态", + "platform": "平台", + "timestamp": "时间戳", + "message": "消息", + "sender": "发送人", + "spansHint": "实际 Span 数(含消息根 Span),不包含事件;折叠不影响计数。" + }, + "chips": { + "provider": "提供商:{id}", + "persona": "人格:{id}", + "toolCalls": "{count} 次工具调用" + }, + "groups": { + "handlerStart": "触发多个插件处理器", + "agentStart": "启动多个 Agent", + "agentToolCall": "调用多个工具", + "agentToolResult": "多个工具返回", + "responseReady": "准备发送多个响应", + "responseSent": "发送多个响应", + "repeated": "多次{label}", + "count": "{count} 条", + "errors": "{count} 项错误" + }, + "filters": { + "button": "过滤器", + "title": "过滤器", + "reset": "重置", + "close": "关闭过滤器", + "errors": "有错误", + "models": "有模型调用", + "plugins": "有插件调用", + "hint": "可勾选多个条件,结果需同时满足;不勾选则显示全部。" + }, + "status": { + "error": "错误", + "complete": "已完成", + "live": "运行中" + }, + "handlerDescription": { + "loading": "正在加载处理器说明…", + "loadFailed": "加载失败,请再次悬停重试", + "empty": "暂无处理器说明" + }, + "handlerCategories": { + "command": "指令/指令组", + "listener": "事件监听器", + "hook": "事件钩子", + "llm_tool": "LLM 工具" + }, + "actions": { + "selectPersona": "选择人格", + "eventReceived": "收到消息", + "pipelineStart": "开始处理管道", + "pipelineEnd": "完成", + "stageStart": "开始阶段", + "stageEnd": "阶段完成", + "handlerStart": "触发插件处理器", + "handlerEnd": "Handler 完成", + "agentStart": "启动 Agent", + "agentEnd": "Agent 完成", + "agentPrepare": "准备 Agent 请求", + "agentComplete": "完成模型请求", + "agentToolCall": "调用工具", + "agentToolResult": "工具返回", + "responseReady": "准备发送响应", + "responseSent": "发送响应" + } } diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index ca0f16c61b0..e4a869a6892 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -747,9 +747,10 @@ const systemConfigGroups = computed(() => { 'log_file_enable', 'log_file_path', 'log_file_max_mb', - 'trace_log_enable', - 'trace_log_path', - 'trace_log_max_mb' + 'trace_enable', + 'trace_db_path', + 'trace_db_max_mb', + 'trace_retention_days' ]), createGroup('tempStorage', [ 'temp_dir_max_size' diff --git a/dashboard/src/views/TracePage.vue b/dashboard/src/views/TracePage.vue index 73941516d84..b9fb65d2308 100644 --- a/dashboard/src/views/TracePage.vue +++ b/dashboard/src/views/TracePage.vue @@ -1,38 +1,44 @@ diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 44adc9e30d6..5c790c9e2ae 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -568,7 +568,30 @@ Log level. Default is `INFO`. Can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, ### `trace_enable` -Whether to enable trace recording. Default is `false`. When enabled, AstrBot records execution traces, which can be viewed under `Data & Logs → Trace` in the admin panel. +Whether to enable trace recording. Default is `false`. Toggle it under `Settings → General → Logs → Enable Trace Recording`; this is the same setting as the trace page’s start/pause button. When enabled, AstrBot records execution traces, which can be viewed under `Data & Logs → Trace` in the admin panel. + +Enabling recording also persists traces. Pausing preserves history and lets existing traces finish. The OpenTelemetry API and SDK manage span IDs, parent relationships, timestamps, status, events, and active context. Logs correlate with the active `trace_id` / `span_id`; plugins continue using `astrbot.api.logger`. AstrBot uses its own TracerProvider without replacing the global provider, adding exporters, or enabling automatic instrumentation. SQLite persistence and JSON API/SSE remain unchanged; existing history stays readable. No OTLP export is enabled. The standard `OTEL_SDK_DISABLED=true` environment variable also disables SDK tracing. + +Framework entry points compose `log_context(...)`, `event_context(event)`, and the independent `trace_execution(event.trace)` scope. Only the outermost trace execution scope completes the trace after awaited processing, delivery, and history saving; failures and cancellations mark it errored and propagate unchanged. `event_context(event)` only binds the current event, registers active execution, and cleans up temporary files. `TraceSpan.context()` only binds the root span without completing it. Plugins do not need to wrap their handlers in these framework-owned execution scopes. + +### `trace_db_path` + +Dedicated SQLite path, default `logs/traces.db`, relative to `data`. Restart after changing the path. Log cleanup protects the database, WAL, and SHM. Trace content may contain messages, plugin arguments, and tool results; restrict file access accordingly. + +### `trace_db_max_mb` / `trace_retention_days` + +Positive integers, default 80 MB capacity target and 7-day retention; unlimited capacity is not supported. Expired or oldest traces are evicted as complete units. WAL, checkpoints, and page reclamation may temporarily add disk usage, so this is not an instantaneous hard limit. Per-trace content, span, and event limits also apply; detail shows truncation warnings. + +Legacy settings migrate on load, with new settings taking precedence. Existing JSONL files remain untouched and are neither appended nor automatically imported: + +| Old setting | New setting/behavior | +| --- | --- | +| `trace_enable` | Unchanged; controls recording and persistence of new traces | +| `trace_log_enable` | Removed; no separate JSONL toggle | +| `trace_log_path` | Removed; replaced by `trace_db_path: logs/traces.db` by default | +| `trace_log_max_mb` | When new capacity is absent, a positive integer is multiplied by 4 for `trace_db_max_mb`, approximating the old current file plus 3 backups; invalid values fall back to 80 MB | + +The new `trace_retention_days` defaults to 7 days. ### `pip_install_arg` diff --git a/docs/en/dev/star/guides/session-control.md b/docs/en/dev/star/guides/session-control.md index faf40569e8e..8e6f608543f 100644 --- a/docs/en/dev/star/guides/session-control.md +++ b/docs/en/dev/star/guides/session-control.md @@ -16,6 +16,8 @@ User: Keen observation (明察秋毫) AstrBot provides out-of-the-box session control functionality: +Follow-up input is routed by the core pipeline, rather than the built-in plugin's former `handle_session_control_agent` handler. A matching active waiter allows input without another mention or wake prefix. After whitelist, session-status, rate-limit, content-safety checks, and preprocessing, `ProcessStage` dispatches the input before ordinary plugin handlers or the Agent. Consumed input does not continue to those handlers; `@session_waiter` usage is unchanged. + Import: ```py diff --git a/docs/en/use/images/trace-timeline-en.jpg b/docs/en/use/images/trace-timeline-en.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b97111c6a3be008be330e2f5c3af102131168f79 GIT binary patch literal 49131 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

7J*;bNF2nXl7tNv=TGMbb9JqjtE2166fZZmxtay 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 [!NOTE] -> Trace recording currently covers only some model-call paths from the AstrBot main Agent. Coverage will continue to improve. +> Traces are persisted in dedicated SQLite at `data/logs/traces.db` by default, with 7-day retention and an 80 MB capacity target. Old traces are evicted as complete units. WAL and checkpoints may temporarily add disk usage; the target is not an instantaneous hard limit. Pausing leaves history readable and allows existing traces to finish. Traces unfinished at restart are marked errored/interrupted. Queue pressure or storage failures show a warning without blocking message processing. Legacy JSONL files are neither appended nor imported; ordinary logs keep their existing file settings. + +Handler names also have a dotted underline. Hover to lazily fetch plugin details and show that handler's description, including nested commands, using the same description as the plugin detail page. Details are cached for the current page; hover again to retry a failed request. + +Streaming responses show one final aggregated response after delivery finishes, without intermediate chunks or protocol closing frames. Messages sent independently by tools or plugins during generation remain separate responses. + +Timeline chips show the runner name without an execution-mode prefix, using the profile page's labels (`local` uses the AstrBot built-in AI title), and plugin names (comma-separated in collapsed groups). Response ownership chips are removed: successful public sends through `event.send()` and `Context.send_message()` record message outlines without a source field. Context sends attach to the current event execution, without creating a new trace when none is active. Nested forwarding is recorded once; streaming keeps only its final aggregate and separate tool messages. + +Consecutive nodes of the same type are collapsed by default, such as "Call plugin" with an "Event listener" chip, with a record count and stacked icons. Click to expand or collapse the individual records. Different handler categories and records separated by another node type remain separate, preserving execution order. Collapsed groups still show error counts, and summary cards continue to count actual calls. + +The message list and message-received node show the platform logo. The message-received node shows `platform_id` in a secondary chip. A generic icon is used when no logo matches or the image fails to load; text still indicates processing status. The former standalone Select persona node is removed; the resolved `persona_id` appears in the Agent request prepared details. The search placeholder is "Search messages, sessions, or plugins in X records". Summary cards show duration, plugin handler calls, and responses, without counting steps. ## Command Management diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 32380bbca47..ded6964b7ec 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -568,7 +568,30 @@ AstrBot WebUI 配置。 ### `trace_enable` -是否启用追踪记录。默认为 `false`。启用后,AstrBot 会记录运行追踪信息,可以在管理面板的 `数据与日志 → 追踪` 页面查看。 +是否启用追踪记录。默认为 `false`。可在 `设置 → 常规 → 日志 → 启用追踪记录` 中切换,与追踪页的启动/暂停按钮共用同一配置。启用后,AstrBot 会记录运行追踪信息,可以在管理面板的 `数据与日志 → 追踪` 页面查看。 + +开启后自动持久化;暂停不删除历史,已开始的追踪仍会记录完成状态。OpenTelemetry API 和 SDK 负责 Span ID、父子关系、起止时间、状态、事件和活动上下文。日志会关联当前活动的 `trace_id` / `span_id`,插件仍使用原来的 `astrbot.api.logger`。AstrBot 使用独立的 TracerProvider,不替换全局 Provider,不添加导出器或自动埋点。SQLite 持久化、JSON API/SSE 和现有历史数据格式保持不变,不启用 OTLP 导出。标准环境变量 `OTEL_SDK_DISABLED=true` 也会关闭 SDK 追踪。 + +框架入口组合使用 `log_context(...)`、`event_context(event)` 和独立的 `trace_execution(event.trace)`。只有最外层追踪执行作用域在消息处理、发送及历史保存结束后完成追踪;异常或取消会标记错误并原样抛出。`event_context(event)` 仅负责绑定当前事件、注册活动事件及清理临时文件;`TraceSpan.context()` 仅绑定根 Span,不负责结束追踪。插件无需在处理器中调用这些由框架入口管理的执行作用域。 + +### `trace_db_path` + +独立 SQLite 路径,默认 `logs/traces.db`(相对 `data` 目录),更改路径后重启生效。数据库、WAL 和 SHM 不受“清理日志”影响。追踪正文可能包含消息、插件参数和工具结果,请限制数据库文件访问权限。 + +### `trace_db_max_mb` / `trace_retention_days` + +正整数,默认容量目标 80 MB、保留 7 天,不支持无限容量。达到容量或过期后按完整 trace 淘汰旧数据;WAL、检查点和回收页面可能暂时增加磁盘占用,因此容量不是瞬时硬上限。单条追踪也有内容、Span 和事件数量限制,截断会在详情中提示。 + +旧配置在加载时自动迁移,新配置优先,旧 JSONL 文件保留但不再写入或自动导入: + +| 旧配置 | 新配置/行为 | +| --- | --- | +| `trace_enable` | 保持不变,控制新追踪记录与持久化 | +| `trace_log_enable` | 移除,不再单独开关 JSONL | +| `trace_log_path` | 移除,默认改用 `trace_db_path: logs/traces.db` | +| `trace_log_max_mb` | 未指定新容量时,正整数乘 4 迁移为 `trace_db_max_mb`(接近原当前文件加 3 份备份预算);无效值改用 80 MB | + +新增 `trace_retention_days` 默认为 7 天。 ### `pip_install_arg` diff --git a/docs/zh/dev/star/guides/session-control.md b/docs/zh/dev/star/guides/session-control.md index 0792b12fdcc..9c804273020 100644 --- a/docs/zh/dev/star/guides/session-control.md +++ b/docs/zh/dev/star/guides/session-control.md @@ -16,6 +16,8 @@ AstrBot 提供了开箱即用的会话控制功能: +后续输入由核心消息管道分发,不再依赖内置插件原来的 `handle_session_control_agent` 处理器。匹配到有效等待会话的消息无需再次 @ 机器人或添加唤醒前缀;经过白名单、会话开关、限流、内容安全检查及预处理后,`ProcessStage` 会先将其交给等待回调。被接管的消息不会继续触发普通插件或 Agent,`@session_waiter` 的用法保持不变。 + 导入: ```py diff --git a/docs/zh/use/images/trace-timeline-zh.jpg b/docs/zh/use/images/trace-timeline-zh.jpg new file mode 100644 index 0000000000000000000000000000000000000000..55c2c0fabc951d7824c07d7ca6b55e5a4c2b0e07 GIT binary patch literal 46649 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 Y3*Z-7vNYV(`03lQ{C}Ctl`r`J1upoeqW}N^ literal 0 HcmV?d00001 diff --git a/docs/zh/use/webui.md b/docs/zh/use/webui.md index d12e8c59d08..c1a96e4b89c 100644 --- a/docs/zh/use/webui.md +++ b/docs/zh/use/webui.md @@ -172,10 +172,52 @@ ChatUI 支持以下常用能力: ### 追踪 -`追踪` 页面实时显示 AstrBot 的运行追踪记录,适合调试模型调用路径和工具调用过程。可以通过页面顶部的开关启用或禁用追踪记录。 +包含 Agent 调用时,详情统计区增加无边框的 Tokens 卡片,展示本轮输入(含缓存输入)与输出的累计总量,以两位小数的 k/M 格式显示,悬浮提示保留精确数量;悬停或键盘聚焦可查看输入、输出及缓存命中明细。同一 Agent 的累计快照只取最新一份,多个 Agent 分别累计。没有已上报用量时显示“—”,不按 0 处理。 + +“Agent 执行”标题后显示已记录的模型 ID、人格 ID,以及非零的实际工具调用次数。模型标签悬停可查看提供商 ID,执行链折叠后标签仍可见;嵌套或并行 Agent 分别统计,缺失的信息不显示。 + +详情标题下的 UMO 和发送人分别以“图标 + 值”独立成行,不再使用点分隔;UMO 别名仍可悬停查看完整标识。 + +追踪区域和悬停提示使用系统默认字体,不使用 Outfit。运行中状态为蓝色,已完成和错误仍分别为绿色和红色。 + +列表和详情时间戳按浏览器本地时区显示月、日,格式为 `MM-DD HH:mm:ss.SSS`;时间线悬停提示使用同一格式。 + +UMO 前新增“类型”列:蓝色 Agent 表示 Agent 调用,绿色“指令”表示指令处理器调用;群聊/私聊使用中性标签独立显示。同一追踪可同时显示这些标签;普通监听器和事件钩子不算作指令。消息类型优先读取追踪摘要,旧记录从 UMO 推导,无法确定时不显示群聊/私聊标签。旧记录的指令标记按当前页数据推导,无需逐条打开详情。 + +新 Span 使用 `astrbot.kind` 表示调用类型,起止时间和状态表示执行结果;不再存储 `astrbot.action`、`astrbot.end_action` 或 `astrbot.duration_ms`。耗时由纳秒起止时间计算,`astrbot.handler_type` 保留。旧追踪仍可读取,历史数据库记录不会自动重写。 + +长消息标题默认显示 2 行,收到消息和发送响应的正文默认显示 4 行;超出时可点击“展开全文 / 收起”。这仅折叠界面展示,不截断已记录内容。 + +“完成”表示整条事件的处理已结束,而不是模型请求或响应发送单独结束。普通消息和 Cron 定时任务共用事件执行生命周期;Cron 不经过消息 Pipeline,在 Agent 执行和历史保存结束后完成追踪,异常或取消也会结束并标记错误。 + +详情时间线采用紧凑分组布局:“触发插件处理器(类型)”改为“调用插件”配合类别标签,“启动 Agent”改为“Agent 执行”。节点右侧统一显示展开箭头,不再每条单独占一行显示“查看详情”;点击普通节点标题查看原始数据,点击调用插件组展开各次调用。平台 ID 放入次行标签,元信息标签使用中性色;时间改为相对追踪开始的偏移,悬停可查看精确时间。 + +Agent 默认折叠内部执行链,点击“Agent 执行”可展开请求准备、工具和插件钩子;“原始数据”可进一步查看 Agent 自身的字段。折叠按实际 Span 父子关系识别,不把时间上交错的其他分支误归入 Agent;展开仍保持记录顺序,最终响应保留在主线上。内部错误在折叠标题中也会提示,展开/收起不改变调用、响应或 Span 统计。 + +![紧凑分组时间线与默认折叠的 Agent(示例数据)](images/trace-timeline-zh.jpg) + +“设置 → 常规”中的“日志与缓存清理”现为“日志、缓存与追踪清理”,总占用包含追踪数据库及其附属文件。菜单新增“清理追踪数据”;“全部清理”也包含历史追踪。清理前会确认,已完成或中断的追踪将永久移除,运行中的追踪保留;数据库文件本身不会直接删除。“清理日志”和“清理缓存”不会清除追踪。 + +在“设置 → 常规 → 日志”中可切换“启用追踪记录”,与追踪页工具栏的启动/暂停按钮共用同一个全局配置;关闭后不再采集新追踪,历史仍可查看。其下可配置“追踪数据库路径”“追踪数据库容量预算 (MB)”和“追踪保留天数”,默认分别为 `logs/traces.db`、80 MB 和 7 天;更改数据库路径后重启后端。系统提示词等顶层文本字段没有单独的 4 KB 截断限制,但整条追踪的内容和节点预算仍然生效。 + +追踪标签页不使用外层灰色卡片,工具栏和列表直接铺在页面上,仅保留必要的分隔线。原来的顶部说明和独立记录开关已移除;工具栏按钮显示“暂停”(启用且已连接)、“连接中”(启用但未连接)或“启动”(停用)。过滤器、记录按钮、刷新均采用图标加文字的浅灰按钮,与搜索框共用背景色,统一为 36px 高;搜索框填满剩余宽度,将操作按钮靠右排列。记录数量并入占位文字:“在 X 条记录中搜索消息、会话、插件”,不单独显示。点击记录按钮可暂停或恢复,更新期间显示加载状态并禁用重复点击;失败时保持原状态并提示错误。暂停不会清除已有列表或关闭详情,明暗主题保持相同布局。 + +`追踪` 页面实时显示消息执行链:收到消息、触发插件处理器、Agent 相关请求、发送响应和完成。默认只展示全宽 Vuetify datatable 追踪列表,列顺序为状态标签(运行中/已完成/错误)、平台 logo、时间戳、时长、消息、类型、UMO、发送人和 Spans;表头和单元格字体为 13px,表头高度为 40px,内容行高和间距采用 Vuetify 默认样式,仅定制状态、平台图标及消息等单元格内容。状态标签使用 flat 样式;平台列宽度为 80px,表头不会换行。时长在执行期间持续更新。点击某行后才打开右侧时间线详情,点击关闭按钮或按 Escape 返回列表;加载历史记录、新记录到达及筛选变化都不会自动打开其他记录的详情。窄屏列表可横向滚动。Spans 统计实际 Span 数(包括消息根 Span),不包含挂载在 Span 上的事件,折叠不改变计数;搜索占位文字显示满足当前条件的消息 trace 总数。历史记录在服务端搜索、筛选和分页;原来的“加载更多”改为 Vuetify datatable 分页器,默认每页 50 条,可选 25、50、100 条,并支持前后翻页和首尾页。浏览器只保留当前页摘要,只有当前打开的 trace 加载完整详情,关闭后释放详情。搜索或筛选会返回第一页;新记录只实时刷新第一页,不打乱正在查看的历史页。点击搜索框左侧的“过滤器”,勾选“有错误”“有模型调用”“有插件调用”;多选需同时满足所有条件,并可叠加搜索。不勾选则显示全部,“重置”清除勾选,关闭侧栏仍保留条件。原先的“全部 / 错误 / Agent / 插件”单选标签已改为多选侧栏。工具栏记录按钮可启用或禁用追踪记录。 + +UMO 有别名时显示别名,带点划线的文字可悬停查看完整 UMO。插件优先显示 display name,悬停可查看插件 ID,同时展示 Handler 名称及耗时。事件监听器不再重复显示底层类型 `AdapterMessageEvent`,其他事件钩子的类型仍保留。插件处理器节点优先使用插件 logo,没有 logo 或加载失败时使用插件卡片的默认蓝色星形图标;标题旁的中性标签展示与插件详情页一致的分类:指令/指令组、事件监听器、事件钩子或 LLM 工具。 > [!NOTE] -> 当前仅记录部分 AstrBot 主 Agent 的模型调用路径,后续会不断完善。 +> 追踪持久化到独立 SQLite:默认 `data/logs/traces.db`,保留 7 天,容量目标 80 MB,按完整 trace 淘汰最旧数据。WAL 和检查点可能暂时增加占用,容量不是瞬时硬上限。暂停后仍可查看历史,已开始的 trace 会完成记录;重启时未完成的 trace 标为错误/中断。写入队列满或存储失败时页面显示警告,不阻塞消息处理。旧 JSONL 不再写入,也不自动导入;普通日志仍沿用原有文件设置。 + +处理器名称也带有点划线,悬停时才请求插件详情并展示该 Handler 的 description(包括指令组内的子指令),与插件详情页使用同一份说明。详情在当前页面内缓存;加载失败时再次悬停可重试。 + +流式响应在发送结束后仅展示一条最终汇总的“发送响应”,不展示中间分片或协议收尾帧。流式生成期间,工具或插件单独发送的消息仍会作为独立响应展示。 + +时间线使用标签展示元信息:Agent 节点沿用配置文件页的执行器名称,不加“执行方式”前缀,`local` 显示为“AstrBot 内置 AI”;插件处理器显示插件名称,折叠组中的多个名称以逗号分隔。“系统发送 / 插件发送”标签已移除:`event.send()` 和 `Context.send_message()` 等公共发送方法记录成功发送的消息 outline,不再记录来源。`Context.send_message()` 关联当前事件执行,没有活跃事件时不额外创建追踪;嵌套转发只记一次。流式响应仍只保留最终汇总,工具单独发送的消息独立保留。 + +时间线上连续的同类型节点默认折叠,例如“调用插件”配合“事件监听器”标签,显示记录数量及叠放图标;点击可展开或收起每条记录。不同处理器分类或被其他类型节点隔开的记录不会合并,执行顺序保持不变。组内有错误时,折叠标题仍会提示错误数量,统计卡片仍按实际调用次数计算。 + +左侧消息列表及“收到消息”节点显示平台 logo;“收到消息”节点的次行标签显示 `platform_id`。没有匹配图标或图片加载失败时使用通用图标,处理状态仍通过文字显示。原先的“选择人格”独立节点已移除,实际选中的 `persona_id` 在“准备 Agent 请求”的详情中显示。搜索框提示为“在 X 条记录中搜索消息、会话、插件”,统计卡片显示耗时、插件处理器次数和发送响应次数,不展示步骤数。 ## 指令管理 diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index da1778b3075..a788b343fd5 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -4656,6 +4656,7 @@ paths: tags: [Stats] summary: Get storage status operationId: getStorageStatus + description: Returns log, cache, and trace database usage (including trace sidecars), plus total bytes. x-astrbot-scope: system responses: "200": @@ -4666,13 +4667,14 @@ paths: tags: [Stats] summary: Clean storage operationId: cleanupStorage + description: Clears logs, cache, finished trace history, or all three. Running traces are retained. x-astrbot-scope: system requestBody: required: false content: application/json: schema: - $ref: "#/components/schemas/DynamicConfig" + $ref: "#/components/schemas/StorageCleanupRequest" responses: "200": $ref: "#/components/responses/Ok" @@ -5085,6 +5087,56 @@ paths: "200": $ref: "#/components/responses/Text" + /api/v1/trace/history: + get: + tags: [Logs] + summary: List persisted trace summaries + operationId: listTraces + x-astrbot-scope: system + parameters: + - name: limit + in: query + schema: {type: integer, minimum: 1, maximum: 100, default: 50} + - name: cursor + description: Legacy keyset cursor; use page for numbered pagination. + in: query + schema: {type: string, maxLength: 64} + - name: page + in: query + schema: {type: integer, minimum: 1, default: 1} + - name: query + in: query + schema: {type: string, maxLength: 256, default: ""} + - name: errors + in: query + schema: {type: boolean, default: false} + - name: models + in: query + schema: {type: boolean, default: false} + - name: plugins + in: query + schema: {type: boolean, default: false} + responses: + "200": + $ref: "#/components/responses/Ok" + + /api/v1/trace/{trace_id}: + get: + tags: [Logs] + summary: Get persisted trace spans and events + operationId: getTraceDetail + x-astrbot-scope: system + parameters: + - name: trace_id + in: path + required: true + schema: {type: string, pattern: "^[0-9a-f]{32}$"} + responses: + "200": + $ref: "#/components/responses/Ok" + "404": + description: Trace not found or already evicted. + /api/v1/trace/settings: get: tags: [Logs] @@ -5539,6 +5591,14 @@ components: data: {} additionalProperties: false + StorageCleanupRequest: + type: object + properties: + target: + type: string + enum: [logs, cache, traces, all] + default: all + DynamicConfig: type: object additionalProperties: true diff --git a/pyproject.toml b/pyproject.toml index 758f20ec58d..56f69931d84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,8 @@ dependencies = [ "pyotp>=2.9.0", "reportlab>=5.0.0", "xlrd>=2.0.2", + "opentelemetry-api>=1.45.1,<2", + "opentelemetry-sdk>=1.45.1,<2", ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index 9bcaa93a61d..dc251b6e278 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,8 @@ httpx[socks]>=0.28.1 lark-oapi>=1.4.15 mcp>=1.8.0,<2 openai>=1.78.0 +opentelemetry-api>=1.45.1,<2 +opentelemetry-sdk>=1.45.1,<2 ormsgpack>=1.9.1 openpyxl>=3.1.5 pandas>=2.2.0 diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index e0447c7bf04..7a22a0c4035 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1,6 +1,7 @@ import asyncio import copy import json +import time from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace @@ -4336,3 +4337,117 @@ async def test_v1_platform_webhook_preserves_tuple_response( assert response.status_code == 202 assert response.headers["content-type"] == "text/plain" assert response.text == "accepted" + + +@pytest.mark.asyncio +async def test_v1_persistent_trace_routes(asgi_client, monkeypatch, tmp_path): + """Verify durable history/detail validation and dashboard authentication.""" + from astrbot.core.utils.trace_store import TraceStore + from astrbot.dashboard.services import log_service + + store = TraceStore(tmp_path / "traces.db", {"trace_db_max_mb": 8}) + monkeypatch.setattr(log_service, "get_trace_store", lambda *_: store) + stamp = str(time.time_ns()) + trace_id = "a" * 32 + span_id = "b" * 16 + summary = { + "trace_id": trace_id, + "root_span_id": span_id, + "start_time_unix_nano": stamp, + "end_time_unix_nano": stamp, + "updated_time_unix_nano": stamp, + "state": "completed", + "has_errors": False, + "has_models": False, + "has_plugins": False, + "span_count": 1, + "attributes": {"astrbot.message_outline": "Hello"}, + } + try: + assert store.append( + { + "trace": summary, + "time_unix_nano": stamp, + "span": { + "trace_id": trace_id, + "span_id": span_id, + "parent_span_id": None, + "name": "message", + "start_time_unix_nano": stamp, + "end_time_unix_nano": stamp, + "attributes": {}, + "status": {"code": 1}, + }, + } + ) + assert store.flush() + response = await asgi_client.get("/api/v1/trace/history") + assert response.status_code == 401 + token = jwt.encode( + {"username": "astrbot", "exp": int(time.time()) + 600}, + JWT_SECRET, + algorithm="HS256", + ) + headers = {"Authorization": f"Bearer {token}"} + response = await asgi_client.get( + "/api/v1/trace/history", + params={"limit": 1, "query": "Hello"}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["data"]["total"] == 1 + response = await asgi_client.get( + "/api/v1/trace/history", params={"limit": 1, "page": 999}, headers=headers + ) + assert response.json()["data"]["page"] == 1 + assert len(response.json()["data"]["traces"]) == 1 + assert ( + await asgi_client.get("/api/v1/trace/history?page=0", headers=headers) + ).status_code == 422 + response = await asgi_client.get(f"/api/v1/trace/{trace_id}", headers=headers) + assert response.status_code == 200 + assert response.json()["data"]["spans"][0]["span_id"] == span_id + assert ( + await asgi_client.get("/api/v1/trace/history?limit=101", headers=headers) + ).status_code == 422 + assert ( + await asgi_client.get("/api/v1/trace/invalid", headers=headers) + ).status_code == 422 + assert ( + await asgi_client.get("/api/v1/trace/" + "c" * 32, headers=headers) + ).status_code == 404 + assert ( + await asgi_client.get("/api/v1/trace/settings", headers=headers) + ).status_code == 200 + finally: + store.close() + + +@pytest.mark.asyncio +async def test_v1_trace_storage_cleanup_target(asgi_client, monkeypatch): + """Validate trace cleanup requests without touching application data.""" + usage = { + "logs": {"size_bytes": 10}, + "cache": {"size_bytes": 20}, + "traces": {"size_bytes": 30}, + "total_bytes": 60, + } + cleanup = AsyncMock(return_value={"removed_traces": 3, "status": usage}) + monkeypatch.setattr(stat_service.StatService, "cleanup_storage", cleanup) + monkeypatch.setattr(stat_service.StatService, "get_storage_status", AsyncMock(return_value=usage)) + token = jwt.encode( + {"username": "astrbot", "exp": int(time.time()) + 600}, + JWT_SECRET, algorithm="HS256", + ) + headers = {"Authorization": f"Bearer {token}"} + assert (await asgi_client.post("/api/v1/stats/storage/cleanup", json={"target": "traces"})).status_code == 401 + response = await asgi_client.post( + "/api/v1/stats/storage/cleanup", json={"target": "traces"}, headers=headers, + ) + assert response.status_code == 200 + assert response.json()["data"]["removed_traces"] == 3 + cleanup.assert_awaited_once_with("traces") + response = await asgi_client.get("/api/v1/stats/storage", headers=headers) + assert response.json()["data"]["total_bytes"] == 60 + assert response.json()["data"]["traces"]["size_bytes"] == 30 + assert (await asgi_client.post("/api/v1/stats/storage/cleanup", json={"target": "invalid"}, headers=headers)).status_code == 422 diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 1734384f8c7..3089bbc7b34 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1156,6 +1156,11 @@ async def test_ensure_persona_from_session(self, mock_event, mock_context): await module._ensure_persona_and_skills(req, {}, mock_context, mock_event) assert "You are helpful." in req.system_prompt + mock_event.set_extra.assert_any_call("_trace_persona_id", "test-persona") + assert not any( + call.args[0] == "sel_persona" + for call in mock_event.trace.record.call_args_list + ) @pytest.mark.asyncio async def test_ensure_persona_from_conversation(self, mock_event, mock_context): @@ -1172,6 +1177,7 @@ async def test_ensure_persona_from_conversation(self, mock_event, mock_context): await module._ensure_persona_and_skills(req, {}, mock_context, mock_event) assert "Custom persona." in req.system_prompt + mock_event.set_extra.assert_any_call("_trace_persona_id", "conv-persona") @pytest.mark.asyncio async def test_inline_genui_prompt_is_added_with_custom_persona( diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py index 5366fca1d44..cf8588cc20a 100644 --- a/tests/unit/test_astr_message_event.py +++ b/tests/unit/test_astr_message_event.py @@ -1,7 +1,9 @@ """Tests for AstrMessageEvent class.""" +import asyncio import re -from unittest.mock import AsyncMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,11 +16,22 @@ Plain, Reply, ) -from astrbot.core.message.message_event_result import MessageEventResult -from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.message.message_event_result import ( + MessageChain, + MessageEventResult, + ResultContentType, +) +from astrbot.core.pipeline.respond.stage import RespondStage +from astrbot.core.platform.astr_message_event import ( + AstrMessageEvent, + current_message_event, + event_context, + streaming_response_event, +) from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.star.context import Context class ConcreteAstrMessageEvent(AstrMessageEvent): @@ -71,6 +84,285 @@ def astr_message_event(platform_meta, astrbot_message): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_response", [False, True]) +async def test_streaming_trace_only_records_final_response( + astr_message_event, monkeypatch, tool_response +): + """Aggregate adapter chunks without hiding independent tool responses.""" + event = astr_message_event + event.trace = MagicMock() + monkeypatch.setattr( + "astrbot.core.platform.astr_message_event.Metric.upload", AsyncMock() + ) + + async def stream(): + yield MessageChain([Plain("Hello")]) + if tool_response: + await event.send(MessageChain([Plain("Tool reply")])) + yield MessageChain([Plain("hidden reasoning")], type="reasoning") + yield MessageChain([], type="break") + yield MessageChain([Plain(" world")]) + + async def deliver(generator, use_fallback): + async for chunk in generator: + if chunk.type not in {"reasoning", "break"}: + await event.send(chunk) + # The QQ streaming protocol may require an empty closing frame. + await event.send(MessageChain([Plain("\n")])) + + event.send_streaming = AsyncMock(side_effect=deliver) + event.set_result( + MessageEventResult() + .set_result_content_type(ResultContentType.STREAMING_RESULT) + .set_async_stream(stream()) + ) + stage = RespondStage() + stage.config = {"provider_settings": {}} + await stage.process(event) + + records = event.trace.record.call_args_list + assert [record.args[0] for record in records] == ["response_sent"] * ( + 2 if tool_response else 1 + ) + assert [record.kwargs["message"] for record in records] == ( + ["Tool reply", "Hello world"] if tool_response else ["Hello world"] + ) + assert records[-1].kwargs["streaming"] is True + assert "source" not in records[-1].kwargs + assert streaming_response_event.get() is None + await event.send(MessageChain([Plain("Normal reply")])) + assert event.trace.record.call_args.kwargs["message"] == "Normal reply" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [ValueError, asyncio.CancelledError]) +async def test_streaming_trace_context_restored_after_failure( + astr_message_event, failure +): + """Failed or cancelled delivery must not suppress subsequent responses.""" + event = astr_message_event + event.trace = MagicMock() + event.send_streaming = AsyncMock(side_effect=failure("delivery interrupted")) + + async def stream(): + yield MessageChain([Plain("Hello")]) + + event.set_result( + MessageEventResult() + .set_result_content_type(ResultContentType.STREAMING_RESULT) + .set_async_stream(stream()) + ) + stage = RespondStage() + stage.config = {"provider_settings": {}} + with pytest.raises(failure): + await stage.process(event) + event.trace.record.assert_not_called() + assert streaming_response_event.get() is None + + +@pytest.mark.asyncio +async def test_streaming_outline_limit_does_not_truncate_delivery(astr_message_event): + """Bound diagnostic content without changing the outgoing stream.""" + event = astr_message_event + event.trace = MagicMock() + fragment = "字" * 20000 + delivered = [] + + async def stream(): + yield MessageChain([Plain(fragment)]) + yield MessageChain([Plain("End")]) + + async def deliver(generator, use_fallback): + async for chunk in generator: + delivered.append(chunk.chain[0].text) + + event.send_streaming = AsyncMock(side_effect=deliver) + event.set_result( + MessageEventResult() + .set_result_content_type(ResultContentType.STREAMING_RESULT) + .set_async_stream(stream()) + ) + stage = RespondStage() + stage.config = {"provider_settings": {}} + await stage.process(event) + event.trace.record.assert_called_once() + fields = event.trace.record.call_args.kwargs + assert len(fields["message"].encode()) <= 4096 + assert fields["truncated"] is True + assert delivered == [fragment, "End"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "result_type", + [ + ResultContentType.GENERAL_RESULT, + ResultContentType.LLM_RESULT, + ResultContentType.STREAMING_RESULT, + ], +) +async def test_response_trace_omits_sending_owner( + astr_message_event, monkeypatch, result_type +): + """Record delivered responses without attributing a sending owner.""" + event = astr_message_event + event.trace = MagicMock() + monkeypatch.setattr( + "astrbot.core.platform.astr_message_event.Metric.upload", AsyncMock() + ) + monkeypatch.setattr( + "astrbot.core.pipeline.respond.stage.call_event_hook", + AsyncMock(return_value=False), + ) + + async def stream(): + yield MessageChain([Plain("Reply")]) + + async def deliver(generator, use_fallback): + async for chunk in generator: + await event.send(chunk) + + result = MessageEventResult().set_result_content_type(result_type) + if result_type == ResultContentType.STREAMING_RESULT: + result.set_async_stream(stream()) + event.send_streaming = AsyncMock(side_effect=deliver) + else: + result.message("Reply") + event.set_result(result) + stage = RespondStage() + stage.config = {"provider_settings": {}} + stage.platform_settings = {"path_mapping": []} + stage.enable_seg = False + await stage.process(event) + + sent = [ + record for record in event.trace.record.call_args_list + if record.args[0] == "response_sent" + ] + assert len(sent) == 1 + assert "source" not in sent[0].kwargs + assert sent[0].kwargs["message"] == "Reply" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "missing", "failure"]) +async def test_public_context_send_records_only_successful_delivery( + astr_message_event, outcome +): + """Attach successful public sends to the active event without a source.""" + event = astr_message_event + event.trace = MagicMock() + platform = SimpleNamespace( + meta=lambda: SimpleNamespace(id="target"), + send_by_session=AsyncMock( + side_effect=RuntimeError("send failed") if outcome == "failure" else None + ), + ) + context = SimpleNamespace( + platform_manager=SimpleNamespace( + platform_insts=[] if outcome == "missing" else [platform] + ), + get_config=lambda **kwargs: {}, + ) + with event_context(event): + assert current_message_event.get() is event + if outcome == "failure": + with pytest.raises(RuntimeError, match="send failed"): + await Context.send_message( + context, "target:FriendMessage:other", MessageChain([Plain("Hello")]) + ) + else: + assert await Context.send_message( + context, "target:FriendMessage:other", MessageChain([Plain("Hello")]) + ) is (outcome == "success") + deliveries = [ + call for call in event.trace.record.call_args_list + if call.args[0] == "response_sent" + ] + assert len(deliveries) == (1 if outcome == "success" else 0) + if deliveries: + assert deliveries[0].kwargs["message"] == "Hello" + assert deliveries[0].kwargs["target_umo"] == "target:FriendMessage:other" + assert "source" not in deliveries[0].kwargs + assert current_message_event.get() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scoped", [False, True]) +async def test_public_context_send_skips_unscoped_and_stream_chunk_records( + astr_message_event, scoped +): + """Do not invent traces or duplicate streaming chunk delivery records.""" + event = astr_message_event + event.trace = MagicMock() + context = SimpleNamespace( + platform_manager=SimpleNamespace(platform_insts=[ + SimpleNamespace( + meta=lambda: SimpleNamespace(id="target"), send_by_session=AsyncMock() + ) + ]), + get_config=lambda **kwargs: {}, + ) + if scoped: + with event_context(event): + token = streaming_response_event.set(event) + try: + await Context.send_message( + context, "target:FriendMessage:other", MessageChain([Plain("Chunk")]) + ) + finally: + streaming_response_event.reset(token) + else: + await Context.send_message( + context, "target:FriendMessage:other", MessageChain([Plain("Hello")]) + ) + assert all( + call.args[0] != "response_sent" + for call in event.trace.record.call_args_list + ) + + +@pytest.mark.asyncio +async def test_nested_event_forwarding_records_delivery_once(monkeypatch): + """Record nested public sends only once without duplicating event metrics.""" + from astrbot.core.cron.events import CronMessageEvent + from astrbot.core.platform.message_session import MessageSession + + monkeypatch.setattr( + "astrbot.core.platform.astr_message_event.Metric.upload", AsyncMock() + ) + context = SimpleNamespace( + platform_manager=SimpleNamespace(platform_insts=[ + SimpleNamespace( + meta=lambda: SimpleNamespace(id="target"), send_by_session=AsyncMock() + ) + ]), + get_config=lambda **kwargs: {}, + ) + + async def send_message(session, chain): + """Dispatch through the real public context method.""" + return await Context.send_message(context, session, chain) + + context.send_message = send_message + event = CronMessageEvent( + context=context, + session=MessageSession.from_str("target:FriendMessage:user"), + message="Scheduled", + ) + event.trace = MagicMock() + with event_context(event): + await event.send(MessageChain([Plain("Hello")])) + deliveries = [ + call for call in event.trace.record.call_args_list + if call.args[0] == "response_sent" + ] + assert len(deliveries) == 1 + assert deliveries[0].kwargs["message"] == "Hello" + assert event._has_send_oper is True + + def test_retained_attachment_is_excluded_from_event_cleanup( astr_message_event, tmp_path ): diff --git a/tests/unit/test_event_bus.py b/tests/unit/test_event_bus.py index 8fc68399e0a..d60c85d249f 100644 --- a/tests/unit/test_event_bus.py +++ b/tests/unit/test_event_bus.py @@ -300,7 +300,7 @@ async def test_session_isolation_precedes_registration( lambda *_args, **_kwargs: [], ) registry = ActiveEventRegistry() - monkeypatch.setattr("astrbot.core.pipeline.scheduler.active_event_registry", registry) + monkeypatch.setattr("astrbot.core.platform.astr_message_event.active_event_registry", registry) started = asyncio.Event() release = asyncio.Event() diff --git a/tests/unit/test_event_execution.py b/tests/unit/test_event_execution.py new file mode 100644 index 00000000000..ec1e40670e9 --- /dev/null +++ b/tests/unit/test_event_execution.py @@ -0,0 +1,346 @@ +"""Shared event lifecycle regression coverage for messages and Cron jobs.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from opentelemetry.trace import format_trace_id, get_current_span + +from astrbot.core.agent.runners.base import AgentState +from astrbot.core.cron.manager import CronJobManager +from astrbot.core.log import _log_context, log_context +from astrbot.core.pipeline import scheduler as scheduler_module +from astrbot.core.platform.astr_message_event import AstrMessageEvent, event_context +from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.utils import trace as trace_module +from astrbot.core.utils.active_event_registry import active_event_registry +from astrbot.core.utils.trace import trace_execution +from astrbot.core.utils.trace_store import TraceStore + + +@pytest.fixture +def frames(monkeypatch): + """Capture trace updates without opening the production database. + + Args: + monkeypatch: Pytest patch manager. + + Returns: + Immutable persistence snapshots from the tested execution. + """ + captured = [] + store = MagicMock() + store.append.side_effect = lambda packet, **kwargs: ( + captured.append(json.loads(json.dumps(packet))) or True + ) + monkeypatch.setitem(trace_module.astrbot_config, "trace_enable", True) + monkeypatch.setattr(trace_module, "get_trace_store", lambda *_: store) + monkeypatch.setattr(trace_module, "_get_log_broker", lambda: None) + return captured + + +@pytest.fixture +def event(frames): + """Create an event after test-only trace persistence is installed. + + Args: + frames: Captured trace snapshots. + + Returns: + Message event with a real trace model. + """ + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.sender = MessageMember(user_id="test-user", nickname="Test User") + message.message = [] + return AstrMessageEvent( + "Test event", + message, + PlatformMetadata(id="test", name="test-platform", description="Test"), + "execution-test", + ) + + +@pytest.mark.parametrize( + "failure", [None, RuntimeError("failed"), asyncio.CancelledError()] +) +def test_event_scope_finishes_and_restores_context(event, frames, tmp_path, failure): + """Finish success, failure, and cancellation without swallowing exceptions. + + Args: + event: Message event to execute. + frames: Captured trace snapshots. + tmp_path: Temporary attachment directory. + failure: Optional failure raised by event processing. + """ + attachment = tmp_path / "attachment.txt" + attachment.write_text("temporary", encoding="utf-8") + event.track_temporary_local_file(str(attachment)) + try: + with ( + log_context(event.get_platform_id(), event.get_platform_name()), + event_context(event), + trace_execution(event.trace), + ): + root_context = get_current_span() + platform_context = _log_context.get() + assert ( + format_trace_id(root_context.get_span_context().trace_id) + == event.trace.trace_id + ) + assert platform_context.platform_id == "test" + assert event in active_event_registry._events[event.unified_msg_origin] + event.trace.record("agent_start", kind="agent") + if failure: + raise failure + event.trace.record("agent_end", status="success") + assert frames[-1]["trace"]["state"] == "running" + except BaseException as exc: + assert failure is exc + else: + assert failure is None + root = frames[-1]["span"] + assert frames[-1]["trace"]["state"] == "completed" + assert root["span_id"] == event.trace.span_id + assert root["status"]["code"] == (2 if failure else 1) + if failure: + assert root["status"]["message"] == (str(failure) or type(failure).__name__) + assert not event.trace._pending + assert not get_current_span().get_span_context().is_valid + assert _log_context.get() is None + assert not root_context.is_recording() and not platform_context.active + assert event.unified_msg_origin not in active_event_registry._events + assert not attachment.exists() + + +def test_nested_event_scopes_do_not_finish_early(event, frames): + """Allow nested entry points without finishing or rebinding the event. + + Args: + event: Message event to execute. + frames: Captured trace snapshots. + """ + assert not hasattr(event, "execution") + with trace_execution(event.trace): + with event_context(event): + event.trace.record("agent_start") + context = get_current_span() + with event_context(event), trace_execution(event.trace): + assert get_current_span() is context + assert event in active_event_registry._events[event.unified_msg_origin] + event.trace.record("agent_end", status="success") + assert event.trace._summary["state"] == "running" + assert event.unified_msg_origin not in active_event_registry._events + final_count = len(frames) + event.trace.record("pipeline_end", status="success") + assert len(frames) == final_count + assert ( + sum( + packet.get("span", {}).get("span_id") == event.trace.span_id + and bool(packet.get("span", {}).get("end_time_unix_nano")) + for packet in frames + ) + == 1 + ) + + +def test_event_completion_reaches_sqlite(event, frames, tmp_path, monkeypatch): + """Persist completion only after the event scope, not after its response. + + Args: + event: Message event to execute. + frames: Initial captured trace snapshots. + tmp_path: Temporary database directory. + monkeypatch: Pytest patch manager. + """ + store = TraceStore(tmp_path / "traces.db", {}) + monkeypatch.setattr(trace_module, "get_trace_store", lambda *_: store) + try: + for frame in frames: + assert store.append(frame) + with event_context(event), trace_execution(event.trace): + event.trace.record( + "response_sent", kind="response", source="core", message="Done" + ) + assert store.flush() + assert store.get_trace(event.trace.trace_id)["trace"]["state"] == "running" + assert store.flush() + detail = store.get_trace(event.trace.trace_id) + assert detail["trace"]["state"] == "completed" + root = next(span for span in detail["spans"] if not span["parent_span_id"]) + assert root["end_time_unix_nano"] + assert root["status"]["code"] == 1 + finally: + store.close() + + +@pytest.mark.asyncio +async def test_task_cancellation_finishes_event(event, frames): + """Close the root when an awaited event task is actually cancelled. + + Args: + event: Message event to execute. + frames: Captured trace snapshots. + """ + started = asyncio.Event() + + async def execute(): + with event_context(event), trace_execution(event.trace): + started.set() + await asyncio.Event().wait() + + task = asyncio.create_task(execute()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert frames[-1]["trace"]["state"] == "completed" + assert frames[-1]["span"]["status"] == {"code": 2, "message": "CancelledError"} + assert event.unified_msg_origin not in active_event_registry._events + assert not get_current_span().get_span_context().is_valid + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure_at", [None, "stages", "delivery"]) +async def test_scheduler_uses_event_scope(event, frames, monkeypatch, failure_at): + """Finish ordinary messages only after stages and closing delivery. + + Args: + event: Message event to execute. + frames: Captured trace snapshots. + monkeypatch: Pytest patch manager. + failure_at: Optional processing step that fails. + """ + scheduler = object.__new__(scheduler_module.PipelineScheduler) + + async def process(current): + assert current is event + assert frames[-1]["trace"]["state"] == "running" + if failure_at == "stages": + raise RuntimeError("stage failed") + + async def deliver(message): + assert message is None + assert frames[-1]["trace"]["state"] == "running" + if failure_at == "delivery": + raise RuntimeError("delivery failed") + + scheduler._process_stages = AsyncMock(side_effect=process) + event.send = AsyncMock(side_effect=deliver) + monkeypatch.setattr(scheduler_module, "WebChatMessageEvent", AstrMessageEvent) + if failure_at: + with pytest.raises(RuntimeError): + await scheduler.execute(event) + else: + await scheduler.execute(event) + assert event.send.await_count == int(failure_at != "stages") + assert frames[-1]["trace"]["state"] == "completed" + assert frames[-1]["span"]["status"]["code"] == (2 if failure_at else 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure_at", + [ + None, + "config", + "conversation", + "build", + "empty", + "run", + "state", + "history", + "cancel", + ], +) +async def test_cron_uses_event_scope(frames, monkeypatch, failure_at): + """Cover Cron construction, execution, saving, failure, and cancellation. + + Args: + frames: Captured trace snapshots. + monkeypatch: Pytest patch manager. + failure_at: Optional execution step that fails. + """ + from astrbot.core import astr_main_agent + from astrbot.core.cron import manager as cron_module + + ctx = MagicMock() + ctx.get_config.return_value = {"admins_id": []} + if failure_at == "config": + ctx.get_config.side_effect = RuntimeError("config failed") + manager = CronJobManager(MagicMock()) + manager.ctx = ctx + captured = {} + + async def conversation(**kwargs): + captured["event"] = kwargs["event"] + assert ( + format_trace_id(get_current_span().get_span_context().trace_id) + == captured["event"].trace.trace_id + ) + if failure_at == "conversation": + raise RuntimeError("conversation failed") + return SimpleNamespace(history="[]") + + async def steps(max_steps): + assert frames[-1]["trace"]["state"] == "running" + if failure_at == "run": + raise RuntimeError("run failed") + if failure_at == "cancel": + raise asyncio.CancelledError() + yield None + + runner = MagicMock( + state=AgentState.ERROR if failure_at == "state" else AgentState.DONE + ) + runner.step_until_done.side_effect = steps + runner.get_final_llm_resp.return_value = None + + async def build(**kwargs): + assert ( + format_trace_id(get_current_span().get_span_context().trace_id) + == kwargs["event"].trace.trace_id + ) + assert _log_context.get().platform_id == "test" + assert _log_context.get().platform_type == "cron" + if failure_at == "build": + raise RuntimeError("build failed") + return None if failure_at == "empty" else SimpleNamespace(agent_runner=runner) + + async def persist(*args, **kwargs): + assert frames[-1]["trace"]["state"] == "running" + assert ( + format_trace_id(get_current_span().get_span_context().trace_id) + == kwargs["event"].trace.trace_id + ) + if failure_at == "history": + raise RuntimeError("history failed") + + monkeypatch.setattr(astr_main_agent, "_get_session_conv", conversation) + monkeypatch.setattr(astr_main_agent, "build_main_agent", build) + save = AsyncMock(side_effect=persist) + monkeypatch.setattr(cron_module, "persist_agent_history", save) + execution = manager._woke_main_agent( + message="Scheduled task", + session_str="test:FriendMessage:cron-execution-test", + extras={"cron_job": {"id": "test-job"}}, + ) + if failure_at: + with pytest.raises( + asyncio.CancelledError if failure_at == "cancel" else RuntimeError + ): + await execution + else: + await execution + assert save.await_count == int(failure_at in (None, "history")) + final = frames[-1] + assert final["trace"]["state"] == "completed" + assert final["span"]["span_id"] == final["trace"]["root_span_id"] + assert final["span"]["status"]["code"] == (2 if failure_at else 1) + assert "test:FriendMessage:cron-execution-test" not in active_event_registry._events + assert not get_current_span().get_span_context().is_valid + assert _log_context.get() is None diff --git a/tests/unit/test_message_tools.py b/tests/unit/test_message_tools.py index 634c3fbd84b..d903a7e1b1d 100644 --- a/tests/unit/test_message_tools.py +++ b/tests/unit/test_message_tools.py @@ -1,12 +1,13 @@ """Tests for send_message_to_user session handling.""" from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.pipeline.respond.stage import RespondStage +from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.tools.message_tools import SendMessageToUserTool @@ -32,9 +33,11 @@ def _make_context( role=role, _has_send_oper=False, get_sender_id=lambda: "user-1", + trace=MagicMock(), ) event.set_extra = lambda key, value: extras.__setitem__(key, value) event.get_extra = lambda key, default=None: extras.get(key, default) + event._outline_chain = lambda chain: AstrMessageEvent._outline_chain(event, chain) return SimpleNamespace( context=SimpleNamespace( event=event, @@ -53,6 +56,7 @@ def __init__(self, result_text: str, sent_plain_texts: list[str]) -> None: } self._result = MessageEventResult().message(result_text) self.send = AsyncMock() + self.trace = MagicMock() self.plugins_name = [] def get_result(self): @@ -151,6 +155,7 @@ async def test_send_message_defaults_to_current_session(): target_session = call_args[0][0] assert str(target_session) == "feishu:GroupMessage:oc_xxx" assert ctx.context.event._has_send_oper is True + ctx.context.event.trace.record.assert_not_called() assert ctx.context.event.get_extra( "_send_message_to_user_current_session_plain_texts", ) == ["hello"] @@ -175,6 +180,20 @@ async def test_send_message_returns_platform_error_to_tool_result(): "qq_official:GroupMessage:group-1: 413 Request Entity Too Large" ) assert ctx.context.event._has_send_oper is False + ctx.context.event.trace.record.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_message_missing_platform_does_not_record_delivery(): + """Keep missing-platform failures out of delivered-response traces.""" + ctx = _make_context() + ctx.context.context.send_message.return_value = False + result = await SendMessageToUserTool().call( + ctx, messages=[{"type": "plain", "text": "hello"}] + ) + assert result.startswith("error: failed to find platform") + ctx.context.event.trace.record.assert_not_called() + assert ctx.context.event._has_send_oper is False @pytest.mark.asyncio @@ -189,6 +208,7 @@ async def test_send_message_other_session_does_not_record_current_text(): ) assert "Message sent to session" in result assert ctx.context.event._has_send_oper is False + ctx.context.event.trace.record.assert_not_called() assert ( ctx.context.event.get_extra( "_send_message_to_user_current_session_plain_texts", @@ -225,6 +245,7 @@ async def test_respond_stage_sends_different_text_after_send_message_to_user(): assert result is None event.send.assert_awaited_once() + event.trace.record.assert_not_called() assert event.get_result() is None diff --git a/tests/unit/test_plugin_handler_trace.py b/tests/unit/test_plugin_handler_trace.py new file mode 100644 index 00000000000..a2c54f80083 --- /dev/null +++ b/tests/unit/test_plugin_handler_trace.py @@ -0,0 +1,138 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot.core.pipeline import context_utils +from astrbot.core.pipeline.process_stage.method import star_request +from astrbot.core.star.filter.command import CommandFilter +from astrbot.core.star.filter.command_group import CommandGroupFilter +from astrbot.core.star.filter.regex import RegexFilter +from astrbot.core.star.star_handler import EventType +from astrbot.dashboard.services.plugin_service import PluginService + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("event_filter", "category"), + [ + (CommandFilter("test"), "command"), + (CommandGroupFilter("test"), "command"), + (RegexFilter("test"), "command"), + (None, "listener"), + ], +) +async def test_message_handler_trace_category(monkeypatch, event_filter, category): + handler = SimpleNamespace( + handler=AsyncMock(return_value=None), + handler_name="test_handler", + handler_full_name="test_plugin.test_handler", + handler_module_path="test_plugin", + event_type=EventType.AdapterMessageEvent, + event_filters=[event_filter] if event_filter else [], + extras_configs={}, + ) + monkeypatch.setitem( + star_request.star_map, + "test_plugin", + SimpleNamespace(name="test_plugin", display_name="Test Plugin"), + ) + extras = {"activated_handlers": [handler], "handlers_parsed_params": {}} + event = MagicMock() + event._extras = extras + event.get_extra.side_effect = extras.get + event.set_extra.side_effect = extras.__setitem__ + event.is_stopped.return_value = False + + async for _ in star_request.StarRequestSubStage().process(event): + pass + + records = event.trace.record.call_args_list + assert [record.args[0] for record in records] == ["handler_start", "handler_end"] + for record in records: + assert record.kwargs["handler_category"] == category + assert record.kwargs["handler_type"] == "AdapterMessageEvent" + assert record.kwargs["plugin_display_name"] == "Test Plugin" + assert "module" not in record.kwargs + assert extras == {"activated_handlers": [handler], "handlers_parsed_params": {}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("hook_type", "category"), + [ + (EventType.OnLLMRequestEvent, "hook"), + (EventType.OnCallingFuncToolEvent, "llm_tool"), + ], +) +@pytest.mark.parametrize("fails", [False, True]) +async def test_event_hook_trace_category(monkeypatch, hook_type, category, fails): + handler = SimpleNamespace( + handler=AsyncMock(side_effect=ValueError("test failure") if fails else None), + handler_name="test_hook", + handler_full_name="test_plugin.test_hook", + handler_module_path="test_plugin", + event_type=hook_type, + ) + monkeypatch.setattr( + context_utils.star_handlers_registry, + "get_handlers_by_event_type", + MagicMock(return_value=[handler]), + ) + monkeypatch.setitem( + context_utils.star_map, + "test_plugin", + SimpleNamespace(name="test_plugin", display_name="Test Plugin"), + ) + extras = {} + event = MagicMock() + event._extras = extras + event.get_extra.side_effect = extras.get + event.set_extra.side_effect = extras.__setitem__ + event.is_stopped.return_value = False + + assert await context_utils.call_event_hook(event, hook_type) is False + + records = event.trace.record.call_args_list + assert [record.args[0] for record in records] == ["handler_start", "handler_end"] + for record in records: + assert record.kwargs["handler_category"] == category + assert record.kwargs["handler_type"] == hook_type.name + assert "module" not in record.kwargs + assert records[-1].kwargs["status"] == ("error" if fails else "success") + assert extras == {} + + +@pytest.mark.asyncio +async def test_nested_command_details_preserve_handler_identity(monkeypatch): + handlers = {} + group = CommandGroupFilter("admin") + for command in ("first", "second"): + event_filter = CommandFilter(command, parent_command_names=["admin"]) + handler = SimpleNamespace( + handler_name=f"handle_{command}", + handler_full_name=f"test_plugin.handle_{command}", + event_type=EventType.AdapterMessageEvent, + event_filters=[event_filter], + desc=f"Description for {command}", + ) + event_filter.handler_md = handler + handlers[handler.handler_full_name] = handler + group.add_sub_command_filter(event_filter) + monkeypatch.setattr( + context_utils.star_handlers_registry, "star_handlers_map", handlers + ) + service = PluginService(MagicMock(), MagicMock()) + components = await service.get_plugin_handler_components(list(handlers)) + + assert len(components) == 1 + assert components[0]["name"] == "admin" + for command, child in zip(("first", "second"), components[0]["subcommands"]): + assert child["handler_full_name"] == f"test_plugin.handle_{command}" + assert child["handler_name"] == f"handle_{command}" + assert child["description"] == f"Description for {command}" + + group_component = service._build_command_group_component(group) + for command, child in zip(("first", "second"), group_component["subcommands"]): + assert child["handler_full_name"] == f"test_plugin.handle_{command}" + assert child["description"] == f"Description for {command}" diff --git a/tests/unit/test_session_waiter_pipeline.py b/tests/unit/test_session_waiter_pipeline.py new file mode 100644 index 00000000000..53811993e7d --- /dev/null +++ b/tests/unit/test_session_waiter_pipeline.py @@ -0,0 +1,122 @@ +"""Session waiters are dispatched by the core pipeline after access checks.""" + +import copy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.config.default import DEFAULT_CONFIG +from astrbot.core.message.components import Plain +from astrbot.core.pipeline.process_stage import stage as processing +from astrbot.core.pipeline.scheduler import PipelineScheduler +from astrbot.core.pipeline.waking_check import stage as waking +from astrbot.core.pipeline.whitelist_check.stage import WhitelistCheckStage +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.star.session_plugin_manager import SessionPluginManager +from astrbot.core.utils.session_waiter import ( + USER_SESSIONS, + DefaultSessionFilter, + SessionFilter, + SessionWaiter, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("private", "state"), + [ + (False, "waiting"), + (True, "waiting"), + (False, "custom"), + (False, "blocked"), + (False, "expired"), + (False, "unmatched"), + (False, "ordinary"), + ], +) +async def test_session_waiter_pipeline(monkeypatch, private, state): + """Preserve wakeup, access checks, custom IDs, and ordinary dispatch. + + Args: + monkeypatch: Isolate waiters and plugin handler discovery. + private: Whether the input is a private rather than group message. + state: Waiting, blocked, expired, unmatched, custom, or ordinary input. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config.update(admins_id=[], wake_prefix=["/"], plugin_set=[]) + config["platform_settings"].update( + friend_message_needs_wake_prefix=True, + unique_session=False, + enable_id_white_list=state == "blocked", + id_whitelist=["another-session"], + ) + context = SimpleNamespace( + astrbot_config=config, astrbot_config_id="test", db_helper=None + ) + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE if private else MessageType.GROUP_MESSAGE + message.sender = MessageMember(user_id="user", nickname="User") + message.message = [Plain("/next" if state == "ordinary" else "next")] + event = AstrMessageEvent( + message.message[0].text, + message, + PlatformMetadata(id="test", name="test", description="Test"), + "session", + ) + + class SenderFilter(SessionFilter): + def filter(self, event): + return event.get_sender_id() + + session_filter = SenderFilter() if state == "custom" else DefaultSessionFilter() + session_id = session_filter.filter(event) + waiter = SessionWaiter(session_filter, session_id, False) + waiter.handler = AsyncMock() + if state == "expired": + waiter.session_controller.stop() + if state not in {"unmatched", "ordinary"}: + monkeypatch.setitem(USER_SESSIONS, session_id, waiter) + for module in (waking, processing): + monkeypatch.setattr(module, "FILTERS", [session_filter, session_filter]) + monkeypatch.setattr( + waking.star_handlers_registry, "get_handlers_by_event_type", lambda *a, **kw: [] + ) + monkeypatch.setattr( + SessionPluginManager, "filter_handlers_by_session", AsyncMock(return_value=[]) + ) + + wake = waking.WakingCheckStage() + whitelist = WhitelistCheckStage() + await wake.initialize(context) + await whitelist.initialize(context) + process = processing.ProcessStage() + process.ctx = context + process.star_request_sub_stage = SimpleNamespace(process=AsyncMock()) + + async def agent(current): + current.set_extra("agent_called", True) + yield + + process.agent_sub_stage = SimpleNamespace(process=agent) + preprocess = AsyncMock( + side_effect=lambda current: current.set_extra("preprocessed", True) + ) + scheduler = object.__new__(PipelineScheduler) + scheduler.stages = [wake, whitelist, SimpleNamespace(process=preprocess), process] + await scheduler._process_stages(event) + + consumed = state in {"waiting", "custom"} + assert waiter.handler.await_count == int(consumed) + process.star_request_sub_stage.process.assert_not_called() + assert bool(event.get_extra("agent_called", False)) == (state == "ordinary") + if consumed: + waiter.handler.assert_awaited_once_with(waiter.session_controller, event) + assert event.is_wake and not event.is_at_or_wake_command + assert event.get_extra("preprocessed") and event.is_stopped() + elif state != "ordinary": + preprocess.assert_not_awaited() + assert event.is_stopped() diff --git a/tests/unit/test_trace_queue_capacity.py b/tests/unit/test_trace_queue_capacity.py new file mode 100644 index 00000000000..e1080395891 --- /dev/null +++ b/tests/unit/test_trace_queue_capacity.py @@ -0,0 +1,62 @@ +"""Regression coverage for bounded trace writer burst capacity.""" + +import json +import threading +import time + +import pytest + +from astrbot.core.utils.trace_store import MAX_FRAME_BYTES, QUEUE_BYTES, TraceStore + + +@pytest.mark.parametrize("body_bytes", [32, 1536 * 1024]) +def test_trace_queue_limits_bytes_without_frame_count_cap(tmp_path, monkeypatch, body_bytes): + """Admit over 1024 small frames while bounding large frames by 128 MiB. + + Args: + tmp_path: Isolated database directory. + monkeypatch: Pytest patch manager. + body_bytes: Payload length used to exercise small bursts or byte capacity. + """ + release = threading.Event() + original_run = TraceStore._run + + def delayed_run(self): + """Hold the consumer until queue admission has been measured.""" + release.wait(10) + original_run(self) + + monkeypatch.setattr(TraceStore, "_run", delayed_run) + store = TraceStore(tmp_path / "traces.db", {}) + now = str(time.time_ns()) + packet = { + "trace": { + "trace_id": "a" * 32, + "root_span_id": "b" * 16, + "start_time_unix_nano": now, + "state": "completed", + "has_errors": False, + "has_models": True, + "has_plugins": False, + "attributes": {"astrbot.message_outline": "x" * body_bytes}, + }, + "time_unix_nano": now, + } + frame_bytes = len(json.dumps(packet, ensure_ascii=False, separators=(",", ":")).encode()) + attempts = 1025 if body_bytes == 32 else QUEUE_BYTES // frame_bytes + 1 + expected = min(attempts, QUEUE_BYTES // frame_bytes) + try: + assert QUEUE_BYTES == 128 * 1024 * 1024 + assert MAX_FRAME_BYTES == 2 * 1024 * 1024 + assert store.queue.maxsize == 0 + accepted = sum(store.append(packet) for _ in range(attempts)) + assert accepted == expected + assert store.queue.qsize() == expected + assert store._pending_bytes <= QUEUE_BYTES + assert store.dropped == attempts - expected + release.set() + assert store.flush(15) + assert store._pending_bytes == 0 + finally: + release.set() + store.close() diff --git a/tests/unit/test_trace_store.py b/tests/unit/test_trace_store.py new file mode 100644 index 00000000000..1e38fa37368 --- /dev/null +++ b/tests/unit/test_trace_store.py @@ -0,0 +1,784 @@ +"""Canonical trace model, correlation, bounded storage, and migration tests.""" + +import asyncio +import gc +import json +import logging +import re +import runpy +import sqlite3 +import threading +import time +import weakref +from pathlib import Path + +import pytest +from opentelemetry.sdk.trace import Span, TracerProvider +from opentelemetry.trace import format_span_id, get_current_span, get_tracer_provider + +from astrbot.core.log import LogBroker, LogManager +from astrbot.core.utils import trace as trace_module +from astrbot.core.utils.migra_helper import migrate_config_on_load +from astrbot.core.utils.storage_cleaner import StorageCleaner +from astrbot.core.utils.telemetry import ( + MAX_ATTRIBUTE_BYTES, + bounded_attributes, +) +from astrbot.core.utils.trace import TraceSpan, trace_execution +from astrbot.core.utils.trace_store import MAX_FRAME_BYTES, TraceStore + + +@pytest.fixture +def capture(monkeypatch): + """Capture immutable snapshots without writing to the default path.""" + frames = [] + + class CaptureStore: + def append(self, payload, **kwargs): + frames.append(json.loads(json.dumps(payload))) + return True + + monkeypatch.setitem(trace_module.astrbot_config, "trace_enable", True) + monkeypatch.setattr(trace_module, "get_trace_store", lambda *_: CaptureStore()) + monkeypatch.setattr(trace_module, "_get_log_broker", lambda: None) + return frames + + +def test_standard_ids_parents_and_immutable_snapshots(capture): + trace = TraceSpan("message", "qq:Group:123", "Test", "Hello") + assert re.fullmatch("[0-9a-f]{32}", trace.trace_id) + assert re.fullmatch("[0-9a-f]{16}", trace.span_id) + with trace.context(): + root = get_current_span() + trace.record("event_received", platform_id="qq", kind="message") + trace.record( + "handler_start", + kind="plugin_handler", + plugin="test", + handler_full_name="test.handler", + ) + handler = get_current_span() + handler_id = format_span_id(handler.get_span_context().span_id) + trace.record("agent_start", kind="agent") + agent = get_current_span() + agent_id = format_span_id(agent.get_span_context().span_id) + trace.record("agent_tool_call", tool_name="weather") + trace.record("agent_tool_result", tool_result="Sunny") + trace.record("agent_end", status="success") + assert ( + format_span_id(get_current_span().get_span_context().span_id) == handler_id + ) + trace.record("handler_end", handler_full_name="test.handler", status="success") + assert ( + format_span_id(get_current_span().get_span_context().span_id) + == trace.span_id + ) + trace.record("response_sent", kind="response", source="core", message="Sunny") + trace.record("pipeline_end", status="success") + assert not get_current_span().get_span_context().is_valid + spans = { + frame["span"]["span_id"]: frame["span"] for frame in capture if "span" in frame + } + assert len(spans) == 4 + assert spans[trace.span_id]["attributes"]["astrbot.kind"] == "message" + assert spans[handler_id]["attributes"]["astrbot.kind"] == "plugin_handler" + assert spans[agent_id]["attributes"]["astrbot.kind"] == "agent" + assert any(span["attributes"]["astrbot.kind"] == "tool" for span in spans.values()) + for frame in capture: + for model in (frame.get("span", {}), frame.get("event", {})): + assert ( + not {"astrbot.action", "astrbot.end_action", "astrbot.duration_ms"} + & model.get("attributes", {}).keys() + ) + assert spans[handler_id]["parent_span_id"] == trace.span_id + assert spans[agent_id]["parent_span_id"] == handler_id + assert all(span["end_time_unix_nano"] for span in spans.values()) + assert capture[0]["span"]["end_time_unix_nano"] is None + assert capture[0]["trace"]["state"] == "running" + assert capture[-1]["trace"]["state"] == "completed" + assert not trace._pending + assert agent.parent == handler.get_span_context() + for sdk_span in (root, handler, agent): + assert isinstance(sdk_span, Span) and not sdk_span.is_recording() + packet = spans[format_span_id(sdk_span.context.span_id)] + assert str(sdk_span.start_time) == packet["start_time_unix_nano"] + assert str(sdk_span.end_time) == packet["end_time_unix_nano"] + assert sdk_span.status.status_code.value == packet["status"]["code"] + assert dict(sdk_span.attributes) == packet["attributes"] + events = [frame["event"] for frame in capture if "event" in frame] + assert len(events) == len(root.events) == 1 + assert root.events[0].name == events[0]["name"] == "response_sent" + assert str(root.events[0].timestamp) == events[0]["time_unix_nano"] + assert dict(root.events[0].attributes) == events[0]["attributes"] + assert all("events" not in frame.get("span", {}) for frame in capture) + + +def test_sdk_context_is_isolated_and_restores_foreign_span(capture): + """Use standard context without taking ownership of other providers. + + Args: + capture: Immutable trace snapshots. + """ + global_provider = get_tracer_provider() + provider = TracerProvider(shutdown_on_exit=False) + try: + with provider.get_tracer("plugin").start_as_current_span("plugin") as foreign: + trace = TraceSpan("message") + assert trace._root.parent is None + assert trace._root.context.trace_id != foreign.context.trace_id + with trace_execution(trace): + assert get_current_span() is trace._root + assert get_current_span() is foreign + assert foreign.is_recording() + assert get_tracer_provider() is global_provider + finally: + provider.shutdown() + + +def test_sdk_disabled_does_not_break_messages(capture, monkeypatch): + """Respect the SDK kill switch without emitting invalid trace IDs. + + Args: + capture: Immutable trace snapshots. + monkeypatch: Pytest patch manager. + """ + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + module = runpy.run_path(trace_module.__file__) + trace = module["TraceSpan"]("disabled") + with trace_execution(trace): + assert not get_current_span().is_recording() + trace.record("event_received") + trace.record("agent_start") + assert not capture + + +def test_agent_and_command_types_are_independent_and_persisted(persisted, capture): + """Keep listener hooks distinct while preserving both call classifications. + + Args: + persisted: Isolated trace database. + capture: Immutable trace snapshots. + """ + trace = TraceSpan("message") + with trace.context(): + trace.record( + "handler_start", + handler_full_name="plugin.listener", + handler_category="listener", + ) + trace.record( + "handler_end", handler_full_name="plugin.listener", status="success" + ) + assert trace._summary["has_commands"] is False + trace.record( + "handler_start", + handler_full_name="plugin.command", + handler_category="command", + ) + trace.record( + "handler_end", handler_full_name="plugin.command", status="success" + ) + assert trace._summary["has_commands"] is True + assert trace._summary["has_models"] is False + trace.record("agent_start") + trace.record("agent_end", status="success") + trace.record("pipeline_end", status="success") + for frame in capture: + assert persisted.append(frame) + assert persisted.flush() + summary = persisted.list_traces()["traces"][0] + assert summary["has_commands"] is True and summary["has_models"] is True + with sqlite3.connect(persisted.path) as connection: + summary.pop("has_commands") + connection.execute( + "UPDATE traces SET data=? WHERE trace_id=?", + (json.dumps(summary), trace.trace_id), + ) + legacy = persisted.list_traces()["traces"][0] + assert legacy["has_commands"] is True + assert persisted.get_trace(trace.trace_id)["trace"].get("has_commands") is None + + +def test_internal_transitions_and_timing_are_not_persisted(capture): + """Keep call matching internal while preserving handler metadata. + + Args: + capture: Immutable trace snapshots. + """ + trace = TraceSpan("message") + with trace.context(): + trace.record("event_received") + trace.record( + "handler_start", + handler_full_name="plugin.handler", + handler_type="AdapterMessageEvent", + duration_ms=999, + ) + handler_id = format_span_id(get_current_span().get_span_context().span_id) + trace.record( + "handler_end", + handler_full_name="plugin.handler", + status="success", + duration_ms=999, + ) + trace.record("pipeline_end", status="success", duration_ms=999) + handler = next( + frame["span"] + for frame in reversed(capture) + if frame.get("span", {}).get("span_id") == handler_id + ) + assert handler["attributes"]["astrbot.kind"] == "plugin_handler" + assert handler["attributes"]["astrbot.handler_type"] == "AdapterMessageEvent" + assert handler["end_time_unix_nano"] + for frame in capture: + assert ( + not {"astrbot.action", "astrbot.end_action", "astrbot.duration_ms"} + & frame.get("span", {}).get("attributes", {}).keys() + ) + + +@pytest.mark.asyncio +async def test_logs_correlate_and_detached_contexts_expire(capture): + trace = TraceSpan("message") + broker = LogBroker() + log = logging.Logger("astrbot.trace_test") + LogManager.set_queue_handler(log, broker) + release = asyncio.Event() + + async def late_log(): + await release.wait() + log.info("Detached") + + with trace.context(): + trace.record("event_received") + log.info("Root") + trace.record("handler_start", handler_full_name="plugin.handler") + child_id = format_span_id(get_current_span().get_span_context().span_id) + task = asyncio.create_task(late_log()) + log.warning("Handler") + trace.record( + "handler_end", handler_full_name="plugin.handler", status="success" + ) + log.info("Root again") + trace.record("pipeline_end", status="success") + release.set() + await task + entries = list(broker.log_cache) + assert entries[0]["trace_id"] == trace.trace_id + assert entries[0]["span_id"] == trace.span_id + assert entries[1]["span_id"] == child_id + assert entries[1]["severity_number"] == 13 + assert entries[1]["body"] == "Handler" + assert entries[2]["span_id"] == trace.span_id + assert "trace_id" not in entries[-1] + + +@pytest.mark.asyncio +async def test_concurrent_abandoned_spans_end(capture): + trace = TraceSpan("message") + release = asyncio.Event() + + async def child(name): + trace.record("handler_start", handler_full_name=name) + context = get_current_span() + await release.wait() + assert not context.is_recording() + + with pytest.raises(RuntimeError, match="Cancelled"), trace_execution(trace): + trace.record("event_received") + tasks = [asyncio.create_task(child(name)) for name in ("a", "b")] + await asyncio.sleep(0) + raise RuntimeError("Cancelled") + release.set() + await asyncio.gather(*tasks) + assert not trace._pending + ended = [ + frame["span"] + for frame in capture + if frame.get("span", {}).get("end_time_unix_nano") + ] + assert len(ended) == 3 + assert all(span["status"]["code"] == 2 for span in ended) + + +def test_pause_preserves_completion(capture, monkeypatch): + trace = TraceSpan("message") + with trace_execution(trace): + trace.record("event_received") + trace.record("handler_start", handler_full_name="test.handler") + monkeypatch.setitem(trace_module.astrbot_config, "trace_enable", False) + trace.record("agent_start") + trace.record("handler_end", handler_full_name="test.handler", status="success") + count = len(capture) + with trace_execution(TraceSpan("disabled")): + assert not get_current_span().get_span_context().is_valid + assert len(capture) == count + assert capture[-1]["trace"]["state"] == "completed" + assert capture[-1]["trace"]["span_count"] == 2 + + +def test_limits_preserve_completion(capture): + attrs = bounded_attributes( + { + "astrbot.body": "字" * 50000, + "astrbot.object": {"nested": ["x" * 50000] * 100}, + } + ) + assert len(json.dumps(attrs, ensure_ascii=False).encode()) <= MAX_ATTRIBUTE_BYTES + assert attrs["astrbot.truncated"] is True + assert all(isinstance(value, (str, bool, int, float)) for value in attrs.values()) + trace = TraceSpan("message") + with trace.context(): + trace.record("event_received") + for index in range(400): + trace.record("handler_start", handler_full_name=str(index)) + trace.record("handler_end", handler_full_name=str(index), status="success") + trace.record("pipeline_end", status="success") + assert trace._span_count <= 128 + assert trace._summary["truncated"] is True + assert capture[-1]["trace"]["state"] == "completed" + + +@pytest.fixture +def persisted(tmp_path, capture): + """Create an isolated dedicated database.""" + store = TraceStore( + tmp_path / "traces.db", {"trace_db_max_mb": 8, "trace_retention_days": 7} + ) + yield store + store.close() + + +def test_database_pages_filters_details_and_restart(persisted, capture): + traces = [] + for index in range(4): + trace = TraceSpan("message", "qq:Group:123", "Test", f"Message {index}") + with trace.context(): + trace.record("event_received") + trace.record( + "handler_start", + plugin="test", + plugin_display_name="Display Test", + handler_full_name="test.handler", + ) + trace.record( + "handler_end", + handler_full_name="test.handler", + status="error" if index == 2 else "success", + ) + trace.record( + "response_sent", kind="response", source="plugin:test", message="Reply" + ) + if index != 3: + trace.record("pipeline_end", status="success") + traces.append(trace) + for frame in capture: + assert persisted.append(frame) + assert persisted.flush() + first = persisted.list_traces(limit=2) + second = persisted.list_traces(limit=2, cursor=first["cursor"]) + numbered = persisted.list_traces(limit=2, page=2) + assert numbered["page"] == 2 + assert [item["trace_id"] for item in numbered["traces"]] == [ + item["trace_id"] for item in second["traces"] + ] + assert persisted.list_traces(limit=2, page=999)["page"] == 2 + assert persisted.list_traces(limit=2, errors=True, page=999)["page"] == 1 + with pytest.raises(ValueError): + persisted.list_traces(page=0) + with pytest.raises(ValueError): + persisted.list_traces(page=2, cursor=first["cursor"]) + assert first["total"] == 4 + assert len(first["traces"]) == len(second["traces"]) == 2 + assert {item["trace_id"] for item in first["traces"]}.isdisjoint( + item["trace_id"] for item in second["traces"] + ) + assert persisted.list_traces(errors=True)["total"] == 1 + assert persisted.list_traces(plugins=True, query="Display Test")["total"] == 4 + assert persisted.list_traces(models=True)["total"] == 0 + detail = persisted.get_trace(traces[0].trace_id) + assert len(detail["spans"]) == 2 + assert any( + event["name"] == "response_sent" + for span in detail["spans"] + for event in span.get("events", []) + ) + assert persisted.get_trace("0" * 32) is None + persisted.close() + restarted = TraceStore(persisted.path, persisted.config) + try: + assert restarted.flush() + detail = restarted.get_trace(traces[3].trace_id) + assert detail["trace"]["state"] == "interrupted" + assert detail["trace"]["has_errors"] is True + assert all(span["end_time_unix_nano"] for span in detail["spans"]) + finally: + restarted.close() + + +@pytest.mark.parametrize("prompt", ["x" * 8192, "字" * 20000, "x" * 131072]) +def test_long_prompt_is_preserved_in_spans_events_and_database( + persisted, capture, prompt +): + trace = TraceSpan("message") + with trace.context(): + trace.record("event_received") + trace.record("agent_start", system_prompt=prompt) + trace.record("astr_agent_prepare", system_prompt=prompt) + trace.record("agent_end", status="success") + trace.record("pipeline_end", status="success") + assert capture[-1]["trace"]["truncated"] is False + for frame in capture: + assert persisted.append(frame) + assert persisted.flush() + detail = persisted.get_trace(trace.trace_id) + agent = next(span for span in detail["spans"] if span["parent_span_id"]) + assert agent["attributes"]["astrbot.system_prompt"] == prompt + assert agent["events"][0]["attributes"]["astrbot.system_prompt"] == prompt + assert detail["trace"]["truncated"] is False + + +def test_retention_deletes_whole_traces(persisted, capture): + trace = TraceSpan("message") + trace.record("event_received") + trace.record("response_sent", message="Reply") + trace.record("pipeline_end", status="success") + for frame in capture: + assert persisted.append(frame) + assert persisted.flush() + with sqlite3.connect(persisted.path) as connection: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute( + "UPDATE traces SET updated_ns=?", (time.time_ns() - 8 * 86400 * 10**9,) + ) + persisted._prune(connection) + for table in ("traces", "spans", "events"): + assert ( + connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0 + ) + + +def test_oversized_queue_records_are_rejected(persisted): + assert persisted.append({"oversized": "x" * MAX_FRAME_BYTES}) is False + assert persisted.dropped == 1 + assert persisted._pending_bytes == 0 + + +def test_orphan_cleanup_preserves_live_and_pending_traces(persisted, capture, monkeypatch): + """Recover dropped completion frames without retaining dead trace objects. + + Args: + persisted: Isolated trace database. + capture: Enable recording without production storage or live clients. + monkeypatch: Pytest patch manager. + """ + monkeypatch.setattr(trace_module, "get_trace_store", lambda *_: persisted) + assert persisted.flush() + live = TraceSpan("live") + live.record("event_received") + abandoned = TraceSpan("abandoned") + abandoned.record("event_received") + abandoned_id = abandoned.trace_id + reference = weakref.ref(abandoned) + del abandoned + gc.collect() + assert reference() is None + assert abandoned_id not in persisted._active_traces + assert persisted.flush() + + entered, release = threading.Event(), threading.Event() + original_write = persisted._write + + def held_write(connection, batch): + """Pause persistence to force a rejected terminal frame.""" + entered.set() + assert release.wait(5) + original_write(connection, batch) + + monkeypatch.setattr(persisted, "_write", held_write) + persisted.queue.maxsize = 1 + lost = TraceSpan("lost completion") + try: + lost.record("event_received") + assert entered.wait(5) + lost.record("response_sent", message="done") + lost.record("pipeline_end", status="success") + assert persisted.dropped == 1 + # Even a finished owner must remain protected until queued frames drain. + with sqlite3.connect(persisted.path) as connection: + persisted._interrupt_inactive(connection) + assert connection.execute( + "SELECT trace_id FROM protected_traces WHERE trace_id=?", (lost.trace_id,) + ).fetchone() + assert persisted.get_trace(live.trace_id)["trace"]["state"] == "running" + release.set() + assert persisted.flush() + assert not persisted._pending_traces + assert lost.trace_id not in persisted._active_traces + with sqlite3.connect(persisted.path) as connection: + persisted._interrupt_inactive(connection) + detail = persisted.get_trace(lost.trace_id) + assert detail["trace"]["state"] == "interrupted" + assert all(span["end_time_unix_nano"] for span in detail["spans"]) + assert persisted.clear_history()["removed_traces"] == 2 + assert persisted.get_trace(live.trace_id)["trace"]["state"] == "running" + finally: + release.set() + live.record("pipeline_end", status="success") + assert persisted.flush() + assert persisted.dropped == 1 + assert persisted._pending_bytes == 0 + + +def test_stale_updates_do_not_reopen_completed_trace(persisted, capture): + trace = TraceSpan("message", message_outline="Literal % _") + trace.record("event_received") + trace.record("pipeline_end", status="success") + for frame in reversed(capture): + assert persisted.append(frame) + assert persisted.flush() + detail = persisted.get_trace(trace.trace_id) + assert detail["trace"]["state"] == "completed" + assert detail["spans"][0]["end_time_unix_nano"] + assert persisted.list_traces(query="% _")["total"] == 1 + assert persisted.list_traces(query="other%")["total"] == 0 + + +def test_capacity_prunes_complete_traces_with_cascade(persisted, capture): + trace = TraceSpan("message") + trace.record("event_received") + trace.record("pipeline_end", status="success") + for index in range(80): + frame = json.loads(json.dumps(capture[-1])) + frame["trace"]["trace_id"] = frame["span"]["trace_id"] = f"{index:032x}" + frame["trace"]["root_span_id"] = frame["span"]["span_id"] = f"{index:016x}" + frame["trace"]["attributes"]["astrbot.payload"] = "x" * 340000 + assert persisted.append(frame) + if index % 16 == 0: + assert persisted.flush() + assert persisted.flush() + persisted.config["trace_db_max_mb"] = 1 + with sqlite3.connect(persisted.path) as connection: + connection.execute("PRAGMA foreign_keys=ON") + persisted._prune(connection) + assert connection.execute("PRAGMA max_page_count").fetchone()[0] == 8192 + count = connection.execute("SELECT COUNT(*) FROM traces").fetchone()[0] + assert 0 < count < 80 + assert connection.execute("SELECT COUNT(*) FROM spans").fetchone()[0] == count + assert ( + sum( + connection.execute( + f"SELECT COALESCE(SUM(size_bytes), 0) FROM {table}" + ).fetchone()[0] + for table in ("traces", "spans", "events") + ) + < 32 * 1024 * 1024 + ) + + +@pytest.mark.asyncio +async def test_background_broker_delivery_is_bounded_and_wakes_reader(): + broker = LogBroker() + queue = broker.register() + try: + waiting = asyncio.create_task(queue.get()) + await asyncio.to_thread(broker.publish, {"type": "trace", "value": 1}) + assert (await asyncio.wait_for(waiting, 1))["value"] == 1 + thread = threading.Thread( + target=lambda: [broker.publish({"type": "trace"}) for _ in range(5000)] + ) + thread.start() + thread.join() + assert broker._pending_callbacks[queue] <= queue.maxsize + await asyncio.sleep(0) + assert queue.qsize() <= queue.maxsize + assert not broker.log_cache + finally: + broker.unregister(queue) + + +def test_foreign_database_is_not_modified(tmp_path): + path = tmp_path / "business.db" + with sqlite3.connect(path) as connection: + connection.execute("CREATE TABLE important(value TEXT)") + connection.execute("INSERT INTO important VALUES ('keep')") + store = TraceStore(path, {}) + try: + assert store._ready.wait(5) + assert store._failed + assert not store.append({}) + with sqlite3.connect(path) as connection: + assert connection.execute("SELECT * FROM important").fetchall() == [ + ("keep",) + ] + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE name='trace_meta'" + ).fetchone() + is None + ) + finally: + store.close() + + +@pytest.mark.parametrize( + "size,expected", [(20, 80), (50, 200), (0, 80), (-1, 80), ("bad", 80)] +) +def test_config_migration(tmp_path, size, expected): + config = { + "trace_enable": False, + "trace_log_enable": True, + "trace_log_path": "logs/old.jsonl", + "trace_log_max_mb": size, + } + old = tmp_path / "old.jsonl" + old.write_text("keep", encoding="utf-8") + assert migrate_config_on_load(config, tmp_path / "cmd_config.json") + assert config["trace_enable"] is False + assert config["trace_db_path"] == "logs/traces.db" + assert config["trace_db_max_mb"] == expected + assert not any(key.startswith("trace_log_") for key in config) + assert old.read_text() == "keep" + config["trace_db_max_mb"] = 123 + config["trace_log_max_mb"] = 5 + migrate_config_on_load(config, tmp_path / "cmd_config.json") + assert config["trace_db_max_mb"] == 123 + + +def test_log_cleanup_protects_databases(tmp_path): + logs = tmp_path / "logs" + logs.mkdir() + for name in ("traces.db", "traces.db-wal", "traces.db-shm", "astrbot.log"): + (logs / name).write_bytes(b"keep") + with sqlite3.connect(logs / "old-custom-name") as connection: + connection.execute("CREATE TABLE important(value TEXT)") + (logs / "old-custom-name-wal").write_bytes(b"keep") + cleaner = StorageCleaner( + {"trace_db_path": "logs/traces.db"}, + data_dir=tmp_path, + temp_dir=tmp_path / "temp", + ) + cleaner.cleanup("logs") + for name in ("traces.db", "traces.db-wal", "traces.db-shm"): + assert (logs / name).read_bytes() == b"keep" + assert not (logs / "astrbot.log").exists() + assert (logs / "old-custom-name").exists() + assert (logs / "old-custom-name-wal").exists() + + +@pytest.mark.parametrize( + "relative_path", ["logs/traces.db", "other/history.db", "temp/history.db"] +) +def test_trace_usage_and_cleanup_preserve_running_traces( + tmp_path, capture, monkeypatch, relative_path +): + """Count all sidecars and clear history without removing active files.""" + from astrbot.core.utils import trace_store as storage_module + + path = tmp_path / relative_path + store = TraceStore(path, {}) + monkeypatch.setattr(storage_module, "get_trace_store", lambda *_: store) + try: + finished = TraceSpan("finished", message_outline="x" * 3000) + finished.record("event_received") + finished.record("response_sent", message="x" * 30000) + finished.record("pipeline_end", status="success") + running = TraceSpan("running") + running.record("event_received") + for frame in capture: + assert store.append( + frame, owner=running if frame["trace"]["trace_id"] == running.trace_id else finished + ) + assert store.flush() + log = tmp_path / "logs" / "astrbot.log" + log.parent.mkdir(parents=True, exist_ok=True) + log.write_bytes(b"x" * 128) + cleaner = StorageCleaner( + {"trace_db_path": relative_path}, + data_dir=tmp_path, + temp_dir=tmp_path / "temp", + ) + before = cleaner.get_status() + expected = sum( + sidecar.stat().st_size + for suffix in ("", "-wal", "-shm", "-journal") + if (sidecar := Path(str(path) + suffix)).exists() + ) + assert before["traces"]["size_bytes"] == expected + assert before["logs"]["size_bytes"] == 128 + assert before["cache"]["size_bytes"] == 0 + assert before["total_bytes"] == expected + 128 + result = cleaner.cleanup("traces") + assert result["removed_traces"] == 1 + assert result["deleted_files"] == result["truncated_files"] == 0 + assert store.get_trace(finished.trace_id) is None + assert store.get_trace(running.trace_id)["trace"]["state"] == "running" + assert log.read_bytes() == b"x" * 128 + assert path.exists() + # Even a database placed inside temp is protected from cache cleanup. + cleaner.cleanup("cache") + assert path.exists() + running.record("pipeline_end", status="success") + assert store.append(capture[-1]) + assert store.flush() + assert store.get_trace(running.trace_id)["trace"]["state"] == "completed" + result = cleaner.cleanup("all") + assert result["removed_traces"] == 1 + assert result["status"]["traces"]["size_bytes"] > 0 + assert result["status"]["logs"]["size_bytes"] == 0 + assert store.list_traces()["total"] == 0 + with sqlite3.connect(path) as connection: + for table in ("traces", "spans", "events"): + assert ( + connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + == 0 + ) + finally: + store.close() + + +def test_trace_cleanup_barrier_preserves_later_writes(persisted, capture, monkeypatch): + """Ensure a cleanup command never deletes writes queued after it.""" + old = TraceSpan("old") + old.record("event_received") + old.record("pipeline_end", status="success") + old_frame = capture[-1] + new = TraceSpan("new") + new.record("event_received") + new.record("pipeline_end", status="success") + new_frame = capture[-1] + entered = threading.Event() + release = threading.Event() + original_write = persisted._write + + def held_write(connection, batch): + """Hold the first write while cleanup and another write are queued.""" + if not entered.is_set(): + entered.set() + assert release.wait(5) + original_write(connection, batch) + + monkeypatch.setattr(persisted, "_write", held_write) + results = [] + cleanup = threading.Thread(target=lambda: results.append(persisted.clear_history())) + try: + assert persisted.append(old_frame) + assert entered.wait(5) + cleanup.start() + # Inspect the synchronized queue to avoid timing-dependent sleeps. + with persisted.queue.not_empty: + assert persisted.queue.not_empty.wait_for( + lambda: bool(persisted.queue.queue), 5 + ) + assert persisted.append(new_frame) + release.set() + cleanup.join(5) + assert not cleanup.is_alive() + assert results[0]["removed_traces"] == 1 + assert persisted.flush() + assert persisted.get_trace(old.trace_id) is None + assert persisted.get_trace(new.trace_id)["trace"]["state"] == "completed" + finally: + release.set() + if cleanup.ident is not None: + cleanup.join(5)