mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-31 01:40:25 +08:00
Feat: Allow adjust plugin log levels (#9342)
* feat(logging): add isolated plugin loggers * feat(dashboard): configure plugin log levels * fix(logging): support legacy plugin loggers * fix(dashboard): sync plugin log level response * perf(logging): avoid rescanning plugin handlers * fix(logging): persist plugin levels atomically * test(logging): use valid logger level mocks
This commit is contained in:
+65
-1
@@ -1,4 +1,6 @@
|
||||
from astrbot import logger
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from astrbot.core import html_renderer, sp
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
@@ -6,6 +8,68 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.star.register import register_agent as agent
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
|
||||
_fallback_logger = logging.getLogger("astrbot")
|
||||
_logger_cache: dict[
|
||||
str,
|
||||
tuple[str | None, str | None, logging.Logger],
|
||||
] = {}
|
||||
|
||||
# Caller modules under these roots may belong to plugins that are not
|
||||
# registered yet, so resolution failures for them are never cached.
|
||||
_PLUGIN_MODULE_ROOTS = ("data.plugins.", "astrbot.builtin_stars.")
|
||||
|
||||
|
||||
def _resolve_caller_logger(module_name: str) -> logging.Logger:
|
||||
"""Resolve the dedicated plugin logger for a caller module.
|
||||
|
||||
Args:
|
||||
module_name: The ``__name__`` of the module that called the logger.
|
||||
|
||||
Returns:
|
||||
The plugin's dedicated logger, or the global ``astrbot`` logger when
|
||||
the caller does not belong to a registered plugin.
|
||||
"""
|
||||
# Imported lazily to avoid a circular import with astrbot.core.star.
|
||||
from astrbot.core.log import LogManager
|
||||
from astrbot.core.star.star import star_map
|
||||
|
||||
cached = _logger_cache.get(module_name)
|
||||
if cached is not None:
|
||||
module_path, plugin_name, cached_logger = cached
|
||||
if module_path is None:
|
||||
return cached_logger
|
||||
metadata = star_map.get(module_path)
|
||||
if metadata is not None and metadata.name == plugin_name:
|
||||
return cached_logger
|
||||
_logger_cache.pop(module_name, None)
|
||||
|
||||
for module_path, metadata in star_map.items():
|
||||
if not module_path or not metadata.name:
|
||||
continue
|
||||
package = module_path.rpartition(".")[0]
|
||||
if module_name == module_path or module_name.startswith(package + "."):
|
||||
resolved = LogManager.get_plugin_logger(metadata.name)
|
||||
_logger_cache[module_name] = (module_path, metadata.name, resolved)
|
||||
return resolved
|
||||
|
||||
if not module_name.startswith(_PLUGIN_MODULE_ROOTS):
|
||||
_logger_cache[module_name] = (None, None, _fallback_logger)
|
||||
return _fallback_logger
|
||||
|
||||
|
||||
class _PluginContextLogger:
|
||||
"""Proxy routing ``astrbot.api.logger`` calls to the caller plugin's logger."""
|
||||
|
||||
def __getattr__(self, item: str):
|
||||
module_name = sys._getframe(1).f_globals.get("__name__", "")
|
||||
return getattr(_resolve_caller_logger(module_name), item)
|
||||
|
||||
|
||||
logger = _PluginContextLogger()
|
||||
"""Plugin-facing logger. Calls are routed to the calling plugin's dedicated
|
||||
logger (``astrbot.plugin.<plugin_name>``) so each plugin's log level can be
|
||||
tuned independently; non-plugin callers fall back to the global logger."""
|
||||
|
||||
__all__ = [
|
||||
"AstrBotConfig",
|
||||
"BaseFunctionToolExecutor",
|
||||
|
||||
+169
-17
@@ -1,21 +1,33 @@
|
||||
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger as _raw_loguru_logger
|
||||
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_data_path,
|
||||
)
|
||||
|
||||
CACHED_SIZE = 500
|
||||
|
||||
PLUGIN_LOGGER_PREFIX = "astrbot.plugin."
|
||||
"""Prefix of per-plugin logger names; full name is ``astrbot.plugin.<plugin_name>``."""
|
||||
|
||||
PLUGIN_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
"""Allowed per-plugin log levels."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
@@ -24,7 +36,13 @@ class _RecordEnricherFilter(logging.Filter):
|
||||
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
||||
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) :]}]"
|
||||
else:
|
||||
record.plugin_tag = (
|
||||
"[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
||||
)
|
||||
record.short_levelname = _get_short_level_name(record.levelname)
|
||||
record.astrbot_version_tag = (
|
||||
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
|
||||
@@ -173,6 +191,9 @@ class LogManager:
|
||||
_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
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
"filelock": logging.WARNING,
|
||||
@@ -262,25 +283,147 @@ class LogManager:
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def _plugin_log_levels_path(cls) -> Path:
|
||||
return Path(get_astrbot_config_path()) / "plugin_log_levels.json"
|
||||
|
||||
@classmethod
|
||||
def _load_plugin_level_overrides(cls) -> dict[str, str]:
|
||||
"""Lazily load persisted per-plugin log level overrides from disk."""
|
||||
if cls._plugin_level_overrides is None:
|
||||
cls._plugin_level_overrides = {}
|
||||
try:
|
||||
with cls._plugin_log_levels_path().open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
cls._plugin_level_overrides = {
|
||||
str(name): str(level).upper()
|
||||
for name, level in data.items()
|
||||
if str(level).upper() in PLUGIN_LOG_LEVELS
|
||||
}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return cls._plugin_level_overrides
|
||||
|
||||
@classmethod
|
||||
def get_plugin_log_level(cls, plugin_name: str) -> str | None:
|
||||
"""Get the log level override of a plugin.
|
||||
|
||||
Args:
|
||||
plugin_name: The plugin name.
|
||||
|
||||
Returns:
|
||||
The configured level name, or None if the plugin follows the global level.
|
||||
"""
|
||||
return cls._load_plugin_level_overrides().get(plugin_name)
|
||||
|
||||
@classmethod
|
||||
def set_plugin_log_level(cls, plugin_name: str, level: str | None) -> None:
|
||||
"""Persist and apply a per-plugin log level override.
|
||||
|
||||
Args:
|
||||
plugin_name: The plugin name.
|
||||
level: The level name to apply, or None to follow the global level.
|
||||
|
||||
Raises:
|
||||
ValueError: If the level name is not valid.
|
||||
"""
|
||||
overrides = dict(cls._load_plugin_level_overrides())
|
||||
if level is None:
|
||||
overrides.pop(plugin_name, None)
|
||||
else:
|
||||
level = level.upper()
|
||||
if level not in PLUGIN_LOG_LEVELS:
|
||||
raise ValueError(f"Invalid log level: {level}")
|
||||
overrides[plugin_name] = level
|
||||
|
||||
config_path = cls._plugin_log_levels_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
dir=config_path.parent,
|
||||
prefix=f".{config_path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as f:
|
||||
temp_path = Path(f.name)
|
||||
json.dump(overrides, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
temp_path.replace(config_path)
|
||||
except Exception:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
cls._plugin_level_overrides = overrides
|
||||
|
||||
if plugin_name in cls._plugin_logger_names:
|
||||
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}").setLevel(
|
||||
cls._effective_plugin_log_level(plugin_name)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _effective_plugin_log_level(cls, plugin_name: str) -> int:
|
||||
override = cls.get_plugin_log_level(plugin_name)
|
||||
if override:
|
||||
return logging.getLevelName(override)
|
||||
base_level = logging.getLogger("astrbot").level
|
||||
return base_level if base_level > 0 else logging.INFO
|
||||
|
||||
@classmethod
|
||||
def get_plugin_logger(cls, plugin_name: str) -> logging.Logger:
|
||||
"""Get or create the dedicated logger for a plugin.
|
||||
|
||||
The logger is isolated from the global ``astrbot`` logger so its level
|
||||
can be tuned independently. Its level defaults to the persisted
|
||||
per-plugin override, falling back to the current global level.
|
||||
|
||||
Args:
|
||||
plugin_name: The plugin name.
|
||||
|
||||
Returns:
|
||||
The plugin's dedicated logger.
|
||||
"""
|
||||
plugin_logger = cls.GetLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}")
|
||||
if plugin_name not in cls._plugin_logger_names:
|
||||
cls._plugin_logger_names.add(plugin_name)
|
||||
if cls._log_broker is not None:
|
||||
cls.set_queue_handler(plugin_logger, cls._log_broker)
|
||||
# GetLogger() resets the level to DEBUG, so re-apply the effective level.
|
||||
plugin_logger.setLevel(cls._effective_plugin_log_level(plugin_name))
|
||||
return plugin_logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||
cls._ensure_logger_enricher_filter(logger)
|
||||
cls._log_broker = log_broker
|
||||
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, LogQueueHandler):
|
||||
return
|
||||
targets = [logger]
|
||||
if logger.name == "astrbot":
|
||||
targets.extend(
|
||||
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}")
|
||||
for name in cls._plugin_logger_names
|
||||
)
|
||||
for target in targets:
|
||||
cls._ensure_logger_enricher_filter(target)
|
||||
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.addFilter(_QueueAnsiColorFilter())
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
||||
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
),
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
if any(isinstance(handler, LogQueueHandler) for handler in target.handlers):
|
||||
continue
|
||||
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.addFilter(_QueueAnsiColorFilter())
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
||||
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
),
|
||||
)
|
||||
target.addHandler(handler)
|
||||
|
||||
@classmethod
|
||||
def _remove_sink(cls, sink_id: int | None) -> None:
|
||||
@@ -353,6 +496,15 @@ class LogManager:
|
||||
except Exception:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Plugin loggers without an explicit override follow the global level.
|
||||
plugin_level = logger.level
|
||||
overrides = cls._load_plugin_level_overrides()
|
||||
for name in cls._plugin_logger_names:
|
||||
if name not in overrides:
|
||||
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}").setLevel(
|
||||
plugin_level
|
||||
)
|
||||
|
||||
if "log_file" in config:
|
||||
file_conf = config.get("log_file") or {}
|
||||
enable_file = bool(file_conf.get("enable", False))
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.log import LogManager
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
@@ -21,9 +22,34 @@ class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
author: str
|
||||
name: str
|
||||
context: Context
|
||||
logger: logging.Logger
|
||||
"""The plugin's dedicated logger, isolated from the global ``astrbot`` logger."""
|
||||
|
||||
def __init__(self, context: Context, config: dict | None = None) -> None:
|
||||
self.context = context
|
||||
# Resolve the plugin name from the metadata registered for this module
|
||||
# first (it matches the name the dashboard uses); the loader also
|
||||
# injects a sanitized ``name`` class attribute as a fallback. When both
|
||||
# are absent (e.g. direct instantiation in tests), fall back to the
|
||||
# global logger.
|
||||
metadata = star_map.get(self.__class__.__module__)
|
||||
plugin_name = (metadata.name if metadata else None) or getattr(
|
||||
self, "name", None
|
||||
)
|
||||
try:
|
||||
self.logger = (
|
||||
LogManager.get_plugin_logger(plugin_name)
|
||||
if plugin_name
|
||||
else logging.getLogger("astrbot")
|
||||
)
|
||||
logger.info(
|
||||
"Plugin %s log level: %s.",
|
||||
plugin_name or self.__class__.__name__,
|
||||
logging.getLevelName(self.logger.getEffectiveLevel()),
|
||||
)
|
||||
except AttributeError:
|
||||
# The plugin defines ``logger`` as a read-only property; keep its own.
|
||||
pass
|
||||
|
||||
def _get_context_config(self) -> Any:
|
||||
get_config = getattr(self.context, "get_config", None)
|
||||
|
||||
@@ -10,12 +10,13 @@ from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
from astrbot.api.web import PluginRequest, bind_request_context
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.log import LogManager
|
||||
from astrbot.dashboard.asgi_runtime import (
|
||||
DashboardRequestState,
|
||||
call_request_view,
|
||||
)
|
||||
from astrbot.dashboard.async_utils import run_maybe_async
|
||||
from astrbot.dashboard.responses import ok
|
||||
from astrbot.dashboard.responses import error, ok
|
||||
from astrbot.dashboard.schemas import (
|
||||
EnabledPatch,
|
||||
PluginByIdRequest,
|
||||
@@ -24,6 +25,7 @@ from astrbot.dashboard.schemas import (
|
||||
PluginConfigUpdateRequest,
|
||||
PluginEnabledRequest,
|
||||
PluginInstallRequest,
|
||||
PluginLogLevelPayload,
|
||||
PluginSourceBindRequest,
|
||||
PluginSourceRequest,
|
||||
PluginUninstallRequest,
|
||||
@@ -712,7 +714,13 @@ async def get_plugin_config_by_id(
|
||||
_auth: AuthContext = Depends(require_plugin_scope),
|
||||
service: ConfigDisplayService = Depends(get_config_display_service),
|
||||
):
|
||||
return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
|
||||
return ok(
|
||||
{
|
||||
"plugin_name": plugin_id,
|
||||
"log_level": LogManager.get_plugin_log_level(plugin_id),
|
||||
**await service.get_configs(plugin_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/plugins/config")
|
||||
@@ -956,7 +964,30 @@ async def get_plugin_config(
|
||||
_auth: AuthContext = Depends(require_plugin_scope),
|
||||
service: ConfigDisplayService = Depends(get_config_display_service),
|
||||
):
|
||||
return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
|
||||
return ok(
|
||||
{
|
||||
"plugin_name": plugin_id,
|
||||
"log_level": LogManager.get_plugin_log_level(plugin_id),
|
||||
**await service.get_configs(plugin_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/plugins/{plugin_id}/log-level")
|
||||
async def update_plugin_log_level(
|
||||
plugin_id: str,
|
||||
payload: PluginLogLevelPayload,
|
||||
_auth: AuthContext = Depends(require_plugin_scope),
|
||||
):
|
||||
try:
|
||||
LogManager.set_plugin_log_level(plugin_id, payload.level)
|
||||
except ValueError as e:
|
||||
return error(str(e))
|
||||
level_desc = payload.level.upper() if payload.level else "global"
|
||||
return ok(
|
||||
message=f"Log level of plugin {plugin_id} set to {level_desc}.",
|
||||
data={"log_level": LogManager.get_plugin_log_level(plugin_id)},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/plugins/{plugin_id}/config")
|
||||
|
||||
@@ -589,6 +589,11 @@ class PluginConfigPayload(OpenModel):
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PluginLogLevelPayload(OpenModel):
|
||||
level: str | None = None
|
||||
"""Log level name (DEBUG/INFO/WARNING/ERROR/CRITICAL), or null to follow the global level."""
|
||||
|
||||
|
||||
class PluginSourceRequest(OpenModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1871,6 +1871,23 @@ export type UpdatePluginConfigResponse = (SuccessEnvelope);
|
||||
|
||||
export type UpdatePluginConfigError = unknown;
|
||||
|
||||
export type UpdatePluginLogLevelData = {
|
||||
body: {
|
||||
/**
|
||||
* Log level name, or null to follow the global level.
|
||||
*/
|
||||
level?: ('DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL') | null;
|
||||
[key: string]: unknown | string;
|
||||
};
|
||||
path: {
|
||||
plugin_id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatePluginLogLevelResponse = (SuccessEnvelope);
|
||||
|
||||
export type UpdatePluginLogLevelError = unknown;
|
||||
|
||||
export type GetPluginConfigSchemaData = {
|
||||
path: {
|
||||
plugin_id: string;
|
||||
|
||||
@@ -1259,6 +1259,17 @@ export const pluginApi = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
updateLogLevel(
|
||||
pluginId: string,
|
||||
level: "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL" | null,
|
||||
) {
|
||||
return typed<OpenConfig>(
|
||||
openApiV1.updatePluginLogLevel({
|
||||
path: { plugin_id: pluginId },
|
||||
body: { level },
|
||||
}),
|
||||
);
|
||||
},
|
||||
listConfigFiles(pluginId: string, configKey: string) {
|
||||
return typed<any>(
|
||||
openApiV1.listPluginConfigFilesById({
|
||||
|
||||
@@ -196,7 +196,12 @@
|
||||
},
|
||||
"config": {
|
||||
"title": "Extension Configuration",
|
||||
"noConfig": "This extension has no configuration"
|
||||
"noConfig": "This extension has no additional configuration",
|
||||
"coreSettings": {
|
||||
"logLevel": "Log Level",
|
||||
"logLevelHint": "Only affects this plugin's log output. Takes effect immediately, no restart required.",
|
||||
"followGlobal": "Follow Global"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"title": "Loading...",
|
||||
@@ -264,6 +269,7 @@
|
||||
"refreshSuccess": "Extension list refreshed!",
|
||||
"refreshFailed": "Error occurred while refreshing extension list",
|
||||
"operationFailed": "Operation failed",
|
||||
"logLevelUpdated": "Log level updated and applied immediately",
|
||||
"reloadSuccess": "Reload successful",
|
||||
"reloadFailed": "Reload failed",
|
||||
"updateSuccess": "Update successful!",
|
||||
|
||||
@@ -195,7 +195,12 @@
|
||||
},
|
||||
"config": {
|
||||
"title": "Настройка плагина",
|
||||
"noConfig": "У этого плагина нет настраиваемых параметров"
|
||||
"noConfig": "У этого плагина нет других настраиваемых параметров",
|
||||
"coreSettings": {
|
||||
"logLevel": "Уровень логирования",
|
||||
"logLevelHint": "Влияет только на логи этого плагина. Применяется сразу, перезапуск не требуется.",
|
||||
"followGlobal": "Как глобально"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"title": "Загрузка...",
|
||||
@@ -263,6 +268,7 @@
|
||||
"refreshSuccess": "Список плагинов обновлен",
|
||||
"refreshFailed": "Ошибка при обновлении списка",
|
||||
"operationFailed": "Ошибка операции",
|
||||
"logLevelUpdated": "Уровень логирования обновлён и применён сразу",
|
||||
"reloadSuccess": "Перезагрузка завершена",
|
||||
"reloadFailed": "Ошибка перезагрузки",
|
||||
"updateSuccess": "Обновление завершено",
|
||||
|
||||
@@ -196,7 +196,12 @@
|
||||
},
|
||||
"config": {
|
||||
"title": "插件配置",
|
||||
"noConfig": "这个插件没有配置"
|
||||
"noConfig": "这个插件没有其他配置项",
|
||||
"coreSettings": {
|
||||
"logLevel": "日志级别",
|
||||
"logLevelHint": "仅影响该插件的日志输出,立即生效,无需重启",
|
||||
"followGlobal": "跟随全局"
|
||||
}
|
||||
},
|
||||
"loading": {
|
||||
"title": "加载中...",
|
||||
@@ -264,6 +269,7 @@
|
||||
"refreshSuccess": "插件列表已刷新!",
|
||||
"refreshFailed": "刷新插件列表时发生错误",
|
||||
"operationFailed": "操作失败",
|
||||
"logLevelUpdated": "日志级别已更新并实时生效",
|
||||
"reloadSuccess": "重载成功",
|
||||
"reloadFailed": "重载失败",
|
||||
"updateSuccess": "更新成功!",
|
||||
|
||||
@@ -128,6 +128,8 @@ const {
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
updatePluginLogLevel,
|
||||
pluginLogLevelSaving,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
@@ -171,6 +173,15 @@ const {
|
||||
searchDebounceTimer,
|
||||
} = pageState;
|
||||
|
||||
const logLevelItems = computed(() => [
|
||||
{ title: tm("dialogs.config.coreSettings.followGlobal"), value: null },
|
||||
{ title: "DEBUG", value: "DEBUG" },
|
||||
{ title: "INFO", value: "INFO" },
|
||||
{ title: "WARNING", value: "WARNING" },
|
||||
{ title: "ERROR", value: "ERROR" },
|
||||
{ title: "CRITICAL", value: "CRITICAL" },
|
||||
]);
|
||||
|
||||
const selectedPluginId = computed(() => {
|
||||
const pluginId = route.params.pluginId;
|
||||
return Array.isArray(pluginId) ? pluginId[0] : pluginId || "";
|
||||
@@ -431,6 +442,30 @@ const updateDialogPluginLogo = computed(() => {
|
||||
tm("dialogs.config.title")
|
||||
}}</v-card-title>
|
||||
<v-card-text>
|
||||
<div
|
||||
class="d-flex align-center justify-space-between flex-wrap"
|
||||
style="gap: 12px"
|
||||
>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ tm("dialogs.config.coreSettings.logLevel") }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ tm("dialogs.config.coreSettings.logLevelHint") }}
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
:model-value="extension_config.log_level"
|
||||
:items="logLevelItems"
|
||||
:loading="pluginLogLevelSaving"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 220px; min-width: 180px"
|
||||
@update:model-value="updatePluginLogLevel"
|
||||
></v-select>
|
||||
</div>
|
||||
<v-divider class="my-4"></v-divider>
|
||||
<div style="max-height: 60vh; overflow-y: auto; padding-right: 8px">
|
||||
<AstrBotConfig
|
||||
v-if="extension_config.metadata"
|
||||
@@ -445,9 +480,13 @@ const updateDialogPluginLogo = computed(() => {
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="updateConfig">{{
|
||||
tm("buttons.saveAndClose")
|
||||
}}</v-btn>
|
||||
<v-btn
|
||||
v-if="extension_config.metadata"
|
||||
color="blue-darken-1"
|
||||
variant="text"
|
||||
@click="updateConfig"
|
||||
>{{ tm("buttons.saveAndClose") }}</v-btn
|
||||
>
|
||||
<v-btn
|
||||
color="blue-darken-1"
|
||||
variant="text"
|
||||
|
||||
@@ -94,7 +94,9 @@ export const useExtensionPage = () => {
|
||||
metadata: {},
|
||||
config: {},
|
||||
i18n: {},
|
||||
log_level: null,
|
||||
});
|
||||
const pluginLogLevelSaving = ref(false);
|
||||
const pluginMarketData = ref([]);
|
||||
const loadingDialog = reactive({
|
||||
show: false,
|
||||
@@ -1084,16 +1086,58 @@ export const useExtensionPage = () => {
|
||||
curr_namespace.value = extension_name;
|
||||
currentConfigPlugin.value = extension_name;
|
||||
configDialog.value = true;
|
||||
extension_config.log_level = null;
|
||||
try {
|
||||
const res = await pluginApi.config(extension_name);
|
||||
// Discard the response if the user has already switched to another
|
||||
// plugin's config dialog while the request was in flight.
|
||||
if (curr_namespace.value !== extension_name) return;
|
||||
extension_config.metadata = res.data.data.metadata;
|
||||
extension_config.config = res.data.data.config;
|
||||
extension_config.i18n = res.data.data.i18n || {};
|
||||
extension_config.log_level = res.data.data.log_level ?? null;
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const updatePluginLogLevel = async (level) => {
|
||||
if (pluginLogLevelSaving.value) return;
|
||||
const pluginName = curr_namespace.value;
|
||||
const previous = extension_config.log_level;
|
||||
extension_config.log_level = level;
|
||||
pluginLogLevelSaving.value = true;
|
||||
try {
|
||||
const res = await pluginApi.updateLogLevel(pluginName, level);
|
||||
if (res.data.status === "ok") {
|
||||
const serverLevel = res.data.data?.log_level;
|
||||
// Preserve null as "follow global" and only skip synchronization
|
||||
// when an older server does not return the field.
|
||||
if (
|
||||
curr_namespace.value === pluginName &&
|
||||
serverLevel !== undefined
|
||||
) {
|
||||
extension_config.log_level = serverLevel;
|
||||
}
|
||||
toast(tm("messages.logLevelUpdated"), "success");
|
||||
} else {
|
||||
// Roll back the optimistic update, unless the dialog has already
|
||||
// been switched to another plugin.
|
||||
if (curr_namespace.value === pluginName) {
|
||||
extension_config.log_level = previous;
|
||||
}
|
||||
toast(res.data.message || tm("messages.operationFailed"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
if (curr_namespace.value === pluginName) {
|
||||
extension_config.log_level = previous;
|
||||
}
|
||||
toast(err, "error");
|
||||
} finally {
|
||||
pluginLogLevelSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = async () => {
|
||||
try {
|
||||
const res = await pluginApi.updateConfig(
|
||||
@@ -1110,6 +1154,7 @@ export const useExtensionPage = () => {
|
||||
extension_config.metadata = {};
|
||||
extension_config.config = {};
|
||||
extension_config.i18n = {};
|
||||
extension_config.log_level = null;
|
||||
getExtensions();
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
@@ -2465,6 +2510,8 @@ export const useExtensionPage = () => {
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
updatePluginLogLevel,
|
||||
pluginLogLevelSaving,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
|
||||
@@ -93,6 +93,7 @@ class MyPlugin(Star):
|
||||
3. 在 `__init__` 方法中会传入 `Context` 对象,这个对象包含了 AstrBot 的大多数组件
|
||||
4. 具体的处理函数 `Handler` 在插件类中定义,如这里的 `helloworld` 函数。
|
||||
5. 请务必使用 `from astrbot.api import logger` 来获取日志对象,而不是使用 `logging` 模块。
|
||||
6. 每个插件在 `__init__` 后会自动拥有独立的 `self.logger`。这个 logger 的等级可以在 WebUI 的插件配置弹窗中单独设置,不会影响其他插件和核心。也可以继续使用 `from astrbot.api import logger`,它会根据调用位置自动路由到当前插件的 logger。
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
|
||||
@@ -2075,6 +2075,32 @@ paths:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
|
||||
/api/v1/plugins/{plugin_id}/log-level:
|
||||
put:
|
||||
tags: [Plugins]
|
||||
summary: Set plugin log level
|
||||
description: Set the log level of a plugin. Pass null to follow the global log level.
|
||||
operationId: updatePluginLogLevel
|
||||
x-astrbot-scope: plugin
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PluginId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
level:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [DEBUG, INFO, WARNING, ERROR, CRITICAL, null]
|
||||
description: Log level name, or null to follow the global level.
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
|
||||
/api/v1/plugins/{plugin_id}/config/schema:
|
||||
get:
|
||||
tags: [Plugins]
|
||||
|
||||
@@ -2843,6 +2843,11 @@ async def test_v1_safe_plugin_routes_accept_slash_ids(
|
||||
params={"plugin_id": plugin_id},
|
||||
headers=headers,
|
||||
)
|
||||
config_response = await asgi_client.get(
|
||||
"/api/v1/plugins/config",
|
||||
params={"plugin_id": plugin_id},
|
||||
headers=headers,
|
||||
)
|
||||
config_files_response = await asgi_client.get(
|
||||
"/api/v1/plugins/config-files",
|
||||
params={"plugin_id": plugin_id, "config_key": "assets/path"},
|
||||
@@ -2863,6 +2868,12 @@ async def test_v1_safe_plugin_routes_accept_slash_ids(
|
||||
}
|
||||
assert readme_response.status_code == 200
|
||||
assert readme_response.json()["data"]["name"] == plugin_id
|
||||
assert config_response.status_code == 200
|
||||
assert config_response.json()["data"] == {
|
||||
"plugin_name": plugin_id,
|
||||
"log_level": None,
|
||||
"schema": {"name": plugin_id},
|
||||
}
|
||||
assert schema_response.status_code == 200
|
||||
assert schema_response.json()["data"]["plugin_name"] == plugin_id
|
||||
assert config_files_response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for the plugin-aware logger exposed by ``astrbot.api``."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import astrbot.api as api
|
||||
from astrbot.core.log import LogBroker, LogManager, LogQueueHandler
|
||||
from astrbot.core.star import Star
|
||||
from astrbot.core.star.star import StarMetadata, star_map
|
||||
|
||||
|
||||
def test_plugin_logger_cache_refreshes_after_plugin_rename(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure a cached logger follows the latest plugin metadata name.
|
||||
|
||||
Args:
|
||||
monkeypatch: Pytest fixture used to isolate the plugin registry change.
|
||||
"""
|
||||
module_path = "data.plugins.cache_refresh.main"
|
||||
caller_module = "data.plugins.cache_refresh.helpers"
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||
monkeypatch.setitem(
|
||||
star_map,
|
||||
module_path,
|
||||
StarMetadata(name="old_name", module_path=module_path),
|
||||
)
|
||||
api._logger_cache.pop(caller_module, None)
|
||||
|
||||
try:
|
||||
old_logger = api._resolve_caller_logger(caller_module)
|
||||
star_map[module_path] = StarMetadata(
|
||||
name="new_name",
|
||||
module_path=module_path,
|
||||
)
|
||||
new_logger = api._resolve_caller_logger(caller_module)
|
||||
|
||||
assert old_logger.name == "astrbot.plugin.old_name"
|
||||
assert new_logger.name == "astrbot.plugin.new_name"
|
||||
finally:
|
||||
api._logger_cache.pop(caller_module, None)
|
||||
|
||||
|
||||
def test_global_level_sync_updates_plugin_loggers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure global level syncing updates plugins without overrides.
|
||||
|
||||
Args:
|
||||
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||
"""
|
||||
plugin_name = "mock_level_sync"
|
||||
plugin_logger = logging.getLogger(f"astrbot.plugin.{plugin_name}")
|
||||
previous_level = plugin_logger.level
|
||||
global_logger = logging.Logger("global_level_sync")
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", {plugin_name})
|
||||
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||
|
||||
try:
|
||||
LogManager.configure_logger(global_logger, {"log_level": "WARNING"})
|
||||
|
||||
assert global_logger.level == logging.WARNING
|
||||
assert plugin_logger.level == logging.WARNING
|
||||
finally:
|
||||
plugin_logger.setLevel(previous_level)
|
||||
|
||||
|
||||
def test_legacy_plugin_without_super_uses_dedicated_logger(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure the historical API logger works without ``Star.__init__``.
|
||||
|
||||
Args:
|
||||
monkeypatch: Pytest fixture used to isolate logger and registry state.
|
||||
"""
|
||||
module_path = "data.plugins.legacy_logger.main"
|
||||
namespace = {
|
||||
"__name__": module_path,
|
||||
"Star": Star,
|
||||
"logger": api.logger,
|
||||
}
|
||||
exec(
|
||||
"class LegacyPlugin(Star):\n"
|
||||
" def __init__(self, context):\n"
|
||||
" self.context = context\n"
|
||||
" def logger_state(self):\n"
|
||||
" return (\n"
|
||||
" logger.name,\n"
|
||||
" logger.getEffectiveLevel(),\n"
|
||||
" logger.isEnabledFor(20),\n"
|
||||
" logger.isEnabledFor(30),\n"
|
||||
" )\n",
|
||||
namespace,
|
||||
)
|
||||
star_map[module_path].name = "legacy_logger"
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||
monkeypatch.setattr(LogManager, "_log_broker", None)
|
||||
monkeypatch.setattr(
|
||||
LogManager,
|
||||
"_plugin_level_overrides",
|
||||
{"legacy_logger": "WARNING"},
|
||||
)
|
||||
api._logger_cache.pop(module_path, None)
|
||||
|
||||
try:
|
||||
plugin = namespace["LegacyPlugin"](object())
|
||||
|
||||
assert plugin.logger_state() == (
|
||||
"astrbot.plugin.legacy_logger",
|
||||
logging.WARNING,
|
||||
False,
|
||||
True,
|
||||
)
|
||||
assert "legacy_logger" in LogManager._plugin_logger_names
|
||||
finally:
|
||||
api._logger_cache.pop(module_path, None)
|
||||
|
||||
|
||||
def test_queue_handler_only_scans_plugins_for_global_logger(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure registering a plugin logger does not rescan every plugin.
|
||||
|
||||
Args:
|
||||
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||
"""
|
||||
broker = LogBroker()
|
||||
existing_name = "queue_handler_existing"
|
||||
existing_logger = logging.getLogger(f"astrbot.plugin.{existing_name}")
|
||||
previous_handlers = existing_logger.handlers.copy()
|
||||
previous_filters = existing_logger.filters.copy()
|
||||
existing_logger.handlers.clear()
|
||||
existing_logger.filters.clear()
|
||||
plugin_logger = logging.Logger("astrbot.plugin.queue_handler_new")
|
||||
global_logger = logging.Logger("astrbot")
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", {existing_name})
|
||||
monkeypatch.setattr(LogManager, "_log_broker", None)
|
||||
|
||||
try:
|
||||
LogManager.set_queue_handler(plugin_logger, broker)
|
||||
|
||||
assert any(
|
||||
isinstance(handler, LogQueueHandler) for handler in plugin_logger.handlers
|
||||
)
|
||||
assert not any(
|
||||
isinstance(handler, LogQueueHandler) for handler in existing_logger.handlers
|
||||
)
|
||||
|
||||
LogManager.set_queue_handler(global_logger, broker)
|
||||
|
||||
assert any(
|
||||
isinstance(handler, LogQueueHandler) for handler in global_logger.handlers
|
||||
)
|
||||
assert any(
|
||||
isinstance(handler, LogQueueHandler) for handler in existing_logger.handlers
|
||||
)
|
||||
finally:
|
||||
existing_logger.handlers[:] = previous_handlers
|
||||
existing_logger.filters[:] = previous_filters
|
||||
|
||||
|
||||
def test_plugin_log_level_is_persisted_atomically(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure a plugin log-level update atomically replaces its config file.
|
||||
|
||||
Args:
|
||||
tmp_path: Temporary directory for the persisted configuration.
|
||||
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||
"""
|
||||
config_path = tmp_path / "plugin_log_levels.json"
|
||||
monkeypatch.setattr(
|
||||
LogManager,
|
||||
"_plugin_log_levels_path",
|
||||
MagicMock(return_value=config_path),
|
||||
)
|
||||
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||
|
||||
LogManager.set_plugin_log_level("atomic_plugin", "warning")
|
||||
|
||||
assert json.loads(config_path.read_text(encoding="utf-8")) == {
|
||||
"atomic_plugin": "WARNING"
|
||||
}
|
||||
assert LogManager._plugin_level_overrides == {"atomic_plugin": "WARNING"}
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_plugin_log_level_write_failure_preserves_previous_state(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Ensure a failed atomic replace preserves disk and memory state.
|
||||
|
||||
Args:
|
||||
tmp_path: Temporary directory for the persisted configuration.
|
||||
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||
"""
|
||||
config_path = tmp_path / "plugin_log_levels.json"
|
||||
config_path.write_text('{"existing_plugin": "INFO"}', encoding="utf-8")
|
||||
previous_overrides = {"existing_plugin": "INFO"}
|
||||
monkeypatch.setattr(
|
||||
LogManager,
|
||||
"_plugin_log_levels_path",
|
||||
MagicMock(return_value=config_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
LogManager,
|
||||
"_plugin_level_overrides",
|
||||
previous_overrides,
|
||||
)
|
||||
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||
|
||||
with (
|
||||
patch.object(Path, "replace", side_effect=OSError("replace failed")),
|
||||
pytest.raises(OSError, match="replace failed"),
|
||||
):
|
||||
LogManager.set_plugin_log_level("new_plugin", "ERROR")
|
||||
|
||||
assert json.loads(config_path.read_text(encoding="utf-8")) == previous_overrides
|
||||
assert LogManager._plugin_level_overrides is previous_overrides
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for AstrBotCoreLifecycle."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -609,6 +610,7 @@ class TestAstrBotCoreLifecycleInitialize:
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
mock_logger.level = logging.INFO
|
||||
# Should not raise, just log the error
|
||||
await lifecycle.initialize()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for astrbot.core.star.base module."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -31,6 +32,33 @@ class TestStarBase:
|
||||
|
||||
assert star.context is mock_context
|
||||
|
||||
def test_star_init_logs_plugin_level(self):
|
||||
"""Test that Star initialization reports the effective plugin level."""
|
||||
from astrbot.core.star import Star
|
||||
|
||||
mock_context = MagicMock()
|
||||
plugin_logger = MagicMock(spec=logging.Logger)
|
||||
plugin_logger.getEffectiveLevel.return_value = logging.WARNING
|
||||
|
||||
class TestLevelStar(Star):
|
||||
name = "test_level_star"
|
||||
author = "test_author"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"astrbot.core.star.base.LogManager.get_plugin_logger",
|
||||
return_value=plugin_logger,
|
||||
),
|
||||
patch("astrbot.core.star.base.logger") as core_logger,
|
||||
):
|
||||
TestLevelStar(context=mock_context)
|
||||
|
||||
core_logger.info.assert_called_once_with(
|
||||
"Plugin %s log level: %s.",
|
||||
"test_level_star",
|
||||
"WARNING",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_to_image_with_config(self):
|
||||
"""Test text_to_image method with valid config."""
|
||||
|
||||
Reference in New Issue
Block a user