This is an automated email from the ASF dual-hosted git repository.
yuluo-yx pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new a4b486d992 fix(ai): improve alert rule validation and parsing (#4251)
a4b486d992 is described below
commit a4b486d9922ad3f9ba5b853c41a3d11eb5df1a05
Author: shown <[email protected]>
AuthorDate: Sun Jul 26 23:07:48 2026 +0800
fix(ai): improve alert rule validation and parsing (#4251)
Signed-off-by: yuluo-yx <[email protected]>
---
.../apache/hertzbeat/ai/utils/UtilityClass.java | 95 ++++++++++-----------
.../hertzbeat/ai/utils/UtilityClassTest.java | 99 ++++++++++++++++++++++
2 files changed, 142 insertions(+), 52 deletions(-)
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/UtilityClass.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/UtilityClass.java
index 462e87d0f2..f1c221aade 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/UtilityClass.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/UtilityClass.java
@@ -27,8 +27,12 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import org.apache.hertzbeat.manager.pojo.dto.Hierarchy;
import tools.jackson.databind.ObjectMapper;
@@ -42,6 +46,14 @@ import tools.jackson.databind.ObjectMapper;
@lombok.experimental.UtilityClass
public class UtilityClass {
+ private static final Pattern SINGLE_EQUALS_PATTERN =
Pattern.compile("(?<![<>=!])=(?!=)");
+ private static final Pattern UPPERCASE_LOGICAL_PATTERN =
Pattern.compile("\\b(AND|OR)\\b");
+ private static final Pattern FUNCTION_FIELD_PATTERN = Pattern.compile(
+
"!?\\b(?:equals|contains|matches|exists)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_]*)");
+ private static final Pattern COMPARISON_FIELD_PATTERN = Pattern.compile(
+ "(?:^|[\\s(])([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?:>=|<=|==|!=|>|<)");
+ private static final Pattern QUOTED_VALUE_PATTERN =
Pattern.compile("\"[^\"]*\"|'[^']*'");
+
/**
* Validates the syntax of field conditions expression
* @param fieldConditions Field conditions string to validate
@@ -111,31 +123,26 @@ public class UtilityClass {
* Validates that only supported operators are used
*/
public String validateOperators(String fieldConditions) {
- // Define supported operators for different field types
- String[] numericOperators = {">", "<", ">=", "<=", "==", "!=",
"exists()", "!exists()"};
- String[] stringOperators = {"equals(", "contains(", "matches(",
"exists()", "!equals(", "!contains(", "!matches(", "!exists()"};
- String[] logicalOperators = {" and ", " or "};
-
// Remove quotes and function calls temporarily for operator checking
String tempExpression = fieldConditions
.replaceAll("\"[^\"]*\"", "VALUE") // Remove quoted strings
.replaceAll("'[^']*'", "VALUE") // Remove single quoted
strings
.replaceAll("\\w+\\([^)]*\\)", "FUNCTION"); // Remove function
calls
- // Check for invalid operators (common mistakes)
- String[] invalidOperators = {"&&", "||", "AND", "OR", "=", "!="};
- for (String invalidOp : invalidOperators) {
- if (tempExpression.contains(invalidOp)) {
- if (invalidOp.equals("&&") || invalidOp.equals("||")) {
- return String.format("Error: Use 'and'/'or' instead of
'%s' for logical operations", invalidOp);
- }
- if (invalidOp.equals("AND") || invalidOp.equals("OR")) {
- return String.format("Error: Use lowercase '%s' for
logical operations", invalidOp.toLowerCase());
- }
- if (invalidOp.equals("=")) {
- return "Error: Use '==' for equality comparison, not '='";
- }
- }
+ if (tempExpression.contains("&&") || tempExpression.contains("||")) {
+ String invalidOp = tempExpression.contains("&&") ? "&&" : "||";
+ return String.format("Error: Use 'and'/'or' instead of '%s' for
logical operations", invalidOp);
+ }
+
+ Matcher uppercaseLogicalMatcher =
UPPERCASE_LOGICAL_PATTERN.matcher(tempExpression);
+ if (uppercaseLogicalMatcher.find()) {
+ return String.format("Error: Use lowercase '%s' for logical
operations",
+ uppercaseLogicalMatcher.group(1).toLowerCase());
+ }
+
+ // Reject only a standalone equals sign without rejecting >=, <=, ==,
or !=.
+ if (SINGLE_EQUALS_PATTERN.matcher(tempExpression).find()) {
+ return "Error: Use '==' for equality comparison, not '='";
}
// Check for unsupported special characters that might indicate syntax
errors
@@ -150,11 +157,6 @@ public class UtilityClass {
* Validates logical connectors syntax
*/
public String validateLogicalConnectors(String fieldConditions) {
- // Check for proper spacing around logical operators
- if (fieldConditions.matches(".*(\\S(and|or)\\S).*")) {
- return "Error: Logical operators 'and'/'or' must be surrounded by
spaces";
- }
-
// Check for consecutive logical operators
if
(fieldConditions.matches(".*(and\\s+and|or\\s+or|and\\s+or\\s+and|or\\s+and\\s+or).*"))
{
return "Error: Consecutive logical operators found. Use
parentheses to group conditions properly.";
@@ -237,7 +239,7 @@ public class UtilityClass {
String[] pairs = input.split(",");
for (String pair : pairs) {
- String[] keyValue = pair.split(":");
+ String[] keyValue = pair.split(":", 2);
if (keyValue.length == 2) {
result.put(keyValue[0].trim(), keyValue[1].trim());
}
@@ -299,34 +301,22 @@ public class UtilityClass {
* Handles simple cases like "field > 80", "equals(field, 'value')",
complex expressions
*/
public List<String> extractFieldNamesFromConditions(String
fieldConditions) {
- List<String> fieldNames = new ArrayList<>();
-
- // Split by logical operators (and, or) and parentheses, but preserve
the field names
- // This is a simple implementation - could be enhanced with a proper
parser
- String[] parts =
fieldConditions.split("\\s+(and|or|&&|\\|\\|)\\s+|[()]+");
+ Set<String> fieldNames = new LinkedHashSet<>();
- for (String part : parts) {
- part = part.trim();
- if (part.isEmpty()) {
- continue;
- }
+ // Extract the first function argument without splitting parentheses
and bypassing field validation.
+ Matcher functionMatcher =
FUNCTION_FIELD_PATTERN.matcher(fieldConditions);
+ while (functionMatcher.find()) {
+ fieldNames.add(functionMatcher.group(1));
+ }
- // Handle equals() function: equals(fieldName, "value")
- if (part.contains("equals(")) {
- String fieldName = extractFieldFromEquals(part);
- if (fieldName != null && !fieldNames.contains(fieldName)) {
- fieldNames.add(fieldName);
- }
- } else {
- // Handle simple comparisons: fieldName > value, fieldName <=
value
- String fieldName = extractFieldFromComparison(part);
- if (fieldName != null && !fieldNames.contains(fieldName)) {
- fieldNames.add(fieldName);
- }
- }
+ // Remove quoted values before scanning comparisons to avoid treating
text such as "value > 1" as a field.
+ Matcher comparisonMatcher = COMPARISON_FIELD_PATTERN.matcher(
+ QUOTED_VALUE_PATTERN.matcher(fieldConditions).replaceAll(""));
+ while (comparisonMatcher.find()) {
+ fieldNames.add(comparisonMatcher.group(1));
}
- return fieldNames;
+ return new ArrayList<>(fieldNames);
}
/**
@@ -391,7 +381,8 @@ public class UtilityClass {
} else {
// Category, app, or metric node
// Determine node type based on children
- boolean hasLeafChildren = hierarchy.getChildren().stream()
+ List<Hierarchy> children = hierarchy.getChildren();
+ boolean hasLeafChildren = children != null && children.stream()
.anyMatch(child -> child.getIsLeaf() != null &&
child.getIsLeaf());
if (hasLeafChildren) {
@@ -402,9 +393,9 @@ public class UtilityClass {
node.put("description", "Application with available metrics");
}
- if (hierarchy.getChildren() != null &&
!hierarchy.getChildren().isEmpty()) {
+ if (children != null && !children.isEmpty()) {
ArrayNode childrenArray = mapper.createArrayNode();
- for (Hierarchy child : hierarchy.getChildren()) {
+ for (Hierarchy child : children) {
childrenArray.add(formatHierarchyAsJson(mapper, child));
}
node.set("children", childrenArray);
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/utils/UtilityClassTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/utils/UtilityClassTest.java
new file mode 100644
index 0000000000..b5ce69f894
--- /dev/null
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/utils/UtilityClassTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.ai.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.hertzbeat.manager.pojo.dto.Hierarchy;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+/**
+ * Verifies input parsing and hierarchy-data tolerance in the AI alert rule
utilities.
+ */
+class UtilityClassTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "cpu_usage >= 80",
+ "cpu_usage <= 80",
+ "cpu_usage == 80",
+ "cpu_usage != 80"
+ })
+ void
validateExpressionSyntaxShouldAcceptSupportedComparisonOperators(String
expression) {
+ assertEquals("VALID",
UtilityClass.validateExpressionSyntax(expression));
+ }
+
+ @Test
+ void
validateExpressionSyntaxShouldNotTreatLogicalTextInsideFieldNameAsOperator() {
+ assertEquals("VALID",
UtilityClass.validateExpressionSyntax("processor_count > 1"));
+ }
+
+ @Test
+ void validateExpressionSyntaxShouldStillRejectSingleEquals() {
+ assertEquals("Error: Use '==' for equality comparison, not '='",
+ UtilityClass.validateExpressionSyntax("cpu_usage = 80"));
+ }
+
+ @Test
+ void parseKeyValuePairsShouldPreserveColonsInValue() {
+ Map<String, String> result = UtilityClass.parseKeyValuePairs(
+ "runbook:https://example.org:8443/alerts, severity:critical");
+
+ assertEquals("https://example.org:8443/alerts", result.get("runbook"));
+ assertEquals("critical", result.get("severity"));
+ }
+
+ @Test
+ void extractFieldNamesShouldHandleFunctionsAndComparisons() {
+ List<String> fields = UtilityClass.extractFieldNamesFromConditions(
+ "equals(VmName, \"prod\") and contains(host, \"db\") "
+ + "and (cpu_usage >= 80 or cpu_usage <= 20)");
+
+ assertEquals(List.of("VmName", "host", "cpu_usage"), fields);
+ }
+
+ @Test
+ void extractFieldNamesShouldIgnoreComparisonTextInsideQuotedValue() {
+ List<String> fields = UtilityClass.extractFieldNamesFromConditions(
+ "equals(message, \"fake_field > 1\")");
+
+ assertEquals(List.of("message"), fields);
+ }
+
+ @Test
+ void formatHierarchyAsJsonShouldAcceptNonLeafWithoutChildren() {
+ Hierarchy hierarchy = new Hierarchy();
+ hierarchy.setValue("linux");
+ hierarchy.setLabel("Linux");
+ hierarchy.setIsLeaf(false);
+
+ ObjectNode result = UtilityClass.formatHierarchyAsJson(new
ObjectMapper(), hierarchy);
+
+ assertEquals("app", result.get("type").asText());
+ assertFalse(result.has("children"));
+ assertTrue(result.has("value"));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]