Skip to the content.

日志规范

这份文档是日志的唯一权威规范:日志放在哪、保留多久、一行长什么样、哪些节点必须打事件。 改日志相关代码或配置时先改这里,再改实现——反过来的话,排障时”文档说会有的那一行”就不存在了。

分工:故障排查 讲”出了什么症状怎么修”,本文讲”日志里有什么、怎么看”。 需要”我这次分析为什么慢/为什么失败”的答案时看这里,需要”服务起不来怎么办”时看那里。

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 原样接住:

真正的应用日志是 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. 保留策略

归档文件形如 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 - 消息。两处值得解释:

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 落实):

渲染出来是这样(含空格 / 等号的值自动加引号,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=-

除事件目录之外,本次还补了四个场景化事件,它们不替代上面任何一个:

全部事件

事件 级别 关键字段 什么时候出现
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. 隐私与边界

8. 加新事件之前

  1. 先在本页第 4 节的表里加一行:事件名、级别、关键字段;
  2. 事件名小写点分,字段 camelCase,带定位 id 与 durationMs;
  3. 用 LogEvents.of(...) 写一行,不拼自然语言句子;
  4. 在 故障排查 里补一条”遇到这个症状时 grep 哪一行”——没有读者的日志等于没写。