Skip to content

日志规范

IoT DC3 的日志要同时服务两个读者:本地开发时的人,和线上排障时的机器。这页讲清两者怎么兼顾——结构化消息、MDC 上下文、级别约定、脱敏红线,以及容器日志怎么轮转,让你写出"能被搜索、不会泄密"的日志。

你在这里:写业务代码或排查线上问题,想知道日志该怎么打、去哪儿看。配套阅读 可观测性故障排查

为什么这样设计

一条日志的价值不在它被打出来的那一刻,而在三天后有人 grep 它的时候。微服务下,一次设备命令会横跨网关、数据中心、驱动多个进程,日志散落在不同容器里——如果每条消息措辞各异、关键 ID 缺失、上下文不可关联,排障就退化成大海捞针。

所以 IoT DC3 把日志拆成两层职责:

  • 应用代码只负责写出稳定的事件名 + 结构化参数——同一件事在所有模块用同样的措辞和参数顺序,跨进程才能拼成一条链路。
  • dc3-common-log 的 Appender负责最终格式——本地输出彩色控制台便于人读,文件输出 JSON 便于机器解析与采集。

业务代码绝不为某种输出格式硬编码内容。这样换采集方案(ELK、Loki…)时,改的是 Appender,不是几百处 log.info

日志怎么流动

下图是一条日志从代码到落盘/采集的完整路径:应用写出事件,经 MDC 上下文槽位,再由两个 Appender 分别格式化。

两个 Appender 都挂在 root(默认级别 INFO),由 dc3-common-loglogback.xml 配置。JSON Appender 用 net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder,逐字段输出 timestampversionmessageloggerNamethreadNamelogLevellogLevelValuemdccontextNamestackTrace——其中 mdc 这一项,是下一节要讲的链路关联的预留槽位。

MDC:预留的链路上下文槽位

MDC(Mapped Diagnostic Context)是 SLF4J 的线程级上下文:往 MDC 里放的键值,会随本线程后续每一条日志自动渲染进输出。dc3-common-loglogback.xml 已在 JSON encoder 里挂上 <mdc/> provider,为这套能力预留了输出槽位——任何被放进 MDC 的字段都会逐条出现在 JSON 的 mdc 项里。

MDC 的目标用法是在请求入口放入 traceIdtenantIduserId,从而支撑两个能力:

  • 跨服务关联:同一个 traceId 贯穿网关 → 数据中心 → 驱动,按 traceId 一搜即可聚齐整条调用链。
  • 租户归因:每条日志带 tenantId,直接回答"是哪个租户触发的"——与平台的租户隔离边界一致。

MDC 自动注入尚未接线

encoder 的 <mdc/> provider 已就位,但当前代码库没有任何过滤器/拦截器/AOP 把 traceId/tenantId/userId 写入 MDC(全仓无 MDC.put),所以现阶段 JSON 输出里的 mdc 项实际为空。上面描述的链路关联是已规划、尚未实现的能力。在它接线之前,需要关联的字段请按下一节作为消息参数显式传入(如 tenantIddeviceId)。

写出结构化的消息

日志正文用英文、稳定事件名和 SLF4J 参数化占位符 {}。占位符让消息模板保持恒定(便于 grep 与日志聚合按模板归类),变量作为参数传入:

java
log.debug("Agentic tool invoked, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId);
log.warn("Agentic tool failed, tool={}, tenantId={}, deviceId={}", toolName, tenantId, deviceId, e);

参数适用时按下面的顺序组织,保证同类事件在不同模块长得一样:

text
module/action, tenantId, userId, resource IDs, filters, status/result, durationMs

示例:

java
log.info("Device registered, tenantId={}, deviceId={}, driverId={}", tenantId, deviceId, driverId);
log.debug("Agentic chat request received, mode={}, model={}, messageCount={}, conversationIdPresent={}, skill={}, tenantId={}, userId={}",
        mode, model, messageCount, conversationIdPresent, skill, tenantId, userId);

不要字符串拼接,不要丢堆栈

避免 + 拼接和 String.format——它们破坏消息模板、且无论级别是否输出都会先求值。捕获异常后,除非明确要隐藏堆栈,否则把异常对象作为最后一个参数传入(而非 e.getMessage()),SLF4J 会自动渲染完整堆栈:

java
// ✅ 模板稳定 + 完整堆栈
log.warn("Point read command failed, tenantId={}, deviceId={}, pointId={}", tenantId, deviceId, pointId, e);

// ❌ 拼接 + 丢失堆栈
log.info("Device registered: " + deviceId);
log.error("Failed to register device: {}", e.getMessage());

声明式方法日志:@Logs

dc3-common-log 提供 @Logs 注解(由 LogsAspect 这个 Spring AOP 切面拦截),可在方法上声明式地记录一条日志,免去手写。注解成员为 value(日志消息)、typeLogsTypeEnum,取值 INFO/WARN/DEBUG/ERROR,默认 INFO)、tag(分类标签)、save(是否持久化,默认 false):

java
@Logs(value = "warn-resource", type = LogsTypeEnum.WARN, tag = "resource", save = true)
public void someMethod() {
    // 方法体
}

当前仅测试用例使用

@Logs 切面已实现,但平台生产代码暂未在任何 Controller/Service 上使用它(仅 LogsAspectTest 覆盖)。它作为可选的声明式日志能力存在,业务日志现行约定仍以本页前述的 SLF4J 参数化写法为主。

日志级别约定

级别不是随手选的,它决定了线上默认输出量和告警噪声。root 默认 INFO,意味着 trace/debug 默认不落盘——把信息放对级别,排障时才能"该有的有、该静的静"。下表给出约定,先理解每一档的判断标准,再对照使用:

级别判断标准(什么时候用)
trace高频诊断细节,默认关闭,只在深挖单点问题时临时打开
debug请求细节、工具调用、查询条件、排障时有用的分支判断;默认不输出
info生命周期事件、启动摘要、长任务成功摘要、重要状态转换——线上稳态下应当能看到
warn可恢复失败、非法客户端输入、重试、降级、外部依赖异常但已有兜底
error不可恢复、需要运维关注、或导致当前操作失败的错误

判断要点:当前操作是否失败、是否需要人介入——失败且无兜底用 error,失败但已降级/重试用 warn,正常流程的关键节点用 info,其余诊断信息压到 debug。第三方框架噪声已在 logback.xml 里逐包压到 WARN(如 org.springframework.*com.zaxxer.hikari、MyBatis 等),不要在业务里把它们重新放大。

脱敏:密钥与隐私绝不明文

密钥、token、密码绝不明文落日志

不要在任何级别记录密钥、Bearer token、密码、完整 Authorization 头、原始私有载荷或任意请求体。一旦写进日志,它就进了文件、进了采集系统、进了备份——撤不回来。

需要佐证时只记录派生信息:是否存在、长度、前几位 + 长度、或资源 ID。例如校验失败时记 tokenPrefix=eyJ0..., tokenLen=212,而不是整段 token。

这条红线对两个高风险字段尤其关键,它们的明文一旦泄露等于整个鉴权链失守(参见 env 目录):

  • DC3_SECURITY_KEY — Auth Center 的 Token 签名密钥。
  • AUTH_HMAC_SECRET — 网关到后端签发 X-Auth-Principal 的 HMAC-SHA256 密钥。

对"可能敏感"的命令值(如写位号下发的 value),同样只记录长度/是否存在/资源 ID 等派生信息,不记原值:

java
// ✅ 只留派生信息
log.info("Point write accepted, tenantId={}, deviceId={}, pointId={}, valueLen={}", tenantId, deviceId, pointId, value.length());

// ❌ 把原始命令值/凭证打进日志
log.info("Point write, value={}, token={}", value, token);

容器日志轮转

应用内的 logback.xml 已自带文件滚动(SizeAndTimeBasedRollingPolicy,应用默认单文件 200MB、总量上限 20GB、保留 30 个历史文件、按天 .gz 归档)。但在容器部署下,进程的 stdout/stderr 由容器运行时接管,需要在 Compose 层另行限制磁盘占用——否则一个长跑容器的日志能把宿主机磁盘写满。

dc3 的 Compose 文件用一个共享的 x-logging 锚点,给每个应用服务统一挂上 Docker json-file 驱动的轮转策略:

yaml
# dc3/docker-compose-dev.yml
x-logging: &default-logging
  driver: json-file
  options:
    max-size: ${DC3_LOG_MAX_SIZE:-10M}   # 单个容器日志文件达到此大小即轮转
    max-file: "${DC3_LOG_MAX_FILE:-20}"  # 保留的轮转文件数

两个开关在根 .env(Compose-only,不注入本地 Java 进程)里调整:

变量默认值作用
DC3_LOG_MAX_SIZE10M单个容器日志文件轮转阈值
DC3_LOG_MAX_FILE20保留的轮转日志文件数

按默认值,每个容器最多占用约 10M × 20 = 200M 磁盘。查看与跟随容器日志:

bash
podman logs -f --tail 200 dc3-center-data
bash
# 从 iot-dc3/ 执行,跟随当前栈最近 200 行
make logs

应用内轮转 vs 容器轮转

两套轮转独立生效:logback.xml 管的是容器内 LOG_FILE 写出的滚动文件(默认在临时目录,体量较大);DC3_LOG_MAX_SIZE/DC3_LOG_MAX_FILE 管的是容器运行时捕获的 stdout/stderr。生产采集通常以后者(json-file)为采集源,前者作为容器内的二级保留。

延伸阅读

  • 可观测性 — 日志、指标、追踪如何协同,以及 ELK/Grafana 栈怎么起
  • 故障排查 — 拿到 traceId/tenantId 后如何定位一次失败
  • env 目录DC3_LOG_*DC3_SECURITY_KEY/AUTH_HMAC_SECRET 等变量的来源与边界

Released under the AGPL-3.0 License · 基于 AGPL-3.0 协议发布