mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 01:53:15 +08:00
feat: unify runtime logging (rotating files, log root, scripts, desktop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
86d4bad107
commit
1490bd5f24
25 changed files with 541 additions and 53 deletions
40
svc/config/log_paths.py
Normal file
40
svc/config/log_paths.py
Normal 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"]
|
||||
9
svc/observability/__init__.py
Normal file
9
svc/observability/__init__.py
Normal 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"]
|
||||
193
svc/observability/logging_setup.py
Normal file
193
svc/observability/logging_setup.py
Normal 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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue