| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- r"""日志目录/命名/格式的唯一口径 (2026-09-17 用户令 2「统一日志输出目录及日志文件命名、内容格式」)。
- ## 统一前是什么样 (实测, 不是推测)
- · 目录: 运行日志散在 `logs/`, 但 CMS 插件把 `analyze_stdout.log` 写进了**产物目录**
- (`outputs/<场>/windcms/...`), `_proc_reg.log` 这种自检残留也躺在 `logs/` 里;
- · 命名: 服务日志是 `<组件>.log` (gateway/detail/cms/sim/sim_sys/viewer), 但对不上服务键的还有
- `serve.log`、`start_hidden.log`; 运维动作是 `ops_<动作>_<YYYYmmdd_HHMMSS>.log` 直接堆在 `logs/` 顶层
- —— 实测 34 个文件里 22 个是历史动作日志, 没有保留策略, 只会越堆越多;
- · 内容: **没有时间戳、没有级别、编码与行尾都不统一** —— `detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF!),
- `cms.log` 首行甚至是一条历史 `SyntaxWarning`; 机器审计反而叫 `.jsonl` 混在 `.log` 里;
- · 全仓 `import logging` 的文件数 = **0** —— 没有统一设施, 每个进程各 print 各的。
- ## 统一后的口径 (机器可查, 见 scripts/log_audit.py)
- logs/<组件>.log 长驻服务与启动器 (组件名 = configs/serve.json 的键 + gateway + serve/start_hidden)
- logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log 运维动作日志 (保留最近 20 份 / 30 天, 超出的自动清理)
- logs/audit/<名字>.jsonl 机器审计流水 (JSON Lines: 一行一条 JSON)
- 行格式 (每一行都要满足, 校验正则见 LINE_RE):
- YYYY-MM-DD HH:MM:SS LEVEL 组件 消息
- 级别: DEBUG/INFO/WARN/ERROR; UTF-8 无 BOM; 行尾 LF; 不含 ANSI 颜色码。
- 服务侧怎么落地: 各服务不用改自己的 print —— 入口处调一次 `prefix_stdout(组件名)`,
- 之后**每一行**都会自动带上时间戳/级别/组件 (实现见下面 _Prefixed 包装器)。这样"内容格式统一"
- 不是靠自觉, 而是由设施保证。
- """
- from __future__ import annotations
- try:
- from ._root import install_root as _install_root
- except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
- from _root import install_root as _install_root
- import datetime as dt
- import json
- import os
- import pathlib
- import re
- import sys
- import pathlib as _p
- ROOT = _install_root(__file__)
- LOGS = _p.Path(os.environ.get('WINDSCADA_LOGS') or (ROOT / 'logs'))
- OPS_DIR = LOGS / 'ops'
- AUDIT_DIR = LOGS / 'audit'
- BUILD_DIR = LOGS / 'build' # 构建/摄入类脚本的日志 (按产物子路径归档; 不许写在产物目录里)
- LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR')
- # 一行日志的规范形式: 时间戳 + 级别 + 组件 + 消息 (组件名允许中文/点/下划线/连字符)
- LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:DEBUG|INFO|WARN|ERROR) [\w\u4e00-\u9fff.\-]+ ')
- ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
- ACTION_KEEP = 20 # 运维动作日志保留份数
- ACTION_DAYS = 30 # 运维动作日志保留天数
- def now() -> str:
- return dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- def line(level: str, comp: str, msg: str, ts: str | None = None) -> str:
- """拼一行规范日志 (纯函数, 便于测试)。多行消息会被逐行加前缀。"""
- lv = (level or 'INFO').upper()
- if lv not in LEVELS:
- lv = 'INFO'
- out = []
- for i, part in enumerate(str(msg).replace('\r\n', '\n').replace('\r', '\n').split('\n')):
- out.append(f'{ts or now()} {lv} {comp} {part}')
- return '\n'.join(out)
- def component_log(comp: str) -> pathlib.Path:
- """长驻服务/启动器的日志路径: logs/<组件>.log"""
- return LOGS / f'{comp}.log'
- def action_log(tag: str, when: dt.datetime | None = None) -> pathlib.Path:
- """运维动作日志路径: logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log (写入前会先按保留策略清理)"""
- w = when or dt.datetime.now()
- return OPS_DIR / f'{tag}_{w:%Y%m%d-%H%M%S}.log'
- def audit_log(name: str) -> pathlib.Path:
- """机器审计流水路径: logs/audit/<名字>.jsonl"""
- return AUDIT_DIR / (name if name.endswith(('.jsonl', '.json')) else f'{name}.jsonl')
- def ensure_dirs() -> None:
- for d in (LOGS, OPS_DIR, AUDIT_DIR, BUILD_DIR):
- d.mkdir(parents=True, exist_ok=True)
- def build_log(rel: str, farm: str | None = None) -> pathlib.Path:
- """构建/摄入脚本的日志路径: `logs/build/<场>/<相对路径>.log`
- ★ 2026-09-17 用户令 2 的由来: 交付包里 39 个构建日志原本**躺在产物目录里**
- (`outputs/<场>/windscada/*.log` 等), 还被产物台账登记成 shipped 随包件 ——
- "日志在产物里"正是这次要统一掉的不一致。新脚本用本函数落 `logs/build/`, 旧的已迁移过去。
- """
- p = pathlib.Path(rel)
- parts = [farm] if farm else []
- return BUILD_DIR.joinpath(*parts, *p.parts)
- def write(comp: str, msg: str, level: str = 'INFO', path: pathlib.Path | None = None) -> pathlib.Path:
- """追加一行规范日志 (UTF-8, LF)。"""
- ensure_dirs()
- p = pathlib.Path(path) if path else component_log(comp)
- p.parent.mkdir(parents=True, exist_ok=True)
- with open(p, 'a', encoding='utf-8', newline='\n') as f:
- f.write(line(level, comp, msg) + '\n')
- return p
- def append_jsonl(name: str, rec: dict) -> pathlib.Path:
- """机器审计流水: 一行一条 JSON (ensure_ascii=False, 时间戳字段 ts)。"""
- ensure_dirs()
- p = audit_log(name)
- rec = dict(rec)
- rec.setdefault('ts', now())
- with open(p, 'a', encoding='utf-8', newline='\n') as f:
- f.write(json.dumps(rec, ensure_ascii=False, default=str) + '\n')
- return p
- def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool = False) -> list[pathlib.Path]:
- """运维动作日志保留策略: 只留最近 keep 份、且不超过 days 天。→ 删掉的文件列表。"""
- if not OPS_DIR.is_dir():
- return []
- files = sorted((f for f in OPS_DIR.glob('*.log') if f.is_file()), key=lambda f: f.stat().st_mtime, reverse=True)
- cutoff = dt.datetime.now().timestamp() - days * 86400
- gone = []
- for i, f in enumerate(files):
- if i < keep and f.stat().st_mtime >= cutoff:
- continue
- gone.append(f)
- if not dry:
- try:
- f.unlink()
- except OSError:
- pass
- return gone
- def normalize_file(path: pathlib.Path, comp: str, when: float | None = None) -> int:
- """把一份**裸输出**日志就地补成统一格式 → 补了几行。
- 为什么需要: 运维动作的日志是"子进程直接写 fd"的产物 (`_ops_run.py` 把本进程的 stdout 句柄交给子命令,
- 见那里的注释 —— 用管道收输出曾经导致日志空白/任务看起来卡住), 所以子命令的 print **绕过**了 Python
- 层的流包装。这里在动作结束时补一次前缀: 已经是规范行的原样保留, 其余行补 `时间戳 级别 组件.raw`,
- 并在开头插一行说明"以下为原样输出、前缀是事后补的" —— 不假装是原始时刻写的。
- """
- if not path or not pathlib.Path(path).is_file():
- return 0
- p = pathlib.Path(path)
- ts = dt.datetime.fromtimestamp(when if when is not None else p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
- raw = p.read_text(encoding='utf-8', errors='replace').replace('\r\n', '\n').split('\n')
- fixed, n = [], 0
- for ln in raw:
- if not ln.strip():
- continue
- if LINE_RE.match(ln):
- fixed.append(ln)
- continue
- fixed.append(f'{ts} INFO {comp}.raw {ln}')
- n += 1
- if n:
- fixed.insert(0, f'{ts} INFO {comp} === 以下 {n} 行为子进程原样输出 (前缀由 logfile.normalize_file 事后补齐) ===')
- p.write_text('\n'.join(fixed) + '\n', encoding='utf-8', newline='\n')
- return n
- # ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
- class _Prefixed:
- """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。
- 刻意做成**流包装**而不是要求 6 个服务各自改用 logging:
- 服务里已有大量 print (启动横幅/请求日志/进度), 逐个改既费事又会漏; 包一层之后
- "内容格式统一"由设施保证。级别默认 INFO; 含 'error'/'traceback' 字样的行按 ERROR 记。
- """
- def __init__(self, stream, comp: str):
- self._s = stream
- self._comp = comp
- self._buf = ''
- def write(self, data):
- if not isinstance(data, str):
- data = str(data)
- self._buf += data
- while '\n' in self._buf:
- ln, self._buf = self._buf.split('\n', 1)
- self._emit(ln)
- return len(data)
- def _emit(self, ln: str):
- clean = ANSI_RE.sub('', ln.rstrip('\r'))
- lv = 'ERROR' if re.search(r'error|traceback|失败|异常', clean, re.I) else 'INFO'
- try:
- self._s.write(line(lv, self._comp, clean) + '\n')
- self._s.flush()
- except Exception:
- pass
- def flush(self):
- if self._buf:
- self._emit(self._buf)
- self._buf = ''
- try:
- self._s.flush()
- except Exception:
- pass
- def __getattr__(self, item):
- return getattr(self._s, item)
- def prefix_stdout(comp: str) -> None:
- """服务入口调一次: 之后 stdout/stderr 的每一行都符合统一格式 (幂等)。"""
- if getattr(sys.stdout, '_guanlan_prefixed', False) or os.environ.get('GUANLAN_LOG_RAW'):
- return
- so, se = _Prefixed(sys.stdout, comp), _Prefixed(sys.stderr, comp)
- so._guanlan_prefixed = se._guanlan_prefixed = True
- sys.stdout, sys.stderr = so, se
- if __name__ == '__main__': # 直接跑 = 打印口径 + 清洗一次动作日志
- ensure_dirs()
- print(f'logs 目录: {LOGS}')
- print(f' 服务日志 logs/<组件>.log (组件: configs/serve.json 的键 + gateway/serve/start_hidden)')
- print(f' 动作日志 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log 保留最近 {ACTION_KEEP} 份 / {ACTION_DAYS} 天')
- print(f' 审计流水 logs/audit/<名字>.jsonl')
- print(f' 行格式 {line("INFO", "示例", "一行长这样")}')
- gone = prune_actions(dry=True)
- print(f' 现有动作日志 {len(list(OPS_DIR.glob("*.log")))} 份, 按保留策略该清理 {len(gone)} 份')
|