---
title: 日志规范
---
# 日志规范
IoT DC3 的日志要同时服务两个读者:本地开发时的人,和线上排障时的机器。这页讲清两者怎么兼顾——结构化消息、MDC
上下文、级别约定、脱敏红线,以及容器日志怎么轮转,让你写出"能被搜索、不会泄密"的日志。
> 你在这里:写业务代码或排查线上问题,想知道日志该怎么打、去哪儿看。配套阅读 [可观测性](./observability)
> 与 [故障排查](./troubleshooting)。
## 为什么这样设计
一条日志的价值不在它被打出来的那一刻,而在三天后有人 `grep` 它的时候。微服务下,一次设备命令会横跨网关、数据中心、驱动多个进程,日志散落在不同容器里——如果每条消息措辞各异、关键
ID 缺失、上下文不可关联,排障就退化成大海捞针。
所以 IoT DC3 把日志拆成两层职责:
- **应用代码**只负责写出**稳定的事件名 + 结构化参数**——同一件事在所有模块用同样的措辞和参数顺序,跨进程才能拼成一条链路。
- **`dc3-common-log` 的 Appender**负责最终格式——本地输出彩色控制台便于人读,文件输出 JSON 便于机器解析与采集。
业务代码绝不为某种输出格式硬编码内容。这样换采集方案(ELK、Loki…)时,改的是 Appender,不是几百处 `log.info`。
## 日志怎么流动
下图是一条日志从代码到落盘/采集的完整路径:应用写出事件,经 MDC 上下文槽位,再由两个 Appender 分别格式化。
```mermaid
flowchart LR
App["业务代码
log.info(事件, 参数...)"] --> MDC["MDC 上下文槽位
(encoder 预留 mdc provider)"]
MDC --> CONSOLE["CONSOLE Appender
彩色文本"]
MDC --> FILE["FILE Appender
JSON (logstash encoder)"]
CONSOLE --> Dev["本地终端 / podman logs"]
FILE --> Roll["滚动文件 *.gz
(应用内 logback 轮转)"]
FILE --> Collect["日志采集
(ELK / Loki, 可选)"]
```
两个 Appender 都挂在 `root`(默认级别 `INFO`),由 `dc3-common-log` 的 `logback.xml` 配置。JSON Appender 用
`net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder`,逐字段输出 `timestamp`、`version`、`message`、`loggerName`、
`threadName`、`logLevel`、`logLevelValue`、**`mdc`**、`contextName`、`stackTrace`——其中 `mdc` 这一项,是下一节要讲的链路关联的预留槽位。
## MDC:预留的链路上下文槽位
MDC(Mapped Diagnostic Context)是 SLF4J 的线程级上下文:往 MDC 里放的键值,会随本线程后续每一条日志自动渲染进输出。
`dc3-common-log` 的 `logback.xml` 已在 JSON encoder 里挂上 `` provider,为这套能力预留了输出槽位——任何被放进 MDC
的字段都会逐条出现在 JSON 的 `mdc` 项里。
MDC 的目标用法是在请求入口放入 `traceId`、`tenantId`、`userId`,从而支撑两个能力:
- **跨服务关联**:同一个 `traceId` 贯穿网关 → 数据中心 → 驱动,按 `traceId` 一搜即可聚齐整条调用链。
- **租户归因**:每条日志带 `tenantId`,直接回答"是哪个租户触发的"——与平台的[租户隔离](../architecture/auth-rbac)边界一致。
::: info MDC 自动注入尚未接线
encoder 的 `` provider 已就位,但当前代码库**没有**任何过滤器/拦截器/AOP 把 `traceId`/`tenantId`/`userId` 写入
MDC(全仓无 `MDC.put`),所以现阶段 JSON 输出里的 `mdc` 项实际为空。上面描述的链路关联是**已规划、尚未实现**
的能力。在它接线之前,需要关联的字段请按下一节作为消息参数显式传入(如 `tenantId`、`deviceId`)。
:::
## 写出结构化的消息
日志正文用英文、稳定事件名和 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);
```
::: warning 不要字符串拼接,不要丢堆栈
避免 `+` 拼接和 `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`(日志消息)、`type`(`LogsTypeEnum`,取值 `INFO`/`WARN`/`DEBUG`/`ERROR`,默认 `INFO`)、`tag`(分类标签)、`save`(是否持久化,默认
`false`):
```java
@Logs(value = "warn-resource", type = LogsTypeEnum.WARN, tag = "resource", save = true)
public void someMethod() {
// 方法体
}
```
::: info 当前仅测试用例使用
`@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 等),不要在业务里把它们重新放大。
## 脱敏:密钥与隐私绝不明文
::: danger 密钥、token、密码绝不明文落日志
不要在任何级别记录密钥、Bearer token、密码、完整 `Authorization` 头、原始私有载荷或任意请求体。一旦写进日志,它就进了文件、进了采集系统、进了备份——撤不回来。
需要佐证时只记录**派生信息**:是否存在、长度、前几位 + 长度、或资源 ID。例如校验失败时记 `tokenPrefix=eyJ0..., tokenLen=212`
,而不是整段 token。
:::
这条红线对两个高风险字段尤其关键,它们的明文一旦泄露等于整个鉴权链失守(参见 [env 目录](../quickstart/environment)):
- `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_SIZE` | `10M` | 单个容器日志文件轮转阈值 |
| `DC3_LOG_MAX_FILE` | `20` | 保留的轮转日志文件数 |
按默认值,每个容器最多占用约 `10M × 20 = 200M` 磁盘。查看与跟随容器日志:
::: code-group
```bash [podman]
podman logs -f --tail 200 dc3-center-data
```
```bash [make]
# 从 iot-dc3/ 执行,跟随当前栈最近 200 行
make logs
```
:::
::: info 应用内轮转 vs 容器轮转
两套轮转独立生效:`logback.xml` 管的是容器内 `LOG_FILE` 写出的滚动文件(默认在临时目录,体量较大);`DC3_LOG_MAX_SIZE`/
`DC3_LOG_MAX_FILE` 管的是容器运行时捕获的 `stdout`/`stderr`。生产采集通常以后者(`json-file`)为采集源,前者作为容器内的二级保留。
:::
## 延伸阅读
- [可观测性](./observability) — 日志、指标、追踪如何协同,以及 ELK/Grafana 栈怎么起
- [故障排查](./troubleshooting) — 拿到 `traceId`/`tenantId` 后如何定位一次失败
- [env 目录](../quickstart/environment) — `DC3_LOG_*` 与 `DC3_SECURITY_KEY`/`AUTH_HMAC_SECRET` 等变量的来源与边界