Skip to content

feat: use loguru for enhanced logging support#5115

Merged
Soulter merged 1 commit intomasterfrom
feat/loguru
Feb 15, 2026
Merged

feat: use loguru for enhanced logging support#5115
Soulter merged 1 commit intomasterfrom
feat/loguru

Conversation

@Soulter
Copy link
Member

@Soulter Soulter commented Feb 15, 2026

using loguru.

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

在保留 AstrBot 特有的元数据和 WebUI 控制台集成的前提下,将现有的日志设置替换为基于 Loguru 的实现。

新功能:

  • 引入 Loguru 作为核心日志后端,包括带有结构化格式和插件感知元数据的控制台与滚动文件 sink。
  • 将标准 logging 模块桥接到 Loguru,使核心和插件的日志记录都通过统一的日志流水线。
  • 为 WebUI 控制台输出新增带 ANSI 颜色的日志条目,包括按级别区分的颜色前缀和增强的上下文字段。

增强:

  • 重构 LogManager,以集中管理 Loguru sink、跟踪日志配置以及嘈杂的第三方 logger 级别。
  • 简化 LogBroker 和 LogQueueHandler 的职责,同时保留为订阅者分发缓存日志的能力。
  • 调整 WebUI 控制台的颜色映射,以与新的日志配色方案保持一致。

构建:

  • requirements.txtpyproject.toml 中用 loguru 替换 colorlog 依赖。
Original summary in English

Summary by Sourcery

Replace the existing logging setup with a Loguru-based implementation while preserving AstrBot-specific metadata and WebUI console integration.

New Features:

  • Introduce Loguru as the central logging backend, including console and rotating file sinks with structured formatting and plugin-aware metadata.
  • Bridge the standard logging module to Loguru so both core and plugin loggers flow through a unified logging pipeline.
  • Add ANSI-colored log entries for WebUI console consumption, including per-level color prefixes and enriched context fields.

Enhancements:

  • Refactor LogManager to manage Loguru sinks, trace logging configuration, and noisy-third-party logger levels in a centralized way.
  • Simplify LogBroker and LogQueueHandler responsibilities while keeping cached log distribution for subscribers.
  • Adjust WebUI console color mapping to align with the new logging color scheme.

Build:

  • Replace the colorlog dependency with loguru in requirements.txt and pyproject.toml.

@dosubot dosubot bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Feb 15, 2026
@dosubot
Copy link

dosubot bot commented Feb 15, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 1 个问题,并给出了一些整体性的反馈:

  • LogManager 中的全局 _file_sink_id_trace_sink_id 意味着在整个进程中只能有一个普通/trace 文件 sink 处于活动状态;如果不同的 logger 以后需要输出到不同的文件,建议改为按 logger 维度跟踪 sink ID(例如使用以 logger 名称为键的字典),而不是使用全局变量。
  • _add_file_sink 中,对整数调用 logging.getLevelName(level) 会得到类似 'Level 0' 的字符串,而这些并不是合法的 Loguru 日志级别名称;更安全的做法是要么直接将整数传给 Loguru,要么在调用 loguru.add 之前显式将其映射到已知的级别字符串(DEBUG/INFO 等)。
给 AI Agents 的提示
Please address the comments from this code review:

## Overall Comments
- `LogManager` 中的全局 `_file_sink_id``_trace_sink_id` 意味着在整个进程中只能有一个普通/trace 文件 sink 处于活动状态;如果不同的 logger 以后需要输出到不同的文件,建议改为按 logger 维度跟踪 sink ID(例如使用以 logger 名称为键的字典),而不是使用全局变量。
-`_add_file_sink` 中,对整数调用 `logging.getLevelName(level)` 会得到类似 `'Level 0'` 的字符串,而这些并不是合法的 Loguru 日志级别名称;更安全的做法是要么直接将整数传给 Loguru,要么在调用 `loguru.add` 之前显式将其映射到已知的级别字符串(DEBUG/INFO 等)。

## Individual Comments

### Comment 1
<location> `astrbot/core/log.py:23` </location>
<code_context>

-    Args:
-        pathname (str): 文件路径
+class _RecordEnricherFilter(logging.Filter):
+    """为 logging.LogRecord 注入 AstrBot 日志字段。"""

</code_context>

<issue_to_address>
**issue (complexity):** 请考虑将计算 AstrBot 特定日志扩展字段的逻辑集中到一个单独的辅助函数中,然后在所有富化路径中复用它,以避免重复和行为漂移。

你目前在三个位置重复了“扩展字段”富化逻辑,从而付出了不必要的复杂度成本:

- `_RecordEnricherFilter.filter`
- `_patch_record`
- `_LoguruInterceptHandler.emit`(构造 `payload` 的地方)

它们在概念上都在计算相同的一组字段:

- `plugin_tag`
- `short_levelname`
- `astrbot_version_tag`
- `source_file`
- `source_line`
- `is_trace`

但通过了不同的代码路径和默认值。这是造成心智负担和行为漂移风险的主要原因。

你可以在保留所有现有特性(loguru 集成、WebUI 颜色、trace 标记等)的前提下,通过把富化逻辑集中到一个辅助函数中,并在 stdlib 和 loguru 的路径里统一使用它,从而简化实现。

### 1. 引入一个统一的富化辅助函数

```python
def _compute_astrbot_extra(
    *,
    pathname: str | None,
    levelname: str,
    levelno: int,
    lineno: int,
    logger_name: str,
    is_trace: bool | None = None,
) -> dict:
    return {
        "plugin_tag": "[Plug]" if _is_plugin_path(pathname) else "[Core]",
        "short_levelname": _get_short_level_name(levelname),
        "astrbot_version_tag": f" [v{VERSION}]" if levelno >= logging.WARNING else "",
        "source_file": _build_source_file(pathname),
        "source_line": lineno,
        "is_trace": logger_name == "astrbot.trace" if is_trace is None else is_trace,
    }
```

### 2. 在 `_RecordEnricherFilter` 中使用它

```python
class _RecordEnricherFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        extra = _compute_astrbot_extra(
            pathname=record.pathname,
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=record.lineno,
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )
        for k, v in extra.items():
            setattr(record, k, v)
        return True
```

### 3. 在 `_LoguruInterceptHandler` 中使用它来替代手动构造 `payload`

```python
class _LoguruInterceptHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        try:
            level: str | int = _loguru.level(record.levelname).name
        except ValueError:
            level = record.levelno

        extra = _compute_astrbot_extra(
            pathname=getattr(record, "pathname", None),
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=getattr(record, "lineno", 0),
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )

        # allow pre-populated attributes (e.g. from custom loggers) to override defaults
        for key, value in extra.items():
            extra[key] = getattr(record, key, value)

        _loguru.bind(**extra).opt(exception=record.exc_info).log(
            level,
            record.getMessage(),
        )
```

### 4. 在 `_patch_record` 中同样复用该辅助函数

```python
def _patch_record(record: "Record") -> None:
    extra = record["extra"]
    computed = _compute_astrbot_extra(
        pathname=record["file"].path,
        levelname=record["level"].name,
        levelno=record["level"].no,
        lineno=record["line"],
        logger_name=record["name"],
        is_trace=extra.get("is_trace"),
    )

    for key, value in computed.items():
        extra.setdefault(key, value)
```

这样可以保持现有行为不变,同时:

- 去除用于计算同一组字段的重复逻辑。
- 确保 `plugin_tag``short_levelname``astrbot_version_tag``source_file``source_line``is_trace` 在以下场景中以一致的方式计算:
  -`logging` logger → 通过 `_RecordEnricherFilter`
  -`logging` 桥接到 loguru → 通过 `_LoguruInterceptHandler`
  - 直接使用 loguru → 通过 `_patch_record`
- 使这些字段的未来更改(例如增加 `module`,或修改版本标记规则)集中在一个函数中完成。
</issue_to_address>

Sourcery 对开源项目免费使用——如果你觉得我们的评审有帮助,欢迎分享给更多人 ✨
为了让我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续评审。
Original comment in English

Hey - I've found 1 issue, and left some high level feedback:

  • The global _file_sink_id and _trace_sink_id in LogManager mean only a single normal/trace file sink can be active across the whole process; if different loggers ever need different file outputs, consider tracking sink IDs per logger (e.g., in a dict keyed by logger name) instead of globally.
  • In _add_file_sink, using logging.getLevelName(level) on an integer can produce strings like 'Level 0' which are not valid Loguru level names; it would be safer to either pass the integer directly to Loguru or map explicitly to known level strings (DEBUG/INFO/etc.) before calling loguru.add.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The global `_file_sink_id` and `_trace_sink_id` in `LogManager` mean only a single normal/trace file sink can be active across the whole process; if different loggers ever need different file outputs, consider tracking sink IDs per logger (e.g., in a dict keyed by logger name) instead of globally.
- In `_add_file_sink`, using `logging.getLevelName(level)` on an integer can produce strings like `'Level 0'` which are not valid Loguru level names; it would be safer to either pass the integer directly to Loguru or map explicitly to known level strings (DEBUG/INFO/etc.) before calling `loguru.add`.

## Individual Comments

### Comment 1
<location> `astrbot/core/log.py:23` </location>
<code_context>

-    Args:
-        pathname (str): 文件路径
+class _RecordEnricherFilter(logging.Filter):
+    """为 logging.LogRecord 注入 AstrBot 日志字段。"""

</code_context>

<issue_to_address>
**issue (complexity):** Consider centralizing the logic that computes AstrBot-specific log extra fields into a single helper function and reusing it across all enrichment paths to avoid duplication and drift.

You’re paying a complexity cost from duplicating the “extra field” enrichment logic in three places:

- `_RecordEnricherFilter.filter`
- `_patch_record`
- `_LoguruInterceptHandler.emit` (the `payload` construction)

They all compute the same conceptual fields:

- `plugin_tag`
- `short_levelname`
- `astrbot_version_tag`
- `source_file`
- `source_line`
- `is_trace`

but via different code paths and defaults. This is the main source of mental overhead and risk of drift.

You can keep all features (loguru integration, WebUI colors, trace flag, etc.) while simplifying by centralizing the enrichment into a single helper and using it from both stdlib and loguru paths.

### 1. Introduce a single enrichment helper

```python
def _compute_astrbot_extra(
    *,
    pathname: str | None,
    levelname: str,
    levelno: int,
    lineno: int,
    logger_name: str,
    is_trace: bool | None = None,
) -> dict:
    return {
        "plugin_tag": "[Plug]" if _is_plugin_path(pathname) else "[Core]",
        "short_levelname": _get_short_level_name(levelname),
        "astrbot_version_tag": f" [v{VERSION}]" if levelno >= logging.WARNING else "",
        "source_file": _build_source_file(pathname),
        "source_line": lineno,
        "is_trace": logger_name == "astrbot.trace" if is_trace is None else is_trace,
    }
```

### 2. Use it in `_RecordEnricherFilter`

```python
class _RecordEnricherFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        extra = _compute_astrbot_extra(
            pathname=record.pathname,
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=record.lineno,
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )
        for k, v in extra.items():
            setattr(record, k, v)
        return True
```

### 3. Use it in `_LoguruInterceptHandler` instead of manually building `payload`

```python
class _LoguruInterceptHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        try:
            level: str | int = _loguru.level(record.levelname).name
        except ValueError:
            level = record.levelno

        extra = _compute_astrbot_extra(
            pathname=getattr(record, "pathname", None),
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=getattr(record, "lineno", 0),
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )

        # allow pre-populated attributes (e.g. from custom loggers) to override defaults
        for key, value in extra.items():
            extra[key] = getattr(record, key, value)

        _loguru.bind(**extra).opt(exception=record.exc_info).log(
            level,
            record.getMessage(),
        )
```

### 4. Use the same helper in `_patch_record`

```python
def _patch_record(record: "Record") -> None:
    extra = record["extra"]
    computed = _compute_astrbot_extra(
        pathname=record["file"].path,
        levelname=record["level"].name,
        levelno=record["level"].no,
        lineno=record["line"],
        logger_name=record["name"],
        is_trace=extra.get("is_trace"),
    )

    for key, value in computed.items():
        extra.setdefault(key, value)
```

This keeps behavior intact but:

- Removes duplicated logic for computing the same fields.
- Ensures `plugin_tag`, `short_levelname`, `astrbot_version_tag`, `source_file`, `source_line`, and `is_trace` are computed consistently for:
  - pure `logging` loggers → via `_RecordEnricherFilter`
  - bridged `logging` → loguru → via `_LoguruInterceptHandler`
  - direct loguru usage → via `_patch_record`
- Makes future changes to these fields (e.g. adding `module`, changing version-tag rule) localized to a single function.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.


Args:
pathname (str): 文件路径
class _RecordEnricherFilter(logging.Filter):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): 请考虑将计算 AstrBot 特定日志扩展字段的逻辑集中到一个单独的辅助函数中,然后在所有富化路径中复用它,以避免重复和行为漂移。

你目前在三个位置重复了“扩展字段”富化逻辑,从而付出了不必要的复杂度成本:

  • _RecordEnricherFilter.filter
  • _patch_record
  • _LoguruInterceptHandler.emit(构造 payload 的地方)

它们在概念上都在计算相同的一组字段:

  • plugin_tag
  • short_levelname
  • astrbot_version_tag
  • source_file
  • source_line
  • is_trace

但通过了不同的代码路径和默认值。这是造成心智负担和行为漂移风险的主要原因。

你可以在保留所有现有特性(loguru 集成、WebUI 颜色、trace 标记等)的前提下,通过把富化逻辑集中到一个辅助函数中,并在 stdlib 和 loguru 的路径里统一使用它,从而简化实现。

1. 引入一个统一的富化辅助函数

def _compute_astrbot_extra(
    *,
    pathname: str | None,
    levelname: str,
    levelno: int,
    lineno: int,
    logger_name: str,
    is_trace: bool | None = None,
) -> dict:
    return {
        "plugin_tag": "[Plug]" if _is_plugin_path(pathname) else "[Core]",
        "short_levelname": _get_short_level_name(levelname),
        "astrbot_version_tag": f" [v{VERSION}]" if levelno >= logging.WARNING else "",
        "source_file": _build_source_file(pathname),
        "source_line": lineno,
        "is_trace": logger_name == "astrbot.trace" if is_trace is None else is_trace,
    }

2. 在 _RecordEnricherFilter 中使用它

class _RecordEnricherFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        extra = _compute_astrbot_extra(
            pathname=record.pathname,
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=record.lineno,
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )
        for k, v in extra.items():
            setattr(record, k, v)
        return True

3. 在 _LoguruInterceptHandler 中使用它来替代手动构造 payload

class _LoguruInterceptHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        try:
            level: str | int = _loguru.level(record.levelname).name
        except ValueError:
            level = record.levelno

        extra = _compute_astrbot_extra(
            pathname=getattr(record, "pathname", None),
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=getattr(record, "lineno", 0),
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )

        # allow pre-populated attributes (e.g. from custom loggers) to override defaults
        for key, value in extra.items():
            extra[key] = getattr(record, key, value)

        _loguru.bind(**extra).opt(exception=record.exc_info).log(
            level,
            record.getMessage(),
        )

4. 在 _patch_record 中同样复用该辅助函数

def _patch_record(record: "Record") -> None:
    extra = record["extra"]
    computed = _compute_astrbot_extra(
        pathname=record["file"].path,
        levelname=record["level"].name,
        levelno=record["level"].no,
        lineno=record["line"],
        logger_name=record["name"],
        is_trace=extra.get("is_trace"),
    )

    for key, value in computed.items():
        extra.setdefault(key, value)

这样可以保持现有行为不变,同时:

  • 去除用于计算同一组字段的重复逻辑。
  • 确保 plugin_tagshort_levelnameastrbot_version_tagsource_filesource_lineis_trace 在以下场景中以一致的方式计算:
    • logging logger → 通过 _RecordEnricherFilter
    • logging 桥接到 loguru → 通过 _LoguruInterceptHandler
    • 直接使用 loguru → 通过 _patch_record
  • 使这些字段的未来更改(例如增加 module,或修改版本标记规则)集中在一个函数中完成。
Original comment in English

issue (complexity): Consider centralizing the logic that computes AstrBot-specific log extra fields into a single helper function and reusing it across all enrichment paths to avoid duplication and drift.

You’re paying a complexity cost from duplicating the “extra field” enrichment logic in three places:

  • _RecordEnricherFilter.filter
  • _patch_record
  • _LoguruInterceptHandler.emit (the payload construction)

They all compute the same conceptual fields:

  • plugin_tag
  • short_levelname
  • astrbot_version_tag
  • source_file
  • source_line
  • is_trace

but via different code paths and defaults. This is the main source of mental overhead and risk of drift.

You can keep all features (loguru integration, WebUI colors, trace flag, etc.) while simplifying by centralizing the enrichment into a single helper and using it from both stdlib and loguru paths.

1. Introduce a single enrichment helper

def _compute_astrbot_extra(
    *,
    pathname: str | None,
    levelname: str,
    levelno: int,
    lineno: int,
    logger_name: str,
    is_trace: bool | None = None,
) -> dict:
    return {
        "plugin_tag": "[Plug]" if _is_plugin_path(pathname) else "[Core]",
        "short_levelname": _get_short_level_name(levelname),
        "astrbot_version_tag": f" [v{VERSION}]" if levelno >= logging.WARNING else "",
        "source_file": _build_source_file(pathname),
        "source_line": lineno,
        "is_trace": logger_name == "astrbot.trace" if is_trace is None else is_trace,
    }

2. Use it in _RecordEnricherFilter

class _RecordEnricherFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        extra = _compute_astrbot_extra(
            pathname=record.pathname,
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=record.lineno,
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )
        for k, v in extra.items():
            setattr(record, k, v)
        return True

3. Use it in _LoguruInterceptHandler instead of manually building payload

class _LoguruInterceptHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        try:
            level: str | int = _loguru.level(record.levelname).name
        except ValueError:
            level = record.levelno

        extra = _compute_astrbot_extra(
            pathname=getattr(record, "pathname", None),
            levelname=record.levelname,
            levelno=record.levelno,
            lineno=getattr(record, "lineno", 0),
            logger_name=record.name,
            is_trace=getattr(record, "is_trace", None),
        )

        # allow pre-populated attributes (e.g. from custom loggers) to override defaults
        for key, value in extra.items():
            extra[key] = getattr(record, key, value)

        _loguru.bind(**extra).opt(exception=record.exc_info).log(
            level,
            record.getMessage(),
        )

4. Use the same helper in _patch_record

def _patch_record(record: "Record") -> None:
    extra = record["extra"]
    computed = _compute_astrbot_extra(
        pathname=record["file"].path,
        levelname=record["level"].name,
        levelno=record["level"].no,
        lineno=record["line"],
        logger_name=record["name"],
        is_trace=extra.get("is_trace"),
    )

    for key, value in computed.items():
        extra.setdefault(key, value)

This keeps behavior intact but:

  • Removes duplicated logic for computing the same fields.
  • Ensures plugin_tag, short_levelname, astrbot_version_tag, source_file, source_line, and is_trace are computed consistently for:
    • pure logging loggers → via _RecordEnricherFilter
    • bridged logging → loguru → via _LoguruInterceptHandler
    • direct loguru usage → via _patch_record
  • Makes future changes to these fields (e.g. adding module, changing version-tag rule) localized to a single function.

@dosubot dosubot bot added the area:core The bug / feature is about astrbot's core, backend label Feb 15, 2026
@Soulter Soulter merged commit c64e1b4 into master Feb 15, 2026
6 checks passed
@Soulter Soulter deleted the feat/loguru branch February 15, 2026 09:11
@Soulter Soulter changed the title feat: replace colorlog with loguru for enhanced logging support feat: use loguru for enhanced logging support Feb 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant