Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion astrbot/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,75 @@
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
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")
Comment thread
RC-CHN marked this conversation as resolved.
_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",
Expand Down
188 changes: 170 additions & 18 deletions astrbot/core/log.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 ""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -262,25 +283,147 @@ def GetLogger(cls, log_name: str = "default") -> logging.Logger:
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

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)
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)

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)
Comment thread
RC-CHN marked this conversation as resolved.

@classmethod
def _remove_sink(cls, sink_id: int | None) -> None:
Expand Down Expand Up @@ -353,6 +496,15 @@ def configure_logger(
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))
Expand Down
26 changes: 26 additions & 0 deletions astrbot/core/star/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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

Expand All @@ -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)
Expand Down
Loading
Loading