日志规范
这份文档是日志的唯一权威规范:日志放在哪、保留多久、一行长什么样、哪些节点必须打事件。 改日志相关代码或配置时先改这里,再改实现——反过来的话,排障时”文档说会有的那一行”就不存在了。
分工:故障排查 讲”出了什么症状怎么修”,本文讲”日志里有什么、怎么看”。 需要”我这次分析为什么慢/为什么失败”的答案时看这里,需要”服务起不来怎么办”时看那里。
1. 日志放在哪
| 文件 | 内容 | 由谁写 |
|---|---|---|
logs/code-association.log |
主日志,INFO 及以上,一次运行的完整时间线 | logback 的 FILE appender |
logs/code-association-error.log |
只有 ERROR,故障速览;同样的行也仍在主日志里 | logback 的 ERROR_FILE appender(ThresholdFilter=ERROR) |
logs/archive/*.log.gz |
滚动出来的历史,主日志与错误日志各一套,gz 压缩 | SizeAndTimeBasedRollingPolicy |
logs/console.log |
进程原始 stdout/stderr 捕获,每次启动截断 | start.sh / start.ps1 的重定向 |
四者都在同一个目录下,目录由 code-association.log-dir 决定:默认 ./logs,
环境变量 LOG_DIR 可覆盖(容器里挂日志卷时用它)。start.sh / start.ps1 不另外指定目录,
所以默认情况下进程输出与应用日志落在同一个 logs/ 里,运维只需要看一个地方。
logs/console.log 不是应用日志。 它只是把 java 进程的 stdout/stderr 原样接住:
- logback 初始化之前就失败的报错(配置语法错、端口被占、jar 损坏)只会出现在这里;
- JVM 崩溃输出(
OutOfMemoryError、hs_err_pid*.log的触发点)也只会出现在这里; - 因为每次启动截断,它没有历史,也不参与滚动与保留。
真正的应用日志是 logs/code-association.log:结构化、按天滚动、保留 7 天。
Windows 上 start.ps1 会把 stderr 单独写到 logs/console.err.log——cmd 不允许两个重定向
同时打开同一个文件,否则两份输出会互相覆盖。
与数据目录 .code-association 的区别
logs/ |
.code-association/ |
|
|---|---|---|
| 里面是什么 | 诊断产物(日志、归档) | 状态(SQLite 库、repos/ 克隆缓存、更新暂存) |
| 丢了会怎样 | 只是丢历史线索,服务照常运行 | 要重新导入代码源、重新分析 |
| 备份 | 不必备份,可随时清空 | 需要备份 |
| 清理节奏 | 自动(7 天 / 500MB 上限) | 手动(repos/ 尤其大) |
这就是两者刻意分开的原因:备份节奏与清理节奏不同,混在一起会让人不敢清日志、 也不敢动数据目录。
为什么文件名固定
code-association.log 这个名字被 README、CONTRIBUTING 与本目录的多篇文档引用,
改名等于让所有排障指引同时失效。要换目录用 LOG_DIR,不要换文件名。
2. 保留策略
- 按天 + 按大小滚动:主日志单文件上限 50MB,错误日志 20MB;跨天或超限就切一份到
logs/archive/。 一次大仓库分析可能刷出几十 MB,只有按天切的话,当天那份会大到打不开。 - 保留 7 天(maxHistory=7):按天计数,超过 7 天的归档在滚动时删除。
- 总量上限:主日志
totalSizeCap=500MB、错误日志 200MB。上限先于时间生效—— 一天刷爆 500MB 时,宁可删掉更早的归档,也不让磁盘被日志占满。 - 启动时清理:
cleanHistoryOnStart=true。停了一周再启动,过期的归档会立刻被清掉, 而不是等到下一次滚动(那可能是几天后)。
归档文件形如 logs/archive/code-association.2026-10-28.0.log.gz(.0 是当天第几个分片),
可以直接 zgrep 查,不必先解压。
3. 一行长什么样
2026-10-28 11:47:27.123 INFO [impact-analysis-2] c.g.a.service.AnalysisService - event=analysis.done analysisId=42 source=order-service status=DONE durationMs=8421 files=6 changes=9 peers=2 high=1 medium=3 low=2
时间 级别 [线程] logger - 消息。两处值得解释:
- 为什么带线程名:一次分析跑在
impact-analysis-N线程上,同一线程的行天然按时间聚在一起。 排查”某次分析卡在哪一步”时,先按线程名过滤,就只剩这一次运行的轨迹,不会混进定时扫描与 HTTP 请求线程的输出。 - logger 只保留 40 个字符:包名统一是
com.geek.association.,全写出来只会挤掉消息本身。
4. 事件目录
重要节点写一行结构化日志,不写自然语言句子:
log.info(LogEvents.of("analysis.done", "analysisId", id, "source", source,
"status", "DONE", "durationMs", LogEvents.msSince(start), "high", high, "medium", medium));
排障时的问题几乎总是”某次分析/某次扫描到底发生了什么”,grep 'event=analysis.done' 能一次拿到
全部结果;自然语言句子则要为每种措辞写一个正则。
约定(由 LogEvents 落实):
- 事件名小写、点分、稳定。改名等于让历史日志与文档同时失效,宁可变字段不改事件名;
- 字段名 camelCase,与代码里的概念同名(
analysisId/sourceId/durationMs); - 每条带定位 id(
analysisId/sourceId/peerId/trigger)与该节点的durationMs; null渲染成-,空串渲染成""(”没有这个值”与”值就是空”在排障时含义不同);- 值含空格、引号或等号时加双引号并转义,保证整行仍可按空白切分;
- 单值超过 200 字符截断加
...——日志里塞完整路径或异常消息会淹没上下文。
渲染出来是这样(含空格 / 等号的值自动加引号,null 是 -):
event=store.init db=/Users/xin/app/data/code-association.db durationMs=87
event=cli.done exitCode=2 reason="missing --peer" durationMs=12
event=ai.test ok=false endpoint=https://api.example.com/v1/chat/completions model=deepseek-chat latencyMs=412 error=-
除事件目录之外,本次还补了四个场景化事件,它们不替代上面任何一个:
analysis.stage(DEBUG):阶段推进。阶段本身写数据库,日志只是给人看的对照, 所以默认 INFO 下看不到它。scan.recover(WARN):启动时把上一次进程残留的 RUNNING 任务标记为失败。出现它就说明 上一次进程没有正常退出。cli.peer(WARN):CI 里某个--peer读不进来。报告里只会留一句”无法读取”, 原因链必须由日志留下。update.settings(INFO):自动更新三个开关被改动。它和update.check分开, 是因为”检查更新”与”改行为”是两件事。
全部事件
| 事件 | 级别 | 关键字段 | 什么时候出现 |
|---|---|---|---|
event=app.start |
INFO | version, mode, port, dataDir, logDir, java | 进程启动完成、开始接请求之前(mode 区分 web / cli) |
event=app.stop |
INFO | version, uptimeMs | 正常停机(stop.sh / 更新重启) |
event=analysis.submit |
INFO | analysisId, sourceId, source, mode, peers, base, target, pr, ai | 收到 POST /api/analyses 并落库后 |
event=analysis.start |
INFO | analysisId, source, mode | 异步任务真正开始跑 |
event=analysis.prepare |
INFO | analysisId, mode, baseCommit, headCommit, durationMs | 快照准备(本地目录 / 克隆 / 拉取)结束,commit 为短 SHA |
event=analysis.diff |
INFO | analysisId, files, durationMs | 语义差异识别结束 |
event=analysis.index |
DEBUG | analysisId, side, files, symbols, modules, unresolved, durationMs | 单个程序重建索引结束(side=base / head) |
event=analysis.detect |
INFO | analysisId, changes, durationMs | 变更分类结束 |
event=analysis.stage |
DEBUG | analysisId, stage, next, percent(跳过时带 skipped=true) | 阶段推进,只在阶段完成 / 跳过时写一行 |
event=analysis.peer |
INFO | analysisId, peerId, peer, contracts, impacts, skippedFiles, durationMs | 单个对比程序分析结束 |
event=analysis.peer(失败) |
WARN | analysisId, peerId, peer, failed, error, durationMs | 该对比程序读取失败,其余程序不受影响 |
event=analysis.ai |
INFO | analysisId, generated, budgetSeconds, durationMs | AI 审核层执行结束;未配置时写 skipped / reason |
event=analysis.ai(异常) |
WARN | analysisId, generated, durationMs, error | AI 解释失败,分析本身照常完成 |
event=analysis.done |
INFO | analysisId, source, status, durationMs, files, changes, peers, high, medium, low, matchedContracts, unresolvedCalls | 一次分析成功收尾 |
event=analysis.failed |
ERROR | analysisId, source, durationMs, error | 任一阶段抛异常,带完整堆栈 |
event=analysis.ai.update |
INFO | analysisId, generated | 为历史报告补生成 / 重新生成 AI 审核 |
event=index.done |
DEBUG | files, symbols, modules, unresolved, durationMs | 代码索引构建完成(每次 index 一行,不带 analysisId) |
event=store.init |
INFO | db, durationMs | SQLite 建表完成;升级后首次启动另有一行 migration=add column ... |
event=cli.done |
INFO | exitCode, mode, peers, high, medium, durationMs | 命令行无头运行收尾 |
event=cli.done(提前失败) |
WARN | exitCode, reason, durationMs | 参数不全或快照准备失败,reason 说明缺什么 |
event=cli.peer |
WARN | peer, failed, error | CLI 模式下某个 --peer 读不进来,其余照常 |
event=scan.start |
INFO | trigger, registered, budget | 定时扫描开始(trigger 区分 cron / 手动,budget 是本次上限) |
event=scan.program |
DEBUG | trigger, sourceId, source, outcome, reason | 单个程序的判定:为什么扫、为什么跳过 |
event=scan.done |
INFO | trigger, status, registered, scanned, skipped, failed, deferred, durationMs, analysisIds | 一轮扫描结束;analysisIds 把这一轮与它发起的分析串起来 |
event=scan.recover |
WARN | recovered, graceMs | 启动时把上次残留的 RUNNING 任务标记为失败 |
event=source.create |
INFO | sourceId, source, type, location, credentialEnv | 新建代码源(location 已脱敏) |
event=source.update |
INFO | sourceId, source, type, location, credentialEnv | 修改代码源 |
event=source.delete |
INFO | sourceId, source, type, analyses, cacheDeleted | 删除代码源(连带删了几次分析、缓存是否清掉) |
event=source.import |
INFO | imported, created, updated, skipped, total | 导入代码源 |
event=source.export |
INFO | exported, file | 导出代码源 |
event=config.export |
INFO | sources, secrets, file | 导出整份配置(设置项 + 全部代码源);sources 是导出的代码源条数,secrets 表示这次是否包含密钥,file 是下载文件名 |
event=config.import |
INFO | applied, errors, secrets, sources, skipped | 导入整份配置;applied 是生效的设置分组数,errors 是设置项错误条数,secrets 表示这次是否写入密钥,sources 是新建 + 更新的代码源条数,skipped 是跳过的条数 |
event=update.check |
INFO / WARN | phase, trigger, version, latest, updateAvailable, releases, durationMs | 版本检查;phase=start / done / failed 共用一个事件 |
event=update.download |
INFO / WARN | phase, version, trigger, bytes, durationMs, jar, sha256, error | 下载并校验 jar;phase=start / done / failed |
event=update.apply |
INFO / WARN | phase, version, trigger, durationMs, jar, backup, restarting, error | 用新 jar 替换当前 jar(先备份旧的) |
event=update.restart |
INFO / WARN / DEBUG | phase, role, version, pid, daemon, jar, backup, healthUrl, timeoutSeconds, durationMs, reason, error | 热重启全过程;phase=start / healthy / failed / refused,role 区分父进程与守护脚本 |
event=update.rollback |
INFO | version, reason, jar, backup | 回滚到备份 jar(reason=manual 是用户点的) |
event=update.settings |
INFO | autoDownload, autoApply, autoRestart | 保存自动更新开关 |
event=ai.settings |
INFO / WARN | loaded, source, file, enabled, baseUrl, model, maxImpacts, reason, error | 启动时加载 / 保存 AI 配置;source=file 表示来自 {data-dir}/ai-settings.json |
event=ai.test |
INFO / WARN | ok, endpoint, model, latencyMs, error | 「测试连接」结束;endpoint 与 error 已按 Key 脱敏 |
event=ai.review |
INFO / WARN | comments, files, unlocated, filtered, reflected, budgetSeconds, expired, durationMs, error | 逐条评审意见流水线结束;expired=true 表示预算耗尽,剩余调用已跳过;error 只记第一条非致命失败,明细在 reviewStats.notes |
event=api.error |
ERROR(500)/ DEBUG(400) | status, method, path, error | 接口层异常;400 是校验失败,只记 DEBUG,不污染错误日志 |
DEBUG 事件是故意关着的:analysis.index、index.done、scan.program 每个程序都会写一行,
默认打开会把日志量翻几倍,而它们只在”为什么没扫这个程序”“索引里为什么没有这个类”时才需要;
analysis.stage 只在阶段完成或跳过时写一行,用来把进度百分比对上具体阶段。
5. 排查配方
以下命令都在仓库根目录(或发布包解压目录)执行,路径按默认 ./logs。
# 最近 20 次分析的结果(每次一行,带耗时)
grep 'event=analysis.done' logs/code-association.log | tail -20
# 所有失败,以及失败发生在哪个阶段
grep 'event=analysis.failed' logs/code-association.log
# 最近 7 轮定时扫描:扫了几个、跳过几个
grep 'event=scan.done' logs/code-association.log | tail -7
# 只看错误速览,不翻几万行 INFO
tail -50 logs/code-association-error.log
# 把一次分析串起来:所有带 analysisId=42 的行,按时间顺序
grep 'analysisId=42' logs/code-association.log
# 慢在哪:按耗时排序(主日志里的 durationMs)
grep 'event=analysis.done' logs/code-association.log | sed 's/.*durationMs=//' | sort -n | tail -5
按 analysisId 串起一次分析是最常用的手法:一次分析会写 analysis.submit → analysis.start →
analysis.prepare → analysis.diff → analysis.detect → 每个 peer 一行 analysis.peer →
analysis.done(或 analysis.failed)。grep 'analysisId=42' 一次拿到全部节点,
各节点的 durationMs 相加就能看出时间花在哪一段。
归档里的历史用 zgrep,不用先解压:
zgrep 'event=analysis.failed' logs/archive/*.log.gz
服务根本没起来时看进程输出,而不是应用日志——logback 还没初始化,应用日志里可能什么都没有:
tail -50 logs/console.log # Windows: logs/console.log + logs/console.err.log
6. 级别与开关
| 级别 | 用在哪 |
|---|---|
| INFO | 会被反复查询的节点:开始 / 结束 / 结果 / 计数 |
| DEBUG | 细节判定,默认关闭 |
| WARN | 可恢复降级(回滚、跳过、用了兜底实现) |
| ERROR | 失败,必须 log.error(msg, e) 保留堆栈 |
默认 logging.level.com.geek.association: INFO。排查”为什么没扫这个程序“时才开 DEBUG:
# application.yml
logging:
level:
com.geek.association: DEBUG
或用启动参数临时开一次(不改文件):java -jar ... --logging.level.com.geek.association=DEBUG。
打开后 scan.program 会为每个程序写一行判定理由(有新提交 / 没有新提交 / 位置不可访问 / 没有对比对象),
看完记得关回去。
第三方日志:org.eclipse.jgit 固定 WARN——它每次 fetch 都会刷 DEBUG,打开后有用的行会被淹掉。
测试不落盘:src/test/resources/logback-test.xml 只往控制台写,跑测试不会污染 logs/,
也不会让 CI 因为日志文件残留而失败。
写盘是异步的(AsyncAppender,discardingThreshold=0、neverBlock=false):
分析线程不该因为日志 IO 停顿,但队列满时阻塞而不是丢日志——诊断日志丢一条,
事后分析就可能少一个关键节点。主日志与错误日志各有一个异步器(队列 2048 / 512):
AsyncAppender 只接受一个 appender-ref,把两份文件挂在同一个异步器上时第二个会被静默忽略,
错误日志就会变成永远空文件。
7. 隐私与边界
- 不打印凭据:API Key、token、带凭据的 URL 一律不进日志。Git 地址由
GitSupport.maskCredentials脱敏,AI 相关字段只记enabled/baseUrl/model/maxImpacts。 所以排查 401 时不要指望从日志里读到 Key——能读到的只有它是否配置过。 - 不打印大对象:完整 prompt、整份报告、HTTP 请求体都不进日志。
- 日志不是报告:报告在界面与数据库里(
GET /api/analyses/{id}/report)。日志只回答 “发生了什么、花了多久”,不承载结论。 - 不为打日志做昂贵操作:不重新解析、不重新查库、不序列化大对象——日志不能成为性能问题的一部分。
8. 加新事件之前
- 先在本页第 4 节的表里加一行:事件名、级别、关键字段;
- 事件名小写点分,字段 camelCase,带定位 id 与
durationMs; - 用
LogEvents.of(...)写一行,不拼自然语言句子; - 在 故障排查 里补一条”遇到这个症状时 grep 哪一行”——没有读者的日志等于没写。