feat(syslog-consumer-rule): 增加多节点原始日志采集并修复天眼/微步JSON规则匹配

一、增加现场原始日志采集功能

1. 新增按设备采集原始日志的API:
   - 指定设备ID;
   - 采集时长最长10分钟;
   - 单次最多返回50条样本。

2. 样本选择策略:
   - 优先保留时间窗口内最早到达的N条日志;
   - 后续出现JSON解析异常、非标、规则执行异常、InfluxDB/达梦写入异常或Retry/DLT时,
     使用异常日志逐条替换最早的正常样本;
   - 异常样本同样遵循先到先得。

3. 支持三个syslog-consumer-rule实例联合采集:
   - 采集请求只需发送到任意一个实例;
   - 三个实例通过公共Redis同步采集任务并汇总候选样本;
   - 不需要配置其他实例IP;
   - Redis只保存有界候选集,不保存时间窗口内的全部日志。

4. 生命周期和资源控制:
   - 采集到期后自动停止接收新日志;
   - 默认进入30秒DRAINING阶段,等待异步处理异常完成样本替换;
   - 最终结果默认保留30分钟,之后自动删除;
   - 单实例、单会话最多保留“前N条+前N条异常”;
   - Redis故障不阻断Kafka主消费链路。

5. 返回规则解析和非标表截断之前的完整原始报文,不受
   HB_XDR_TRUNCATED字段截断影响。

6. 原始日志采集和多实例汇聚默认启用:
   SYSLOG_RAW_CAPTURE_ENABLED=true
   SYSLOG_RAW_CAPTURE_CLUSTER_ENABLED=true

二、修复天眼/微步JSON规则匹配问题

1. JSON规则配置了cropper时,先判断原始报文是否包含对应head_key;
   不匹配的规则不再将当前报文误判为非法JSON。

2. 同一设备绑定多条规则时,单条不适用或解析失败的JSON规则不再立即终止整个规则链,
   程序会继续匹配后续适用规则。

3. 只有全部适用JSON规则均解析失败、且没有其他规则成功处理时,才写入
   “JSON解析异常”非标记录。

4. 对cropper截取后仍包含Syslog或设备前缀的内容,在JSON专用路径中重新收敛到
   第一个“{”至最后一个“}”,确保交给Jackson的是完整外层JSON。

5. 修复以下现场场景:
   - 天眼规则129截取结果仍带有alarm前缀;
   - 微步设备同时绑定固定偏移规则和Threatbook/TDP JSON规则;
   - 不适用的固定偏移规则抢先产生JSON解析异常;
   - Syslog头加JSON正文不能继续匹配后续适用规则。

三、增加现场采集工具及运维说明

新增upgrade-doc/v5:
   - README.md:升级、配置、生命周期和故障排查说明;
   - capture-device-raw-logs.sh:一键创建采集会话、等待完成并自动落盘。

脚本输出:
   - result.json:完整采集结果;
   - samples.jsonl:每行一条样本及处理状态;
   - raw/*.log:每条未截断原始报文;
   - summary.txt:采集数量、异常数量和替换数量;
   - latest.json:中断或超时时的最后有效快照。

四、兼容性与升级范围

- 兼容JDK 8;
- 无需新增或修改达梦数据库表;
- 无需创建新的Kafka Topic;
- 需要滚动升级全部syslog-consumer-rule实例;
- 三个实例必须连接相同的Redis;
- 本次提交不包含syslog-serve的高内存、WAL或JVM参数调整。
This commit is contained in:
王绍宏
2026-09-14 23:11:45 +08:00
parent 7e6b2fc4dc
commit 1c07acc01d
17 changed files with 3131 additions and 43 deletions
+5 -2
View File
@@ -8,12 +8,15 @@
| `v2/` | 从提交 `b61ec2e` 升级到当前源码所需的增量资料 |
| `v3/` | 日统计异步投影,以及大日志独立 Kafka 通道、字节背压和存储截断升级资料 |
| `v4/` | Kafka 积压分级采样、告警优先落库和 InfluxDB 非阻塞旁路升级资料 |
| `v5/` | 按设备短时采集未截断原始日志,并将异常样本优先落盘的现场诊断工具 |
新环境按 v1、v2、v3、v4 的顺序执行;已经完成 v3 的现场只执行 v4。已经执行过早期 v3 日统计升级的
现场,只需补做 v3 中的大日志 Topic、应用升级和验证步骤,再执行 v4;不要重复执行数据库升级脚本。
新环境按 v1、v2、v3、v4、v5 的顺序执行;已经完成 v4 的现场只需升级应用并执行 v5。已经执行过早期
v3 日统计升级的现场,只需补做 v3 中的大日志 Topic、应用升级和验证步骤,再执行 v4、v5;不要重复执行
数据库升级脚本。
数据库脚本不要重复执行。执行过程中有报错时,先确认已经落库的对象,再决定从哪里继续。
v2 的执行步骤见 [v2/README.md](./v2/README.md)。
v3 的执行步骤见 [v3/README.md](./v3/README.md)。
v4 的执行步骤见 [v4/README.md](./v4/README.md)。
v5 的执行步骤见 [v5/README.md](./v5/README.md)。
@@ -18,6 +18,16 @@ NORMALIZATION_RULE_SELECTION_DETAIL_LOG_ENABLED=false
NORMALIZATION_RULE_SELECTION_EXPLICIT_ENABLED=true
NORMALIZATION_RULE_SELECTION_AUTOMATIC_FALLBACK_ENABLED=true
# 现场原始日志短时采样;三个消费实例通过同一个 Redis 汇聚候选结果。
SYSLOG_RAW_CAPTURE_ENABLED=true
SYSLOG_RAW_CAPTURE_CLUSTER_ENABLED=true
SYSLOG_RAW_CAPTURE_MAX_ACTIVE_SESSIONS=20
SYSLOG_RAW_CAPTURE_MAX_RETAINED_SESSIONS=100
SYSLOG_RAW_CAPTURE_CLUSTER_ACTIVATION_DELAY_MS=1000
SYSLOG_RAW_CAPTURE_CLUSTER_SYNC_INTERVAL_MS=200
SYSLOG_RAW_CAPTURE_SETTLE_SECONDS=30
SYSLOG_RAW_CAPTURE_RESULT_RETENTION_MINUTES=30
# 统计投影解耦:三个 syslog-consumer-rule 实例保持完全相同的 Topic 与 Group ID。
STATS_MATERIALIZATION_ENABLED=true
STATS_MATERIALIZATION_TOPIC=agent-01-syslog-topic.STATS.MATERIALIZE
@@ -23,9 +23,23 @@ syslog-serve/docker-compose/docker-compose-serve-prod.yaml
- SYSLOG_MESSAGE_MAX_BYTES=6291456
- SYSLOG_LARGE_MESSAGE_THRESHOLD_BYTES=131072
# WAL 和 Kafka 投递最多各占用 256 MiB 在途内存。
- SYSLOG_WAL_MAX_PENDING_BYTES=268435456
- SYSLOG_DISPATCH_MAX_INFLIGHT_BYTES=268435456
# 200KiB/条容量基线;WAL 高水位约在 14,000 条、2.7~2.8GiB 对齐触发。
- SYSLOG_WAL_QUEUE_CAPACITY=20000
- SYSLOG_WAL_MAX_PENDING_BYTES=4294967296
- SYSLOG_WAL_HIGH_WATERMARK_PERCENT=70
- SYSLOG_WAL_LOW_WATERMARK_PERCENT=40
- SYSLOG_WAL_FLUSH_RECORDS=200
- SYSLOG_WAL_FLUSH_INTERVAL_MS=100
- SYSLOG_WAL_SEGMENT_MAX_BYTES=4294967296
# Kafka 投递限制正文最多 1GiB 在途,并增加加密后的 Producer 缓冲。
- SYSLOG_DISPATCH_WORKER_THREADS=8
- SYSLOG_DISPATCH_MAX_INFLIGHT=5000
- SYSLOG_DISPATCH_MAX_INFLIGHT_BYTES=1073741824
- SPRING_KAFKA_PRODUCER_PROPERTIES_BUFFER_MEMORY=536870912
# Dockerfile 不展开 JAVA_OPTS,使用 JVM 自动读取的 JAVA_TOOL_OPTIONS。
- JAVA_TOOL_OPTIONS=-Xms8g -Xmx24g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxDirectMemorySize=8g -XX:+ExitOnOutOfMemoryError
# 大日志 Kafka Topic 及 Producer 请求上限。
- KAFKA_PRODUCER_LARGE_TOPIC=agent-01-syslog-topic.large
@@ -37,7 +51,9 @@ syslog-serve/docker-compose/docker-compose-serve-prod.yaml
- `SYSLOG_MAX_FRAME_LENGTH`:TCP 单帧最大长度;
- `SYSLOG_MESSAGE_MAX_BYTES`:所有来源的单条日志硬上限,超过后拒绝;
- `SYSLOG_LARGE_MESSAGE_THRESHOLD_BYTES`:达到该值后走大日志 Topic;
- `SYSLOG_WAL_MAX_PENDING_BYTES`、`SYSLOG_DISPATCH_MAX_INFLIGHT_BYTES`:限制 JVM 在途数据量。
- `SYSLOG_WAL_QUEUE_CAPACITY`、`SYSLOG_WAL_MAX_PENDING_BYTES`:共同限制 WAL 接入条数和原文字节量;
- `SYSLOG_DISPATCH_MAX_INFLIGHT_BYTES`:限制 Kafka Dispatcher 在途正文总量;
- `JAVA_TOOL_OPTIONS`:设置实际 JVM 堆和直接内存上限,不能替换成当前 Dockerfile 不会展开的 `JAVA_OPTS`。
`SYSLOG_MAX_FRAME_LENGTH` 必须大于或等于 `SYSLOG_MESSAGE_MAX_BYTES`。如果设备发送 GBK,应将
`SYSLOG_CHARSET` 改为 `GBK`。
@@ -0,0 +1,45 @@
# 查询 Kafka 当前积压
在 Kafka 的 `bin` 目录执行。查询操作只读,不会修改消息或消费组 offset。
## 查询主日志消费积压
```bash
./kafka-consumer-groups.sh \
--bootstrap-server 10.141.121.35:9092 \
--group agent-01-syslog-group-dm \
--describe
```
如果现场主消费组仍使用旧名称,将组名改为 `agent-01-syslog-group`。
## 查询所有消费组的总积压
```bash
./kafka-consumer-groups.sh \
--bootstrap-server 10.141.121.35:9092 \
--all-groups \
--describe \
| awk '$6 ~ /^[0-9]+$/ {lag[$1]+=$6} END {for (g in lag) print lag[g], g}' \
| sort -nr
```
输出第一列是总积压条数,第二列是消费组名称,积压最多的排在最前面。
需要查看某个消费组各分区的明细时执行:
```bash
./kafka-consumer-groups.sh \
--bootstrap-server 10.141.121.35:9092 \
--group 消费组名称 \
--describe
```
重点查看:
- `CURRENT-OFFSET`:已经消费并提交的位置;
- `LOG-END-OFFSET`:Kafka 当前最新位置;
- `LAG`:该分区积压条数。
间隔一分钟查询两次:`LAG` 下降表示正在追赶;`LAG` 上升表示消费速度低于数据进入速度;
`CURRENT-OFFSET` 不变且 `LAG` 大于零,表示消费没有进展。
@@ -0,0 +1,61 @@
# 查询主动丢弃的非告警数据
积压采样只丢弃部分普通日志和非标消息,告警全部保留。审计数据保存在达梦表
`syslog_backlog_retention_hourly`。
## 查看当前运行累计值
```bash
docker exec syslog-consumer-rule-dm sh -c \
'curl -fsS http://127.0.0.1:8289/xdrrule/api/syslog/backlog-sampling/status'
```
主要字段:
- `normal_rows_dropped_total`:本次应用启动后主动丢弃的普通日志行数;
- `non_normal_rows_dropped_total`:本次应用启动后主动丢弃的非标消息行数;
- `raw_dropped_total`:本次应用启动后没有写入 InfluxDB 的原文条数。
## 查询指定一天的丢弃总数
以下查询统计上海时间 `2026-09-13` 一整天:
```sql
SELECT
NVL(SUM(normal_rows_dropped), 0) AS normal_dropped,
NVL(SUM(non_normal_rows_dropped), 0) AS non_normal_dropped,
NVL(SUM(normal_rows_dropped), 0)
+ NVL(SUM(non_normal_rows_dropped), 0) AS non_alarm_rows_dropped,
NVL(SUM(raw_dropped), 0) AS raw_dropped,
NVL(SUM(alarm_rows_total), 0) AS alarm_total,
NVL(SUM(alarm_rows_saved), 0) AS alarm_saved
FROM syslog_backlog_retention_hourly
WHERE source_hour >= TIMESTAMP '2026-09-13 00:00:00'
AND source_hour < TIMESTAMP '2026-09-14 00:00:00';
```
## 按小时统计
```sql
SELECT
source_hour,
SUM(normal_rows_dropped) AS normal_dropped,
SUM(non_normal_rows_dropped) AS non_normal_dropped,
SUM(normal_rows_dropped) + SUM(non_normal_rows_dropped)
AS non_alarm_rows_dropped,
SUM(raw_dropped) AS raw_dropped,
SUM(alarm_rows_total) AS alarm_total,
SUM(alarm_rows_saved) AS alarm_saved,
MAX(max_lag_ms) AS max_lag_ms
FROM syslog_backlog_retention_hourly
WHERE source_hour >= TIMESTAMP '2026-09-13 00:00:00'
AND source_hour < TIMESTAMP '2026-09-14 00:00:00'
GROUP BY source_hour
ORDER BY source_hour;
```
`non_alarm_rows_dropped` 是普通和非标两类达梦业务行的合计。`raw_dropped` 是原始消息口径,
可能与这两类记录重叠,因此不要再把 `raw_dropped` 加到 `non_alarm_rows_dropped` 中。
正常情况下 `alarm_total` 应等于 `alarm_saved`。查询结果为零或没有小时记录,表示该时间段没有触发
积压采样,或者采样审计尚未成功写入。
@@ -0,0 +1,139 @@
# v5 现场设备原始日志采样
## 功能说明
v5 为 `syslog-consumer-rule` 增加按设备短时采集原始日志的诊断接口,并提供自动落盘脚本。
- 单次采集最长 600 秒;
- 单次最多返回 50 条;
- 先保留采集窗口内最早的 N 条日志;
- 后续出现非标、规则执行异常、InfluxDB 写入异常、达梦写入异常或 Retry/DLT 时,用异常日志逐条替换最早的正常样本;
- 返回内容来自规则解析和非标字段截断之前,不受 `HB_XDR_TRUNCATED` 影响;
- 三个 `syslog-consumer-rule` 实例通过相同 Redis 汇聚有界候选集;
- 不新增数据库表,也不需要执行达梦 SQL。
## 升级与配置
滚动升级全部 `syslog-consumer-rule` 实例,并确保各实例连接同一个 Redis。原始日志采集和多实例汇聚默认
均已启用;建议在所有实例显式保留以下配置,便于现场识别和后续调整:
```yaml
- SYSLOG_RAW_CAPTURE_ENABLED=true
- SYSLOG_RAW_CAPTURE_CLUSTER_ENABLED=true
```
推荐保留默认配置:
```yaml
- SYSLOG_RAW_CAPTURE_MAX_ACTIVE_SESSIONS=20
- SYSLOG_RAW_CAPTURE_MAX_RETAINED_SESSIONS=100
- SYSLOG_RAW_CAPTURE_CLUSTER_ACTIVATION_DELAY_MS=1000
- SYSLOG_RAW_CAPTURE_CLUSTER_SYNC_INTERVAL_MS=200
- SYSLOG_RAW_CAPTURE_SETTLE_SECONDS=30
- SYSLOG_RAW_CAPTURE_RESULT_RETENTION_MINUTES=30
```
如果多个 Java 进程运行在同一主机且没有不同的容器 `HOSTNAME`,应为每个实例配置不同标识:
```yaml
- SYSLOG_RAW_CAPTURE_NODE_ID=consumer-rule-01
```
接口会返回完整原文,应只允许受信任的运维网络或现有管理网关访问。
## 一键采集
脚本依赖 `bash`、`curl` 和 `python3`。先增加执行权限:
```bash
chmod 700 /升级资料路径/v5/capture-device-raw-logs.sh
```
基本用法:
```bash
/升级资料路径/v5/capture-device-raw-logs.sh \
38 \
60 \
10 \
http://127.0.0.1:8289/xdrrule \
/data/haobang/raw-log-captures
```
参数依次是:
1. 设备 ID;
2. 采集秒数,默认 60,最大 600;
3. 样本数,默认 10,最大 50;
4. `syslog-consumer-rule` 基础地址,默认 `http://127.0.0.1:8289/xdrrule`;
5. 本地输出目录,默认当前目录下的 `raw-log-captures`。
如果接口经过鉴权网关,可通过环境变量传入请求头:
```bash
RAW_CAPTURE_AUTH_HEADER='Authorization: Bearer 替换为现场令牌' \
./capture-device-raw-logs.sh 38 120 20 https://管理地址/xdrrule /data/raw-samples
```
## 输出文件
每次运行生成独立目录:
```text
device-38_20260914-190000_会话ID/
├── start.json
├── latest.json
├── result.json
├── samples.jsonl
├── session-id.txt
├── summary.txt
└── raw/
├── 001_normal_事件ID.log
└── 002_anomaly_事件ID.log
```
- `result.json`:最终完整 API 响应,是最完整的交付文件;
- `samples.jsonl`:每行一条样本及其处理状态、异常原因和原文;
- `raw/*.log`:每条原始报文单独保存,内容不添加分隔符,也不主动截断;
- `summary.txt`:会话时间、收到的消息数、异常数和替换数;
- `latest.json`:脚本被中断或等待超时时保留的最后一次有效快照。
脚本设置 `umask 077`,新生成文件默认只允许执行脚本的账号访问。
## 生命周期
状态依次为:
```text
SCHEDULED -> RUNNING -> DRAINING -> COMPLETED
```
采集时间到达后不再接收新日志,随后默认等待 30 秒,让窗口内日志的异步处理异常完成替换。进入
`COMPLETED` 后结果默认继续保留 30 分钟,之后 Redis TTL 自动删除。
脚本会一直轮询到 `COMPLETED` 并自动导出,不需要人工再次查询。关闭终端或脚本等待超时不会使服务端会话
永久运行,会话仍按上述时间自动结束。
## 常见问题
### 返回 503:现场原始日志采样功能未启用
确认实例没有通过环境变量设置 `SYSLOG_RAW_CAPTURE_ENABLED=false`,并确认实例已经升级到 v5 JAR。
### 返回 503:跨实例原始日志采样暂不可用
检查所有 `syslog-consumer-rule` 实例到 Redis 的网络、密码、数据库编号是否一致。Redis 故障只影响诊断
采集,不会中断主 Kafka 消费。
### 返回 429
活动采集会话达到上限。等待已有会话进入 `COMPLETED`,或核对是否有人重复创建采集任务。
### 脚本退出码为 2
表示客户端等待超时,但已经把最后一次有效快照写入输出目录。服务端会话仍会自动结束,可在结果保留期内
使用 `session-id.txt` 中的 ID 再查询:
```bash
curl 'http://127.0.0.1:8289/xdrrule/api/syslog/raw-captures/会话ID'
```
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 原始日志可能包含敏感字段,确保新建目录和文件默认只允许当前用户读取。
umask 077
usage() {
cat <<'EOF'
用法:
capture-device-raw-logs.sh DEVICE_ID [DURATION_SECONDS] [MESSAGE_COUNT] [BASE_URL] [OUTPUT_DIR]
参数:
DEVICE_ID 必填,设备ID
DURATION_SECONDS 可选,采集秒数,1~600,默认60
MESSAGE_COUNT 可选,样本条数,1~50,默认10
BASE_URL 可选,默认 http://127.0.0.1:8289/xdrrule
OUTPUT_DIR 可选,默认当前目录下 raw-log-captures
可选环境变量:
RAW_CAPTURE_AUTH_HEADER 例如 Authorization: Bearer xxx
RAW_CAPTURE_POLL_SECONDS 查询间隔,默认2秒
RAW_CAPTURE_WAIT_TIMEOUT_SECONDS 总等待超时;默认采集时间加420秒
RAW_CAPTURE_HTTP_MAX_TIME_SECONDS 单次HTTP超时,默认120秒
EOF
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "缺少命令: $1"
}
positive_integer() {
[[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1 > 0 ))
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
DEVICE_ID="${1:-}"
DURATION_SECONDS="${2:-60}"
MESSAGE_COUNT="${3:-10}"
BASE_URL="${4:-http://127.0.0.1:8289/xdrrule}"
OUTPUT_DIR="${5:-$(pwd)/raw-log-captures}"
POLL_SECONDS="${RAW_CAPTURE_POLL_SECONDS:-2}"
HTTP_MAX_TIME_SECONDS="${RAW_CAPTURE_HTTP_MAX_TIME_SECONDS:-120}"
[[ -n "$DEVICE_ID" ]] || { usage >&2; fail "必须提供 DEVICE_ID"; }
positive_integer "$DEVICE_ID" || fail "DEVICE_ID 必须是大于0的整数"
positive_integer "$DURATION_SECONDS" || fail "DURATION_SECONDS 必须是大于0的整数"
positive_integer "$MESSAGE_COUNT" || fail "MESSAGE_COUNT 必须是大于0的整数"
positive_integer "$POLL_SECONDS" || fail "RAW_CAPTURE_POLL_SECONDS 必须是大于0的整数"
positive_integer "$HTTP_MAX_TIME_SECONDS" || fail "RAW_CAPTURE_HTTP_MAX_TIME_SECONDS 必须是大于0的整数"
(( 10#$DURATION_SECONDS <= 600 )) || fail "DURATION_SECONDS 不能超过600"
(( 10#$MESSAGE_COUNT <= 50 )) || fail "MESSAGE_COUNT 不能超过50"
(( 10#$POLL_SECONDS <= 30 )) || fail "RAW_CAPTURE_POLL_SECONDS 不能超过30"
DEFAULT_WAIT_TIMEOUT=$((10#$DURATION_SECONDS + 420))
WAIT_TIMEOUT_SECONDS="${RAW_CAPTURE_WAIT_TIMEOUT_SECONDS:-$DEFAULT_WAIT_TIMEOUT}"
positive_integer "$WAIT_TIMEOUT_SECONDS" || fail "RAW_CAPTURE_WAIT_TIMEOUT_SECONDS 必须是大于0的整数"
require_command curl
require_command python3
require_command mktemp
BASE_URL="${BASE_URL%/}"
if [[ "$BASE_URL" == */api/syslog/raw-captures ]]; then
CAPTURE_API="$BASE_URL"
else
CAPTURE_API="$BASE_URL/api/syslog/raw-captures"
fi
mkdir -p -- "$OUTPUT_DIR"
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hb-raw-capture.XXXXXX")"
SESSION_ID=""
CAPTURE_DIR=""
cleanup() {
rm -rf -- "$TEMP_DIR"
}
trap cleanup EXIT
CURL_HEADERS=(-H 'Accept: application/json')
if [[ -n "${RAW_CAPTURE_AUTH_HEADER:-}" ]]; then
CURL_HEADERS+=(-H "$RAW_CAPTURE_AUTH_HEADER")
fi
http_request() {
local method="$1" url="$2" output="$3" body="${4:-}"
local args=(-sS -o "$output" -w '%{http_code}' --connect-timeout 10
--max-time "$HTTP_MAX_TIME_SECONDS" -X "$method" "${CURL_HEADERS[@]}")
if [[ -n "$body" ]]; then
args+=(-H 'Content-Type: application/json' --data "$body")
fi
curl "${args[@]}" "$url"
}
START_RESPONSE="$TEMP_DIR/start.json"
REQUEST_BODY="{\"deviceId\":$((10#$DEVICE_ID)),\"durationSeconds\":$((10#$DURATION_SECONDS)),\"messageCount\":$((10#$MESSAGE_COUNT))}"
printf '创建采集会话: deviceId=%s, duration=%ss, count=%s\n' \
"$DEVICE_ID" "$DURATION_SECONDS" "$MESSAGE_COUNT"
if ! HTTP_CODE="$(http_request POST "$CAPTURE_API" "$START_RESPONSE" "$REQUEST_BODY")"; then
fail "无法连接采集API: $CAPTURE_API"
fi
if [[ "$HTTP_CODE" != "202" && "$HTTP_CODE" != "200" ]]; then
printf 'API响应(%s):\n' "$HTTP_CODE" >&2
python3 -m json.tool "$START_RESPONSE" 2>/dev/null >&2 || sed -n '1,20p' "$START_RESPONSE" >&2
fail "创建采集会话失败"
fi
SESSION_ID="$(python3 - "$START_RESPONSE" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as source:
value = json.load(source)
print(value.get("sessionId", ""))
PY
)"
[[ -n "$SESSION_ID" ]] || fail "API响应缺少 sessionId"
START_STAMP="$(date '+%Y%m%d-%H%M%S')"
CAPTURE_DIR="$OUTPUT_DIR/device-${DEVICE_ID}_${START_STAMP}_${SESSION_ID}"
mkdir -p -- "$CAPTURE_DIR/raw"
cp -- "$START_RESPONSE" "$CAPTURE_DIR/start.json"
printf '%s\n' "$SESSION_ID" > "$CAPTURE_DIR/session-id.txt"
printf '会话已创建: %s\n结果目录: %s\n' "$SESSION_ID" "$CAPTURE_DIR"
LATEST_TEMP="$TEMP_DIR/latest.json"
DEADLINE=$(( $(date +%s) + 10#$WAIT_TIMEOUT_SECONDS ))
FINAL_STATUS=""
while (( $(date +%s) <= DEADLINE )); do
if ! HTTP_CODE="$(http_request GET "$CAPTURE_API/$SESSION_ID" "$LATEST_TEMP")"; then
printf 'WARN: 本次查询失败,%s秒后重试\n' "$POLL_SECONDS" >&2
sleep "$POLL_SECONDS"
continue
fi
if [[ "$HTTP_CODE" != "200" ]]; then
printf 'WARN: 查询返回HTTP %s,%s秒后重试\n' "$HTTP_CODE" "$POLL_SECONDS" >&2
sleep "$POLL_SECONDS"
continue
fi
cp -- "$LATEST_TEMP" "$CAPTURE_DIR/latest.json.tmp"
mv -- "$CAPTURE_DIR/latest.json.tmp" "$CAPTURE_DIR/latest.json"
FINAL_STATUS="$(python3 - "$LATEST_TEMP" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as source:
print(json.load(source).get("status", "UNKNOWN"))
PY
)"
COUNTS="$(python3 - "$LATEST_TEMP" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as source:
value = json.load(source)
print("samples=%s, anomalies=%s, received=%s" % (
len(value.get("messages") or []), value.get("retainedAnomalyCount", 0),
value.get("receivedCount", 0)))
PY
)"
printf '状态: %-10s %s\n' "$FINAL_STATUS" "$COUNTS"
if [[ "$FINAL_STATUS" == "COMPLETED" || "$FINAL_STATUS" == "CANCELLED" ]]; then
break
fi
sleep "$POLL_SECONDS"
done
if [[ ! -f "$CAPTURE_DIR/latest.json" ]]; then
fail "等待期间没有取得有效结果;sessionId=$SESSION_ID"
fi
cp -- "$CAPTURE_DIR/latest.json" "$CAPTURE_DIR/result.json"
python3 - "$CAPTURE_DIR/result.json" "$CAPTURE_DIR" <<'PY'
import json
import os
import re
import sys
result_path, output_dir = sys.argv[1], sys.argv[2]
with open(result_path, encoding="utf-8") as source:
result = json.load(source)
messages = result.get("messages") or []
jsonl_path = os.path.join(output_dir, "samples.jsonl")
with open(jsonl_path, "w", encoding="utf-8", newline="\n") as target:
for message in messages:
target.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
raw_dir = os.path.join(output_dir, "raw")
os.makedirs(raw_dir, exist_ok=True)
for index, message in enumerate(messages, 1):
event_id = str(message.get("eventId") or "no-event-id")
safe_event_id = re.sub(r"[^A-Za-z0-9._-]", "_", event_id)[:80]
kind = "anomaly" if message.get("anomaly") else "normal"
path = os.path.join(raw_dir, "%03d_%s_%s.log" % (index, kind, safe_event_id))
raw = message.get("rawMessage")
with open(path, "w", encoding="utf-8", newline="") as target:
target.write("" if raw is None else str(raw))
summary = [
"sessionId=%s" % result.get("sessionId", ""),
"deviceId=%s" % result.get("deviceId", ""),
"status=%s" % result.get("status", ""),
"startedAt=%s" % result.get("startedAt", ""),
"captureEndsAt=%s" % result.get("captureEndsAt", ""),
"settleEndsAt=%s" % result.get("settleEndsAt", ""),
"receivedCount=%s" % result.get("receivedCount", 0),
"sampleCount=%s" % len(messages),
"retainedAnomalyCount=%s" % result.get("retainedAnomalyCount", 0),
"replacementCount=%s" % result.get("replacementCount", 0),
]
with open(os.path.join(output_dir, "summary.txt"), "w", encoding="utf-8", newline="\n") as target:
target.write("\n".join(summary) + "\n")
PY
if [[ "$FINAL_STATUS" != "COMPLETED" && "$FINAL_STATUS" != "CANCELLED" ]]; then
printf 'WARN: 等待超时,已保存最后一次快照;会话仍会在服务端自动结束。\n' >&2
printf '结果目录: %s\n' "$CAPTURE_DIR"
exit 2
fi
printf '采集完成。\n完整结果: %s/result.json\n原文目录: %s/raw\n' \
"$CAPTURE_DIR" "$CAPTURE_DIR"