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:
Ruochen Pan
2026-07-24 10:03:55 +08:00
committed by GitHub
parent 2035dbd079
commit 10e5ca6958
19 changed files with 745 additions and 28 deletions
+65 -1
View File
@@ -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
View File
@@ -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))
+26
View File
@@ -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)
+34 -3
View File
@@ -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")
+5
View File
@@ -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;
+11
View File
@@ -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": "更新成功!",
+42 -3
View File
@@ -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,
+1
View File
@@ -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]
>
+26
View File
@@ -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]
+11
View File
@@ -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
+228
View File
@@ -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"))
+2
View File
@@ -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()
+28
View File
@@ -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."""