Compare commits

...

3 Commits

Author SHA1 Message Date
Soulter eafb339281 feat(logging): add file and trace logging configuration options 2026-01-30 11:54:08 +08:00
Soulter f03dd87502 fix(log): increase log cache size from 200 to 500 2026-01-30 10:53:15 +08:00
Soulter 6e475074a4 feat: trace 2026-01-29 20:56:45 +08:00
22 changed files with 959 additions and 10 deletions
@@ -38,7 +38,12 @@ class ProcessLLMRequest:
req.func_tool.add_tool(LOCAL_PYTHON_TOOL) req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
async def _ensure_persona( async def _ensure_persona(
self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str self,
req: ProviderRequest,
cfg: dict,
umo: str,
platform_type: str,
event: AstrMessageEvent,
): ):
"""确保用户人格已加载""" """确保用户人格已加载"""
if not req.conversation: if not req.conversation:
@@ -121,6 +126,9 @@ class ProcessLLMRequest:
req.func_tool = toolset req.func_tool = toolset
else: else:
req.func_tool.merge(toolset) req.func_tool.merge(toolset)
event.trace.record(
"sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
)
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}") logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
async def _ensure_img_caption( async def _ensure_img_caption(
@@ -225,7 +233,7 @@ class ProcessLLMRequest:
# inject persona for this request # inject persona for this request
platform_type = event.get_platform_name() platform_type = event.get_platform_name()
await self._ensure_persona( await self._ensure_persona(
req, cfg, event.unified_msg_origin, platform_type req, cfg, event.unified_msg_origin, platform_type, event
) )
# image caption # image caption
+2
View File
@@ -20,6 +20,8 @@ astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img") t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url) html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot") logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(logger, astrbot_config)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH) db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中 # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences(db_helper=db_helper) sp = SharedPreferences(db_helper=db_helper)
+48
View File
@@ -182,6 +182,12 @@ DEFAULT_CONFIG = {
}, },
"wake_prefix": ["/"], "wake_prefix": ["/"],
"log_level": "INFO", "log_level": "INFO",
"log_file_enable": False,
"log_file_path": "logs/astrbot.log",
"log_file_max_mb": 20,
"trace_log_enable": False,
"trace_log_path": "logs/astrbot.trace.log",
"trace_log_max_mb": 20,
"pip_install_arg": "", "pip_install_arg": "",
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/", "pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
"persona": [], # deprecated "persona": [], # deprecated
@@ -2321,6 +2327,18 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
}, },
"log_file_enable": {"type": "bool"},
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
"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},
},
"t2i_strategy": { "t2i_strategy": {
"type": "string", "type": "string",
"options": ["remote", "local"], "options": ["remote", "local"],
@@ -3253,6 +3271,36 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "控制台输出日志的级别。", "hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
}, },
"log_file_enable": {
"description": "启用文件日志",
"type": "bool",
"hint": "开启后会将日志写入指定文件。",
},
"log_file_path": {
"description": "日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。",
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"type": "bool",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。",
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。",
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"pip_install_arg": { "pip_install_arg": {
"description": "pip 安装额外参数", "description": "pip 安装额外参数",
"type": "string", "type": "string",
+7 -3
View File
@@ -17,7 +17,7 @@ import traceback
from asyncio import Queue from asyncio import Queue
from astrbot.api import logger, sp from astrbot.api import logger, sp
from astrbot.core import LogBroker from astrbot.core import LogBroker, LogManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.conversation_mgr import ConversationManager
@@ -80,9 +80,13 @@ class AstrBotCoreLifecycle:
# 初始化日志代理 # 初始化日志代理
logger.info("AstrBot v" + VERSION) logger.info("AstrBot v" + VERSION)
if os.environ.get("TESTING", ""): if os.environ.get("TESTING", ""):
logger.setLevel("DEBUG") # 测试模式下设置日志级别为 DEBUG LogManager.configure_logger(
logger, self.astrbot_config, override_level="DEBUG"
)
LogManager.configure_trace_logger(self.astrbot_config)
else: else:
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别 LogManager.configure_logger(logger, self.astrbot_config)
LogManager.configure_trace_logger(self.astrbot_config)
await self.db.initialize() await self.db.initialize()
+1
View File
@@ -54,6 +54,7 @@ class EventBus:
event (AstrMessageEvent): 事件对象 event (AstrMessageEvent): 事件对象
""" """
event.trace.record("event_dispatch", config_name=conf_name)
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要 # 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name(): if event.get_sender_name():
logger.info( logger.info(
+189 -1
View File
@@ -27,13 +27,15 @@ import sys
import time import time
from asyncio import Queue from asyncio import Queue
from collections import deque from collections import deque
from logging.handlers import RotatingFileHandler
import colorlog import colorlog
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
# 日志缓存大小 # 日志缓存大小
CACHED_SIZE = 200 CACHED_SIZE = 500
# 日志颜色配置 # 日志颜色配置
log_color_config = { log_color_config = {
"DEBUG": "green", "DEBUG": "green",
@@ -163,6 +165,9 @@ class LogManager:
提供了获取默认日志记录器logger和设置队列处理器的方法 提供了获取默认日志记录器logger和设置队列处理器的方法
""" """
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
@classmethod @classmethod
def GetLogger(cls, log_name: str = "default"): def GetLogger(cls, log_name: str = "default"):
"""获取指定名称的日志记录器logger """获取指定名称的日志记录器logger
@@ -266,3 +271,186 @@ class LogManager:
), ),
) )
logger.addHandler(handler) logger.addHandler(handler)
@classmethod
def _default_log_path(cls) -> str:
return os.path.join(get_astrbot_data_path(), "logs", "astrbot.log")
@classmethod
def _resolve_log_path(cls, configured_path: str | None) -> str:
if not configured_path:
return cls._default_log_path()
if os.path.isabs(configured_path):
return configured_path
return os.path.join(get_astrbot_data_path(), configured_path)
@classmethod
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
]
@classmethod
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
]
@classmethod
def _remove_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _remove_trace_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_trace_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _add_file_handler(
cls,
logger: logging.Logger,
file_path: str,
max_mb: int | None = None,
backup_count: int = 3,
trace: bool = False,
):
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
max_bytes = 0
if max_mb and max_mb > 0:
max_bytes = max_mb * 1024 * 1024
if max_bytes > 0:
file_handler = RotatingFileHandler(
file_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
else:
file_handler = logging.FileHandler(file_path, encoding="utf-8")
file_handler.setLevel(logger.level)
if trace:
formatter = logging.Formatter(
"[%(asctime)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
else:
formatter = logging.Formatter(
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
setattr(
file_handler,
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
True,
)
logger.addHandler(file_handler)
@classmethod
def configure_logger(
cls,
logger: logging.Logger,
config: dict | None,
override_level: str | None = None,
):
"""根据配置设置日志级别和文件日志。
Args:
logger: 需要配置的 logger
config: 配置字典
override_level: 若提供将覆盖配置中的日志级别
"""
if not config:
return
level = override_level or config.get("log_level")
if level:
try:
logger.setLevel(level)
except Exception:
logger.setLevel(logging.INFO)
# 兼容旧版嵌套配置
if "log_file" in config:
file_conf = config.get("log_file") or {}
enable_file = bool(file_conf.get("enable", False))
file_path = file_conf.get("path")
max_mb = file_conf.get("max_mb")
else:
enable_file = bool(config.get("log_file_enable", False))
file_path = config.get("log_file_path")
max_mb = config.get("log_file_max_mb")
file_path = cls._resolve_log_path(file_path)
existing = cls._get_file_handlers(logger)
if not enable_file:
cls._remove_file_handlers(logger)
return
# 如果已有文件处理器且路径一致,则仅同步级别
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(logger.level)
return
cls._remove_file_handlers(logger)
cls._add_file_handler(logger, file_path, max_mb=max_mb)
@classmethod
def configure_trace_logger(cls, config: dict | None):
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
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")
if not enable:
trace_logger = logging.getLogger("astrbot.trace")
cls._remove_trace_file_handlers(trace_logger)
return
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
trace_logger = logging.getLogger("astrbot.trace")
trace_logger.setLevel(logging.INFO)
trace_logger.propagate = False
existing = cls._get_trace_file_handlers(trace_logger)
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(trace_logger.level)
return
cls._remove_trace_file_handlers(trace_logger)
cls._add_file_handler(
trace_logger,
file_path,
max_mb=max_mb,
trace=True,
)
@@ -691,6 +691,17 @@ class InternalAgentSubStage(Stage):
if action_type == "live": if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n" req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
event.trace.record(
"astr_agent_prepare",
system_prompt=req.system_prompt,
tools=req.func_tool.names() if req.func_tool else [],
stream=streaming_response,
chat_provider={
"id": provider.provider_config.get("id", ""),
"model": provider.get_model(),
},
)
await agent_runner.reset( await agent_runner.reset(
provider=provider, provider=provider,
request=req, request=req,
@@ -795,12 +806,20 @@ class InternalAgentSubStage(Stage):
): ):
yield yield
final_resp = agent_runner.get_final_llm_resp()
event.trace.record(
"astr_agent_complete",
stats=agent_runner.stats.to_dict(),
resp=final_resp.completion_text if final_resp else None,
)
# 检查事件是否被停止,如果被停止则不保存历史记录 # 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped(): if not event.is_stopped():
await self._save_to_history( await self._save_to_history(
event, event,
req, req,
agent_runner.get_final_llm_resp(), final_resp,
agent_runner.run_context.messages, agent_runner.run_context.messages,
agent_runner.stats, agent_runner.stats,
) )
+2
View File
@@ -85,4 +85,6 @@ class PipelineScheduler:
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None) await event.send(None)
event.trace.record("event_end")
logger.debug("pipeline 执行完毕。") logger.debug("pipeline 执行完毕。")
@@ -4,6 +4,7 @@ import hashlib
import re import re
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from time import time
from typing import Any from typing import Any
from astrbot import logger from astrbot import logger
@@ -22,6 +23,7 @@ from astrbot.core.message.message_event_result import MessageChain, MessageEvent
from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entities import ProviderRequest from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.trace import TraceSpan
from .astrbot_message import AstrBotMessage, Group from .astrbot_message import AstrBotMessage, Group
from .message_session import MessageSesion, MessageSession # noqa from .message_session import MessageSesion, MessageSession # noqa
@@ -59,6 +61,21 @@ class AstrMessageEvent(abc.ABC):
self._result: MessageEventResult | None = None self._result: MessageEventResult | None = None
"""消息事件的结果""" """消息事件的结果"""
self.created_at = time()
"""事件创建时间(Unix timestamp)"""
self.trace = TraceSpan(
name="AstrMessageEvent",
umo=self.unified_msg_origin,
sender_name=self.get_sender_name(),
message_outline=self.get_message_outline(),
)
"""用于记录事件处理的 TraceSpan 对象"""
self.span = self.trace
"""事件级 TraceSpan(别名: span)"""
self.trace.record("umo", umo=self.unified_msg_origin)
self.trace.record("event_created", created_at=self.created_at)
self._has_send_oper = False self._has_send_oper = False
"""在此次事件中是否有过至少一次发送消息的操作""" """在此次事件中是否有过至少一次发送消息的操作"""
self.call_llm = False self.call_llm = False
+73
View File
@@ -0,0 +1,73 @@
import json
import logging
import time
import uuid
from typing import Any
from astrbot import logger
from astrbot.core import LogManager, astrbot_config
from astrbot.core.log import LogQueueHandler
_cached_log_broker = None
_trace_logger = None
def _get_log_broker():
global _cached_log_broker
if _cached_log_broker is not None:
return _cached_log_broker
for handler in logger.handlers:
if isinstance(handler, LogQueueHandler):
_cached_log_broker = handler.log_broker
return _cached_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:
def __init__(
self,
name: str,
umo: str | None = None,
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.started_at = time.time()
def record(self, action: str, **fields: Any) -> None:
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,
}
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))
@@ -0,0 +1,487 @@
<script setup>
import axios from 'axios';
import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div class="trace-wrapper">
<div class="trace-table" ref="scrollEl" :style="{ height: tableHeight }">
<div class="trace-row trace-header">
<div class="trace-cell time">Time</div>
<div class="trace-cell span">Event ID</div>
<div class="trace-cell umo">UMO</div>
<!-- <div class="trace-cell count">Records</div> -->
<!-- <div class="trace-cell last">Last</div> -->
<div class="trace-cell sender">Sender</div>
<div class="trace-cell outline">Outline</div>
<div class="trace-cell fields"></div>
</div>
<div class="trace-group" :class="{ highlight: highlightMap[event.span_id] }" v-for="event in events"
:key="event.span_id">
<div class="trace-row trace-event">
<div class="trace-cell time">{{ formatTime(event.first_time) }}</div>
<div class="trace-cell span" :title="event.span_id">
<div class="event-title">
{{ shortSpan(event.span_id) }}
</div>
</div>
<div class="trace-cell umo">{{ event.umo }}</div>
<!-- <div class="trace-cell count">
<div class="event-meta">{{ event.records.length }}</div>
</div> -->
<!-- <div class="trace-cell last">
<div class="event-meta">{{ formatTime(event.last_time) }}</div>
</div> -->
<div class="trace-cell sender">
<div class="event-sub" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{
event.sender_name || '-' }}</div>
</div>
<div class="trace-cell outline">
<div class="event-sub outline">{{ event.message_outline || '-' }}</div>
</div>
<div class="trace-cell fields event-controls">
<v-btn size="x-small" variant="text" color="primary" @click="toggleEvent(event.span_id)">
{{ event.collapsed ? 'Expand' : 'Collapse' }}
<span v-if="event.hasAgentPrepare" class="agent-dot" />
</v-btn>
</div>
</div>
<div class="trace-records" v-if="!event.collapsed">
<div class="trace-record" v-for="record in getVisibleRecords(event)" :key="record.key">
<div class="trace-record-time">{{ record.timeLabel }}</div>
<div class="trace-record-action">{{ record.action }}</div>
<pre class="trace-record-fields">{{ record.fieldsText }}</pre>
</div>
<div class="event-more" v-if="event.visibleCount < event.records.length">
<v-btn size="x-small" variant="tonal" color="primary" @click="showMore(event.span_id)">
Show more
</v-btn>
</div>
</div>
</div>
<div v-if="events.length === 0" class="trace-empty">No trace data yet.</div>
</div>
</div>
</template>
<script>
export default {
name: 'TraceDisplayer',
props: {
autoScroll: {
type: Boolean,
default: true
},
maxItems: {
type: Number,
default: 300
}
},
data() {
return {
events: [],
eventIndex: {},
highlightMap: {},
highlightTimers: {},
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
tableHeight: 'auto'
};
},
async mounted() {
await this.fetchTraceHistory();
this.connectSSE();
this.updateTableHeight();
window.addEventListener('resize', this.updateTableHeight);
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryAttempts = 0;
window.removeEventListener('resize', this.updateTableHeight);
},
methods: {
updateTableHeight() {
this.$nextTick(() => {
const el = this.$refs.scrollEl;
if (!el || typeof window === 'undefined') return;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const offsetTop = el.getBoundingClientRect().top;
const height = Math.max(viewportHeight - offsetTop, 0);
this.tableHeight = `${height}px`;
});
},
async fetchTraceHistory() {
try {
const res = await axios.get('/api/log-history');
const logs = res.data?.data?.logs || [];
const traces = logs.filter((item) => item.type === 'trace');
this.processNewTraces(traces);
} catch (err) {
console.error('Failed to fetch trace history:', err);
}
},
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
const token = localStorage.getItem('token');
this.eventSource = new EventSourcePolyfill('/api/live-log', {
headers: {
Authorization: token ? `Bearer ${token}` : ''
},
heartbeatTimeout: 300000,
withCredentials: true
});
this.eventSource.onopen = () => {
this.retryAttempts = 0;
if (!this.lastEventId) {
this.fetchTraceHistory();
}
};
this.eventSource.onmessage = (event) => {
try {
if (event.lastEventId) {
this.lastEventId = event.lastEventId;
}
const payload = JSON.parse(event.data);
if (payload?.type !== 'trace') {
return;
}
this.processNewTraces([payload]);
} catch (e) {
console.error('Failed to parse trace payload:', e);
}
};
this.eventSource.onerror = (err) => {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error('Trace stream reached max retry attempts.');
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000
);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) {
await this.fetchTraceHistory();
}
this.connectSSE();
}, delay);
};
},
processNewTraces(newTraces) {
if (!newTraces || newTraces.length === 0) return;
let hasUpdate = false;
const touched = new Set();
newTraces.forEach((trace) => {
if (!trace.span_id) return;
const recordKey = `${trace.time}-${trace.span_id}-${trace.action}`;
let event = this.eventIndex[trace.span_id];
if (!event) {
event = {
span_id: trace.span_id,
name: trace.name,
umo: trace.umo,
sender_name: trace.sender_name,
message_outline: trace.message_outline,
first_time: trace.time,
last_time: trace.time,
collapsed: true,
visibleCount: 20,
records: [],
hasAgentPrepare: trace.action === 'astr_agent_prepare'
};
this.eventIndex[trace.span_id] = event;
this.events.push(event);
hasUpdate = true;
}
const exists = event.records.some((item) => item.key === recordKey);
if (exists) return;
event.records.push({
time: trace.time,
action: trace.action,
fieldsText: this.formatFields(trace.fields),
timeLabel: this.formatTime(trace.time),
key: recordKey
});
if (trace.action === 'astr_agent_prepare') {
event.hasAgentPrepare = true;
}
if (!event.first_time || trace.time < event.first_time) {
event.first_time = trace.time;
}
if (!event.last_time || trace.time > event.last_time) {
event.last_time = trace.time;
}
if (!event.sender_name && trace.sender_name) {
event.sender_name = trace.sender_name;
}
if (!event.message_outline && trace.message_outline) {
event.message_outline = trace.message_outline;
}
touched.add(trace.span_id);
hasUpdate = true;
});
if (hasUpdate) {
this.events.forEach((event) => {
event.records.sort((a, b) => b.time - a.time);
});
this.events.sort((a, b) => b.first_time - a.first_time);
if (this.events.length > this.maxItems) {
const overflow = this.events.length - this.maxItems;
const removed = this.events.splice(this.maxItems, overflow);
removed.forEach((event) => {
delete this.eventIndex[event.span_id];
});
}
touched.forEach((spanId) => {
this.pulseEvent(spanId);
});
}
},
scrollToBottom() {
const el = this.$refs.scrollEl;
if (!el) return;
el.scrollTop = el.scrollHeight;
},
toggleEvent(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.collapsed = !event.collapsed;
},
showMore(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.visibleCount = Math.min(event.records.length, event.visibleCount + 20);
},
pulseEvent(spanId) {
if (!spanId) return;
if (this.highlightTimers[spanId]) {
clearTimeout(this.highlightTimers[spanId]);
}
this.highlightMap = { ...this.highlightMap, [spanId]: true };
const remove = setTimeout(() => {
const next = { ...this.highlightMap };
delete next[spanId];
this.highlightMap = next;
const timers = { ...this.highlightTimers };
delete timers[spanId];
this.highlightTimers = timers;
}, 1200);
this.highlightTimers = { ...this.highlightTimers, [spanId]: remove };
},
getVisibleRecords(event) {
if (!event.records.length) return [];
return event.records.slice(0, event.visibleCount);
},
formatTime(ts) {
if (!ts) return '';
const date = new Date(ts * 1000);
const base = date.toLocaleString();
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${base}.${ms}`;
},
shortSpan(spanId) {
if (!spanId) return '';
return spanId.slice(0, 8);
},
formatFields(fields) {
if (!fields) return '';
try {
const text = JSON.stringify(fields, null, 2);
if (text.length > 2000) {
return `${text}`;
}
return text;
} catch (e) {
return String(fields);
}
}
}
};
</script>
<style scoped>
.trace-wrapper {
height: 100%;
}
.trace-table {
background: transparent;
border-radius: 0;
padding: 0;
height: 100%;
overflow-y: auto;
color: #2b3340;
font-family: 'Fira Code', monospace;
}
.trace-row {
display: grid;
grid-template-columns: 200px 100px 300px 90px 180px 140px 200px 1fr;
gap: 12px;
}
.trace-group {
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: transparent;
padding: 8px 0;
}
.trace-group.highlight {
background: rgba(59, 130, 246, 0.08);
transition: background 0.6s ease;
}
.trace-event {
align-items: start;
}
.trace-header {
font-weight: 600;
color: #6b7280;
border-bottom: 1px solid rgba(15, 23, 42, 0.12);
padding-bottom: 10px;
}
.trace-cell {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.event-title {
font-weight: 600;
color: #1f2937;
}
.event-meta {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.event-sub {
font-size: 12px;
color: #4b5563;
margin-top: 2px;
word-break: break-word;
}
.event-sub.outline {
color: #6b7280;
}
.event-controls {
display: flex;
justify-content: flex-end;
}
.agent-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
margin-left: 6px;
vertical-align: middle;
}
.trace-cell.fields pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
}
.trace-empty {
padding: 24px;
text-align: center;
color: #6b7280;
}
@media (max-width: 1200px) {
.trace-row {
grid-template-columns: 140px 160px 300px 70px 140px 180px 1fr;
}
.trace-cell.fields {
grid-column: 1 / -1;
}
}
.trace-record {
display: grid;
grid-template-columns: 200px 120px 1fr;
gap: 8px;
padding: 2px 0;
}
.trace-record:last-child {
border-bottom: none;
}
.trace-record-time {
color: #6b7280;
font-size: 11px;
}
.trace-record-action {
color: #1f2937;
font-weight: 600;
font-size: 11px;
}
.trace-record-fields {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
font-size: 10px;
}
.event-more {
display: flex;
justify-content: center;
padding: 6px 0 2px;
}
.trace-records {
padding: 4px 0 2px 0;
}
</style>
+1
View File
@@ -46,6 +46,7 @@ export class I18nLoader {
{ name: 'features/config', path: 'features/config.json' }, { name: 'features/config', path: 'features/config.json' },
{ name: 'features/config-metadata', path: 'features/config-metadata.json' }, { name: 'features/config-metadata', path: 'features/config-metadata.json' },
{ name: 'features/console', path: 'features/console.json' }, { name: 'features/console', path: 'features/console.json' },
{ name: 'features/trace', path: 'features/trace.json' },
{ name: 'features/about', path: 'features/about.json' }, { name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' }, { name: 'features/settings', path: 'features/settings.json' },
{ name: 'features/auth', path: 'features/auth.json' }, { name: 'features/auth', path: 'features/auth.json' },
@@ -11,6 +11,7 @@
"conversation": "Conversations", "conversation": "Conversations",
"sessionManagement": "Custom Rules", "sessionManagement": "Custom Rules",
"console": "Console", "console": "Console",
"trace": "Trace",
"alkaid": "Alkaid Lab", "alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base", "knowledgeBase": "Knowledge Base",
"about": "About", "about": "About",
@@ -564,6 +564,30 @@
"description": "Console Log Level", "description": "Console Log Level",
"hint": "Log level for console output." "hint": "Log level for console output."
}, },
"log_file_enable": {
"description": "Enable File Logging",
"hint": "Write logs to a file in addition to the console."
},
"log_file_path": {
"description": "Log File Path",
"hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.log; absolute paths are supported."
},
"log_file_max_mb": {
"description": "Log File Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"trace_log_enable": {
"description": "Enable Trace File Logging",
"hint": "Write trace events to a separate file (does not change console output)."
},
"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_log_max_mb": {
"description": "Trace Log Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"pip_install_arg": { "pip_install_arg": {
"description": "Additional pip Installation Arguments", "description": "Additional pip Installation Arguments",
"hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`." "hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`."
@@ -0,0 +1,7 @@
{
"title": "Trace",
"autoScroll": {
"enabled": "Auto-scroll: On",
"disabled": "Auto-scroll: Off"
}
}
@@ -11,6 +11,7 @@
"conversation": "对话数据", "conversation": "对话数据",
"sessionManagement": "自定义规则", "sessionManagement": "自定义规则",
"console": "平台日志", "console": "平台日志",
"trace": "追踪",
"alkaid": "Alkaid", "alkaid": "Alkaid",
"knowledgeBase": "知识库", "knowledgeBase": "知识库",
"about": "关于", "about": "关于",
@@ -562,6 +562,30 @@
"description": "控制台日志级别", "description": "控制台日志级别",
"hint": "控制台输出日志的级别。" "hint": "控制台输出日志的级别。"
}, },
"log_file_enable": {
"description": "启用文件日志",
"hint": "在控制台输出的同时,将日志写入文件。"
},
"log_file_path": {
"description": "日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。"
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。"
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。"
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"pip_install_arg": { "pip_install_arg": {
"description": "pip 安装额外参数", "description": "pip 安装额外参数",
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。" "hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。"
@@ -0,0 +1,7 @@
{
"title": "追踪",
"autoScroll": {
"enabled": "自动滚动:开",
"disabled": "自动滚动:关"
}
}
+4
View File
@@ -19,6 +19,7 @@ import zhCNPlatform from './locales/zh-CN/features/platform.json';
import zhCNConfig from './locales/zh-CN/features/config.json'; import zhCNConfig from './locales/zh-CN/features/config.json';
import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.json'; import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.json';
import zhCNConsole from './locales/zh-CN/features/console.json'; import zhCNConsole from './locales/zh-CN/features/console.json';
import zhCNTrace from './locales/zh-CN/features/trace.json';
import zhCNAbout from './locales/zh-CN/features/about.json'; import zhCNAbout from './locales/zh-CN/features/about.json';
import zhCNSettings from './locales/zh-CN/features/settings.json'; import zhCNSettings from './locales/zh-CN/features/settings.json';
import zhCNAuth from './locales/zh-CN/features/auth.json'; import zhCNAuth from './locales/zh-CN/features/auth.json';
@@ -56,6 +57,7 @@ import enUSPlatform from './locales/en-US/features/platform.json';
import enUSConfig from './locales/en-US/features/config.json'; import enUSConfig from './locales/en-US/features/config.json';
import enUSConfigMetadata from './locales/en-US/features/config-metadata.json'; import enUSConfigMetadata from './locales/en-US/features/config-metadata.json';
import enUSConsole from './locales/en-US/features/console.json'; import enUSConsole from './locales/en-US/features/console.json';
import enUSTrace from './locales/en-US/features/trace.json';
import enUSAbout from './locales/en-US/features/about.json'; import enUSAbout from './locales/en-US/features/about.json';
import enUSSettings from './locales/en-US/features/settings.json'; import enUSSettings from './locales/en-US/features/settings.json';
import enUSAuth from './locales/en-US/features/auth.json'; import enUSAuth from './locales/en-US/features/auth.json';
@@ -97,6 +99,7 @@ export const translations = {
config: zhCNConfig, config: zhCNConfig,
'config-metadata': zhCNConfigMetadata, 'config-metadata': zhCNConfigMetadata,
console: zhCNConsole, console: zhCNConsole,
trace: zhCNTrace,
about: zhCNAbout, about: zhCNAbout,
settings: zhCNSettings, settings: zhCNSettings,
auth: zhCNAuth, auth: zhCNAuth,
@@ -142,6 +145,7 @@ export const translations = {
config: enUSConfig, config: enUSConfig,
'config-metadata': enUSConfigMetadata, 'config-metadata': enUSConfigMetadata,
console: enUSConsole, console: enUSConsole,
trace: enUSTrace,
about: enUSAbout, about: enUSAbout,
settings: enUSSettings, settings: enUSSettings,
auth: enUSAuth, auth: enUSAuth,
@@ -72,6 +72,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-console', icon: 'mdi-console',
to: '/console' to: '/console'
}, },
{
title: 'core.navigation.trace',
icon: 'mdi-timeline-text-outline',
to: '/trace'
},
] ]
} }
// { // {
+5
View File
@@ -61,6 +61,11 @@ const MainRoutes = {
path: '/console', path: '/console',
component: () => import('@/views/ConsolePage.vue') component: () => import('@/views/ConsolePage.vue')
}, },
{
name: 'Trace',
path: '/trace',
component: () => import('@/views/TracePage.vue')
},
{ {
name: 'NativeKnowledgeBase', name: 'NativeKnowledgeBase',
path: '/knowledge-base', path: '/knowledge-base',
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/trace');
</script>
<template>
<div style="height: 100%;">
<TraceDisplayer />
</div>
</template>
<script>
export default {
name: 'TracePage',
components: {
TraceDisplayer
}
};
</script>