
在日常 Odoo 开发中,我们经常会遇到一个痛点:
日志全是白色、密密麻麻,异常堆栈不明显
看文件路径需要手动复制粘贴
werkzeug / Odoo / 3rd-party 日志格式还不统一
如果你在 VS Code 的终端里运行 Odoo,大概是这样的 👇
2026-01-06 03:38:55,432 51844 INFO ? odoo.addons...: You need Wkhtmltopdf...
Traceback (most recent call last):
...
说实话:
👉 问题能排,但效率不高。
🎯 本文目标:让 Odoo 日志变成这样
(VS Code 终端内 ↓)
-
🌈 日志带颜色(INFO / WARNING / ERROR 非常明显)
-
📎 右侧显示
file.py:line -
🖱 Ctrl+点击 → 直接跳到源码对应行
-
🧩 Odoo / werkzeug / 模块日志 格式统一
-
❌ 没有 "同一条日志打印两次" 的问题
效果类似:
[2026-01-06 03:33:37] INFO HTTP service running ... server.py:239
[2026-01-06 03:33:44] INFO Generating asset bundle ... assetsbundle.py:302
[2026-01-06 03:33:52] ERROR Something bad happened my_module.py:88
🔧 思路:用 Rich 接管 Odoo 日志系统
Python 的 logging 系统是分层级的 logger 树。
默认情况:
root
├── odoo
│ ├── odoo.modules.loading
│ ├── odoo.service.server
│ └── ...
└── werkzeug
Odoo 会注册自己的 handler(格式化日志)
导致:
-
输出"老样式"
-
颜色为零
-
有时同一条日志被打印两次
我们做的事情只有两步:
✔ 使用 Rich 渲染日志(支持颜色+可点击路径)
✔ 让 Odoo & werkzeug 的日志统统走这一个 handler
📁 一、创建启动脚本 run_odoo_rich.py
在你的项目根目录里:
project/
├── bin/
│ ├── odoo.conf
│ └── run_odoo_rich.py ← 新建这个
├── src/
│ └── odoo/
粘贴完整脚本(已经调试稳定):
📌 这个版本包含:
可点击路径修复(Windows-friendly
file:///C:/...)仅挂接必要 logger(避免重复输出)
彩色 traceback
统一 Odoo/werkzeug 日志格式
👉 完整代码见下:
python
# bin/run_odoo_rich.py
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from typing import Iterable, Optional, Union
from rich._log_render import FormatTimeCallable, LogRender
from rich.console import Console, RenderableType
from rich.table import Table
from rich.text import Text, TextType
from rich.logging import RichHandler
from rich.traceback import install
# ========= 让 Python 能找到 src/odoo =========
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ODOO_PATH = os.path.join(BASE_DIR, "src", "odoo")
if ODOO_PATH not in sys.path:
sys.path.insert(0, ODOO_PATH)
# ===========================================
class OdooLogRender(LogRender):
"""
自定义 LogRender:
把 Rich 默认的 file://C:\\... 改成合法的 file:///C:/... 超链接,
这样 VSCode 里右侧的 path 点击才不会报错。
"""
def __call__(
self,
console: "Console",
renderables: Iterable["RenderableType"],
log_time: Optional[datetime] = None,
time_format: Optional[Union[str, FormatTimeCallable]] = None,
level: TextType = "",
path: Optional[str] = None,
line_no: Optional[int] = None,
link_path: Optional[str] = None,
) -> "Table":
from rich.containers import Renderables # 避免循环导入
output = Table.grid(padding=(0, 5), pad_edge=True)
output.expand = True
if self.show_time:
output.add_column(style="log.time")
if self.show_level:
output.add_column(style="log.level")
output.add_column(style="log.message", ratio=1)
if self.show_path and path:
output.add_column(style="log.path")
row = []
# 时间列
if self.show_time:
if log_time is not None:
if isinstance(time_format, str) or time_format is None:
_fmt = time_format or "[%x %X]"
def _time_fmt(dt: datetime) -> str:
return dt.strftime(_fmt)
time_format_callable: FormatTimeCallable = _time_fmt
else:
time_format_callable = time_format
log_time_display = Text(time_format_callable(log_time))
else:
log_time_display = Text("")
if log_time_display == self._last_time and self.omit_repeated_times:
row.append(Text(" " * len(log_time_display)))
else:
row.append(log_time_display)
self._last_time = log_time_display
# 等级
if self.show_level:
row.append(level)
# 消息内容
row.append(Renderables(renderables))
# 右侧文件列(可点击)
if self.show_path and path:
path_text = Text()
url: Optional[str] = None
if link_path:
try:
url = Path(link_path).resolve().as_uri()
except Exception:
url = None
style_path = f"link {url}" if url else ""
style_line = f"link {url}#{line_no}" if (url and line_no) else style_path
path_text.append(path, style=style_path)
if line_no:
path_text.append(":")
path_text.append(str(line_no), style=style_line)
row.append(path_text)
output.add_row(*row)
return output
class OdooRichHandler(RichHandler):
def __init__(
self,
level=logging.NOTSET,
console=None,
*,
show_time=True,
omit_repeated_times=True,
show_level=True,
show_path=True,
enable_link_path=True,
markup=True,
rich_tracebacks=True,
log_time_format="[ %Y-%m-%d %H:%M:%S ]",
**kwargs,
):
super().__init__(
level=level,
console=console,
show_time=show_time,
omit_repeated_times=omit_repeated_times,
show_level=show_level,
show_path=show_path,
enable_link_path=enable_link_path,
markup=markup,
rich_tracebacks=rich_tracebacks,
log_time_format=log_time_format,
**kwargs,
)
self._log_render = OdooLogRender(
show_time=show_time,
show_level=show_level,
show_path=show_path,
time_format=log_time_format,
omit_repeated_times=omit_repeated_times,
)
install(show_locals=False)
LOG_LEVEL = logging.INFO
handler = OdooRichHandler(level=LOG_LEVEL)
# Odoo 日志
odoo_logger = logging.getLogger("odoo")
odoo_logger.handlers.clear()
odoo_logger.setLevel(LOG_LEVEL)
odoo_logger.addHandler(handler)
odoo_logger.propagate = False
# werkzeug 日志
werkzeug_logger = logging.getLogger("werkzeug")
werkzeug_logger.handlers.clear()
werkzeug_logger.setLevel(LOG_LEVEL)
werkzeug_logger.addHandler(handler)
werkzeug_logger.propagate = False
if __name__ == "__main__":
from odoo.cli import main
main()
▶️ 二、VS Code 启动 Odoo:走这个脚本
在 .vscode/launch.json 中:
jsonc
{
"program": "${workspaceFolder}/bin/run_odoo_rich.py",
"args": [
"-c",
"${workspaceFolder}/bin/odoo.conf"
],
"console": "integratedTerminal",
"justMyCode": false
}
按 F5,启动!
🧪 验证 5 个关键点
1️⃣ 彩色 INFO / WARNING / ERROR
有。
2️⃣ Traceback 彩色
异常时特别清晰。
3️⃣ 右侧 file.py:line
鼠标悬停 → Ctrl + 点击
✔ 直接跳进源码。
4️⃣ werkzeug 日志格式统一
请求日志和 Odoo 日志一样清爽。
5️⃣ 没有重复日志
(同一条不会一行老样式一行新样式)
🧨 常见坑(以及我们已经帮你避开的)
| 问题 | 原因 | 我们的解决 |
|---|---|---|
file://C:\... 点击报错 |
Windows file URL 不合法 | 转为 file:///C:/... |
| 同一日志打印两次 | logger 上挂了两个 handler | 清理 + 精准接管 |
| werkzeug 日志不统一 | 走 root handler | 独立接管 werkzeug |
| 路径不显示 | Rich 默认裁剪 | 自定义 LogRender |
| Traceback 没颜色 | 默认 logging | 启用 rich.traceback |
🎁 进阶
后续做:
-
SQL 语句单独高亮
-
某些 noisy 模块降级日志级别
-
根据数据库名做颜色分组
-
给这套日志输出写一个 toggle 开关