diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index aa7b80f93..1a718b261 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -69,15 +69,20 @@ class EventBus: event (AstrMessageEvent): 事件对象 """ + log_extra = { + "category": "user_chat", + "platform_id": event.get_platform_id(), + "platform_type": event.get_platform_name(), + } # 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要 if event.get_sender_name(): logger.info( f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}", - extra={"category": "user_chat"}, + extra=log_extra, ) # 没有发送者名称: [平台名] 发送者ID: 消息概要 else: logger.info( f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}", - extra={"category": "user_chat"}, + extra=log_extra, ) diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 15b50dbdd..01b29fd54 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -9,6 +9,10 @@ import tempfile import time from asyncio import Queue from collections import deque +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -28,6 +32,54 @@ PLUGIN_LOGGER_PREFIX = "astrbot.plugin." PLUGIN_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") """Allowed per-plugin log levels.""" + +@dataclass +class LogContext: + """Platform metadata attached to logs emitted inside an async operation. + + Attributes: + platform_id: Unique configured platform instance ID. + platform_type: Platform adapter type, such as ``telegram``. + active: Whether copied async contexts may still use this metadata. + """ + + platform_id: str | None + platform_type: str | None + active: bool = True + + +_log_context: ContextVar[LogContext | None] = ContextVar( + "astrbot_log_context", + default=None, +) + + +@contextmanager +def log_context( + platform_id: str | None, + platform_type: str | None, +) -> Iterator[None]: + """Bind platform metadata to logs in the current execution context. + + Copied contexts share the same mutable object. Marking it inactive on exit + prevents detached tasks from retaining stale event platform metadata. + + Args: + platform_id: Unique configured platform instance ID. + platform_type: Platform adapter type. + + Yields: + Control while the platform metadata is active. + """ + context = LogContext(platform_id=platform_id, platform_type=platform_type) + token = _log_context.set(context) + try: + yield + finally: + context.active = False + _log_context.reset(token) + + if TYPE_CHECKING: from loguru import Record @@ -38,11 +90,15 @@ class _RecordEnricherFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: if record.name.startswith(PLUGIN_LOGGER_PREFIX): # Records from a per-plugin logger are tagged with the plugin name. - record.plugin_tag = f"[{record.name[len(PLUGIN_LOGGER_PREFIX) :]}]" + plugin_name = record.name[len(PLUGIN_LOGGER_PREFIX) :] + record.plugin_tag = f"[{plugin_name}]" + record.source = "plugin" + record.plugin_name = plugin_name else: - record.plugin_tag = ( - "[Plug]" if _is_plugin_path(record.pathname) else "[Core]" - ) + is_plugin = _is_plugin_path(record.pathname) + record.plugin_tag = "[Plug]" if is_plugin else "[Core]" + record.source = "plugin" if is_plugin else "core" + record.plugin_name = None record.short_levelname = _get_short_level_name(record.levelname) record.astrbot_version_tag = ( f" [v{VERSION}]" if record.levelno >= logging.WARNING else "" @@ -51,6 +107,14 @@ class _RecordEnricherFilter(logging.Filter): record.source_line = record.lineno record.is_trace = record.name == "astrbot.trace" record.category = getattr(record, "category", None) or "system" + context = _log_context.get() + context_is_active = context is not None and context.active + record.platform_id = getattr(record, "platform_id", None) or ( + context.platform_id if context_is_active else None + ) + record.platform_type = getattr(record, "platform_type", None) or ( + context.platform_type if context_is_active else None + ) return True @@ -108,6 +172,10 @@ def _patch_record(record: "Record") -> None: extra.setdefault("source_line", record["line"]) extra.setdefault("is_trace", False) extra.setdefault("category", "system") + extra.setdefault("source", "core") + extra.setdefault("plugin_name", None) + extra.setdefault("platform_id", None) + extra.setdefault("platform_type", None) _loguru = _raw_loguru_logger.patch(_patch_record) @@ -135,6 +203,11 @@ class _LoguruInterceptHandler(logging.Handler): ), "source_line": getattr(record, "source_line", record.lineno), "is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"), + "category": getattr(record, "category", None) or "system", + "source": getattr(record, "source", "core"), + "plugin_name": getattr(record, "plugin_name", None), + "platform_id": getattr(record, "platform_id", None), + "platform_type": getattr(record, "platform_type", None), } _loguru.bind(**payload).opt(exception=record.exc_info).log( @@ -182,6 +255,10 @@ class LogQueueHandler(logging.Handler): "time": time.time(), "data": log_entry, "category": getattr(record, "category", None) or "system", + "source": getattr(record, "source", "core"), + "plugin_name": getattr(record, "plugin_name", None), + "platform_id": getattr(record, "platform_id", None), + "platform_type": getattr(record, "platform_type", None), }, ) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index a7be1b558..a7ddf6cd4 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator from typing import cast from astrbot.core import logger +from astrbot.core.log import log_context from astrbot.core.platform import AstrMessageEvent from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEvent from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( @@ -87,14 +88,15 @@ class PipelineScheduler: """ active_event_registry.register(event) - try: - await self._process_stages(event) + with log_context(event.get_platform_id(), event.get_platform_name()): + try: + await self._process_stages(event) - # 发送一个空消息, 以便于后续的处理 - if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): - await event.send(None) + # 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) + logger.debug("pipeline execution completed.") + finally: + event.cleanup_temporary_local_files() + active_event_registry.unregister(event) diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 0e08c8e4f..f80ae0081 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.log import log_context from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config @@ -49,7 +50,10 @@ class PlatformManager: return sanitized, sanitized != platform_id def _start_platform_task(self, task_name: str, inst: Platform) -> None: - run_task = asyncio.create_task(inst.run(), name=task_name) + run_task = asyncio.create_task( + self._run_platform(inst), + name=task_name, + ) wrapper_task = asyncio.create_task( self._task_wrapper(run_task, platform=inst), name=f"{task_name}_wrapper", @@ -59,6 +63,17 @@ class PlatformManager: wrapper=wrapper_task, ) + async def _run_platform(self, inst: Platform) -> None: + """Run an adapter with its platform metadata bound to emitted logs. + + Args: + inst: Platform adapter instance to run. + """ + meta = inst.meta() + platform_id = meta.id or inst.config.get("id") + with log_context(platform_id, meta.name): + await inst.run() + async def _stop_platform_task(self, client_id: str) -> None: tasks = self._platform_tasks.pop(client_id, None) if not tasks: @@ -130,6 +145,10 @@ class PlatformManager: "Loading IM platform adapter %s(%s) ...", platform_config["type"], platform_config["id"], + extra={ + "platform_id": platform_config["id"], + "platform_type": platform_config["type"], + }, ) match platform_config["type"]: case "aiocqhttp": @@ -240,26 +259,30 @@ class PlatformManager: async def _task_wrapper( self, task: asyncio.Task, platform: Platform | None = None ) -> None: - # 设置平台状态为运行中 - if platform: - platform.status = PlatformStatus.RUNNING - - try: - await task - except asyncio.CancelledError: + meta = platform.meta() if platform else None + platform_id = (meta.id or platform.config.get("id")) if meta else None + platform_type = meta.name if meta else None + with log_context(platform_id, platform_type): + # 设置平台状态为运行中 if platform: - platform.status = PlatformStatus.STOPPED - except Exception as e: - error_msg = str(e) - tb_str = traceback.format_exc() - logger.error(f"------- Task {task.get_name()} failed: {e}") - for line in tb_str.split("\n"): - logger.error(f"| {line}") - logger.error("-------") + platform.status = PlatformStatus.RUNNING - # 记录错误到平台实例 - if platform: - platform.record_error(error_msg, tb_str) + try: + await task + except asyncio.CancelledError: + if platform: + platform.status = PlatformStatus.STOPPED + except Exception as e: + error_msg = str(e) + tb_str = traceback.format_exc() + logger.error(f"------- Task {task.get_name()} failed: {e}") + for line in tb_str.split("\n"): + logger.error(f"| {line}") + logger.error("-------") + + # 记录错误到平台实例 + if platform: + platform.record_error(error_msg, tb_str) async def reload(self, platform_config: dict) -> None: await self.terminate_platform(platform_config["id"]) diff --git a/astrbot/dashboard/services/platform_service.py b/astrbot/dashboard/services/platform_service.py index f30bd544a..513ca9a14 100644 --- a/astrbot/dashboard/services/platform_service.py +++ b/astrbot/dashboard/services/platform_service.py @@ -5,6 +5,7 @@ import string from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.log import log_context from astrbot.core.platform import Platform from astrbot.core.platform.sources.dingtalk.app_registration import ( poll_dingtalk_app_registration_once, @@ -46,16 +47,17 @@ class PlatformService: logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台") raise PlatformServiceError("未找到对应平台", 404) - try: - return await platform_adapter.webhook_callback(request_obj) - except NotImplementedError as exc: - logger.error( - f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法" - ) - raise PlatformServiceError("平台未支持统一 Webhook 模式", 500) from exc - except Exception as exc: - logger.error(f"处理 webhook 回调时发生错误: {exc}", exc_info=True) - raise PlatformServiceError("处理回调失败", 500) from exc + meta = platform_adapter.meta() + platform_id = meta.id or platform_adapter.config.get("id") + with log_context(platform_id, meta.name): + try: + return await platform_adapter.webhook_callback(request_obj) + except NotImplementedError as exc: + logger.error(f"平台 {meta.name} 未实现 webhook_callback 方法") + raise PlatformServiceError("平台未支持统一 Webhook 模式", 500) from exc + except Exception as exc: + logger.error(f"处理 webhook 回调时发生错误: {exc}", exc_info=True) + raise PlatformServiceError("处理回调失败", 500) from exc def find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None: for platform in self.platform_manager.platform_insts: diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 79e701b84..3dd93a875 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -1 +1,9 @@ # Unreleased + +## 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. diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 0bfb2cd1b..2be4a9970 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 – 266 icons */ +/* Auto-generated MDI subset – 267 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -484,6 +484,10 @@ content: "\F0234"; } +.mdi-filter-variant::before { + content: "\F0236"; +} + .mdi-folder-move::before { content: "\F0252"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 737510904..0d34c1b90 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index b428589c9..bd2b65aa5 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index bd47c9a95..7fcfb7005 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -1,6 +1,5 @@