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:
+1016
File diff suppressed because it is too large
Load Diff
+43
-21
@@ -411,9 +411,6 @@ public class LogNormalProcessor {
|
|||||||
}
|
}
|
||||||
if (ctx.dmNormalizeRuleList.size() > 1 && selectionConfig.isShadowEnabled()) {
|
if (ctx.dmNormalizeRuleList.size() > 1 && selectionConfig.isShadowEnabled()) {
|
||||||
shadowSelect(ctx);
|
shadowSelect(ctx);
|
||||||
if (ctx.invalidJson) {
|
|
||||||
return invalidJsonResult(ctx, deviceID);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (optimizationProperties.getCompiledRule().isEnabled()) {
|
if (optimizationProperties.getCompiledRule().isEnabled()) {
|
||||||
return executeCompiled(ctx, deviceID);
|
return executeCompiled(ctx, deviceID);
|
||||||
@@ -433,15 +430,15 @@ public class LogNormalProcessor {
|
|||||||
for (RuleSource source : ctx.dmNormalizeRuleList) {
|
for (RuleSource source : ctx.dmNormalizeRuleList) {
|
||||||
try {
|
try {
|
||||||
executeLegacyRule(ctx, deviceId, source);
|
executeLegacyRule(ctx, deviceId, source);
|
||||||
if (ctx.invalidJson) {
|
|
||||||
return invalidJsonResult(ctx, deviceId);
|
|
||||||
}
|
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
logger.error("泛化规则处理失败, eventId={}, ruleId={}",
|
logger.error("泛化规则处理失败, eventId={}, ruleId={}",
|
||||||
ctx.strSyslogUUID, source.getId(), failure);
|
ctx.strSyslogUUID, source.getId(), failure);
|
||||||
throw new IllegalStateException("标准化规则处理失败", failure);
|
throw new IllegalStateException("标准化规则处理失败", failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ctx.savedCount == 0 && ctx.filteredCount == 0 && ctx.invalidJson) {
|
||||||
|
return invalidJsonResult(ctx, deviceId);
|
||||||
|
}
|
||||||
return finish(ctx);
|
return finish(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +447,7 @@ public class LogNormalProcessor {
|
|||||||
*/
|
*/
|
||||||
private NormalizationResult executeSelected(ProcessContext ctx, long deviceId) {
|
private NormalizationResult executeSelected(ProcessContext ctx, long deviceId) {
|
||||||
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(ctx);
|
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(ctx);
|
||||||
if (ctx.invalidJson) {
|
if (candidates.isEmpty() && ctx.invalidJson) {
|
||||||
return invalidJsonResult(ctx, deviceId);
|
return invalidJsonResult(ctx, deviceId);
|
||||||
}
|
}
|
||||||
RuleSelectionEngine.Decision<ExecutionPlan> decision =
|
RuleSelectionEngine.Decision<ExecutionPlan> decision =
|
||||||
@@ -505,9 +502,9 @@ public class LogNormalProcessor {
|
|||||||
/** 关闭切换开关时仅做归属影子计算,不改变旧的多结果行为。 */
|
/** 关闭切换开关时仅做归属影子计算,不改变旧的多结果行为。 */
|
||||||
private void shadowSelect(ProcessContext ctx) {
|
private void shadowSelect(ProcessContext ctx) {
|
||||||
try {
|
try {
|
||||||
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(ctx);
|
ProcessContext shadow = shadowContext(ctx);
|
||||||
if (ctx.invalidJson) return;
|
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(shadow);
|
||||||
ruleSelectionEngine.select(candidates, ctx.strLogMsg, true);
|
ruleSelectionEngine.select(candidates, shadow.strLogMsg, true);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
// 影子链路不能影响生产结果。
|
// 影子链路不能影响生产结果。
|
||||||
logger.debug("规则归属影子计算失败: eventHash={}, error={}",
|
logger.debug("规则归属影子计算失败: eventHash={}, error={}",
|
||||||
@@ -525,12 +522,14 @@ public class LogNormalProcessor {
|
|||||||
if (compiledEnabled) {
|
if (compiledEnabled) {
|
||||||
try {
|
try {
|
||||||
CompiledRule compiled = compiledRuleCache.get(source, ctx.executionMetrics);
|
CompiledRule compiled = compiledRuleCache.get(source, ctx.executionMetrics);
|
||||||
|
if (!compiled.isApplicableTo(ctx.strLogMsg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ParsedMessage parsed = ctx.messageParseContext.parse(
|
ParsedMessage parsed = ctx.messageParseContext.parse(
|
||||||
compiled, ctx.strLogMsg, ctx.executionMetrics);
|
compiled, ctx.strLogMsg, ctx.executionMetrics);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
if (isJsonRule(source)) {
|
if (isJsonRule(source)) {
|
||||||
markInvalidJson(ctx, source);
|
markInvalidJson(ctx, source);
|
||||||
return candidates;
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -557,6 +556,9 @@ public class LogNormalProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ExecutionPlan legacyCandidate(ProcessContext ctx, RuleSource source) {
|
private ExecutionPlan legacyCandidate(ProcessContext ctx, RuleSource source) {
|
||||||
|
if (isJsonRule(source) && !isJsonRuleApplicable(source, ctx.strLogMsg)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
long parseStarted = System.nanoTime();
|
long parseStarted = System.nanoTime();
|
||||||
globalMetrics.ruleJsonParse();
|
globalMetrics.ruleJsonParse();
|
||||||
LinkedHashMap<String, Object> parsed = getMessageToMap(
|
LinkedHashMap<String, Object> parsed = getMessageToMap(
|
||||||
@@ -678,9 +680,6 @@ public class LogNormalProcessor {
|
|||||||
// 首次失败原因由 CompiledRuleCache 按 fingerprint 限频记录;此处只回退该规则。
|
// 首次失败原因由 CompiledRuleCache 按 fingerprint 限频记录;此处只回退该规则。
|
||||||
try {
|
try {
|
||||||
executeLegacyRule(ctx, deviceId, source);
|
executeLegacyRule(ctx, deviceId, source);
|
||||||
if (ctx.invalidJson) {
|
|
||||||
return invalidJsonResult(ctx, deviceId);
|
|
||||||
}
|
|
||||||
} catch (Exception legacyFailure) {
|
} catch (Exception legacyFailure) {
|
||||||
logger.error("编译失败规则回退旧执行器后仍失败, eventId={}, ruleId={}",
|
logger.error("编译失败规则回退旧执行器后仍失败, eventId={}, ruleId={}",
|
||||||
ctx.strSyslogUUID, source.getId(), legacyFailure);
|
ctx.strSyslogUUID, source.getId(), legacyFailure);
|
||||||
@@ -689,10 +688,13 @@ public class LogNormalProcessor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (!rule.isApplicableTo(ctx.strLogMsg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ParsedMessage parsed = ctx.messageParseContext.parse(rule, ctx.strLogMsg, ctx.executionMetrics);
|
ParsedMessage parsed = ctx.messageParseContext.parse(rule, ctx.strLogMsg, ctx.executionMetrics);
|
||||||
if (parsed == null && isJsonRule(source)) {
|
if (parsed == null && isJsonRule(source)) {
|
||||||
markInvalidJson(ctx, source);
|
markInvalidJson(ctx, source);
|
||||||
return invalidJsonResult(ctx, deviceId);
|
continue;
|
||||||
}
|
}
|
||||||
if (parsed == null || parsed.getFields().isEmpty()) {
|
if (parsed == null || parsed.getFields().isEmpty()) {
|
||||||
rejectParsedMessage(ctx, deviceId, source);
|
rejectParsedMessage(ctx, deviceId, source);
|
||||||
@@ -718,11 +720,17 @@ public class LogNormalProcessor {
|
|||||||
throw new IllegalStateException("编译标准化规则处理失败", failure);
|
throw new IllegalStateException("编译标准化规则处理失败", failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ctx.savedCount == 0 && ctx.filteredCount == 0 && ctx.invalidJson) {
|
||||||
|
return invalidJsonResult(ctx, deviceId);
|
||||||
|
}
|
||||||
return finish(ctx);
|
return finish(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行单条旧格式规则,供旧主路径和编译不兼容规则的精确回退共同使用。 */
|
/** 执行单条旧格式规则,供旧主路径和编译不兼容规则的精确回退共同使用。 */
|
||||||
private void executeLegacyRule(ProcessContext ctx, long deviceId, RuleSource source) {
|
private void executeLegacyRule(ProcessContext ctx, long deviceId, RuleSource source) {
|
||||||
|
if (isJsonRule(source) && !isJsonRuleApplicable(source, ctx.strLogMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Map<String, Object> rule = source.asLegacyMap();
|
Map<String, Object> rule = source.asLegacyMap();
|
||||||
long parseStarted = System.nanoTime();
|
long parseStarted = System.nanoTime();
|
||||||
globalMetrics.ruleJsonParse();
|
globalMetrics.ruleJsonParse();
|
||||||
@@ -773,14 +781,11 @@ public class LogNormalProcessor {
|
|||||||
ctx.rejectedCount++;
|
ctx.rejectedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 所有适用规则均未形成有效候选后,只产生一条 JSON 非标记录。 */
|
||||||
* JSON 语法失败是当前消息可确定的业务终态:只产生一条非标记录,
|
|
||||||
* 不再尝试后续规则,也不抛异常进入 Retry/DLT。
|
|
||||||
*/
|
|
||||||
private NormalizationResult invalidJsonResult(ProcessContext ctx, long deviceId) {
|
private NormalizationResult invalidJsonResult(ProcessContext ctx, long deviceId) {
|
||||||
RuleSource source = ctx.invalidJsonRule;
|
RuleSource source = ctx.invalidJsonRule;
|
||||||
ctx.syslogNonNormalMessage.setReason("JSON解析异常");
|
ctx.syslogNonNormalMessage.setReason("JSON解析异常");
|
||||||
ctx.syslogNonNormalMessage.setReasonDetail("发现非法JSON,已停止后续标准化规则处理, ruleId="
|
ctx.syslogNonNormalMessage.setReasonDetail("所有适用JSON规则均解析失败,未形成可用候选, ruleId="
|
||||||
+ (source == null ? "unknown" : source.getId()) + ", ruleName="
|
+ (source == null ? "unknown" : source.getId()) + ", ruleName="
|
||||||
+ (source == null ? "unknown" : source.getDisplayName()));
|
+ (source == null ? "unknown" : source.getDisplayName()));
|
||||||
if (!ctx.shadowMode) {
|
if (!ctx.shadowMode) {
|
||||||
@@ -803,6 +808,15 @@ public class LogNormalProcessor {
|
|||||||
return source != null && "json".equalsIgnoreCase(source.getDataType());
|
return source != null && "json".equalsIgnoreCase(source.getDataType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isJsonRuleApplicable(RuleSource source, String message) {
|
||||||
|
Cropper_paramsType cropper = getCropperParams(source.asLegacyMap());
|
||||||
|
if (cropper == null || cropper.gethead_key() == null
|
||||||
|
|| cropper.gethead_key().trim().isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return message != null && message.contains(cropper.gethead_key());
|
||||||
|
}
|
||||||
|
|
||||||
/** 现场坏 JSON 可能高频出现,WARN 每分钟最多一条,避免日志 I/O 反向拖慢消费。 */
|
/** 现场坏 JSON 可能高频出现,WARN 每分钟最多一条,避免日志 I/O 反向拖慢消费。 */
|
||||||
private void logInvalidJsonRateLimited(ProcessContext ctx, long deviceId, RuleSource source) {
|
private void logInvalidJsonRateLimited(ProcessContext ctx, long deviceId, RuleSource source) {
|
||||||
invalidJsonSinceLastWarn.incrementAndGet();
|
invalidJsonSinceLastWarn.incrementAndGet();
|
||||||
@@ -813,7 +827,7 @@ public class LogNormalProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
long count = invalidJsonSinceLastWarn.getAndSet(0L);
|
long count = invalidJsonSinceLastWarn.getAndSet(0L);
|
||||||
logger.warn("JSON解析失败已直接转非标: countSinceLastLog={}, eventId={}, deviceId={}, "
|
logger.warn("全部适用JSON候选解析失败已转非标: countSinceLastLog={}, eventId={}, deviceId={}, "
|
||||||
+ "ruleId={}, messageLength={}",
|
+ "ruleId={}, messageLength={}",
|
||||||
count, ctx.strSyslogUUID, deviceId,
|
count, ctx.strSyslogUUID, deviceId,
|
||||||
source == null ? null : source.getId(),
|
source == null ? null : source.getId(),
|
||||||
@@ -998,6 +1012,9 @@ public class LogNormalProcessor {
|
|||||||
Cropper_paramsType cropperParams = getCropperParams(dmNormalizeRule);
|
Cropper_paramsType cropperParams = getCropperParams(dmNormalizeRule);
|
||||||
if (dataType.equals("json")) {
|
if (dataType.equals("json")) {
|
||||||
String strJson = logNormalData.ParserMessageJsonType(MsgContent, cropperParams);
|
String strJson = logNormalData.ParserMessageJsonType(MsgContent, cropperParams);
|
||||||
|
if (cropperParams != null && !hasCompleteJsonObjectBoundary(strJson)) {
|
||||||
|
strJson = JsonParser.extractJsonFromLogMessage(strJson);
|
||||||
|
}
|
||||||
LinkedHashMap<String, Object> result1 = NestedJsonParserUtil.safeParseJson(strJson);
|
LinkedHashMap<String, Object> result1 = NestedJsonParserUtil.safeParseJson(strJson);
|
||||||
if (result1 == null) return null;
|
if (result1 == null) return null;
|
||||||
LinkedHashMap<String, Object> flattened = NestedJsonUtils.flattenNestedJson(result1);
|
LinkedHashMap<String, Object> flattened = NestedJsonUtils.flattenNestedJson(result1);
|
||||||
@@ -1028,6 +1045,11 @@ public class LogNormalProcessor {
|
|||||||
return linkMap;
|
return linkMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasCompleteJsonObjectBoundary(String value) {
|
||||||
|
return value != null && value.length() >= 2
|
||||||
|
&& value.charAt(0) == '{' && value.charAt(value.length() - 1) == '}';
|
||||||
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> getRuleList(long device_id) {
|
public List<Map<String, Object>> getRuleList(long device_id) {
|
||||||
List<Map<String, Object>> rulelst = dmNormalizeRuleService.selectByDeviceId((long) 1);
|
List<Map<String, Object>> rulelst = dmNormalizeRuleService.selectByDeviceId((long) 1);
|
||||||
if (rulelst == null) {
|
if (rulelst == null) {
|
||||||
|
|||||||
+51
-4
@@ -9,6 +9,7 @@ import com.config.AppProperties;
|
|||||||
import com.config.KafkaConsumerProperties;
|
import com.config.KafkaConsumerProperties;
|
||||||
import com.common.service.DmStandardizedBatchWriter;
|
import com.common.service.DmStandardizedBatchWriter;
|
||||||
import com.common.service.RawLaneFieldLimiter;
|
import com.common.service.RawLaneFieldLimiter;
|
||||||
|
import com.common.service.RawLogCaptureService;
|
||||||
import com.common.service.DirectStatsProjectionWriter;
|
import com.common.service.DirectStatsProjectionWriter;
|
||||||
import com.common.service.SyslogNonNormalMessageService;
|
import com.common.service.SyslogNonNormalMessageService;
|
||||||
import com.common.service.SyslogNormalDataService;
|
import com.common.service.SyslogNormalDataService;
|
||||||
@@ -134,6 +135,7 @@ public class SysLogProcessor {
|
|||||||
@Autowired private BacklogSamplingDecider backlogSamplingDecider;
|
@Autowired private BacklogSamplingDecider backlogSamplingDecider;
|
||||||
@Autowired private BacklogSamplingAuditService backlogSamplingAuditService;
|
@Autowired private BacklogSamplingAuditService backlogSamplingAuditService;
|
||||||
@Autowired private SyslogNormalDataService syslogNormalDataService;
|
@Autowired private SyslogNormalDataService syslogNormalDataService;
|
||||||
|
@Autowired(required = false) private RawLogCaptureService rawLogCaptureService;
|
||||||
|
|
||||||
private final AtomicInteger totalProcessed = new AtomicInteger(0);
|
private final AtomicInteger totalProcessed = new AtomicInteger(0);
|
||||||
private final AtomicLong nonNormalAsyncCompletedTotal = new AtomicLong();
|
private final AtomicLong nonNormalAsyncCompletedTotal = new AtomicLong();
|
||||||
@@ -620,6 +622,9 @@ public class SysLogProcessor {
|
|||||||
for (PreparedMessage message : messages) {
|
for (PreparedMessage message : messages) {
|
||||||
RuntimeException dltFailure = tryRouteContinuityRecord(
|
RuntimeException dltFailure = tryRouteContinuityRecord(
|
||||||
message.record, message.context, failure, stage);
|
message.record, message.context, failure, stage);
|
||||||
|
captureFailure(message, stage,
|
||||||
|
dltFailure == null ? SyslogFailureRouter.TerminalState.DLT_PUBLISHED : null,
|
||||||
|
dltFailure == null ? failure : dltFailure);
|
||||||
if (dltFailure != null) {
|
if (dltFailure != null) {
|
||||||
recordContinuityLoss(messages.size(), published, message.record,
|
recordContinuityLoss(messages.size(), published, message.record,
|
||||||
failure, dltFailure, stage);
|
failure, dltFailure, stage);
|
||||||
@@ -884,9 +889,12 @@ public class SysLogProcessor {
|
|||||||
parsedEventTime.getLocalDateTime(), deviceId, collectId,
|
parsedEventTime.getLocalDateTime(), deviceId, collectId,
|
||||||
deviceInfo.get("data_type"), SyslogParser.substringAfterFirstCloseBracket(decryptedMessage),
|
deviceInfo.get("data_type"), SyslogParser.substringAfterFirstCloseBracket(decryptedMessage),
|
||||||
decryptedMessage, deviceHeader, deviceInfo);
|
decryptedMessage, deviceHeader, deviceInfo);
|
||||||
return new PreparedMessage(record, messageContext, decryptedMessage, eventId, rawPoint.getPoint(),
|
PreparedMessage prepared = new PreparedMessage(record, messageContext, decryptedMessage,
|
||||||
|
eventId, rawPoint.getPoint(),
|
||||||
rawPoint.getBucket(), rawPoint.isV2(), parsedReceiveTime.getLocalDateTime(), deviceId, collectId,
|
rawPoint.getBucket(), rawPoint.isV2(), parsedReceiveTime.getLocalDateTime(), deviceId, collectId,
|
||||||
startTime, normalizationInput);
|
startTime, normalizationInput);
|
||||||
|
captureReceived(prepared);
|
||||||
|
return prepared;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -948,7 +956,9 @@ public class SysLogProcessor {
|
|||||||
persisted.add(message);
|
persisted.add(message);
|
||||||
} catch (Exception singleFailure) {
|
} catch (Exception singleFailure) {
|
||||||
// 原文格式问题进入 DLT,但不能因此丢掉已经识别出的告警/标准化结果。
|
// 原文格式问题进入 DLT,但不能因此丢掉已经识别出的告警/标准化结果。
|
||||||
|
SyslogFailureRouter.TerminalState state =
|
||||||
routeFailure(message.record, message.context, singleFailure);
|
routeFailure(message.record, message.context, singleFailure);
|
||||||
|
captureFailure(message, "RAW_STORAGE", state, singleFailure);
|
||||||
persisted.add(message);
|
persisted.add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1018,7 @@ public class SysLogProcessor {
|
|||||||
RuleExecutionMetrics executionMetrics) {
|
RuleExecutionMetrics executionMetrics) {
|
||||||
LogNormalProcessor.NormalizationResult result = logNormalProcessor.prepare(
|
LogNormalProcessor.NormalizationResult result = logNormalProcessor.prepare(
|
||||||
message.normalizationInput, ruleSnapshot, enrichmentContext, executionMetrics);
|
message.normalizationInput, ruleSnapshot, enrichmentContext, executionMetrics);
|
||||||
|
captureNormalization(message, result);
|
||||||
log.debug("消息标准化计算完成, offset: {}, rows={}, 耗时: {}ms", message.record.offset(),
|
log.debug("消息标准化计算完成, offset: {}, rows={}, 耗时: {}ms", message.record.offset(),
|
||||||
result.getRows().size(), System.currentTimeMillis() - message.startTime);
|
result.getRows().size(), System.currentTimeMillis() - message.startTime);
|
||||||
return new PreparedNormalization(message, result, null);
|
return new PreparedNormalization(message, result, null);
|
||||||
@@ -1063,8 +1074,10 @@ public class SysLogProcessor {
|
|||||||
results[index] = prepareNormalization(message, ruleSnapshot,
|
results[index] = prepareNormalization(message, ruleSnapshot,
|
||||||
enrichmentContext, executionMetrics);
|
enrichmentContext, executionMetrics);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
results[index] = new PreparedNormalization(message, null,
|
SyslogFailureRouter.TerminalState state =
|
||||||
routeFailure(message.record, message.context, failure));
|
routeFailure(message.record, message.context, failure);
|
||||||
|
captureFailure(message, "RULE_PROCESSING", state, failure);
|
||||||
|
results[index] = new PreparedNormalization(message, null, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1185,7 +1198,10 @@ public class SysLogProcessor {
|
|||||||
enterStage("DM_FAILURE_ROUTE");
|
enterStage("DM_FAILURE_ROUTE");
|
||||||
for (Map.Entry<PreparedMessage, RuntimeException> failure : failures.entrySet()) {
|
for (Map.Entry<PreparedMessage, RuntimeException> failure : failures.entrySet()) {
|
||||||
PreparedMessage message = failure.getKey();
|
PreparedMessage message = failure.getKey();
|
||||||
metrics.record(routeFailure(message.record, message.context, failure.getValue()));
|
SyslogFailureRouter.TerminalState state =
|
||||||
|
routeFailure(message.record, message.context, failure.getValue());
|
||||||
|
captureFailure(message, "DM_WRITE", state, failure.getValue());
|
||||||
|
metrics.record(state);
|
||||||
}
|
}
|
||||||
for (PreparedMessage message : plan.pendingDatabaseMessages) {
|
for (PreparedMessage message : plan.pendingDatabaseMessages) {
|
||||||
if (!failedMessages.contains(message)) {
|
if (!failedMessages.contains(message)) {
|
||||||
@@ -1363,6 +1379,37 @@ public class SysLogProcessor {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void captureReceived(PreparedMessage message) {
|
||||||
|
if (rawLogCaptureService == null || message == null) return;
|
||||||
|
rawLogCaptureService.observeReceived(message.startTime, message.deviceId,
|
||||||
|
message.deviceCollectId, message.eventId, captureTopic(message),
|
||||||
|
message.receivedAt, message.normalizationInput.getSyslogBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureNormalization(PreparedMessage message,
|
||||||
|
LogNormalProcessor.NormalizationResult result) {
|
||||||
|
if (rawLogCaptureService == null || message == null) return;
|
||||||
|
rawLogCaptureService.observeNormalization(message.startTime, message.deviceId,
|
||||||
|
message.deviceCollectId, message.eventId, captureTopic(message),
|
||||||
|
message.receivedAt, message.normalizationInput.getSyslogBody(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureFailure(PreparedMessage message, String stage,
|
||||||
|
SyslogFailureRouter.TerminalState state, Throwable failure) {
|
||||||
|
if (rawLogCaptureService == null || message == null) return;
|
||||||
|
rawLogCaptureService.observeFailure(message.startTime, message.deviceId,
|
||||||
|
message.deviceCollectId, message.eventId, captureTopic(message),
|
||||||
|
message.receivedAt, message.normalizationInput.getSyslogBody(), stage,
|
||||||
|
state == null ? null : state.name(), failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String captureTopic(PreparedMessage message) {
|
||||||
|
if (message.context != null && message.context.getOriginalTopic() != null) {
|
||||||
|
return message.context.getOriginalTopic();
|
||||||
|
}
|
||||||
|
return message.record == null ? null : message.record.topic();
|
||||||
|
}
|
||||||
|
|
||||||
/** 已解密、已构造原文点,但尚未提交 Kafka offset 的批内消息。 */
|
/** 已解密、已构造原文点,但尚未提交 Kafka offset 的批内消息。 */
|
||||||
static final class PreparedMessage {
|
static final class PreparedMessage {
|
||||||
private final ConsumerRecord<String, String> record;
|
private final ConsumerRecord<String, String> record;
|
||||||
|
|||||||
+27
@@ -1,6 +1,7 @@
|
|||||||
package com.Modules.NormalData.compiled;
|
package com.Modules.NormalData.compiled;
|
||||||
|
|
||||||
import com.Modules.NormalData.logNormalData;
|
import com.Modules.NormalData.logNormalData;
|
||||||
|
import com.common.util.JsonParser;
|
||||||
import com.common.util.NestedJsonParserUtil;
|
import com.common.util.NestedJsonParserUtil;
|
||||||
import com.common.util.NestedJsonUtils;
|
import com.common.util.NestedJsonUtils;
|
||||||
|
|
||||||
@@ -85,6 +86,16 @@ public final class CompiledRule {
|
|||||||
LinkedHashMap<String, Object> parsed;
|
LinkedHashMap<String, Object> parsed;
|
||||||
switch (dataType) {
|
switch (dataType) {
|
||||||
case "json":
|
case "json":
|
||||||
|
// 历史 JSON 规则的空 tail cropper 使用“head 起点 + offset”语义,部分规则
|
||||||
|
// 因而会保留诸如 "7|!alarm|!" 的前缀。只在 JSON 专用路径重新收敛到
|
||||||
|
// 完整对象边界,避免改变 KV/SEP/REGEX 及既有 offset 的全局兼容语义。
|
||||||
|
if (cropper != null && !hasCompleteObjectBoundary(cropped)) {
|
||||||
|
try {
|
||||||
|
cropped = JsonParser.extractJsonFromLogMessage(cropped);
|
||||||
|
} catch (IllegalArgumentException incompleteJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
LinkedHashMap<String, Object> nested = NestedJsonParserUtil.safeParseJson(cropped);
|
LinkedHashMap<String, Object> nested = NestedJsonParserUtil.safeParseJson(cropped);
|
||||||
parsed = nested == null ? null : NestedJsonUtils.flattenNestedJson(nested);
|
parsed = nested == null ? null : NestedJsonUtils.flattenNestedJson(nested);
|
||||||
break;
|
break;
|
||||||
@@ -106,6 +117,22 @@ public final class CompiledRule {
|
|||||||
return parsed == null ? null : new ParsedMessage(parsed);
|
return parsed == null ? null : new ParsedMessage(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cropper 的 head_key 是规则格式指纹。消息中不存在该指纹时表示规则不适用,
|
||||||
|
* 不能把空截取结果误判为原始消息 JSON 非法。
|
||||||
|
*/
|
||||||
|
public boolean isApplicableTo(String message) {
|
||||||
|
if (!"json".equals(dataType) || cropper == null || empty(cropper.getHeadKey())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return message != null && message.contains(cropper.getHeadKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasCompleteObjectBoundary(String value) {
|
||||||
|
return value != null && value.length() >= 2
|
||||||
|
&& value.charAt(0) == '{' && value.charAt(value.length() - 1) == '}';
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> map(ParsedMessage message) {
|
public Map<String, Object> map(ParsedMessage message) {
|
||||||
Map<String, Object> destination = new LinkedHashMap<>();
|
Map<String, Object> destination = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, Object> source : message.getFields().entrySet()) {
|
for (Map.Entry<String, Object> source : message.getFields().entrySet()) {
|
||||||
|
|||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
package com.common.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/** Redis只保存每个消费实例的有界候选集,用于多实例汇聚,不承载逐条日志流。 */
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RawLogCaptureClusterStore {
|
||||||
|
private static final String PREFIX = "hb:xdr:raw-capture:";
|
||||||
|
private static final String ACTIVE_KEY = PREFIX + "active";
|
||||||
|
private static final TypeReference<Map<String, Object>> MAP_TYPE =
|
||||||
|
new TypeReference<Map<String, Object>>() { };
|
||||||
|
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final String nodeId;
|
||||||
|
private final AtomicLong lastFailureLogAt = new AtomicLong();
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.cluster-activation-delay-ms:1000}")
|
||||||
|
private long activationDelayMs = 1000L;
|
||||||
|
|
||||||
|
public RawLogCaptureClusterStore(StringRedisTemplate redis, ObjectMapper mapper,
|
||||||
|
@Value("${syslog.raw-capture.node-id:${HOSTNAME:unknown}}")
|
||||||
|
String configuredNodeId) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.nodeId = "unknown".equals(configuredNodeId)
|
||||||
|
? UUID.randomUUID().toString() : configuredNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Definition create(long deviceId, int durationSeconds, int settleSeconds, int messageCount,
|
||||||
|
int maxActiveSessions, int retentionMinutes) {
|
||||||
|
try {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
redis.opsForZSet().removeRangeByScore(ACTIVE_KEY, 0D, now - 1D);
|
||||||
|
Long active = redis.opsForZSet().zCard(ACTIVE_KEY);
|
||||||
|
if (active != null && active >= maxActiveSessions) {
|
||||||
|
throw new RawLogCaptureService.CaptureCapacityException(maxActiveSessions);
|
||||||
|
}
|
||||||
|
long startedAt = now + Math.max(200L, activationDelayMs);
|
||||||
|
Definition definition = new Definition(UUID.randomUUID().toString(), deviceId,
|
||||||
|
startedAt, startedAt + durationSeconds * 1000L,
|
||||||
|
startedAt + (durationSeconds + settleSeconds) * 1000L, messageCount,
|
||||||
|
retentionMinutes);
|
||||||
|
String metaKey = metaKey(definition.id);
|
||||||
|
redis.opsForHash().putAll(metaKey, definition.toHash());
|
||||||
|
redis.expire(metaKey, definition.ttlSeconds(now), TimeUnit.SECONDS);
|
||||||
|
redis.opsForZSet().add(ACTIVE_KEY, definition.id, definition.settleEndsAt);
|
||||||
|
return definition;
|
||||||
|
} catch (RawLogCaptureService.CaptureCapacityException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw new RawLogCaptureService.CaptureCoordinationException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Definition> active(long now) {
|
||||||
|
redis.opsForZSet().removeRangeByScore(ACTIVE_KEY, 0D, now - 1D);
|
||||||
|
Set<String> ids = redis.opsForZSet().rangeByScore(ACTIVE_KEY, now, Double.MAX_VALUE);
|
||||||
|
if (ids == null || ids.isEmpty()) return Collections.emptyList();
|
||||||
|
List<Definition> result = new ArrayList<>(ids.size());
|
||||||
|
for (String id : ids) {
|
||||||
|
Definition definition = definition(id);
|
||||||
|
if (definition != null && definition.stoppedAt == 0L) result.add(definition);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void publish(RawLogCaptureService.CaptureCandidates candidates) {
|
||||||
|
try {
|
||||||
|
Definition definition = definition(candidates.getSessionId());
|
||||||
|
if (definition == null || definition.stoppedAt > 0L) return;
|
||||||
|
String snapshotKey = snapshotKey(candidates.getSessionId(), nodeId);
|
||||||
|
redis.opsForValue().set(snapshotKey, mapper.writeValueAsString(toMap(candidates)),
|
||||||
|
definition.ttlSeconds(System.currentTimeMillis()), TimeUnit.SECONDS);
|
||||||
|
String nodesKey = nodesKey(candidates.getSessionId());
|
||||||
|
redis.opsForSet().add(nodesKey, nodeId);
|
||||||
|
redis.expire(nodesKey, definition.ttlSeconds(System.currentTimeMillis()), TimeUnit.SECONDS);
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new RawLogCaptureService.CaptureCoordinationException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RawLogCaptureService.CaptureSnapshot get(String sessionId) {
|
||||||
|
try {
|
||||||
|
Definition definition = definition(sessionId);
|
||||||
|
if (definition == null) return null;
|
||||||
|
Set<String> nodes = redis.opsForSet().members(nodesKey(sessionId));
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> first = new ArrayList<>();
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> anomalies = new ArrayList<>();
|
||||||
|
long received = 0L, processed = 0L, observedAnomalies = 0L;
|
||||||
|
if (nodes != null) {
|
||||||
|
for (String node : nodes) {
|
||||||
|
String json = redis.opsForValue().get(snapshotKey(sessionId, node));
|
||||||
|
if (json == null) continue;
|
||||||
|
Map<String, Object> candidate = mapper.readValue(json, MAP_TYPE);
|
||||||
|
received += longValue(candidate.get("receivedCount"));
|
||||||
|
processed += longValue(candidate.get("processedCount"));
|
||||||
|
observedAnomalies += longValue(candidate.get("observedAnomalyCount"));
|
||||||
|
first.addAll(messages(candidate.get("firstMessages")));
|
||||||
|
anomalies.addAll(messages(candidate.get("firstAnomalies")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> selected =
|
||||||
|
RawLogCaptureService.mergeCandidates(first, anomalies, definition.messageCount);
|
||||||
|
int retainedAnomalies = 0;
|
||||||
|
for (RawLogCaptureService.CapturedLogSnapshot message : selected) {
|
||||||
|
if (message.isAnomaly()) retainedAnomalies++;
|
||||||
|
}
|
||||||
|
long replacements = replacementCount(first, anomalies, definition.messageCount);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
return new RawLogCaptureService.CaptureSnapshot(definition.id, definition.deviceId,
|
||||||
|
definition.status(now), RawLogCaptureService.instant(definition.startedAt),
|
||||||
|
RawLogCaptureService.instant(definition.captureEndsAt),
|
||||||
|
RawLogCaptureService.instant(definition.settleEndsAt), definition.messageCount,
|
||||||
|
received, processed, observedAnomalies, retainedAnomalies, replacements, selected);
|
||||||
|
} catch (RawLogCaptureService.CaptureCoordinationException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new RawLogCaptureService.CaptureCoordinationException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RawLogCaptureService.CaptureSnapshot stop(String sessionId) {
|
||||||
|
try {
|
||||||
|
Definition definition = definition(sessionId);
|
||||||
|
if (definition == null) return null;
|
||||||
|
redis.opsForHash().put(metaKey(sessionId), "stoppedAt",
|
||||||
|
String.valueOf(System.currentTimeMillis()));
|
||||||
|
redis.opsForZSet().remove(ACTIVE_KEY, sessionId);
|
||||||
|
return get(sessionId);
|
||||||
|
} catch (RawLogCaptureService.CaptureCoordinationException failure) {
|
||||||
|
throw failure;
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw new RawLogCaptureService.CaptureCoordinationException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordSyncFailure(RuntimeException failure) {
|
||||||
|
long now = System.currentTimeMillis(), previous = lastFailureLogAt.get();
|
||||||
|
if (now - previous >= 30_000L && lastFailureLogAt.compareAndSet(previous, now)) {
|
||||||
|
log.warn("原始日志采样跨实例同步失败;不影响主消费链路: {}", failure.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Definition definition(String id) {
|
||||||
|
Map<Object, Object> values = redis.opsForHash().entries(metaKey(id));
|
||||||
|
return values == null || values.isEmpty() ? null : Definition.from(id, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toMap(RawLogCaptureService.CaptureCandidates candidates) {
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("receivedCount", candidates.getReceivedCount());
|
||||||
|
value.put("processedCount", candidates.getProcessedCount());
|
||||||
|
value.put("observedAnomalyCount", candidates.getObservedAnomalyCount());
|
||||||
|
value.put("firstMessages", candidates.getFirstMessages());
|
||||||
|
value.put("firstAnomalies", candidates.getFirstAnomalies());
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<RawLogCaptureService.CapturedLogSnapshot> messages(Object value) {
|
||||||
|
if (!(value instanceof List)) return Collections.emptyList();
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> result = new ArrayList<>();
|
||||||
|
for (Object item : (List<?>) value) {
|
||||||
|
if (!(item instanceof Map)) continue;
|
||||||
|
Map<String, Object> map = (Map<String, Object>) item;
|
||||||
|
Object receivedAt = map.get("receivedAt");
|
||||||
|
result.add(new RawLogCaptureService.CapturedLogSnapshot(
|
||||||
|
longValue(map.get("sequence")), string(map.get("eventId")),
|
||||||
|
nullableLong(map.get("deviceId")), nullableLong(map.get("deviceCollectId")),
|
||||||
|
string(map.get("topic")), localDateTime(receivedAt), string(map.get("observedAt")),
|
||||||
|
string(map.get("processingStatus")), booleanValue(map.get("anomaly")),
|
||||||
|
string(map.get("reason")), string(map.get("reasonDetail")),
|
||||||
|
string(map.get("processedAt")), string(map.get("rawMessage"))));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long replacementCount(List<RawLogCaptureService.CapturedLogSnapshot> first,
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> anomalies, int limit) {
|
||||||
|
int finalAnomalies = 0, initialAnomalies = 0;
|
||||||
|
for (RawLogCaptureService.CapturedLogSnapshot value
|
||||||
|
: RawLogCaptureService.mergeCandidates(first, anomalies, limit)) {
|
||||||
|
if (value.isAnomaly()) finalAnomalies++;
|
||||||
|
}
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> initial =
|
||||||
|
RawLogCaptureService.mergeCandidates(first, Collections.emptyList(), limit);
|
||||||
|
for (RawLogCaptureService.CapturedLogSnapshot value : initial) {
|
||||||
|
if (value.isAnomaly()) initialAnomalies++;
|
||||||
|
}
|
||||||
|
return Math.max(0, finalAnomalies - initialAnomalies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String metaKey(String id) { return PREFIX + "{" + id + "}:meta"; }
|
||||||
|
private static String nodesKey(String id) { return PREFIX + "{" + id + "}:nodes"; }
|
||||||
|
private static String snapshotKey(String id, String node) {
|
||||||
|
return PREFIX + "{" + id + "}:node:" + node;
|
||||||
|
}
|
||||||
|
private static String string(Object value) { return value == null ? null : String.valueOf(value); }
|
||||||
|
private static long longValue(Object value) {
|
||||||
|
return value == null ? 0L : Long.parseLong(String.valueOf(value));
|
||||||
|
}
|
||||||
|
private static Long nullableLong(Object value) {
|
||||||
|
return value == null ? null : Long.valueOf(String.valueOf(value));
|
||||||
|
}
|
||||||
|
private static boolean booleanValue(Object value) {
|
||||||
|
return value != null && Boolean.parseBoolean(String.valueOf(value));
|
||||||
|
}
|
||||||
|
private static LocalDateTime localDateTime(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value instanceof List) {
|
||||||
|
List<?> parts = (List<?>) value;
|
||||||
|
if (parts.size() >= 6) {
|
||||||
|
int nano = parts.size() >= 7 ? ((Number) parts.get(6)).intValue() : 0;
|
||||||
|
return LocalDateTime.of(((Number) parts.get(0)).intValue(),
|
||||||
|
((Number) parts.get(1)).intValue(), ((Number) parts.get(2)).intValue(),
|
||||||
|
((Number) parts.get(3)).intValue(), ((Number) parts.get(4)).intValue(),
|
||||||
|
((Number) parts.get(5)).intValue(), nano);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LocalDateTime.parse(String.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Definition {
|
||||||
|
private final String id;
|
||||||
|
private final long deviceId;
|
||||||
|
private final long startedAt;
|
||||||
|
private final long captureEndsAt;
|
||||||
|
private final long settleEndsAt;
|
||||||
|
private final int messageCount;
|
||||||
|
private final int retentionMinutes;
|
||||||
|
private final long stoppedAt;
|
||||||
|
|
||||||
|
Definition(String id, long deviceId, long startedAt, long captureEndsAt,
|
||||||
|
long settleEndsAt, int messageCount, int retentionMinutes) {
|
||||||
|
this(id, deviceId, startedAt, captureEndsAt, settleEndsAt, messageCount,
|
||||||
|
retentionMinutes, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Definition(String id, long deviceId, long startedAt, long captureEndsAt,
|
||||||
|
long settleEndsAt, int messageCount, int retentionMinutes,
|
||||||
|
long stoppedAt) {
|
||||||
|
this.id = id; this.deviceId = deviceId; this.startedAt = startedAt;
|
||||||
|
this.captureEndsAt = captureEndsAt; this.settleEndsAt = settleEndsAt;
|
||||||
|
this.messageCount = messageCount; this.retentionMinutes = retentionMinutes;
|
||||||
|
this.stoppedAt = stoppedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> toHash() {
|
||||||
|
Map<String, String> value = new LinkedHashMap<>();
|
||||||
|
value.put("deviceId", String.valueOf(deviceId));
|
||||||
|
value.put("startedAt", String.valueOf(startedAt));
|
||||||
|
value.put("captureEndsAt", String.valueOf(captureEndsAt));
|
||||||
|
value.put("settleEndsAt", String.valueOf(settleEndsAt));
|
||||||
|
value.put("messageCount", String.valueOf(messageCount));
|
||||||
|
value.put("retentionMinutes", String.valueOf(retentionMinutes));
|
||||||
|
value.put("stoppedAt", String.valueOf(stoppedAt));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Definition from(String id, Map<Object, Object> map) {
|
||||||
|
return new Definition(id, longValue(map.get("deviceId")), longValue(map.get("startedAt")),
|
||||||
|
longValue(map.get("captureEndsAt")), longValue(map.get("settleEndsAt")),
|
||||||
|
(int) longValue(map.get("messageCount")),
|
||||||
|
(int) longValue(map.get("retentionMinutes")), longValue(map.get("stoppedAt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ttlSeconds(long now) {
|
||||||
|
long remainingMillis = Math.max(0L, settleEndsAt - now);
|
||||||
|
return Math.max(60L, (remainingMillis + 999L) / 1000L
|
||||||
|
+ retentionMinutes * 60L);
|
||||||
|
}
|
||||||
|
private String status(long now) {
|
||||||
|
if (stoppedAt > 0L) return "CANCELLED";
|
||||||
|
if (now < startedAt) return "SCHEDULED";
|
||||||
|
if (now <= captureEndsAt) return "RUNNING";
|
||||||
|
if (now <= settleEndsAt) return "DRAINING";
|
||||||
|
return "COMPLETED";
|
||||||
|
}
|
||||||
|
String getId() { return id; }
|
||||||
|
long getDeviceId() { return deviceId; }
|
||||||
|
long getStartedAt() { return startedAt; }
|
||||||
|
long getCaptureEndsAt() { return captureEndsAt; }
|
||||||
|
long getSettleEndsAt() { return settleEndsAt; }
|
||||||
|
int getMessageCount() { return messageCount; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+798
@@ -0,0 +1,798 @@
|
|||||||
|
package com.common.service;
|
||||||
|
|
||||||
|
import com.Modules.NormalData.LogNormalProcessor;
|
||||||
|
import com.common.entity.SyslogNonNormalMessage;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面向现场诊断的有界原始日志采样器。
|
||||||
|
*
|
||||||
|
* <p>普通消息按到达顺序填充固定容量;容量满后,新的异常消息会淘汰最早的非异常消息。
|
||||||
|
* 异常消息之间仍保持先到先得,避免高流量设备导致内存随消息数增长。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RawLogCaptureService {
|
||||||
|
|
||||||
|
public static final int MAX_DURATION_SECONDS = 600;
|
||||||
|
public static final int MAX_MESSAGE_COUNT = 50;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.enabled:true}")
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.max-active-sessions:20}")
|
||||||
|
private int maxActiveSessions = 20;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.max-retained-sessions:100}")
|
||||||
|
private int maxRetainedSessions = 100;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.settle-seconds:30}")
|
||||||
|
private int settleSeconds = 30;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.result-retention-minutes:30}")
|
||||||
|
private int resultRetentionMinutes = 30;
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final ConcurrentHashMap<String, CaptureSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<Long, CopyOnWriteArrayList<CaptureSession>> sessionsByDevice =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
|
|
||||||
|
/** 跨实例同步由独立存储组件完成;单元测试或明确关闭时退化为单实例模式。 */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private RawLogCaptureClusterStore clusterStore;
|
||||||
|
|
||||||
|
@Value("${syslog.raw-capture.cluster-enabled:true}")
|
||||||
|
private boolean clusterEnabled = true;
|
||||||
|
|
||||||
|
public RawLogCaptureService() {
|
||||||
|
this(Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
RawLogCaptureService(Clock clock) {
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void validateConfiguration() {
|
||||||
|
if (maxActiveSessions < 1 || maxActiveSessions > 1000) {
|
||||||
|
throw new IllegalArgumentException("syslog.raw-capture.max-active-sessions 必须在 1~1000 之间");
|
||||||
|
}
|
||||||
|
if (maxRetainedSessions < maxActiveSessions || maxRetainedSessions > 5000) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"syslog.raw-capture.max-retained-sessions 必须不小于活动会话上限且不超过 5000");
|
||||||
|
}
|
||||||
|
if (settleSeconds < 0 || settleSeconds > 300) {
|
||||||
|
throw new IllegalArgumentException("syslog.raw-capture.settle-seconds 必须在 0~300 之间");
|
||||||
|
}
|
||||||
|
if (resultRetentionMinutes < 1 || resultRetentionMinutes > 1440) {
|
||||||
|
throw new IllegalArgumentException("syslog.raw-capture.result-retention-minutes 必须在 1~1440 之间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CaptureSnapshot start(long deviceId, int durationSeconds, int messageCount) {
|
||||||
|
if (!enabled) throw new CaptureDisabledException();
|
||||||
|
if (deviceId <= 0L) throw new IllegalArgumentException("deviceId 必须大于 0");
|
||||||
|
if (durationSeconds < 1 || durationSeconds > MAX_DURATION_SECONDS) {
|
||||||
|
throw new IllegalArgumentException("durationSeconds 必须在 1~600 之间");
|
||||||
|
}
|
||||||
|
if (messageCount < 1 || messageCount > MAX_MESSAGE_COUNT) {
|
||||||
|
throw new IllegalArgumentException("messageCount 必须在 1~50 之间");
|
||||||
|
}
|
||||||
|
if (useClusterStore()) {
|
||||||
|
RawLogCaptureClusterStore.Definition definition = clusterStore.create(deviceId,
|
||||||
|
durationSeconds, settleSeconds, messageCount, maxActiveSessions,
|
||||||
|
resultRetentionMinutes);
|
||||||
|
ensureClusterSession(definition);
|
||||||
|
return clusterStore.get(definition.getId());
|
||||||
|
}
|
||||||
|
cleanup(clock.millis());
|
||||||
|
synchronized (this) {
|
||||||
|
long startedAt = clock.millis();
|
||||||
|
if (activeSessionCount(startedAt) >= maxActiveSessions) {
|
||||||
|
throw new CaptureCapacityException(maxActiveSessions);
|
||||||
|
}
|
||||||
|
makeRoomForRetainedSession(startedAt);
|
||||||
|
CaptureSession session = new CaptureSession(UUID.randomUUID().toString(), deviceId,
|
||||||
|
startedAt, startedAt + durationSeconds * 1000L,
|
||||||
|
startedAt + (durationSeconds + settleSeconds) * 1000L, messageCount);
|
||||||
|
sessions.put(session.id, session);
|
||||||
|
sessionsByDevice.computeIfAbsent(deviceId, ignored -> new CopyOnWriteArrayList<>())
|
||||||
|
.add(session);
|
||||||
|
return session.snapshot(startedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CaptureSnapshot get(String sessionId) {
|
||||||
|
if (useClusterStore()) return clusterStore.get(sessionId);
|
||||||
|
long now = clock.millis();
|
||||||
|
cleanup(now);
|
||||||
|
CaptureSession session = sessions.get(sessionId);
|
||||||
|
return session == null ? null : session.snapshot(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CaptureSnapshot stop(String sessionId) {
|
||||||
|
if (useClusterStore()) {
|
||||||
|
CaptureSnapshot snapshot = clusterStore.stop(sessionId);
|
||||||
|
CaptureSession local = sessions.get(sessionId);
|
||||||
|
if (local != null) local.stop(clock.millis());
|
||||||
|
if (local != null) removeFromActiveIndex(local);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
long now = clock.millis();
|
||||||
|
CaptureSession session = sessions.get(sessionId);
|
||||||
|
if (session == null) return null;
|
||||||
|
session.stop(now);
|
||||||
|
removeFromActiveIndex(session);
|
||||||
|
return session.snapshot(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在解密并识别设备后调用;只保存固定容量的最早样本。 */
|
||||||
|
public void observeReceived(long observedAtEpochMillis, Long deviceId, Long deviceCollectId,
|
||||||
|
String eventId, String topic, LocalDateTime receivedAt,
|
||||||
|
String rawMessage) {
|
||||||
|
if (!enabled || deviceId == null) return;
|
||||||
|
List<CaptureSession> active = sessionsByDevice.get(deviceId);
|
||||||
|
if (active == null || active.isEmpty()) return;
|
||||||
|
ReceivedLog received = new ReceivedLog(orderSequence(observedAtEpochMillis), observedAtEpochMillis,
|
||||||
|
deviceId, deviceCollectId, eventId, topic, receivedAt, rawMessage);
|
||||||
|
for (CaptureSession session : active) session.received(received);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录规则处理结果;非标结果具有异常优先级。 */
|
||||||
|
public void observeNormalization(long observedAtEpochMillis, Long deviceId, Long deviceCollectId,
|
||||||
|
String eventId, String topic, LocalDateTime receivedAt,
|
||||||
|
String rawMessage,
|
||||||
|
LogNormalProcessor.NormalizationResult result) {
|
||||||
|
if (result == null) return;
|
||||||
|
if (!result.getNonNormalMessages().isEmpty()) {
|
||||||
|
SyslogNonNormalMessage first = result.getNonNormalMessages().get(0);
|
||||||
|
observeOutcome(observedAtEpochMillis, deviceId, deviceCollectId, eventId, topic,
|
||||||
|
receivedAt, rawMessage, "NON_NORMAL", true,
|
||||||
|
first.getReason(), first.getReasonDetail());
|
||||||
|
} else if (result.getOutcome() == LogNormalProcessor.ProcessingOutcome.FILTERED) {
|
||||||
|
observeOutcome(observedAtEpochMillis, deviceId, deviceCollectId, eventId, topic,
|
||||||
|
receivedAt, rawMessage, "FILTERED", false, "规则过滤", null);
|
||||||
|
} else {
|
||||||
|
observeOutcome(observedAtEpochMillis, deviceId, deviceCollectId, eventId, topic,
|
||||||
|
receivedAt, rawMessage, "SUCCESS", false, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录 Retry/DLT、规则执行或持久化异常;异常可替换已有普通样本。 */
|
||||||
|
public void observeFailure(long observedAtEpochMillis, Long deviceId, Long deviceCollectId,
|
||||||
|
String eventId, String topic, LocalDateTime receivedAt,
|
||||||
|
String rawMessage, String stage, String terminalState,
|
||||||
|
Throwable failure) {
|
||||||
|
String status = terminalState == null || terminalState.trim().isEmpty()
|
||||||
|
? "ERROR" : terminalState;
|
||||||
|
String reason = stage == null || stage.trim().isEmpty() ? "处理异常" : stage;
|
||||||
|
String detail = failure == null ? null : failure.getClass().getName()
|
||||||
|
+ (failure.getMessage() == null ? "" : ": " + failure.getMessage());
|
||||||
|
observeOutcome(observedAtEpochMillis, deviceId, deviceCollectId, eventId, topic,
|
||||||
|
receivedAt, rawMessage, status, true, reason, abbreviate(detail, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void observeOutcome(long observedAtEpochMillis, Long deviceId, Long deviceCollectId,
|
||||||
|
String eventId, String topic, LocalDateTime receivedAt,
|
||||||
|
String rawMessage, String status, boolean anomaly,
|
||||||
|
String reason, String detail) {
|
||||||
|
if (!enabled || deviceId == null) return;
|
||||||
|
List<CaptureSession> active = sessionsByDevice.get(deviceId);
|
||||||
|
if (active == null || active.isEmpty()) return;
|
||||||
|
ReceivedLog received = new ReceivedLog(orderSequence(observedAtEpochMillis), observedAtEpochMillis,
|
||||||
|
deviceId, deviceCollectId, eventId, topic, receivedAt, rawMessage);
|
||||||
|
long now = clock.millis();
|
||||||
|
for (CaptureSession session : active) {
|
||||||
|
session.outcome(received, status, anomaly, reason, detail, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多实例每200ms同步一次会话定义和变化过的有界候选集;单实例降级模式用同一定时器
|
||||||
|
* 主动清理过期结果。API创建时预留1秒激活时间,避免轮询传播丢掉窗口开头的日志。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${syslog.raw-capture.cluster-sync-interval-ms:200}")
|
||||||
|
public void synchronizeClusterSessions() {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!useClusterStore()) {
|
||||||
|
cleanup(clock.millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
long now = clock.millis();
|
||||||
|
List<RawLogCaptureClusterStore.Definition> definitions = clusterStore.active(now);
|
||||||
|
Map<String, Boolean> activeIds = new LinkedHashMap<>();
|
||||||
|
for (RawLogCaptureClusterStore.Definition definition : definitions) {
|
||||||
|
activeIds.put(definition.getId(), Boolean.TRUE);
|
||||||
|
CaptureSession session = ensureClusterSession(definition);
|
||||||
|
CaptureCandidates candidates = session.candidatesIfChanged();
|
||||||
|
if (candidates != null) {
|
||||||
|
clusterStore.publish(candidates);
|
||||||
|
session.markPublished(candidates.getRevision());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (CaptureSession session : sessions.values()) {
|
||||||
|
if (session.clusterSession && !activeIds.containsKey(session.id)) {
|
||||||
|
clusterStore.publish(session.finalCandidates());
|
||||||
|
session.stop(now);
|
||||||
|
removeFromActiveIndex(session);
|
||||||
|
sessions.remove(session.id, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
clusterStore.recordSyncFailure(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean useClusterStore() {
|
||||||
|
return clusterEnabled && clusterStore != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaptureSession ensureClusterSession(RawLogCaptureClusterStore.Definition definition) {
|
||||||
|
CaptureSession existing = sessions.get(definition.getId());
|
||||||
|
if (existing != null) return existing;
|
||||||
|
CaptureSession created = new CaptureSession(definition.getId(), definition.getDeviceId(),
|
||||||
|
definition.getStartedAt(), definition.getCaptureEndsAt(),
|
||||||
|
definition.getSettleEndsAt(), definition.getMessageCount(), true);
|
||||||
|
CaptureSession winner = sessions.putIfAbsent(created.id, created);
|
||||||
|
CaptureSession session = winner == null ? created : winner;
|
||||||
|
if (winner == null) {
|
||||||
|
sessionsByDevice.computeIfAbsent(created.deviceId,
|
||||||
|
ignored -> new CopyOnWriteArrayList<>()).add(created);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized int activeSessionCount(long now) {
|
||||||
|
int count = 0;
|
||||||
|
for (CaptureSession session : sessions.values()) {
|
||||||
|
if (session.isActive(now)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总结果数满时优先淘汰最早完成的会话,活动会话绝不被淘汰。 */
|
||||||
|
private void makeRoomForRetainedSession(long now) {
|
||||||
|
while (sessions.size() >= maxRetainedSessions) {
|
||||||
|
CaptureSession oldestFinished = null;
|
||||||
|
for (CaptureSession session : sessions.values()) {
|
||||||
|
if (session.isActive(now)) continue;
|
||||||
|
if (oldestFinished == null || session.finishedAt() < oldestFinished.finishedAt()) {
|
||||||
|
oldestFinished = session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestFinished == null) {
|
||||||
|
throw new CaptureCapacityException(maxRetainedSessions);
|
||||||
|
}
|
||||||
|
sessions.remove(oldestFinished.id, oldestFinished);
|
||||||
|
removeFromActiveIndex(oldestFinished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanup(long now) {
|
||||||
|
for (CaptureSession session : sessions.values()) {
|
||||||
|
if (!session.isActive(now)) removeFromActiveIndex(session);
|
||||||
|
if (session.removableAt(resultRetentionMinutes) < now) sessions.remove(session.id, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeFromActiveIndex(CaptureSession session) {
|
||||||
|
CopyOnWriteArrayList<CaptureSession> active = sessionsByDevice.get(session.deviceId);
|
||||||
|
if (active == null) return;
|
||||||
|
active.remove(session);
|
||||||
|
if (active.isEmpty()) sessionsByDevice.remove(session.deviceId, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String abbreviate(String value, int maxChars) {
|
||||||
|
if (value == null || value.length() <= maxChars) return value;
|
||||||
|
return value.substring(0, maxChars) + "...[truncated]";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String instant(long epochMillis) {
|
||||||
|
return Instant.ofEpochMilli(epochMillis).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long orderSequence(long observedAtEpochMillis) {
|
||||||
|
return observedAtEpochMillis * 1_000_000L + sequence.incrementAndGet() % 1_000_000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<CapturedLogSnapshot> mergeCandidates(List<CapturedLogSnapshot> firstCandidates,
|
||||||
|
List<CapturedLogSnapshot> anomalyCandidates,
|
||||||
|
int limit) {
|
||||||
|
Map<String, CapturedLogSnapshot> uniqueFirst = uniqueByEvent(firstCandidates);
|
||||||
|
List<CapturedLogSnapshot> first = new ArrayList<>(uniqueFirst.values());
|
||||||
|
first.sort(Comparator.comparingLong(CapturedLogSnapshot::getSequence));
|
||||||
|
if (first.size() > limit) first = new ArrayList<>(first.subList(0, limit));
|
||||||
|
|
||||||
|
Map<String, CapturedLogSnapshot> uniqueAnomalies = uniqueByEvent(anomalyCandidates);
|
||||||
|
List<CapturedLogSnapshot> anomalies = new ArrayList<>(uniqueAnomalies.values());
|
||||||
|
anomalies.sort(Comparator.comparingLong(CapturedLogSnapshot::getSequence));
|
||||||
|
if (anomalies.size() > limit) anomalies = anomalies.subList(0, limit);
|
||||||
|
|
||||||
|
Map<String, CapturedLogSnapshot> selected = uniqueByEvent(first);
|
||||||
|
for (CapturedLogSnapshot anomaly : anomalies) {
|
||||||
|
String key = eventKey(anomaly);
|
||||||
|
if (selected.containsKey(key)) {
|
||||||
|
selected.put(key, anomaly);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selected.size() < limit) {
|
||||||
|
selected.put(key, anomaly);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String oldestNormal = null;
|
||||||
|
long oldestSequence = Long.MAX_VALUE;
|
||||||
|
for (Map.Entry<String, CapturedLogSnapshot> entry : selected.entrySet()) {
|
||||||
|
CapturedLogSnapshot candidate = entry.getValue();
|
||||||
|
if (!candidate.isAnomaly() && candidate.getSequence() < oldestSequence) {
|
||||||
|
oldestNormal = entry.getKey();
|
||||||
|
oldestSequence = candidate.getSequence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestNormal == null) break;
|
||||||
|
selected.remove(oldestNormal);
|
||||||
|
selected.put(key, anomaly);
|
||||||
|
}
|
||||||
|
List<CapturedLogSnapshot> result = new ArrayList<>(selected.values());
|
||||||
|
result.sort(Comparator.comparingLong(CapturedLogSnapshot::getSequence));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, CapturedLogSnapshot> uniqueByEvent(
|
||||||
|
List<CapturedLogSnapshot> candidates) {
|
||||||
|
Map<String, CapturedLogSnapshot> result = new LinkedHashMap<>();
|
||||||
|
if (candidates == null) return result;
|
||||||
|
for (CapturedLogSnapshot candidate : candidates) {
|
||||||
|
String key = eventKey(candidate);
|
||||||
|
CapturedLogSnapshot existing = result.get(key);
|
||||||
|
if (existing == null || (!existing.isAnomaly() && candidate.isAnomaly())) {
|
||||||
|
result.put(key, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String eventKey(CapturedLogSnapshot candidate) {
|
||||||
|
return candidate.getEventId() == null
|
||||||
|
? "sequence:" + candidate.getSequence() : "event:" + candidate.getEventId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CapturedLogSnapshot> snapshots(List<CapturedLog> logs) {
|
||||||
|
List<CapturedLogSnapshot> result = new ArrayList<>(logs.size());
|
||||||
|
for (CapturedLog log : logs) result.add(log.snapshot());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countAnomalies(List<CapturedLogSnapshot> messages) {
|
||||||
|
int count = 0;
|
||||||
|
for (CapturedLogSnapshot message : messages) if (message.isAnomaly()) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long replacementCount(List<CapturedLogSnapshot> first,
|
||||||
|
List<CapturedLogSnapshot> anomalies, int limit) {
|
||||||
|
Map<String, CapturedLogSnapshot> firstSet = uniqueByEvent(first);
|
||||||
|
int laterAnomalies = 0;
|
||||||
|
for (CapturedLogSnapshot anomaly : uniqueByEvent(anomalies).values()) {
|
||||||
|
if (!firstSet.containsKey(eventKey(anomaly))) laterAnomalies++;
|
||||||
|
}
|
||||||
|
int availableNormals = 0;
|
||||||
|
List<CapturedLogSnapshot> globalFirst = new ArrayList<>(firstSet.values());
|
||||||
|
globalFirst.sort(Comparator.comparingLong(CapturedLogSnapshot::getSequence));
|
||||||
|
for (int i = 0; i < Math.min(limit, globalFirst.size()); i++) {
|
||||||
|
if (!globalFirst.get(i).isAnomaly()) availableNormals++;
|
||||||
|
}
|
||||||
|
return Math.min(laterAnomalies, availableNormals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ReceivedLog {
|
||||||
|
private final long sequence;
|
||||||
|
private final long observedAt;
|
||||||
|
private final Long deviceId;
|
||||||
|
private final Long deviceCollectId;
|
||||||
|
private final String eventId;
|
||||||
|
private final String topic;
|
||||||
|
private final LocalDateTime receivedAt;
|
||||||
|
private final String rawMessage;
|
||||||
|
|
||||||
|
private ReceivedLog(long sequence, long observedAt, Long deviceId, Long deviceCollectId,
|
||||||
|
String eventId, String topic, LocalDateTime receivedAt, String rawMessage) {
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.observedAt = observedAt;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.deviceCollectId = deviceCollectId;
|
||||||
|
this.eventId = eventId;
|
||||||
|
this.topic = topic;
|
||||||
|
this.receivedAt = receivedAt;
|
||||||
|
this.rawMessage = rawMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CaptureSession {
|
||||||
|
private final String id;
|
||||||
|
private final long deviceId;
|
||||||
|
private final long startedAt;
|
||||||
|
private final long captureEndsAt;
|
||||||
|
private final long settleEndsAt;
|
||||||
|
private final int messageCount;
|
||||||
|
private final List<CapturedLog> firstMessages = new ArrayList<>();
|
||||||
|
private final List<CapturedLog> firstAnomalies = new ArrayList<>();
|
||||||
|
private final Map<String, CapturedLog> firstByEventId = new LinkedHashMap<>();
|
||||||
|
private final Map<String, CapturedLog> anomalyByEventId = new LinkedHashMap<>();
|
||||||
|
private final boolean clusterSession;
|
||||||
|
private long revision;
|
||||||
|
private long publishedRevision = -1L;
|
||||||
|
private long stoppedAt;
|
||||||
|
private long receivedCount;
|
||||||
|
private long processedCount;
|
||||||
|
private long observedAnomalyCount;
|
||||||
|
private long replacementCount;
|
||||||
|
|
||||||
|
private CaptureSession(String id, long deviceId, long startedAt, long captureEndsAt,
|
||||||
|
long settleEndsAt, int messageCount) {
|
||||||
|
this(id, deviceId, startedAt, captureEndsAt, settleEndsAt, messageCount, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaptureSession(String id, long deviceId, long startedAt, long captureEndsAt,
|
||||||
|
long settleEndsAt, int messageCount, boolean clusterSession) {
|
||||||
|
this.id = id;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.captureEndsAt = captureEndsAt;
|
||||||
|
this.settleEndsAt = settleEndsAt;
|
||||||
|
this.messageCount = messageCount;
|
||||||
|
this.clusterSession = clusterSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void received(ReceivedLog log) {
|
||||||
|
if (!accepts(log.observedAt) || stoppedAt > 0L) return;
|
||||||
|
receivedCount++;
|
||||||
|
if (findFirst(log.eventId) != null || firstMessages.size() >= messageCount) return;
|
||||||
|
CapturedLog captured = new CapturedLog(log);
|
||||||
|
firstMessages.add(captured);
|
||||||
|
if (captured.eventId != null) firstByEventId.put(captured.eventId, captured);
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void outcome(ReceivedLog log, String status, boolean anomaly,
|
||||||
|
String reason, String detail, long now) {
|
||||||
|
if (!accepts(log.observedAt) || stoppedAt > 0L || now > settleEndsAt) return;
|
||||||
|
processedCount++;
|
||||||
|
if (anomaly) observedAnomalyCount++;
|
||||||
|
CapturedLog first = findFirst(log.eventId);
|
||||||
|
if (first != null) {
|
||||||
|
first.update(status, anomaly, reason, detail, now);
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
CapturedLog existingAnomaly = findAnomaly(log.eventId);
|
||||||
|
if (existingAnomaly != null) {
|
||||||
|
existingAnomaly.update(status, anomaly, reason, detail, now);
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
if (!anomaly || existingAnomaly != null || firstAnomalies.size() >= messageCount) return;
|
||||||
|
CapturedLog captured = first == null ? new CapturedLog(log) : first.copy();
|
||||||
|
captured.update(status, true, reason, detail, now);
|
||||||
|
firstAnomalies.add(captured);
|
||||||
|
if (captured.eventId != null) anomalyByEventId.put(captured.eventId, captured);
|
||||||
|
revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean accepts(long observedAt) {
|
||||||
|
return observedAt >= startedAt && observedAt <= captureEndsAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedLog findFirst(String eventId) {
|
||||||
|
return eventId == null ? null : firstByEventId.get(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedLog findAnomaly(String eventId) {
|
||||||
|
return eventId == null ? null : anomalyByEventId.get(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void stop(long now) {
|
||||||
|
if (stoppedAt == 0L) stoppedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized boolean isActive(long now) {
|
||||||
|
return stoppedAt == 0L && now <= settleEndsAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized long removableAt(int retentionMinutes) {
|
||||||
|
return finishedAt() + retentionMinutes * 60_000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized long finishedAt() {
|
||||||
|
return stoppedAt > 0L ? stoppedAt : settleEndsAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized CaptureSnapshot snapshot(long now) {
|
||||||
|
List<CapturedLogSnapshot> first = snapshots(firstMessages);
|
||||||
|
List<CapturedLogSnapshot> anomalies = snapshots(firstAnomalies);
|
||||||
|
List<CapturedLogSnapshot> logs = mergeCandidates(first, anomalies, messageCount);
|
||||||
|
int retainedAnomalies = countAnomalies(logs);
|
||||||
|
replacementCount = replacementCount(first, anomalies, messageCount);
|
||||||
|
return new CaptureSnapshot(id, deviceId, status(now), instant(startedAt),
|
||||||
|
instant(captureEndsAt), instant(settleEndsAt), messageCount,
|
||||||
|
receivedCount, processedCount, observedAnomalyCount,
|
||||||
|
retainedAnomalies, replacementCount, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized CaptureCandidates candidatesIfChanged() {
|
||||||
|
if (!clusterSession || revision == publishedRevision) return null;
|
||||||
|
return candidates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized CaptureCandidates finalCandidates() {
|
||||||
|
return candidates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaptureCandidates candidates() {
|
||||||
|
return new CaptureCandidates(id, deviceId, startedAt, captureEndsAt, settleEndsAt,
|
||||||
|
messageCount, receivedCount, processedCount, observedAnomalyCount,
|
||||||
|
snapshots(firstMessages), snapshots(firstAnomalies), revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void markPublished(long candidateRevision) {
|
||||||
|
if (candidateRevision > publishedRevision) publishedRevision = candidateRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String status(long now) {
|
||||||
|
if (stoppedAt > 0L) return "CANCELLED";
|
||||||
|
if (now < startedAt) return "SCHEDULED";
|
||||||
|
if (now <= captureEndsAt) return "RUNNING";
|
||||||
|
if (now <= settleEndsAt) return "DRAINING";
|
||||||
|
return "COMPLETED";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturedLog {
|
||||||
|
private final long sequence;
|
||||||
|
private final String eventId;
|
||||||
|
private final Long deviceId;
|
||||||
|
private final Long deviceCollectId;
|
||||||
|
private final String topic;
|
||||||
|
private final LocalDateTime receivedAt;
|
||||||
|
private final String observedAt;
|
||||||
|
private final String rawMessage;
|
||||||
|
private String processingStatus = "PENDING";
|
||||||
|
private boolean anomaly;
|
||||||
|
private String reason;
|
||||||
|
private String reasonDetail;
|
||||||
|
private String processedAt;
|
||||||
|
|
||||||
|
private CapturedLog(ReceivedLog log) {
|
||||||
|
this.sequence = log.sequence;
|
||||||
|
this.eventId = log.eventId;
|
||||||
|
this.deviceId = log.deviceId;
|
||||||
|
this.deviceCollectId = log.deviceCollectId;
|
||||||
|
this.topic = log.topic;
|
||||||
|
this.receivedAt = log.receivedAt;
|
||||||
|
this.observedAt = instant(log.observedAt);
|
||||||
|
this.rawMessage = log.rawMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedLog copy() {
|
||||||
|
return new CapturedLog(sequence, eventId, deviceId, deviceCollectId, topic,
|
||||||
|
receivedAt, observedAt, rawMessage, processingStatus, anomaly,
|
||||||
|
reason, reasonDetail, processedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedLog(long sequence, String eventId, Long deviceId, Long deviceCollectId,
|
||||||
|
String topic, LocalDateTime receivedAt, String observedAt,
|
||||||
|
String rawMessage, String processingStatus, boolean anomaly,
|
||||||
|
String reason, String reasonDetail, String processedAt) {
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.eventId = eventId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.deviceCollectId = deviceCollectId;
|
||||||
|
this.topic = topic;
|
||||||
|
this.receivedAt = receivedAt;
|
||||||
|
this.observedAt = observedAt;
|
||||||
|
this.rawMessage = rawMessage;
|
||||||
|
this.processingStatus = processingStatus;
|
||||||
|
this.anomaly = anomaly;
|
||||||
|
this.reason = reason;
|
||||||
|
this.reasonDetail = reasonDetail;
|
||||||
|
this.processedAt = processedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update(String status, boolean anomaly, String reason, String detail, long now) {
|
||||||
|
if (this.anomaly && !anomaly) return;
|
||||||
|
this.processingStatus = status;
|
||||||
|
this.anomaly = anomaly;
|
||||||
|
this.reason = reason;
|
||||||
|
this.reasonDetail = detail;
|
||||||
|
this.processedAt = instant(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CapturedLogSnapshot snapshot() {
|
||||||
|
return new CapturedLogSnapshot(sequence, eventId, deviceId, deviceCollectId, topic,
|
||||||
|
receivedAt, observedAt, processingStatus, anomaly, reason, reasonDetail,
|
||||||
|
processedAt, rawMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class CaptureCandidates {
|
||||||
|
private final String sessionId;
|
||||||
|
private final long deviceId;
|
||||||
|
private final long startedAt;
|
||||||
|
private final long captureEndsAt;
|
||||||
|
private final long settleEndsAt;
|
||||||
|
private final int messageCount;
|
||||||
|
private final long receivedCount;
|
||||||
|
private final long processedCount;
|
||||||
|
private final long observedAnomalyCount;
|
||||||
|
private final List<CapturedLogSnapshot> firstMessages;
|
||||||
|
private final List<CapturedLogSnapshot> firstAnomalies;
|
||||||
|
private final long revision;
|
||||||
|
|
||||||
|
CaptureCandidates(String sessionId, long deviceId, long startedAt, long captureEndsAt,
|
||||||
|
long settleEndsAt, int messageCount, long receivedCount,
|
||||||
|
long processedCount, long observedAnomalyCount,
|
||||||
|
List<CapturedLogSnapshot> firstMessages,
|
||||||
|
List<CapturedLogSnapshot> firstAnomalies, long revision) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.captureEndsAt = captureEndsAt;
|
||||||
|
this.settleEndsAt = settleEndsAt;
|
||||||
|
this.messageCount = messageCount;
|
||||||
|
this.receivedCount = receivedCount;
|
||||||
|
this.processedCount = processedCount;
|
||||||
|
this.observedAnomalyCount = observedAnomalyCount;
|
||||||
|
this.firstMessages = firstMessages;
|
||||||
|
this.firstAnomalies = firstAnomalies;
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getSessionId() { return sessionId; }
|
||||||
|
long getDeviceId() { return deviceId; }
|
||||||
|
long getStartedAt() { return startedAt; }
|
||||||
|
long getCaptureEndsAt() { return captureEndsAt; }
|
||||||
|
long getSettleEndsAt() { return settleEndsAt; }
|
||||||
|
int getMessageCount() { return messageCount; }
|
||||||
|
long getReceivedCount() { return receivedCount; }
|
||||||
|
long getProcessedCount() { return processedCount; }
|
||||||
|
long getObservedAnomalyCount() { return observedAnomalyCount; }
|
||||||
|
List<CapturedLogSnapshot> getFirstMessages() { return firstMessages; }
|
||||||
|
List<CapturedLogSnapshot> getFirstAnomalies() { return firstAnomalies; }
|
||||||
|
long getRevision() { return revision; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class CaptureSnapshot {
|
||||||
|
private final String sessionId;
|
||||||
|
private final long deviceId;
|
||||||
|
private final String status;
|
||||||
|
private final String startedAt;
|
||||||
|
private final String captureEndsAt;
|
||||||
|
private final String settleEndsAt;
|
||||||
|
private final int messageCount;
|
||||||
|
private final long receivedCount;
|
||||||
|
private final long processedCount;
|
||||||
|
private final long observedAnomalyCount;
|
||||||
|
private final int retainedAnomalyCount;
|
||||||
|
private final long replacementCount;
|
||||||
|
private final List<CapturedLogSnapshot> messages;
|
||||||
|
|
||||||
|
CaptureSnapshot(String sessionId, long deviceId, String status, String startedAt,
|
||||||
|
String captureEndsAt, String settleEndsAt, int messageCount,
|
||||||
|
long receivedCount, long processedCount, long observedAnomalyCount,
|
||||||
|
int retainedAnomalyCount, long replacementCount,
|
||||||
|
List<CapturedLogSnapshot> messages) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.status = status;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.captureEndsAt = captureEndsAt;
|
||||||
|
this.settleEndsAt = settleEndsAt;
|
||||||
|
this.messageCount = messageCount;
|
||||||
|
this.receivedCount = receivedCount;
|
||||||
|
this.processedCount = processedCount;
|
||||||
|
this.observedAnomalyCount = observedAnomalyCount;
|
||||||
|
this.retainedAnomalyCount = retainedAnomalyCount;
|
||||||
|
this.replacementCount = replacementCount;
|
||||||
|
this.messages = Collections.unmodifiableList(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionId() { return sessionId; }
|
||||||
|
public long getDeviceId() { return deviceId; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public String getStartedAt() { return startedAt; }
|
||||||
|
public String getCaptureEndsAt() { return captureEndsAt; }
|
||||||
|
public String getSettleEndsAt() { return settleEndsAt; }
|
||||||
|
public int getMessageCount() { return messageCount; }
|
||||||
|
public long getReceivedCount() { return receivedCount; }
|
||||||
|
public long getProcessedCount() { return processedCount; }
|
||||||
|
public long getObservedAnomalyCount() { return observedAnomalyCount; }
|
||||||
|
public int getRetainedAnomalyCount() { return retainedAnomalyCount; }
|
||||||
|
public long getReplacementCount() { return replacementCount; }
|
||||||
|
public List<CapturedLogSnapshot> getMessages() { return messages; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class CapturedLogSnapshot {
|
||||||
|
private final long sequence;
|
||||||
|
private final String eventId;
|
||||||
|
private final Long deviceId;
|
||||||
|
private final Long deviceCollectId;
|
||||||
|
private final String topic;
|
||||||
|
private final LocalDateTime receivedAt;
|
||||||
|
private final String observedAt;
|
||||||
|
private final String processingStatus;
|
||||||
|
private final boolean anomaly;
|
||||||
|
private final String reason;
|
||||||
|
private final String reasonDetail;
|
||||||
|
private final String processedAt;
|
||||||
|
private final String rawMessage;
|
||||||
|
|
||||||
|
CapturedLogSnapshot(long sequence, String eventId, Long deviceId,
|
||||||
|
Long deviceCollectId, String topic, LocalDateTime receivedAt,
|
||||||
|
String observedAt, String processingStatus, boolean anomaly,
|
||||||
|
String reason, String reasonDetail, String processedAt,
|
||||||
|
String rawMessage) {
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.eventId = eventId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.deviceCollectId = deviceCollectId;
|
||||||
|
this.topic = topic;
|
||||||
|
this.receivedAt = receivedAt;
|
||||||
|
this.observedAt = observedAt;
|
||||||
|
this.processingStatus = processingStatus;
|
||||||
|
this.anomaly = anomaly;
|
||||||
|
this.reason = reason;
|
||||||
|
this.reasonDetail = reasonDetail;
|
||||||
|
this.processedAt = processedAt;
|
||||||
|
this.rawMessage = rawMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSequence() { return sequence; }
|
||||||
|
public String getEventId() { return eventId; }
|
||||||
|
public Long getDeviceId() { return deviceId; }
|
||||||
|
public Long getDeviceCollectId() { return deviceCollectId; }
|
||||||
|
public String getTopic() { return topic; }
|
||||||
|
public LocalDateTime getReceivedAt() { return receivedAt; }
|
||||||
|
public String getObservedAt() { return observedAt; }
|
||||||
|
public String getProcessingStatus() { return processingStatus; }
|
||||||
|
public boolean isAnomaly() { return anomaly; }
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public String getReasonDetail() { return reasonDetail; }
|
||||||
|
public String getProcessedAt() { return processedAt; }
|
||||||
|
public String getRawMessage() { return rawMessage; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
public static final class CaptureDisabledException extends RuntimeException {
|
||||||
|
public CaptureDisabledException() { super("现场原始日志采样功能未启用"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
public static final class CaptureCapacityException extends RuntimeException {
|
||||||
|
public CaptureCapacityException(int max) { super("采样会话容量已达到上限: " + max); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
public static final class CaptureCoordinationException extends RuntimeException {
|
||||||
|
public CaptureCoordinationException(Throwable cause) {
|
||||||
|
super("跨实例原始日志采样暂不可用,请检查 Redis 连接", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.controllers;
|
||||||
|
|
||||||
|
import com.common.service.RawLogCaptureService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import javax.validation.constraints.Max;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 现场按设备开启短时间、异常优先的原始日志采样。 */
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/syslog/raw-captures")
|
||||||
|
public class RawLogCaptureController {
|
||||||
|
|
||||||
|
private final RawLogCaptureService captureService;
|
||||||
|
|
||||||
|
public RawLogCaptureController(RawLogCaptureService captureService) {
|
||||||
|
this.captureService = captureService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<RawLogCaptureService.CaptureSnapshot> start(
|
||||||
|
@Valid @RequestBody StartCaptureRequest request) {
|
||||||
|
RawLogCaptureService.CaptureSnapshot snapshot = captureService.start(
|
||||||
|
request.getDeviceId(), request.getDurationSeconds(), request.getMessageCount());
|
||||||
|
return ResponseEntity.accepted()
|
||||||
|
.location(URI.create("/api/syslog/raw-captures/" + snapshot.getSessionId()))
|
||||||
|
.body(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{sessionId}")
|
||||||
|
public ResponseEntity<RawLogCaptureService.CaptureSnapshot> get(
|
||||||
|
@PathVariable String sessionId) {
|
||||||
|
RawLogCaptureService.CaptureSnapshot snapshot = captureService.get(sessionId);
|
||||||
|
return snapshot == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{sessionId}")
|
||||||
|
public ResponseEntity<RawLogCaptureService.CaptureSnapshot> stop(
|
||||||
|
@PathVariable String sessionId) {
|
||||||
|
RawLogCaptureService.CaptureSnapshot snapshot = captureService.stop(sessionId);
|
||||||
|
return snapshot == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RawLogCaptureService.CaptureDisabledException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> disabled(
|
||||||
|
RawLogCaptureService.CaptureDisabledException failure) {
|
||||||
|
return ResponseEntity.status(503)
|
||||||
|
.body(Collections.singletonMap("message", failure.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RawLogCaptureService.CaptureCapacityException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> capacity(
|
||||||
|
RawLogCaptureService.CaptureCapacityException failure) {
|
||||||
|
return ResponseEntity.status(429)
|
||||||
|
.body(Collections.singletonMap("message", failure.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RawLogCaptureService.CaptureCoordinationException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> coordination(
|
||||||
|
RawLogCaptureService.CaptureCoordinationException failure) {
|
||||||
|
return ResponseEntity.status(503)
|
||||||
|
.body(Collections.singletonMap("message", failure.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class StartCaptureRequest {
|
||||||
|
@NotNull(message = "deviceId 不能为空")
|
||||||
|
@Min(value = 1, message = "deviceId 必须大于 0")
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
@NotNull(message = "durationSeconds 不能为空")
|
||||||
|
@Min(value = 1, message = "durationSeconds 最小为 1")
|
||||||
|
@Max(value = RawLogCaptureService.MAX_DURATION_SECONDS,
|
||||||
|
message = "durationSeconds 不能超过 600")
|
||||||
|
private Integer durationSeconds;
|
||||||
|
|
||||||
|
@NotNull(message = "messageCount 不能为空")
|
||||||
|
@Min(value = 1, message = "messageCount 最小为 1")
|
||||||
|
@Max(value = RawLogCaptureService.MAX_MESSAGE_COUNT,
|
||||||
|
message = "messageCount 不能超过 50")
|
||||||
|
private Integer messageCount;
|
||||||
|
|
||||||
|
public Long getDeviceId() { return deviceId; }
|
||||||
|
public void setDeviceId(Long deviceId) { this.deviceId = deviceId; }
|
||||||
|
public Integer getDurationSeconds() { return durationSeconds; }
|
||||||
|
public void setDurationSeconds(Integer durationSeconds) { this.durationSeconds = durationSeconds; }
|
||||||
|
public Integer getMessageCount() { return messageCount; }
|
||||||
|
public void setMessageCount(Integer messageCount) { this.messageCount = messageCount; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,19 @@ influxdb.raw.query-max-response-bytes=${INFLUXDB_RAW_QUERY_MAX_RESPONSE_BYTES:10
|
|||||||
influxdb.raw.message-max-utf8-bytes=${INFLUXDB_RAW_MESSAGE_MAX_UTF8_BYTES:262144}
|
influxdb.raw.message-max-utf8-bytes=${INFLUXDB_RAW_MESSAGE_MAX_UTF8_BYTES:262144}
|
||||||
# Influx 同步写按 Line Protocol 估算字节切批,避免少量大日志形成超大 HTTP 请求。
|
# Influx 同步写按 Line Protocol 估算字节切批,避免少量大日志形成超大 HTTP 请求。
|
||||||
influxdb.raw.write-batch-max-bytes=${INFLUXDB_RAW_WRITE_BATCH_MAX_BYTES:8388608}
|
influxdb.raw.write-batch-max-bytes=${INFLUXDB_RAW_WRITE_BATCH_MAX_BYTES:8388608}
|
||||||
|
|
||||||
|
# 现场短时原始日志采样:单会话硬限制仍为最长600秒、最多50条。
|
||||||
|
syslog.raw-capture.enabled=${SYSLOG_RAW_CAPTURE_ENABLED:true}
|
||||||
|
syslog.raw-capture.cluster-enabled=${SYSLOG_RAW_CAPTURE_CLUSTER_ENABLED:true}
|
||||||
|
syslog.raw-capture.max-active-sessions=${SYSLOG_RAW_CAPTURE_MAX_ACTIVE_SESSIONS:20}
|
||||||
|
# 包含已完成且仍可查询的会话,满载时淘汰最早完成的结果。
|
||||||
|
syslog.raw-capture.max-retained-sessions=${SYSLOG_RAW_CAPTURE_MAX_RETAINED_SESSIONS:100}
|
||||||
|
# 给所有消费实例预留会话同步时间;API返回SCHEDULED后从startedAt正式开始计时。
|
||||||
|
syslog.raw-capture.cluster-activation-delay-ms=${SYSLOG_RAW_CAPTURE_CLUSTER_ACTIVATION_DELAY_MS:1000}
|
||||||
|
syslog.raw-capture.cluster-sync-interval-ms=${SYSLOG_RAW_CAPTURE_CLUSTER_SYNC_INTERVAL_MS:200}
|
||||||
|
# 采集停止后继续接收窗口内消息的迟到处理结果,便于异常样本替换普通样本。
|
||||||
|
syslog.raw-capture.settle-seconds=${SYSLOG_RAW_CAPTURE_SETTLE_SECONDS:30}
|
||||||
|
syslog.raw-capture.result-retention-minutes=${SYSLOG_RAW_CAPTURE_RESULT_RETENTION_MINUTES:30}
|
||||||
# InfluxDB 2.7 连接超时配置
|
# InfluxDB 2.7 连接超时配置
|
||||||
influxdb.connection.timeout=30s
|
influxdb.connection.timeout=30s
|
||||||
influxdb.connection.read-timeout=30s
|
influxdb.connection.read-timeout=30s
|
||||||
|
|||||||
+111
-11
@@ -106,37 +106,117 @@ class LogNormalProcessorRuleSelectionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidJsonStopsAtFirstJsonRuleAndGoesDirectlyToDeferredNonNormal() {
|
void invalidJsonCandidateDoesNotStopLaterMatchingRule() {
|
||||||
RuleSource invalidJson = jsonRule(10L, "json-first", "", equalMapping(), "");
|
RuleSource invalidJson = jsonRule(10L, "json-first", "", equalMapping(), "");
|
||||||
RuleSource regexWouldMatch = regexRule(20L, "regex-second", "value=(\\S+)");
|
RuleSource regexWouldMatch = regexRuleWithCropper(
|
||||||
|
20L, "regex-second", "value=(\\S+)", "value=");
|
||||||
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(true));
|
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(true));
|
||||||
|
|
||||||
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
||||||
"value=ok {\"broken\":\"unterminated");
|
"value=ok {\"broken\":\"unterminated");
|
||||||
|
|
||||||
assertEquals(LogNormalProcessor.ProcessingOutcome.FILTERED, result.getOutcome());
|
assertEquals(LogNormalProcessor.ProcessingOutcome.SUCCESS, result.getOutcome());
|
||||||
assertTrue(result.getRows().isEmpty());
|
assertEquals(1, result.getRows().size());
|
||||||
assertEquals(1, result.getNonNormalMessages().size());
|
assertEquals(20L, result.getRows().get(0).get("normalize_rule_id"));
|
||||||
assertEquals("JSON解析异常", result.getNonNormalMessages().get(0).getReason());
|
assertTrue(result.getNonNormalMessages().isEmpty());
|
||||||
assertTrue(result.getNonNormalMessages().get(0).getReasonDetail().contains("ruleId=10"));
|
|
||||||
verify(fixture.nonNormalService, never()).saveMessage(any());
|
verify(fixture.nonNormalService, never()).saveMessage(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void jsonWithoutObjectBoundaryAlsoGoesDirectlyToDeferredNonNormal() {
|
void invalidJsonCandidateDoesNotStopLaterMatchingRuleInLegacyParser() {
|
||||||
|
RuleSource invalidJson = jsonRule(10L, "json-first", "", equalMapping(), "");
|
||||||
|
RuleSource regexWouldMatch = regexRuleWithCropper(
|
||||||
|
20L, "regex-second", "value=(\\S+)", "value=");
|
||||||
|
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(false));
|
||||||
|
|
||||||
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
||||||
|
"value=ok {\"broken\":\"unterminated");
|
||||||
|
|
||||||
|
assertEquals(LogNormalProcessor.ProcessingOutcome.SUCCESS, result.getOutcome());
|
||||||
|
assertEquals(1, result.getRows().size());
|
||||||
|
assertEquals(20L, result.getRows().get(0).get("normalize_rule_id"));
|
||||||
|
assertTrue(result.getNonNormalMessages().isEmpty());
|
||||||
|
verify(fixture.nonNormalService, never()).saveMessage(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonWithoutObjectBoundaryDoesNotStopLaterMatchingRule() {
|
||||||
RuleSource invalidJson = new RuleSource(10L, "json-no-cropper", "json-no-cropper",
|
RuleSource invalidJson = new RuleSource(10L, "json-no-cropper", "json-no-cropper",
|
||||||
"json", "{\"mappers\":[" + equalMapping()
|
"json", "{\"mappers\":[" + equalMapping()
|
||||||
+ "],\"complete_columns\":[]}", true, "0");
|
+ "],\"complete_columns\":[]}", true, "0");
|
||||||
RuleSource regexWouldMatch = regexRule(20L, "regex-second", "value=(\\S+)");
|
RuleSource regexWouldMatch = regexRuleWithCropper(
|
||||||
|
20L, "regex-second", "value=(\\S+)", "value=");
|
||||||
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(true));
|
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(true));
|
||||||
|
|
||||||
LogNormalProcessor.NormalizationResult result = fixture.prepareActive("value=ok");
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive("value=ok");
|
||||||
|
|
||||||
|
assertEquals(LogNormalProcessor.ProcessingOutcome.SUCCESS, result.getOutcome());
|
||||||
|
assertEquals(1, result.getRows().size());
|
||||||
|
assertEquals(20L, result.getRows().get(0).get("normalize_rule_id"));
|
||||||
|
assertTrue(result.getNonNormalMessages().isEmpty());
|
||||||
|
verify(fixture.nonNormalService, never()).saveMessage(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void threatbookUsesTdpRuleAfterFixedOffsetHoneypotRulesAreRejected() {
|
||||||
|
String honeypotMapping = "{\"origin_field\":\"title\",\"dest_field\":\"device_name\","
|
||||||
|
+ "\"action\":{\"type\":\"equal\",\"param\":{}}}";
|
||||||
|
String tdpMapping = "{\"origin_field\":\"machine\",\"dest_field\":\"victim_ip\","
|
||||||
|
+ "\"action\":{\"type\":\"equal\",\"param\":{}}}";
|
||||||
|
RuleSource honeypotV12 = jsonRuleWithCropper(158L, "weibujsomv2",
|
||||||
|
"<", 58, honeypotMapping);
|
||||||
|
RuleSource honeypotV13 = jsonRuleWithCropper(159L, "weibujson",
|
||||||
|
"<", 57, honeypotMapping);
|
||||||
|
RuleSource tdp = jsonRuleWithCropper(150L, "weibu_TDP_alarmlog_JSON",
|
||||||
|
"{", -1, tdpMapping);
|
||||||
|
Fixture fixture = fixture(Arrays.asList(honeypotV12, honeypotV13, tdp), config(true));
|
||||||
|
|
||||||
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
||||||
|
"<1>2026-09-14T03:34:12+08:00 localhost.localdomain Threatbook[2477960]: "
|
||||||
|
+ "{\"time\":1789328052,\"machine\":\"10.174.2.19\","
|
||||||
|
+ "\"net\":{\"http\":{\"reqs_body\":"
|
||||||
|
+ "\"{\\\"operatorID\\\":\\\"MA59RW1N3\\\"}\"}}}");
|
||||||
|
|
||||||
|
assertEquals(LogNormalProcessor.ProcessingOutcome.SUCCESS, result.getOutcome());
|
||||||
|
assertEquals(1, result.getRows().size());
|
||||||
|
assertEquals(150L, result.getRows().get(0).get("normalize_rule_id"));
|
||||||
|
assertEquals("10.174.2.19", result.getRows().get(0).get("victim_ip"));
|
||||||
|
assertTrue(result.getNonNormalMessages().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skyeyeCropperPrefixIsNarrowedToOuterJsonBeforeJackson() {
|
||||||
|
RuleSource skyeye = jsonRuleWithCropper(129L, "skyeye",
|
||||||
|
"|!alarm|!{", -1, equalMapping());
|
||||||
|
RuleSource unrelated = regexRule(200L, "unrelated", "never=(\\S+)");
|
||||||
|
Fixture fixture = fixture(Arrays.asList(skyeye, unrelated), config(true));
|
||||||
|
|
||||||
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
||||||
|
"<158>Sep 14 11:07:16 host SyslogClient[1]: "
|
||||||
|
+ "2026-09-14 11:07:16|!10.56.170.107|!alarm|!"
|
||||||
|
+ "{\"status\":\"ok\",\"payload\":{\"rsp_header\":\"a\\r\\nb\"}}");
|
||||||
|
|
||||||
|
assertEquals(LogNormalProcessor.ProcessingOutcome.SUCCESS, result.getOutcome());
|
||||||
|
assertEquals(1, result.getRows().size());
|
||||||
|
assertEquals(129L, result.getRows().get(0).get("normalize_rule_id"));
|
||||||
|
assertEquals("ok", result.getRows().get(0).get("action"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allApplicableJsonCandidatesInvalidStillProduceJsonException() {
|
||||||
|
RuleSource first = jsonRuleWithCropper(10L, "first", "marker={", -1,
|
||||||
|
equalMapping());
|
||||||
|
RuleSource second = jsonRuleWithCropper(20L, "second", "marker={", -1,
|
||||||
|
equalMapping());
|
||||||
|
Fixture fixture = fixture(Arrays.asList(first, second), config(true));
|
||||||
|
|
||||||
|
LogNormalProcessor.NormalizationResult result = fixture.prepareActive(
|
||||||
|
"marker={\"broken\":\"unterminated");
|
||||||
|
|
||||||
assertEquals(LogNormalProcessor.ProcessingOutcome.FILTERED, result.getOutcome());
|
assertEquals(LogNormalProcessor.ProcessingOutcome.FILTERED, result.getOutcome());
|
||||||
assertTrue(result.getRows().isEmpty());
|
|
||||||
assertEquals(1, result.getNonNormalMessages().size());
|
assertEquals(1, result.getNonNormalMessages().size());
|
||||||
assertEquals("JSON解析异常", result.getNonNormalMessages().get(0).getReason());
|
assertEquals("JSON解析异常", result.getNonNormalMessages().get(0).getReason());
|
||||||
verify(fixture.nonNormalService, never()).saveMessage(any());
|
assertTrue(result.getNonNormalMessages().get(0).getReasonDetail().contains("ruleId=10"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -250,6 +330,26 @@ class LogNormalProcessorRuleSelectionTest {
|
|||||||
return new RuleSource(id, name, name, "regex", content, true, "0");
|
return new RuleSource(id, name, name, "regex", content, true, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RuleSource regexRuleWithCropper(long id, String name, String regexp,
|
||||||
|
String headKey) {
|
||||||
|
String content = "{\"cropper_params\":{\"head_key\":\"" + headKey
|
||||||
|
+ "\",\"head_offset\":0,\"tail_key\":\"\",\"tail_offset\":0},"
|
||||||
|
+ "\"regexp\":\"" + regexp.replace("\\", "\\\\") + "\","
|
||||||
|
+ "\"mappers\":[{\"origin_field\":\"1\",\"dest_field\":\"value\","
|
||||||
|
+ "\"action\":{\"type\":\"equal\",\"param\":{}}}]}";
|
||||||
|
return new RuleSource(id, name, name, "regex", content, true, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuleSource jsonRuleWithCropper(long id, String name, String headKey,
|
||||||
|
int headOffset, String mapper) {
|
||||||
|
String escapedHeadKey = headKey.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
String content = "{\"cropper_params\":{\"head_key\":\"" + escapedHeadKey
|
||||||
|
+ "\",\"head_offset\":" + headOffset
|
||||||
|
+ ",\"tail_key\":\"\",\"tail_offset\":0},"
|
||||||
|
+ "\"mappers\":[" + mapper + "],\"complete_columns\":[]}";
|
||||||
|
return new RuleSource(id, name, name, "json", content, true, "0");
|
||||||
|
}
|
||||||
|
|
||||||
private static String equalMapping() {
|
private static String equalMapping() {
|
||||||
return "{\"origin_field\":\"status\",\"dest_field\":\"action\","
|
return "{\"origin_field\":\"status\",\"dest_field\":\"action\","
|
||||||
+ "\"action\":{\"type\":\"equal\",\"param\":{}}}";
|
+ "\"action\":{\"type\":\"equal\",\"param\":{}}}";
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package com.common.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class RawLogCaptureServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsFirstMessagesThenReplacesOldestNormalWithLaterAnomalies() {
|
||||||
|
MutableClock clock = new MutableClock(1_700_000_000_000L);
|
||||||
|
RawLogCaptureService service = new RawLogCaptureService(clock);
|
||||||
|
RawLogCaptureService.CaptureSnapshot started = service.start(7L, 60, 3);
|
||||||
|
|
||||||
|
receive(service, clock, 7L, "e1");
|
||||||
|
receive(service, clock, 7L, "e2");
|
||||||
|
receive(service, clock, 7L, "e3");
|
||||||
|
receive(service, clock, 7L, "e4");
|
||||||
|
assertEquals(Arrays.asList("e1", "e2", "e3"), ids(service.get(started.getSessionId())));
|
||||||
|
|
||||||
|
fail(service, clock, 7L, "e4");
|
||||||
|
fail(service, clock, 7L, "e5");
|
||||||
|
fail(service, clock, 7L, "e6");
|
||||||
|
RawLogCaptureService.CaptureSnapshot anomalies = service.get(started.getSessionId());
|
||||||
|
|
||||||
|
assertEquals(Arrays.asList("e4", "e5", "e6"), ids(anomalies));
|
||||||
|
assertEquals(3, anomalies.getRetainedAnomalyCount());
|
||||||
|
assertEquals(3L, anomalies.getReplacementCount());
|
||||||
|
|
||||||
|
fail(service, clock, 7L, "e7");
|
||||||
|
assertEquals(Arrays.asList("e4", "e5", "e6"),
|
||||||
|
ids(service.get(started.getSessionId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upgradesSelectedMessageToAnomalyWithoutReplacement() {
|
||||||
|
MutableClock clock = new MutableClock(1_700_000_000_000L);
|
||||||
|
RawLogCaptureService service = new RawLogCaptureService(clock);
|
||||||
|
RawLogCaptureService.CaptureSnapshot started = service.start(7L, 60, 2);
|
||||||
|
|
||||||
|
receive(service, clock, 7L, "e1");
|
||||||
|
receive(service, clock, 7L, "e2");
|
||||||
|
fail(service, clock, 7L, "e2");
|
||||||
|
|
||||||
|
RawLogCaptureService.CaptureSnapshot snapshot = service.get(started.getSessionId());
|
||||||
|
assertEquals(Arrays.asList("e1", "e2"), ids(snapshot));
|
||||||
|
assertEquals(1, snapshot.getRetainedAnomalyCount());
|
||||||
|
assertEquals(0L, snapshot.getReplacementCount());
|
||||||
|
assertFalse(snapshot.getMessages().get(0).isAnomaly());
|
||||||
|
assertTrue(snapshot.getMessages().get(1).isAnomaly());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isolatesDevicesAndAcceptsLateOutcomeOnlyDuringSettlingPeriod() {
|
||||||
|
MutableClock clock = new MutableClock(1_700_000_000_000L);
|
||||||
|
RawLogCaptureService service = new RawLogCaptureService(clock);
|
||||||
|
RawLogCaptureService.CaptureSnapshot started = service.start(7L, 2, 1);
|
||||||
|
long observedInsideWindow = clock.millis();
|
||||||
|
|
||||||
|
receive(service, clock, 8L, "other-device");
|
||||||
|
receive(service, clock, 7L, "normal");
|
||||||
|
clock.advanceMillis(3_000L);
|
||||||
|
assertEquals("DRAINING", service.get(started.getSessionId()).getStatus());
|
||||||
|
|
||||||
|
service.observeFailure(observedInsideWindow, 7L, 1L, "late-error", "topic",
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(observedInsideWindow), ZoneOffset.UTC),
|
||||||
|
"raw-late-error", "RULE_PROCESSING", "DLT_PUBLISHED",
|
||||||
|
new IllegalStateException("bad json"));
|
||||||
|
RawLogCaptureService.CaptureSnapshot draining = service.get(started.getSessionId());
|
||||||
|
assertEquals(Arrays.asList("late-error"), ids(draining));
|
||||||
|
assertEquals(1L, draining.getReplacementCount());
|
||||||
|
|
||||||
|
clock.advanceMillis(31_000L);
|
||||||
|
assertEquals("COMPLETED", service.get(started.getSessionId()).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesHardApiLimits() {
|
||||||
|
RawLogCaptureService service = new RawLogCaptureService(
|
||||||
|
new MutableClock(1_700_000_000_000L));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.start(0L, 1, 1));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.start(1L, 601, 1));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.start(1L, 1, 51));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergesCandidatesAcrossConsumerInstancesBeforeApplyingAnomalyReplacement() {
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> first = Arrays.asList(
|
||||||
|
snapshot(2, "a2", false), snapshot(3, "a3", false), snapshot(4, "a4", false),
|
||||||
|
snapshot(1, "b1", false), snapshot(100, "b100", false),
|
||||||
|
snapshot(101, "b101", false));
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> anomalies =
|
||||||
|
Arrays.asList(snapshot(200, "a200", true));
|
||||||
|
|
||||||
|
List<RawLogCaptureService.CapturedLogSnapshot> selected =
|
||||||
|
RawLogCaptureService.mergeCandidates(first, anomalies, 3);
|
||||||
|
|
||||||
|
assertEquals(Arrays.asList("a2", "a3", "a200"), selected.stream()
|
||||||
|
.map(RawLogCaptureService.CapturedLogSnapshot::getEventId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawLogCaptureService.CapturedLogSnapshot snapshot(
|
||||||
|
long sequence, String eventId, boolean anomaly) {
|
||||||
|
return new RawLogCaptureService.CapturedLogSnapshot(sequence, eventId, 7L, 1L,
|
||||||
|
"topic", null, "2026-09-14T00:00:00Z",
|
||||||
|
anomaly ? "NON_NORMAL" : "SUCCESS", anomaly,
|
||||||
|
anomaly ? "JSON解析异常" : null, null, null, "raw-" + eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void receive(RawLogCaptureService service, Clock clock,
|
||||||
|
long deviceId, String eventId) {
|
||||||
|
service.observeReceived(clock.millis(), deviceId, 1L, eventId, "topic",
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(clock.millis()), ZoneOffset.UTC),
|
||||||
|
"raw-" + eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fail(RawLogCaptureService service, Clock clock,
|
||||||
|
long deviceId, String eventId) {
|
||||||
|
service.observeFailure(clock.millis(), deviceId, 1L, eventId, "topic",
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(clock.millis()), ZoneOffset.UTC),
|
||||||
|
"raw-" + eventId, "RULE_PROCESSING", "DLT_PUBLISHED",
|
||||||
|
new IllegalArgumentException("broken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> ids(RawLogCaptureService.CaptureSnapshot snapshot) {
|
||||||
|
return snapshot.getMessages().stream()
|
||||||
|
.map(RawLogCaptureService.CapturedLogSnapshot::getEventId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MutableClock extends Clock {
|
||||||
|
private long millis;
|
||||||
|
|
||||||
|
private MutableClock(long millis) { this.millis = millis; }
|
||||||
|
private void advanceMillis(long value) { millis += value; }
|
||||||
|
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||||
|
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||||
|
@Override public Instant instant() { return Instant.ofEpochMilli(millis); }
|
||||||
|
@Override public long millis() { return millis; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,15 @@
|
|||||||
| `v2/` | 从提交 `b61ec2e` 升级到当前源码所需的增量资料 |
|
| `v2/` | 从提交 `b61ec2e` 升级到当前源码所需的增量资料 |
|
||||||
| `v3/` | 日统计异步投影,以及大日志独立 Kafka 通道、字节背压和存储截断升级资料 |
|
| `v3/` | 日统计异步投影,以及大日志独立 Kafka 通道、字节背压和存储截断升级资料 |
|
||||||
| `v4/` | Kafka 积压分级采样、告警优先落库和 InfluxDB 非阻塞旁路升级资料 |
|
| `v4/` | Kafka 积压分级采样、告警优先落库和 InfluxDB 非阻塞旁路升级资料 |
|
||||||
|
| `v5/` | 按设备短时采集未截断原始日志,并将异常样本优先落盘的现场诊断工具 |
|
||||||
|
|
||||||
新环境按 v1、v2、v3、v4 的顺序执行;已经完成 v3 的现场只执行 v4。已经执行过早期 v3 日统计升级的
|
新环境按 v1、v2、v3、v4、v5 的顺序执行;已经完成 v4 的现场只需升级应用并执行 v5。已经执行过早期
|
||||||
现场,只需补做 v3 中的大日志 Topic、应用升级和验证步骤,再执行 v4;不要重复执行数据库升级脚本。
|
v3 日统计升级的现场,只需补做 v3 中的大日志 Topic、应用升级和验证步骤,再执行 v4、v5;不要重复执行
|
||||||
|
数据库升级脚本。
|
||||||
|
|
||||||
数据库脚本不要重复执行。执行过程中有报错时,先确认已经落库的对象,再决定从哪里继续。
|
数据库脚本不要重复执行。执行过程中有报错时,先确认已经落库的对象,再决定从哪里继续。
|
||||||
|
|
||||||
v2 的执行步骤见 [v2/README.md](./v2/README.md)。
|
v2 的执行步骤见 [v2/README.md](./v2/README.md)。
|
||||||
v3 的执行步骤见 [v3/README.md](./v3/README.md)。
|
v3 的执行步骤见 [v3/README.md](./v3/README.md)。
|
||||||
v4 的执行步骤见 [v4/README.md](./v4/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_EXPLICIT_ENABLED=true
|
||||||
NORMALIZATION_RULE_SELECTION_AUTOMATIC_FALLBACK_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。
|
# 统计投影解耦:三个 syslog-consumer-rule 实例保持完全相同的 Topic 与 Group ID。
|
||||||
STATS_MATERIALIZATION_ENABLED=true
|
STATS_MATERIALIZATION_ENABLED=true
|
||||||
STATS_MATERIALIZATION_TOPIC=agent-01-syslog-topic.STATS.MATERIALIZE
|
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_MESSAGE_MAX_BYTES=6291456
|
||||||
- SYSLOG_LARGE_MESSAGE_THRESHOLD_BYTES=131072
|
- SYSLOG_LARGE_MESSAGE_THRESHOLD_BYTES=131072
|
||||||
|
|
||||||
# WAL 和 Kafka 投递最多各占用 256 MiB 在途内存。
|
# 200KiB/条容量基线;WAL 高水位约在 14,000 条、2.7~2.8GiB 对齐触发。
|
||||||
- SYSLOG_WAL_MAX_PENDING_BYTES=268435456
|
- SYSLOG_WAL_QUEUE_CAPACITY=20000
|
||||||
- SYSLOG_DISPATCH_MAX_INFLIGHT_BYTES=268435456
|
- 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 Topic 及 Producer 请求上限。
|
||||||
- KAFKA_PRODUCER_LARGE_TOPIC=agent-01-syslog-topic.large
|
- 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_MAX_FRAME_LENGTH`:TCP 单帧最大长度;
|
||||||
- `SYSLOG_MESSAGE_MAX_BYTES`:所有来源的单条日志硬上限,超过后拒绝;
|
- `SYSLOG_MESSAGE_MAX_BYTES`:所有来源的单条日志硬上限,超过后拒绝;
|
||||||
- `SYSLOG_LARGE_MESSAGE_THRESHOLD_BYTES`:达到该值后走大日志 Topic;
|
- `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_MAX_FRAME_LENGTH` 必须大于或等于 `SYSLOG_MESSAGE_MAX_BYTES`。如果设备发送 GBK,应将
|
||||||
`SYSLOG_CHARSET` 改为 `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"
|
||||||
Reference in New Issue
Block a user