logfile.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""日志目录/命名/格式的唯一口径 (2026-09-17 用户令 2「统一日志输出目录及日志文件命名、内容格式」)。
  4. ## 统一前是什么样 (实测, 不是推测)
  5. · 目录: 运行日志散在 `logs/`, 但 CMS 插件把 `analyze_stdout.log` 写进了**产物目录**
  6. (`outputs/<场>/windcms/...`), `_proc_reg.log` 这种自检残留也躺在 `logs/` 里;
  7. · 命名: 服务日志是 `<组件>.log` (gateway/detail/cms/sim/sim_sys/viewer), 但对不上服务键的还有
  8. `serve.log`、`start_hidden.log`; 运维动作是 `ops_<动作>_<YYYYmmdd_HHMMSS>.log` 直接堆在 `logs/` 顶层
  9. —— 实测 34 个文件里 22 个是历史动作日志, 没有保留策略, 只会越堆越多;
  10. · 内容: **没有时间戳、没有级别、编码与行尾都不统一** —— `detail.log` 首行是 `b'windscada serve :18033\r\n'`(CRLF!),
  11. `cms.log` 首行甚至是一条历史 `SyntaxWarning`; 机器审计反而叫 `.jsonl` 混在 `.log` 里;
  12. · 全仓 `import logging` 的文件数 = **0** —— 没有统一设施, 每个进程各 print 各的。
  13. ## 统一后的口径 (机器可查, 见 scripts/log_audit.py)
  14. logs/<组件>.log 长驻服务与启动器 (组件名 = configs/serve.json 的键 + gateway + serve/start_hidden)
  15. logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log 运维动作日志 (保留最近 20 份 / 30 天, 超出的自动清理)
  16. logs/audit/<名字>.jsonl 机器审计流水 (JSON Lines: 一行一条 JSON)
  17. 行格式 (每一行都要满足, 校验正则见 LINE_RE):
  18. YYYY-MM-DD HH:MM:SS LEVEL 组件 消息
  19. 级别: DEBUG/INFO/WARN/ERROR; UTF-8 无 BOM; 行尾 LF; 不含 ANSI 颜色码。
  20. 服务侧怎么落地: 各服务不用改自己的 print —— 入口处调一次 `prefix_stdout(组件名)`,
  21. 之后**每一行**都会自动带上时间戳/级别/组件 (实现见下面 _Prefixed 包装器)。这样"内容格式统一"
  22. 不是靠自觉, 而是由设施保证。
  23. """
  24. from __future__ import annotations
  25. try:
  26. from ._root import install_root as _install_root
  27. except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
  28. from _root import install_root as _install_root
  29. import datetime as dt
  30. import json
  31. import os
  32. import pathlib
  33. import re
  34. import sys
  35. import pathlib as _p
  36. ROOT = _install_root(__file__)
  37. LOGS = _p.Path(os.environ.get('WINDSCADA_LOGS') or (ROOT / 'logs'))
  38. OPS_DIR = LOGS / 'ops'
  39. AUDIT_DIR = LOGS / 'audit'
  40. BUILD_DIR = LOGS / 'build' # 构建/摄入类脚本的日志 (按产物子路径归档; 不许写在产物目录里)
  41. LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR')
  42. # 一行日志的规范形式: 时间戳 + 级别 + 组件 + 消息 (组件名允许中文/点/下划线/连字符)
  43. LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:DEBUG|INFO|WARN|ERROR) [\w\u4e00-\u9fff.\-]+ ')
  44. ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
  45. ACTION_KEEP = 20 # 运维动作日志保留份数
  46. ACTION_DAYS = 30 # 运维动作日志保留天数
  47. def now() -> str:
  48. return dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  49. def line(level: str, comp: str, msg: str, ts: str | None = None) -> str:
  50. """拼一行规范日志 (纯函数, 便于测试)。多行消息会被逐行加前缀。"""
  51. lv = (level or 'INFO').upper()
  52. if lv not in LEVELS:
  53. lv = 'INFO'
  54. out = []
  55. for i, part in enumerate(str(msg).replace('\r\n', '\n').replace('\r', '\n').split('\n')):
  56. out.append(f'{ts or now()} {lv} {comp} {part}')
  57. return '\n'.join(out)
  58. def component_log(comp: str) -> pathlib.Path:
  59. """长驻服务/启动器的日志路径: logs/<组件>.log"""
  60. return LOGS / f'{comp}.log'
  61. def action_log(tag: str, when: dt.datetime | None = None) -> pathlib.Path:
  62. """运维动作日志路径: logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log (写入前会先按保留策略清理)"""
  63. w = when or dt.datetime.now()
  64. return OPS_DIR / f'{tag}_{w:%Y%m%d-%H%M%S}.log'
  65. def audit_log(name: str) -> pathlib.Path:
  66. """机器审计流水路径: logs/audit/<名字>.jsonl"""
  67. return AUDIT_DIR / (name if name.endswith(('.jsonl', '.json')) else f'{name}.jsonl')
  68. def ensure_dirs() -> None:
  69. for d in (LOGS, OPS_DIR, AUDIT_DIR, BUILD_DIR):
  70. d.mkdir(parents=True, exist_ok=True)
  71. def build_log(rel: str, farm: str | None = None) -> pathlib.Path:
  72. """构建/摄入脚本的日志路径: `logs/build/<场>/<相对路径>.log`
  73. ★ 2026-09-17 用户令 2 的由来: 交付包里 39 个构建日志原本**躺在产物目录里**
  74. (`outputs/<场>/windscada/*.log` 等), 还被产物台账登记成 shipped 随包件 ——
  75. "日志在产物里"正是这次要统一掉的不一致。新脚本用本函数落 `logs/build/`, 旧的已迁移过去。
  76. """
  77. p = pathlib.Path(rel)
  78. parts = [farm] if farm else []
  79. return BUILD_DIR.joinpath(*parts, *p.parts)
  80. def write(comp: str, msg: str, level: str = 'INFO', path: pathlib.Path | None = None) -> pathlib.Path:
  81. """追加一行规范日志 (UTF-8, LF)。"""
  82. ensure_dirs()
  83. p = pathlib.Path(path) if path else component_log(comp)
  84. p.parent.mkdir(parents=True, exist_ok=True)
  85. with open(p, 'a', encoding='utf-8', newline='\n') as f:
  86. f.write(line(level, comp, msg) + '\n')
  87. return p
  88. def append_jsonl(name: str, rec: dict) -> pathlib.Path:
  89. """机器审计流水: 一行一条 JSON (ensure_ascii=False, 时间戳字段 ts)。"""
  90. ensure_dirs()
  91. p = audit_log(name)
  92. rec = dict(rec)
  93. rec.setdefault('ts', now())
  94. with open(p, 'a', encoding='utf-8', newline='\n') as f:
  95. f.write(json.dumps(rec, ensure_ascii=False, default=str) + '\n')
  96. return p
  97. def prune_actions(keep: int = ACTION_KEEP, days: int = ACTION_DAYS, dry: bool = False) -> list[pathlib.Path]:
  98. """运维动作日志保留策略: 只留最近 keep 份、且不超过 days 天。→ 删掉的文件列表。"""
  99. if not OPS_DIR.is_dir():
  100. return []
  101. files = sorted((f for f in OPS_DIR.glob('*.log') if f.is_file()), key=lambda f: f.stat().st_mtime, reverse=True)
  102. cutoff = dt.datetime.now().timestamp() - days * 86400
  103. gone = []
  104. for i, f in enumerate(files):
  105. if i < keep and f.stat().st_mtime >= cutoff:
  106. continue
  107. gone.append(f)
  108. if not dry:
  109. try:
  110. f.unlink()
  111. except OSError:
  112. pass
  113. return gone
  114. def normalize_file(path: pathlib.Path, comp: str, when: float | None = None) -> int:
  115. """把一份**裸输出**日志就地补成统一格式 → 补了几行。
  116. 为什么需要: 运维动作的日志是"子进程直接写 fd"的产物 (`_ops_run.py` 把本进程的 stdout 句柄交给子命令,
  117. 见那里的注释 —— 用管道收输出曾经导致日志空白/任务看起来卡住), 所以子命令的 print **绕过**了 Python
  118. 层的流包装。这里在动作结束时补一次前缀: 已经是规范行的原样保留, 其余行补 `时间戳 级别 组件.raw`,
  119. 并在开头插一行说明"以下为原样输出、前缀是事后补的" —— 不假装是原始时刻写的。
  120. """
  121. if not path or not pathlib.Path(path).is_file():
  122. return 0
  123. p = pathlib.Path(path)
  124. ts = dt.datetime.fromtimestamp(when if when is not None else p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
  125. raw = p.read_text(encoding='utf-8', errors='replace').replace('\r\n', '\n').split('\n')
  126. fixed, n = [], 0
  127. for ln in raw:
  128. if not ln.strip():
  129. continue
  130. if LINE_RE.match(ln):
  131. fixed.append(ln)
  132. continue
  133. fixed.append(f'{ts} INFO {comp}.raw {ln}')
  134. n += 1
  135. if n:
  136. fixed.insert(0, f'{ts} INFO {comp} === 以下 {n} 行为子进程原样输出 (前缀由 logfile.normalize_file 事后补齐) ===')
  137. p.write_text('\n'.join(fixed) + '\n', encoding='utf-8', newline='\n')
  138. return n
  139. # ── 服务侧: 让 print 出来的每一行都符合格式 ─────────────────────────────────────────────
  140. class _Prefixed:
  141. """把写到 stdout/stderr 的每一行加上 `时间戳 级别 组件` 前缀。
  142. 刻意做成**流包装**而不是要求 6 个服务各自改用 logging:
  143. 服务里已有大量 print (启动横幅/请求日志/进度), 逐个改既费事又会漏; 包一层之后
  144. "内容格式统一"由设施保证。级别默认 INFO; 含 'error'/'traceback' 字样的行按 ERROR 记。
  145. """
  146. def __init__(self, stream, comp: str):
  147. self._s = stream
  148. self._comp = comp
  149. self._buf = ''
  150. def write(self, data):
  151. if not isinstance(data, str):
  152. data = str(data)
  153. self._buf += data
  154. while '\n' in self._buf:
  155. ln, self._buf = self._buf.split('\n', 1)
  156. self._emit(ln)
  157. return len(data)
  158. def _emit(self, ln: str):
  159. clean = ANSI_RE.sub('', ln.rstrip('\r'))
  160. lv = 'ERROR' if re.search(r'error|traceback|失败|异常', clean, re.I) else 'INFO'
  161. try:
  162. self._s.write(line(lv, self._comp, clean) + '\n')
  163. self._s.flush()
  164. except Exception:
  165. pass
  166. def flush(self):
  167. if self._buf:
  168. self._emit(self._buf)
  169. self._buf = ''
  170. try:
  171. self._s.flush()
  172. except Exception:
  173. pass
  174. def __getattr__(self, item):
  175. return getattr(self._s, item)
  176. def prefix_stdout(comp: str) -> None:
  177. """服务入口调一次: 之后 stdout/stderr 的每一行都符合统一格式 (幂等)。"""
  178. if getattr(sys.stdout, '_guanlan_prefixed', False) or os.environ.get('GUANLAN_LOG_RAW'):
  179. return
  180. so, se = _Prefixed(sys.stdout, comp), _Prefixed(sys.stderr, comp)
  181. so._guanlan_prefixed = se._guanlan_prefixed = True
  182. sys.stdout, sys.stderr = so, se
  183. if __name__ == '__main__': # 直接跑 = 打印口径 + 清洗一次动作日志
  184. ensure_dirs()
  185. print(f'logs 目录: {LOGS}')
  186. print(f' 服务日志 logs/<组件>.log (组件: configs/serve.json 的键 + gateway/serve/start_hidden)')
  187. print(f' 动作日志 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log 保留最近 {ACTION_KEEP} 份 / {ACTION_DAYS} 天')
  188. print(f' 审计流水 logs/audit/<名字>.jsonl')
  189. print(f' 行格式 {line("INFO", "示例", "一行长这样")}')
  190. gone = prune_actions(dry=True)
  191. print(f' 现有动作日志 {len(list(OPS_DIR.glob("*.log")))} 份, 按保留策略该清理 {len(gone)} 份')