文章

09 项目中的审计、日志、及可观测性

09 项目中的审计、日志、及可观测性

本文主要讲iot-alarm-copilot项目中的审计、日志、可观测性是怎么做的。这三者都是留痕,但受众不同、粒度不同、保留策略也不同,本篇详细展开阐述。

项目地址:https://github.com/Liyuwen85/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有很多,AlarmAcknowledgedAuditHandlerAlarmClosedAuditHandlerCommandCreatedAuditHandlerCommandTimedOutAuditHandlerInspectionTicketCreatedAuditHandler……每一种值得审计的领域事件都有一个对应的 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”
重试/降级WARNKafka retry listener 里的 attempt 日志
失败但被吞掉ERRORdead-letter capture 写库失败
第三方框架噪音WARNorg.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

每个上下文都有一个独立的 *MetricsRecorderAlarmMetricsRecorderAiSummaryMetricsRecorderRuleMetricsRecorderInspectionMetricsRecorder,它们都注入 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 值要有限可枚举

ruleCodeseverity 都是有限可枚举的领域概念,绝对不要把 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等都没做),但对于入门学习来说,已经完全足够了。


项目地址:https://github.com/Liyuwen85/iot-alarm-copilot

本文由作者按照 CC BY 4.0 进行授权