feat: unify runtime logging (rotating files, log root, scripts, desktop)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-14 10:11:51 +08:00
parent 86d4bad107
commit 1490bd5f24
25 changed files with 541 additions and 53 deletions

View file

@ -27,6 +27,8 @@ This repository is fully consolidated under `oclaw/`.
3. **助手主库(PostgreSQL,可选)**:默认使用本地 SQLite;若要将助手持久化迁到 PostgreSQL 或从零搭 PG,步骤与脚本说明见 `docs/ASSISTANT_PG_MIGRATION.md`(环境变量仍以 `docs/ENVIRONMENT_VARIABLES.md` 为准)。
4. **日志**:运行与轮转日志目录、排障时查看哪些文件,见 `docs/LOGGING.md`。
### 命令(仓库根目录执行)
```powershell
@ -129,6 +131,7 @@ WhatsApp install/start guide: see `docs/RUNBOOK.md` → “4.2 WhatsApp(实验
Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`.
Branding guide (external-first assets under `_local/branding`): `docs/BRANDING.md`.
Assistant main store: SQLite by default; optional PostgreSQL setup and SQLite→PG cutover: `docs/ASSISTANT_PG_MIGRATION.md`.
Runtime logging layout and troubleshooting: `docs/LOGGING.md`.
Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README.
@ -172,6 +175,7 @@ Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at
See `docs/ENVIRONMENT_VARIABLES.md` for full runtime variable reference.
PostgreSQL assistant-store migration (schema, data import, cutover): `docs/ASSISTANT_PG_MIGRATION.md`.
Logging directories and rotating files: `docs/LOGGING.md`.
## License and security

View file

@ -279,8 +279,21 @@ AIA_MAX_ATTACHMENT_BYTES=
# -----------------------------------------------------------------------------
# 十二、路由与运行时日志
# -----------------------------------------------------------------------------
# 详见 docs/LOGGING.md。根目录默认:<db_path 父目录>/logs(常为 data/logs/)。
AIA_OCLAW_ROUTER_MODE=
AIA_RUNTIME_LOG_DIR=
# OCLAW_DESKTOP_LOG_ROOT 可选;Electron 写 desktop.log/backend.log 的根;start_desktop.ps1 会设为与 AIA_RUNTIME_LOG_DIR 相同。
OCLAW_DESKTOP_LOG_ROOT=
# OCLAW_LOG_LEVEL / AIA_LOG_LEVEL 应用与 uvicorn 文件日志级别,默认 INFO。
# OCLAW_LOG_MAX_BYTES 单文件上限(字节),默认 20971520(20 MiB)。
# OCLAW_LOG_BACKUP_COUNT 轮转保留份数,默认 5。
# AIA_LOG_TO_FILE 设为 0/false/off 时不写 app/*.log(pytest 也会跳过文件 handler)。
# OCLAW_HOOK_LOG_USE_STATE_DIR 设为 1/true 时,command-logger / boot-md 改回 ~/.oclaw/logs(或 OCLAW_STATE_DIR/logs);默认写入运行日志根下 hooks/。
OCLAW_LOG_LEVEL=
OCLAW_LOG_MAX_BYTES=
OCLAW_LOG_BACKUP_COUNT=
AIA_LOG_TO_FILE=
OCLAW_HOOK_LOG_USE_STATE_DIR=
# -----------------------------------------------------------------------------
# 十三、RAG / 记忆

View file

@ -27,9 +27,12 @@ The app will:
2. Start backend using `python -m runtime.operations gateway start --host 127.0.0.1 --port <port>`.
3. Open `http://127.0.0.1:<port>/chat` in the desktop window.
Logs are written under:
Logs (embedded backend, desktop shell, optional WeCom channel child) are written under the **runtime log root** (same as `AIA_RUNTIME_LOG_DIR` / `data/logs/` — see repo root [`docs/LOGGING.md`](../docs/LOGGING.md)). When launched via `runtime/operations/scripts/start_desktop.ps1`, `OCLAW_DESKTOP_LOG_ROOT` is set for you.
- `%APPDATA%/oclaw/logs/backend.log` (Windows)
Typical files:
- `desktop.log`
- `backend.log`
## Environment knobs

View file

@ -23,8 +23,16 @@ const APP_ICON_PATH = (() => {
}
return candidates[candidates.length - 1];
})();
const REPO_ROOT_FOR_LOGS = path.resolve(__dirname, "..");
function resolveFileLogRoot() {
const fromEnv = String(
process.env.OCLAW_DESKTOP_LOG_ROOT || process.env.AIA_RUNTIME_LOG_DIR || ""
).trim();
if (fromEnv) return path.resolve(fromEnv);
return path.join(REPO_ROOT_FOR_LOGS, "data", "logs");
}
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
const LOG_ROOT = resolveFileLogRoot();
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
const STARTUP_TIMEOUT_MS = 30000;
const POLL_INTERVAL_MS = 600;

View file

@ -519,9 +519,40 @@
- 生效:`oclaw/interfaces/http/fastapi_app.py`
- `AIA_RUNTIME_LOG_DIR`
- 默认:空(使用内部默认目录)
- 作用:运行日志目录
- 生效:`oclaw/runtime/operations/runtime.py`
- 默认:空(使用 `<assistant DB 文件父目录>/logs`,常见为 `data/logs/`)
- 作用:运行日志根目录(网关、渠道、wiki worker、微信/WhatsApp sidecar 重定向、Desktop 文件日志、Hook `hooks/` 等)
- 说明:布局与排障见 [`LOGGING.md`](./LOGGING.md)
- 生效:`oclaw/svc/config/log_paths.py`, `oclaw/runtime/operations/runtime.py`, `oclaw/svc/observability/logging_setup.py`, `oclaw/interfaces/http/fastapi_app.py`, `oclaw/desktop/main.js`, `oclaw/runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1` 及依赖该库的启动脚本
- `OCLAW_DESKTOP_LOG_ROOT`
- 默认:空(Desktop 使用 `AIA_RUNTIME_LOG_DIR`;再空则 `<仓库根>/data/logs/`,见 `desktop/main.js`)
- 作用:Electron 内嵌网关等写入 `desktop.log` / `backend.log` 等的目录;`start_desktop.ps1` 会设为与 `AIA_RUNTIME_LOG_DIR` 相同
- 生效:`oclaw/desktop/main.js`, `oclaw/runtime/operations/scripts/start_desktop.ps1`
- `OCLAW_LOG_LEVEL` / `AIA_LOG_LEVEL`
- 默认:`INFO`
- 作用:写入 `app/oclaw.log`(及 uvicorn 相关 logger)的级别;取值 `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`(`WARN`→`WARNING`,`FATAL`→`CRITICAL`)
- 生效:`oclaw/svc/observability/logging_setup.py`
- `OCLAW_LOG_MAX_BYTES`
- 默认:`20971520`(20 MiB)
- 作用:单一日志文件达到该大小后轮转
- 生效:`oclaw/svc/observability/logging_setup.py`
- `OCLAW_LOG_BACKUP_COUNT`
- 默认:`5`
- 作用:轮转文件保留数量(含当前文件在内的上限由 handler 实现决定)
- 生效:`oclaw/svc/observability/logging_setup.py`
- `AIA_LOG_TO_FILE`
- 默认:未设置(启用文件日志;pytest 进程内仍会因 `PYTEST_CURRENT_TEST` 跳过写文件)
- 作用:设为 `0` / `false` / `no` / `off` 时不配置轮转文件 handler(便于前台调试或特殊环境)
- 生效:`oclaw/svc/observability/logging_setup.py`, `oclaw/interfaces/http/fastapi_app.py`(网关不传 `log_config`)
- `OCLAW_HOOK_LOG_USE_STATE_DIR`
- 默认:未设置(`command-logger` / `boot-md` 写入运行日志根下的 `hooks/`,与 `data/logs` 同树)
- 作用:设为 `1` / `true` / `yes` / `on` 时,改回旧布局:写入 `OCLAW_STATE_DIR`(或 `~/.oclaw`)下的 `logs/`
- 生效:`oclaw/runtime/hooks/bundled/command-logger/handler.py`, `oclaw/runtime/hooks/bundled/boot-md/handler.py`, `oclaw/svc/config/log_paths.py`
- `AIA_SSE_QUEUE_MAXSIZE`
- 默认:`2000`

43
docs/LOGGING.md Normal file
View file

@ -0,0 +1,43 @@
# 日志与排障
本文说明 Oclaw **日志根目录**、进程级日志与 **Python 轮转日志** 的落点,便于线上/本机排障。环境变量完整列表仍以 [`ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md) 为准。
## 日志根目录
由 **`AIA_RUNTIME_LOG_DIR`** 指定;未设置时与 `runtime.operations.runtime.assistant_runtime_log_dir()` 一致:**助手库文件(`db_path()`)所在目录下的 `logs/`**(常见为仓库下 `data/logs/`)。
网关、`stack up` 启用的渠道进程、**轮转应用日志**、**内置 Hook 文件日志**(`hooks/`)、**微信/WhatsApp sidecar 的 stdout/stderr**(`weixin_sidecar.*` / `whatsapp_sidecar.*`),以及 **Desktop(Electron)** 写入的 `desktop.log` / `backend.log` / `channel-wecom.log`,均默认使用该根目录(Desktop 由 `start_desktop.ps1` 设置 `AIA_RUNTIME_LOG_DIR` / `OCLAW_DESKTOP_LOG_ROOT`,或直接运行 Electron 时回落到 **`<仓库根>/data/logs/`**)。Wiki worker 若用 `start_wiki_worker.ps1` 后台启动,其 stdout/stderr 为 `wiki_worker.out.log` / `wiki_worker.err.log`。
## 目录布局(根目录下)
| 路径 | 说明 |
|------|------|
| `gateway.out.log` / `gateway.err.log` | `start_service` 重定向子进程的 stdout/stderr;裸 `print`、未走 `logging` 的输出等。**追加写,无应用内轮转**;生产可配合系统 logrotate。 |
| `channel_*.out.log` / `.err.log` | 同上,渠道名中的 `:` 会替换为 `_`。 |
| `app/oclaw.log` | **轮转**:应用与 uvicorn 主日志(`RotatingFileHandler`)。 |
| `app/uvicorn-access.log` | **轮转**:HTTP access(仅网关 uvicorn 模式)。 |
| `weixin_sidecar.log` / `weixin_sidecar.err.log` | `weixin_start.ps1` 重定向 sidecar 进程输出(原在 `data/channel_sidecar/.../logs`,已统一到运行日志根)。 |
| `whatsapp_sidecar.log` / `whatsapp_sidecar.err.log` | `whatsapp_start.ps1` 同上。 |
| `desktop.log` / `backend.log` / `channel-wecom.log` | Desktop Electron 内嵌网关与渠道子进程日志(`desktop/main.js`)。 |
| `wiki_worker.out.log` / `wiki_worker.err.log` | `start_wiki_worker.ps1` 后台模式重定向的 stdout/stderr(与运行日志根一致)。 |
| `hooks/commands.log`、`boot-md.log` | 默认在 **`{运行日志根}/hooks/`**(与网关同根)。若需恢复旧行为(`OCLAW_STATE_DIR` 或 `~/.oclaw/logs`),设置 **`OCLAW_HOOK_LOG_USE_STATE_DIR=1`**。 |
## 环境变量(摘要)
| 变量 | 作用 |
|------|------|
| `AIA_RUNTIME_LOG_DIR` | 上述根目录。 |
| `OCLAW_LOG_LEVEL` / `AIA_LOG_LEVEL` | 文件日志级别,默认 `INFO`(支持 `DEBUG`、`WARNING`、`ERROR`、`CRITICAL` 等)。 |
| `OCLAW_LOG_MAX_BYTES` | 单文件最大字节,默认 `20971520`(20 MiB)。 |
| `OCLAW_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `5`。 |
| `AIA_LOG_TO_FILE` | 设为 `0` / `false` / `off` 等时**不写**轮转文件(pytest 也会自动跳过文件 handler)。 |
| `OCLAW_DESKTOP_LOG_ROOT` | 可选;Desktop 写入 `desktop.log` / `backend.log` 等的目录。未设置时读 `AIA_RUNTIME_LOG_DIR`;再未设置则用 **`<仓库根>/data/logs/`**(与 `desktop/main.js` 一致)。`start_desktop.ps1` 会将其设为与 `AIA_RUNTIME_LOG_DIR` 相同。 |
| `OCLAW_HOOK_LOG_USE_STATE_DIR` | 设为 `1` / `true` 时,内置钩子日志改写到 **`OCLAW_STATE_DIR`(或 `~/.oclaw`)下的 `logs/`**;未设置时默认 **`{运行日志根}/hooks/`**。 |
## 实现位置
- 根路径:[`svc/config/log_paths.py`](../svc/config/log_paths.py)(`oclaw_log_root`、`oclaw_hooks_log_dir`)
- 配置:[`svc/observability/logging_setup.py`](../svc/observability/logging_setup.py)
- 网关:[`interfaces/http/fastapi_app.py`](../interfaces/http/fastapi_app.py) 的 `main()` 在 `uvicorn.run(..., log_config=...)`
- PowerShell 解析与网关相同的根目录:[`runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1`](../runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1)(`Get-OclawRuntimeLogDir`)
- 企微 longconn / wiki worker:进程入口 `configure_oclaw_logging(...)`

View file

@ -650,6 +650,11 @@ def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response
def run_forever() -> int:
from svc.config.bootstrap_env import load_system_env
from svc.observability.logging_setup import configure_oclaw_logging
load_system_env()
configure_oclaw_logging(service_name="wecom-longconn", include_uvicorn_formatters=False)
store = get_assistant_store()
sender = WeComClient(store)
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")

View file

@ -300,6 +300,13 @@ def create_app() -> FastAPI:
def main() -> int:
load_system_env()
from svc.observability.logging_setup import (
build_uvicorn_logging_dict_config,
configure_oclaw_logging,
skip_file_logging,
)
configure_oclaw_logging(service_name="gateway", include_uvicorn_formatters=True)
host = (os.getenv("AIA_ASSISTANT_GATEWAY_HOST") or "0.0.0.0").strip()
port = int(os.getenv("AIA_ASSISTANT_GATEWAY_PORT") or "8787")
try:
@ -313,14 +320,17 @@ def main() -> int:
except Exception:
ws_max_size = int(MAX_PAYLOAD_BYTES)
ws_max_size = max(1024, min(int(ws_max_size), 200_000_000))
uvicorn.run(
"interfaces.http.fastapi_app:create_app",
host=host,
port=port,
reload=False,
factory=True,
ws_max_size=ws_max_size,
)
run_kw: dict[str, Any] = {
"app": "interfaces.http.fastapi_app:create_app",
"host": host,
"port": port,
"reload": False,
"factory": True,
"ws_max_size": ws_max_size,
}
if not skip_file_logging():
run_kw["log_config"] = build_uvicorn_logging_dict_config()
uvicorn.run(**run_kw)
return 0

View file

@ -5,6 +5,8 @@ import os
from pathlib import Path
from typing import Any, Iterable
from svc.config.log_paths import oclaw_hooks_log_dir
def _resolve_state_dir() -> Path:
override = os.environ.get("OCLAW_STATE_DIR") or os.environ.get("OCLAW_HOME")
@ -49,9 +51,7 @@ def handle(event: Any) -> None:
if getattr(event, "type", None) != "gateway" or getattr(event, "action", None) != "startup":
return
state = _resolve_state_dir()
log_dir = state / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_dir = oclaw_hooks_log_dir(state_dir_if_legacy=_resolve_state_dir())
out_log = log_dir / "boot-md.log"
now = getattr(event, "timestamp", None)

View file

@ -5,6 +5,8 @@ import os
from pathlib import Path
from typing import Any, Dict
from svc.config.log_paths import oclaw_hooks_log_dir
def _resolve_state_dir() -> Path:
# Keep this compatible with typical Oclaw layouts.
@ -18,9 +20,7 @@ def handle(event) -> None:
if getattr(event, "type", None) != "command":
return
state_dir = _resolve_state_dir()
log_dir = state_dir / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_dir = oclaw_hooks_log_dir(state_dir_if_legacy=_resolve_state_dir())
payload: Dict[str, Any] = {
"timestamp": getattr(getattr(event, "timestamp", None), "isoformat", lambda: None)(),

View file

@ -9,6 +9,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from svc.config.log_paths import oclaw_log_root
from svc.config.paths import db_path
@ -17,10 +18,7 @@ def _runtime_file() -> Path:
def _runtime_log_dir() -> Path:
p = str(os.getenv("AIA_RUNTIME_LOG_DIR") or "").strip()
if p:
return Path(p).expanduser().resolve()
return Path(db_path()).resolve().parent / "logs"
return oclaw_log_root()
def assistant_runtime_log_dir() -> Path:
@ -29,7 +27,7 @@ def assistant_runtime_log_dir() -> Path:
Same rule as ``AIA_RUNTIME_LOG_DIR`` or ``<parent of sqlite db_path>)/logs``.
Use this from shell scripts so paths match Python ``stack up`` / ``start_service``.
"""
return _runtime_log_dir()
return oclaw_log_root()
def _read_state() -> dict[str, Any]:

View file

@ -0,0 +1,23 @@
# Shared: resolve the same directory as Python ``assistant_runtime_log_dir()`` / ``AIA_RUNTIME_LOG_DIR``.
function Get-OclawRuntimeLogDir {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot
)
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
$pythonExe = if (Test-Path $venvPython) { $venvPython } else { "python" }
$savedPyPath = $env:PYTHONPATH
$env:PYTHONPATH = $RepoRoot
$dir = $null
try {
$dir = (& $pythonExe -c "from runtime.operations.runtime import assistant_runtime_log_dir; print(str(assistant_runtime_log_dir()))" 2>$null | Select-Object -Last 1).Trim()
} catch { }
if ($null -ne $savedPyPath) {
$env:PYTHONPATH = $savedPyPath
} else {
Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue
}
if (-not $dir) {
$dir = Join-Path $RepoRoot "data\logs"
}
return $dir
}

View file

@ -12,8 +12,6 @@ $desktopDir = Join-Path $repoRoot "desktop"
$runDir = Join-Path $PSScriptRoot ".run"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
$pidFile = Join-Path $runDir "desktop.pid"
$outLog = Join-Path $runDir "desktop.out.log"
$errLog = Join-Path $runDir "desktop.err.log"
if (-not (Test-Path $desktopDir)) {
Write-Host "[ERROR] desktop directory not found: $desktopDir" -ForegroundColor Red
@ -27,6 +25,12 @@ $env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot
$env:AIA_RUNTIME_LOG_DIR = $runtimeLogDir
$env:OCLAW_DESKTOP_LOG_ROOT = $runtimeLogDir
New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null
# Do not inherit attach-mode env from a parent shell when launching standalone desktop.
Remove-Item Env:OCLAW_DESKTOP_EMBED_BACKEND -ErrorAction SilentlyContinue
Remove-Item Env:OCLAW_DESKTOP_GATEWAY_BASE_URL -ErrorAction SilentlyContinue
@ -73,7 +77,7 @@ if ($Background) {
}
Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii
Write-Host "desktop.pid = $pidFile" -ForegroundColor DarkGray
Write-Host "Embedded backend/chat logs (Electron): see %APPDATA%\oclaw\logs\desktop.log / backend.log" -ForegroundColor DarkGray
Write-Host "Desktop / embedded gateway file logs: $runtimeLogDir\desktop.log , backend.log , channel-wecom.log" -ForegroundColor DarkGray
Write-Host "PID = $($p.Id)" -ForegroundColor Green
Write-Host "==> Desktop launched in background (this script exits now)." -ForegroundColor Cyan
exit 0

View file

@ -77,14 +77,8 @@ if (-not $SkipInstall) {
Write-Step "Skip dependency install"
}
# Same directory as runtime.operations.runtime.start_service (db_path parent / logs, or AIA_RUNTIME_LOG_DIR).
$logDir = $null
try {
$logDir = (& $pythonExe -c "from runtime.operations.runtime import assistant_runtime_log_dir; print(str(assistant_runtime_log_dir()))" 2>$null | Select-Object -Last 1).Trim()
} catch { }
if (-not $logDir) {
$logDir = Join-Path $repoRoot "data\logs"
}
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot
Write-Step "Runtime log dir: $logDir"
Write-Host " (stack up / start_service: gateway.err.log + gateway.out.log here)" -ForegroundColor DarkGray
Write-Host " (this script foreground: gateway.foreground.log when log mirror on)" -ForegroundColor DarkGray

View file

@ -13,8 +13,6 @@ $repoRoot = Resolve-RepoRoot
$runDir = Join-Path $PSScriptRoot ".run"
$null = New-Item -ItemType Directory -Force -Path $runDir -ErrorAction SilentlyContinue
$pidFile = Join-Path $runDir "wiki_worker.pid"
$outLog = Join-Path $runDir "wiki_worker.out.log"
$errLog = Join-Path $runDir "wiki_worker.err.log"
function Test-AlivePid([int]$procId) {
try {
@ -51,6 +49,12 @@ $env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot
$null = New-Item -ItemType Directory -Force -Path $logDir -ErrorAction SilentlyContinue
$outLog = Join-Path $logDir "wiki_worker.out.log"
$errLog = Join-Path $logDir "wiki_worker.err.log"
if (-not $Background) {
& $pythonExe -m runtime.workers.wiki.main
exit 0

View file

@ -6,10 +6,16 @@ function Warn([string]$msg) {
Write-Host "[WARN] $msg" -ForegroundColor Yellow
}
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$env:PYTHONPATH = $repoRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot
$runDir = Join-Path $PSScriptRoot ".run"
$pidFile = Join-Path $runDir "desktop.pid"
$outLog = Join-Path $runDir "desktop.out.log"
$errLog = Join-Path $runDir "desktop.err.log"
$desktopLog = Join-Path $logDir "desktop.log"
$backendLog = Join-Path $logDir "backend.log"
$channelWecomLog = Join-Path $logDir "channel-wecom.log"
if (-not (Test-Path $pidFile)) {
Warn "desktop.pid not found: $pidFile"
@ -29,8 +35,9 @@ try {
Write-Host "desktop_running=1" -ForegroundColor Green
Write-Host "pid=$procId"
Write-Host "name=$($p.ProcessName)"
if (Test-Path $outLog) { Write-Host "out_log=$outLog" }
if (Test-Path $errLog) { Write-Host "err_log=$errLog" }
if (Test-Path $desktopLog) { Write-Host "desktop_log=$desktopLog" }
if (Test-Path $backendLog) { Write-Host "backend_log=$backendLog" }
if (Test-Path $channelWecomLog) { Write-Host "channel_wecom_log=$channelWecomLog" }
exit 0
} catch {
Warn "desktop process not found: PID=$procId"

View file

@ -25,7 +25,6 @@ function Sync-WeixinBridgeRunners {
}
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $sidecarRoot "logs") | Out-Null
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
function Ensure-OfficialPluginRuntimeDeps {

View file

@ -14,11 +14,14 @@ function Resolve-RepoRoot {
$oclawRoot = Resolve-RepoRoot
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
$stateDir = Join-Path $sidecarRoot "state"
$logDir = Join-Path $sidecarRoot "logs"
$pidFile = Join-Path $sidecarRoot "pid.txt"
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\weixin_bridge"
$pluginRoot = Join-Path $env:USERPROFILE ".openclaw\\extensions\\openclaw-weixin"
$env:PYTHONPATH = $oclawRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot
function Get-SidecarProcesses {
$escapedSidecarRoot = $sidecarRoot.Replace("\", "\\")
$patterns = @(
@ -74,8 +77,8 @@ function Ensure-OfficialPluginRuntimeDeps {
if (-not (Test-Path $sidecarRoot)) {
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
}
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null
$cleaned = Stop-SidecarProcesses
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
@ -89,8 +92,8 @@ if (Test-Path $bridgeSrc) {
}
}
$logPath = Join-Path $logDir "weixin_sidecar.log"
$errPath = Join-Path $logDir "weixin_sidecar.err.log"
$logPath = Join-Path $runtimeLogDir "weixin_sidecar.log"
$errPath = Join-Path $runtimeLogDir "weixin_sidecar.err.log"
if (Test-Path (Join-Path $sidecarRoot "official_runner.ts")) {
Ensure-OfficialPluginRuntimeDeps
$cmd = "cmd.exe"

View file

@ -17,7 +17,6 @@ function Resolve-RepoRoot {
$oclawRoot = Resolve-RepoRoot
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
$stateDir = Join-Path $sidecarRoot "state"
$logDir = Join-Path $sidecarRoot "logs"
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
# Pin known-good versions to avoid breakage from upstream latest releases.
@ -29,7 +28,6 @@ $typescriptVersion = "5.8.2"
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
Push-Location $sidecarRoot
try {

View file

@ -14,15 +14,18 @@ function Resolve-RepoRoot {
$oclawRoot = Resolve-RepoRoot
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
$stateDir = Join-Path $sidecarRoot "state"
$logDir = Join-Path $sidecarRoot "logs"
$sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
$env:PYTHONPATH = $oclawRoot
. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1")
$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null
$logPath = Join-Path $logDir "whatsapp_sidecar.log"
$errPath = Join-Path $logDir "whatsapp_sidecar.err.log"
$logPath = Join-Path $runtimeLogDir "whatsapp_sidecar.log"
$errPath = Join-Path $runtimeLogDir "whatsapp_sidecar.err.log"
$systemNodeDir = "C:\\Program Files\\nodejs"
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
$env:PATH = "$systemNodeDir;$env:PATH"

View file

@ -337,4 +337,9 @@ def run_worker() -> int:
if __name__ == "__main__":
from svc.config.bootstrap_env import load_system_env
from svc.observability.logging_setup import configure_oclaw_logging
load_system_env()
configure_oclaw_logging(service_name="wiki-worker", include_uvicorn_formatters=False)
raise SystemExit(run_worker())

40
svc/config/log_paths.py Normal file
View file

@ -0,0 +1,40 @@
"""Runtime log root (gateway / channel workers / stack scripts).
Matches ``AIA_RUNTIME_LOG_DIR`` or ``<parent of assistant DB file>/logs`` — same rule as
``runtime.operations.runtime.assistant_runtime_log_dir`` but lives under ``svc.config`` to avoid
import cycles when HTTP or workers configure logging early.
"""
from __future__ import annotations
import os
from pathlib import Path
from svc.config.paths import db_path
def oclaw_log_root() -> Path:
p = str(os.getenv("AIA_RUNTIME_LOG_DIR") or "").strip()
if p:
return Path(p).expanduser().resolve()
return Path(db_path()).resolve().parent / "logs"
def oclaw_hooks_log_dir(*, state_dir_if_legacy: Path) -> Path:
"""Directory for bundled hook file logs (``command-logger``, ``boot-md``).
Default: ``oclaw_log_root() / "hooks"`` (same tree as gateway/channel ``start_service`` logs).
Set ``OCLAW_HOOK_LOG_USE_STATE_DIR=1`` to restore the legacy layout
``state_dir_if_legacy / "logs"`` (typically ``~/.oclaw/logs``).
"""
raw = str(os.getenv("OCLAW_HOOK_LOG_USE_STATE_DIR") or "").strip().lower()
if raw in ("1", "true", "yes", "on"):
out = state_dir_if_legacy / "logs"
else:
out = oclaw_log_root() / "hooks"
out.mkdir(parents=True, exist_ok=True)
return out
__all__ = ["oclaw_hooks_log_dir", "oclaw_log_root"]

View file

@ -0,0 +1,9 @@
"""Observability helpers (logging, metrics hooks)."""
from svc.observability.logging_setup import (
build_uvicorn_logging_dict_config,
configure_oclaw_logging,
skip_file_logging,
)
__all__ = ["build_uvicorn_logging_dict_config", "configure_oclaw_logging", "skip_file_logging"]

View file

@ -0,0 +1,193 @@
"""Central logging setup: rotating files under :func:`svc.config.log_paths.oclaw_log_root`."""
from __future__ import annotations
import logging.config
import os
from pathlib import Path
from typing import Any
from svc.config.log_paths import oclaw_log_root
_CONFIGURED = False
def _skip_file_handlers() -> bool:
if str(os.getenv("AIA_LOG_TO_FILE") or "").strip().lower() in ("0", "false", "no", "off"):
return True
if os.environ.get("PYTEST_CURRENT_TEST"):
return True
return False
def skip_file_logging() -> bool:
"""Whether rotating file handlers are disabled (pytest or ``AIA_LOG_TO_FILE=0``)."""
return _skip_file_handlers()
def _resolve_level_name() -> str:
raw = (os.environ.get("OCLAW_LOG_LEVEL") or os.environ.get("AIA_LOG_LEVEL") or "INFO").strip().upper()
if raw not in ("DEBUG", "INFO", "WARNING", "WARN", "ERROR", "CRITICAL", "FATAL"):
return "INFO"
if raw == "WARN":
return "WARNING"
if raw == "FATAL":
return "CRITICAL"
return raw
def _int_env(name: str, default: int) -> int:
raw = str(os.getenv(name) or "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
return default
def build_worker_logging_dict_config(
*,
log_root: Path | None = None,
level: str | None = None,
max_bytes: int | None = None,
backup_count: int | None = None,
) -> dict[str, Any]:
"""dictConfig for non-uvicorn workers (plain :mod:`logging` formatters)."""
root = log_root or oclaw_log_root()
lvl = level or _resolve_level_name()
mb = int(max_bytes if max_bytes is not None else _int_env("OCLAW_LOG_MAX_BYTES", 20_971_520))
bc = int(backup_count if backup_count is not None else _int_env("OCLAW_LOG_BACKUP_COUNT", 5))
app_dir = root / "app"
app_dir.mkdir(parents=True, exist_ok=True)
oclaw_path = app_dir / "oclaw.log"
fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"file": {
"format": fmt,
"datefmt": datefmt,
},
},
"handlers": {
"oclaw_file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "file",
"filename": str(oclaw_path),
"maxBytes": max(256_000, mb),
"backupCount": max(0, min(bc, 100)),
"encoding": "utf-8",
},
},
"root": {"handlers": ["oclaw_file"], "level": lvl},
}
def build_uvicorn_logging_dict_config(
*,
log_root: Path | None = None,
level: str | None = None,
max_bytes: int | None = None,
backup_count: int | None = None,
) -> dict[str, Any]:
"""dictConfig compatible with ``uvicorn.run(log_config=...)`` (uses uvicorn formatters)."""
root = log_root or oclaw_log_root()
lvl = level or _resolve_level_name()
mb = int(max_bytes if max_bytes is not None else _int_env("OCLAW_LOG_MAX_BYTES", 20_971_520))
bc = int(backup_count if backup_count is not None else _int_env("OCLAW_LOG_BACKUP_COUNT", 5))
app_dir = root / "app"
app_dir.mkdir(parents=True, exist_ok=True)
oclaw_path = app_dir / "oclaw.log"
access_path = app_dir / "uvicorn-access.log"
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": False,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
},
},
"handlers": {
"default_file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "default",
"filename": str(oclaw_path),
"maxBytes": max(256_000, mb),
"backupCount": max(0, min(bc, 100)),
"encoding": "utf-8",
},
"access_file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "access",
"filename": str(access_path),
"maxBytes": max(256_000, mb),
"backupCount": max(0, min(bc, 100)),
"encoding": "utf-8",
},
},
"loggers": {
"uvicorn": {"handlers": ["default_file"], "level": lvl, "propagate": False},
"uvicorn.error": {"handlers": ["default_file"], "level": lvl, "propagate": False},
"uvicorn.access": {"handlers": ["access_file"], "level": lvl, "propagate": False},
},
"root": {"handlers": ["default_file"], "level": lvl},
}
def configure_oclaw_logging(
*,
service_name: str | None = None,
include_uvicorn_formatters: bool = False,
_force_file_handlers: bool = False,
) -> None:
"""Prepare or apply rotating file logging under the runtime log root (idempotent).
Skips file handlers during pytest (``PYTEST_CURRENT_TEST``) or when
``AIA_LOG_TO_FILE=0`` (false/no/off), unless ``_force_file_handlers`` is true (tests only).
When ``include_uvicorn_formatters`` is true, only creates ``log_root`` / ``app``; the caller
must pass :func:`build_uvicorn_logging_dict_config` to ``uvicorn.run(log_config=...)`` so
``dictConfig`` runs once (avoids duplicate handlers).
``service_name`` is reserved for future structured fields; process/service is still visible
in logger names and file layout (``app/oclaw.log`` per process).
"""
global _CONFIGURED
_ = service_name
if _CONFIGURED:
return
if _skip_file_handlers() and not _force_file_handlers:
_CONFIGURED = True
return
log_root = oclaw_log_root()
log_root.mkdir(parents=True, exist_ok=True)
(log_root / "app").mkdir(parents=True, exist_ok=True)
if include_uvicorn_formatters:
_CONFIGURED = True
return
logging.config.dictConfig(build_worker_logging_dict_config(log_root=log_root))
_CONFIGURED = True
def reset_oclaw_logging_for_tests() -> None:
"""Clear idempotency flag (tests only)."""
global _CONFIGURED
_CONFIGURED = False
__all__ = [
"build_uvicorn_logging_dict_config",
"build_worker_logging_dict_config",
"configure_oclaw_logging",
"reset_oclaw_logging_for_tests",
"skip_file_logging",
]

View file

@ -0,0 +1,91 @@
"""Tests for :mod:`svc.observability.logging_setup` and :mod:`svc.config.log_paths`."""
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from svc.config import log_paths
from svc.observability import logging_setup
def test_oclaw_log_root_respects_aia_runtime_log_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "xlogs"))
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
assert log_paths.oclaw_log_root() == (tmp_path / "xlogs").resolve()
def test_configure_worker_writes_rotating_log(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
log_root = tmp_path / "logs"
monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(log_root))
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
logging_setup.reset_oclaw_logging_for_tests()
logging_setup.configure_oclaw_logging(
service_name="test",
include_uvicorn_formatters=False,
_force_file_handlers=True,
)
log = logging.getLogger("test_logging_module")
log.warning("hello from logging setup test")
app_log = log_root / "app" / "oclaw.log"
assert app_log.is_file()
text = app_log.read_text(encoding="utf-8", errors="replace")
assert "hello from logging setup test" in text
logging_setup.configure_oclaw_logging(
service_name="test",
include_uvicorn_formatters=False,
_force_file_handlers=True,
)
handlers = [h for h in logging.root.handlers if hasattr(h, "baseFilename")]
assert len(handlers) == 1
def test_configure_uvicorn_deferred_marks_configured_without_dictconfig(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
log_root = tmp_path / "logs2"
monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(log_root))
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
logging_setup.reset_oclaw_logging_for_tests()
logging_setup.configure_oclaw_logging(
service_name="gw",
include_uvicorn_formatters=True,
_force_file_handlers=True,
)
assert (log_root / "app").is_dir()
assert not (log_root / "app" / "oclaw.log").exists()
def test_oclaw_hooks_log_dir_defaults_under_runtime_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "logs"))
monkeypatch.delenv("OCLAW_HOOK_LOG_USE_STATE_DIR", raising=False)
state = tmp_path / "fake_state"
state.mkdir()
d = log_paths.oclaw_hooks_log_dir(state_dir_if_legacy=state)
assert d == (tmp_path / "logs" / "hooks").resolve()
assert d.is_dir()
def test_oclaw_hooks_log_dir_legacy_when_env_set(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "logs"))
monkeypatch.setenv("OCLAW_HOOK_LOG_USE_STATE_DIR", "1")
state = tmp_path / "fake_state"
state.mkdir()
d = log_paths.oclaw_hooks_log_dir(state_dir_if_legacy=state)
assert d == (state / "logs").resolve()