09 项目中的审计、日志、及可观测性
本文主要讲iot-alarm-copilot项目中的审计、日志、可观测性是怎么做的。这三者都是留痕,但受众不同、粒度不同、保留策略也不同,本篇详细展开阐述。
目录
受众不同、粒度不同、保留策略不同
审计:业务事实的留痕
日志:开发者的视角
可观测性:系统指标和追踪
三层之间的协作
受众不同、粒度不同、保留策略不同
| 维度 | 审计(audit) | 日志(log) | 可观测性(metric / trace) |
|---|---|---|---|
| 受众 | 业务运营、合规、客户 | 开发、运维 | 监控系统、SRE、自动化告警 |
| 粒度 | 业务事件粒度(一条告警、一次确认) | 函数调用粒度(线程、栈、消息) | 聚合粒度(QPS、p99、错误率) |
| 保留时长 | 月~年(合规要求) | 周~月(成本受限) | 分钟~季度(按层降采样) |
| 可查询性 | SQL / 业务 API / BI | 全文搜索 / 关键字过滤 | 时间序列查询 / 标签过滤 |
| 可解释性 | 必须可读、可追溯 | 工程师能看懂就行 | 数字 + 维度 |
| 是否影响业务正确性 | 是(业务事实) | 否(旁观者) | 否(旁观者) |
这三件事各做各的,工具栈各不相同:
- 审计,独立领域聚合(
AuditLogEntry)+ 独立数据库表 + 业务 API; - 日志,本地logback / SLF4J,附加 traceId / spanId 用于关联;
- 可观测性,Micrometer + Prometheus + Spring Boot Actuator;
审计:业务事实的留痕
audit 上下文在本项目里是一个独立的限界上下文,不是一个工具类、不是一个 AOP 切面、也不是日志框架的一部分。
它是一个聚合:AuditLogEntry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record AuditLogEntry(
Long id,
AuditEventType eventType,
AuditAggregateType aggregateType,
AuditAggregateId aggregateId,
String deviceId,
String payloadJson,
Instant occurredAt) {
public AuditLogEntry {
if (payloadJson.isBlank()) {
throw new BaseDomainException("payloadJson must not be blank");
}
}
}
四个关键字段:
eventType:业务事件类型(alarm.created/command.timed_out/inspection.confirmed);aggregateType+aggregateId:被审计的业务对象类型 + ID(alarm_event+ 123 /command+ 456);deviceId:跨上下文常用的查询维度;payloadJson:事件发生时的完整内容。
它由跨上下文事件驱动
audit 上下文不主动产生数据,它订阅其他上下文的领域事件,看 AlarmCreatedAuditHandler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class AlarmCreatedAuditHandler {
@EventListener
public void onAlarmCreated(AlarmCreatedEvent event) {
auditApplicationService.record(new RecordAuditLogCommand(
event.eventType(),
"alarm_event",
event.alarmId().toString(),
event.deviceId(),
writePayload(event), // ← 完整事件序列化成 JSON
event.occurredAt()));
log.info("Audit recorded for alarmId={}", event.alarmId());
}
private String writePayload(AlarmCreatedEvent event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Failed to serialize alarm audit payload", exception);
}
}
}
audit/interfaces/event 目录下,这种 handler有很多,AlarmAcknowledgedAuditHandler、AlarmClosedAuditHandler、CommandCreatedAuditHandler、CommandTimedOutAuditHandler、InspectionTicketCreatedAuditHandler……每一种值得审计的领域事件都有一个对应的 handler。
这种“集中订阅 + 分散转换”的模式有几个直接好处,如审计逻辑不污染业务上下文、审计字段可以独立演进、新增需要审计的事件类型零成本等等。
日志:开发者的视角
本项目简单处理SLF4J + Logback,没有用 ELK / Loki 这类集中式平台(学习项目阶段还没必要)。
日志格式里带 traceId / spanId
logback-spring.xml 的 pattern 长这样:
1
%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] ${appName} %logger{36} traceId=%X{traceId:-} spanId=%X{spanId:-} - %msg%n
注意 %X{traceId:-},这是 SLF4J 的 MDC(Mapped Diagnostic Context),从当前线程上下文里取 traceId 这个 key,串联了整个上行链路。
本项目目前还没接 OpenTelemetry / Skywalking 等,但日志格式已经为接入留了位,以后 traceId / spanId 会由这些组件自动填进 MDC,不需要再改 logback。
日志级别按场景分
级别虽然很简单,但还是要说一下,主要突出业务场景选择,对应不同级别:
| 场景 | 级别 | 例子 |
|---|---|---|
| 业务正常推进 | INFO | “Audit recorded for alarmId=123” |
| 业务规则拦截 | WARN | “Alarm created. ruleCode=temperature_critical” |
| 重试/降级 | WARN | Kafka retry listener 里的 attempt 日志 |
| 失败但被吞掉 | ERROR | dead-letter capture 写库失败 |
| 第三方框架噪音 | WARN | org.apache.kafka 调成 WARN |
第三方组件的日志级别设置
logback-spring.xml 里这几行:
1
2
3
<logger name="org.apache.kafka" level="WARN"/>
<logger name="org.eclipse.paho.client.mqttv3" level="WARN"/>
<logger name="org.springframework.web" level="INFO"/>
把 Kafka 和 MQTT 客户端的日志设为 WARN,这两个组件默认 INFO 级别会喷出大量心跳、重连、metadata 同步的日志。
可观测性:系统指标和追踪
可观测性这一层走的是 Spring Boot Actuator + Micrometer + Prometheus 的标准栈。
暴露 Prometheus 端点
application.yml 里这几行打开了核心入口:
1
2
3
4
5
6
7
8
9
10
11
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
tags:
application: ${spring.application.name}
/actuator/prometheus 暴露给 Prometheus 抓取,/actuator/health 给 K8s liveness/readiness probe。所有指标自动带上 application tag,多服务拉到同一个 Prometheus 时不会混淆。
业务指标用 MetricsRecorder
每个上下文都有一个独立的 *MetricsRecorder:AlarmMetricsRecorder、AiSummaryMetricsRecorder、RuleMetricsRecorder、InspectionMetricsRecorder,它们都注入 Micrometer 的 MeterRegistry,把业务指标登记成 counter/timer。
看 AlarmMetricsRecorder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class AlarmMetricsRecorder {
private final MeterRegistry meterRegistry;
public void recordCreated(String ruleCode, String severity) {
meterRegistry.counter(
"iot.alarm.processed.total",
"result", "created",
"ruleCode", ruleCode,
"severity", severity).increment();
}
public void recordDeduplicated(String ruleCode, String severity) {
meterRegistry.counter(
"iot.alarm.processed.total",
"result", "deduplicated",
"ruleCode", ruleCode,
"severity", severity).increment();
}
}
注意三个方面:
1. 指标名是稳定的,差异通过 tag 区别
iot.alarm.processed.total 是同一个指标名,”创建”和”去重”通过 result tag 区分。Prometheus 上 iot_alarm_processed_total{result="created"} 和 iot_alarm_processed_total{result="deduplicated"} 是同一指标的两个序列。
为什么不用两个指标名(iot.alarm.created.total + iot.alarm.deduplicated.total)? 因为 PromQL 里要算”去重比例” = deduplicated / total,同名 + tag 一行就能算出来;不同名要写 sum(deduplicated) / (sum(created) + sum(deduplicated)),麻烦且容易写错。
2. tag 值要有限可枚举
ruleCode 和 severity 都是有限可枚举的领域概念,绝对不要把 deviceId、alarmId、用户 ID 这种高基数字段当 tag,大量设备就把Prometheus 时间序列数会撑爆了。高基数维度的需求,应该走日志/审计,不能走指标。
3. recorder 在 interfaces/event 层
AlarmMetricsRecorder 放在 alarm/interfaces/event/ 目录下,跟 RuleTriggeredAlarmHandler 同层。这是因为指标记录是接口层的事,它是”上下文对外暴露运行时状态”的一个出口,不属于领域逻辑。
如果把 metrics 调用塞到 AlarmApplicationService 里,就把”业务行为”和”可观测性”耦合了,以后想换监控栈(比如换成 OpenTelemetry),得动核心业务代码。
AI 上下文的指标更典型
AiSummaryMetricsRecorder 同时记 counter 和 timer:
1
2
3
4
5
6
7
public void recordGenerated(String result) {
// counter: 成功 / 失败 数量
}
public void recordGenerateLatency(Duration duration) {
// timer: LLM 调用延迟分布
}
LLM 调用是慢调用 + 失败概率高 + 成本敏感的典型,这就要监控:QPS(counter)、延迟分布(timer)、失败率(counter 用 result tag 拆分)。AiSummaryApplicationService.generateIfPending 在成功和失败两条路径上都调了这两个 recorder:
1
2
3
publishGenerated(succeededTask);
aiSummaryMetricsRecorder.recordGenerated("success");
aiSummaryMetricsRecorder.recordGenerateLatency(Duration.between(startedAt, Instant.now()));
三类指标都要有
一个相对完整的可观测体系需要这三类:
- 黄金指标(Golden Signals):QPS、延迟、错误率、饱和度,
recordGenerated+recordGenerateLatency是这一层; - 业务指标:每个领域上下文的关键业务事件计数,告警创建数、规则命中数、AI 摘要成功率;
- 资源指标:JVM、连接池、线程池、Kafka lag,这部分 Spring Boot Actuator 自动暴露,不需要自己写。
本项目只覆盖了业务指标和资源指标,黄金指标里的”延迟”主要在 AI 这条慢链路上做了,其他链路还没补齐。
三层之间的协作
一条告警从产生到结束,三层各自留下什么痕迹:
告警创建那一刻
1
2
3
4
5
6
7
8
9
10
// alarm/application/AlarmApplicationService.java
@Transactional
public AlarmSaveResult createIfAbsent(CreateAlarmFromRuleCommand command) {
Alarm alarm = Alarm.openFromRule(...);
AlarmSaveResult saveResult = alarmRepository.saveIfAbsent(alarm);
if (saveResult.created()) {
publishCreated(saveResult.alarm()); // 发 AlarmCreatedEvent
}
return saveResult;
}
事件发出后,三层并行被触发:
| 层 | 谁触发 | 留下什么 |
|---|---|---|
| 审计 | AlarmCreatedAuditHandler.onAlarmCreated(...) | 数据库 audit_log 表新增一行,payloadJson 里是完整事件序列化 |
| 日志 | AlarmCreatedAuditHandler 内 + RuleTriggeredAlarmHandler 内 | 控制台/文件出现两条带 traceId 的日志 |
| 指标 | RuleTriggeredAlarmHandler.onRuleTriggered 内调 AlarmMetricsRecorder.recordCreated(...) | Prometheus iot.alarm.processed.total{result=created, ruleCode=..., severity=...} +1 |
这三者互不依赖、互不重叠、互相可对账。如下对账可互相验证:
- 业务质疑”昨天为什么少处理了 100 条告警”,可查
iot.alarm.processed.total指标,和 audit 表 count 对账; - SRE 看见某条
recordGenerated("failed")突增,可按时间窗口去日志里 grep 错误细节,再去 audit 表看哪些告警的 AI 摘要失败了; - 合规要求审计某个客户的所有告警动作,可audit 表按 deviceId 直查,结果可以用日志里同一时间段的 traceId 还原完整调用链。
这种对账机制,任何一层数据出现异常或可疑,都能用另外两层交叉验证。
至此这几篇,已经把
backend中的核心讲透彻了,在遵循DDD原则的情况下,构建一个切近生产的IoT后端学习项目。虽然只是一个单体项目,距离百万级生产环境,相差还很大(如:OTA、多租户、服务治理、异常检测、CEP等都没做),但对于入门学习来说,已经完全足够了。