This is an automated email from the ASF dual-hosted git repository.
shuber pushed a commit to branch unomi-3-dev
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/unomi-3-dev by this push:
new 9f938ff92 UNOMI-881 Add LogChecker for InputValidationIT to filter
expected validation errors in logs. Optimize LogChecker for performance with
hierarchical matching and multi-part substrings.
9f938ff92 is described below
commit 9f938ff9232a3feb3d4e82f23190a8287c09c0d1
Author: Serge Huber <[email protected]>
AuthorDate: Thu Jan 1 16:18:47 2026 +0100
UNOMI-881 Add LogChecker for InputValidationIT to filter expected
validation errors in logs. Optimize LogChecker for performance with
hierarchical matching and multi-part substrings.
---
.../test/java/org/apache/unomi/itests/BaseIT.java | 254 ++----
.../unomi/itests/CopyPropertiesActionIT.java | 19 +-
.../org/apache/unomi/itests/InputValidationIT.java | 29 +-
.../java/org/apache/unomi/itests/JSONSchemaIT.java | 31 +
.../org/apache/unomi/itests/tools/LogChecker.java | 860 ++++++++++++++-------
.../apache/unomi/itests/tools/LogCheckerTest.java | 396 ++++++++++
6 files changed, 1114 insertions(+), 475 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
index 234ccace6..424f331b5 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -22,7 +22,11 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Route;
+import org.apache.camel.ServiceStatus;
import org.apache.commons.io.IOUtils;
+import org.apache.http.HttpEntity;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.*;
import org.apache.http.config.Registry;
@@ -30,14 +34,13 @@ import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.entity.BufferedHttpEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
-import org.apache.http.HttpEntity;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
-import org.apache.http.entity.BufferedHttpEntity;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.karaf.itests.KarafTestSupport;
import org.apache.unomi.api.Item;
@@ -52,22 +55,19 @@ import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.api.utils.ConditionBuilder;
import org.apache.unomi.groovy.actions.services.GroovyActionsService;
-import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
import org.apache.unomi.itests.tools.LogChecker;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
import org.apache.unomi.lifecycle.BundleWatcher;
import org.apache.unomi.persistence.spi.CustomObjectMapper;
import org.apache.unomi.persistence.spi.PersistenceService;
+import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
import org.apache.unomi.router.api.ExportConfiguration;
import org.apache.unomi.router.api.IRouterCamelContext;
-import org.apache.camel.CamelContext;
-import org.apache.camel.Route;
-import org.apache.camel.ServiceStatus;
import org.apache.unomi.router.api.ImportConfiguration;
import org.apache.unomi.router.api.services.ImportExportConfigurationService;
import org.apache.unomi.schema.api.SchemaService;
import org.apache.unomi.services.UserListService;
import org.apache.unomi.shell.services.UnomiManagementService;
-import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -102,7 +102,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyManagementException;
-import java.util.Hashtable;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
@@ -209,19 +208,13 @@ public abstract class BaseIT extends KarafTestSupport {
*/
protected void checkSearchEngine() {
searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY,
SEARCH_ENGINE_ELASTICSEARCH);
- // Fix elasticsearch-maven-plugin default_template issue before any
test setup
- // The plugin creates a default_template with very high priority that
overrides all user templates
- // This must be done very early, before Unomi starts or any migration
runs
- if (SEARCH_ENGINE_ELASTICSEARCH.equals(searchEngine)) {
- fixDefaultTemplateIfNeeded();
- }
}
@Before
public void waitForStartup() throws InterruptedException {
// disable retry
retry = new KarafTestSupport.Retry(false);
-
+
// Check search engine and apply any necessary fixes (e.g.,
default_template deletion)
checkSearchEngine();
@@ -301,14 +294,16 @@ public abstract class BaseIT extends KarafTestSupport {
// init httpClient without credentials provider - all auth handled via
headers
httpClient = initHttpClient(null);
-
+
// Initialize log checker if enabled
if (isLogCheckingEnabled()) {
- logChecker = new LogChecker();
+ // Use builder API - by default enable all patterns for backward
compatibility
+ // Individual tests can override createLogChecker() to specify
only needed patterns
+ logChecker = createLogChecker();
LOGGER.info("Log checking enabled using in-memory appender");
}
}
-
+
/**
* Mark log checkpoint before each test
* This method is called automatically by JUnit before each test method
@@ -356,7 +351,7 @@ public abstract class BaseIT extends KarafTestSupport {
public void shutdown() {
// Check logs for unexpected errors/warnings before cleanup
checkLogsForUnexpectedIssues();
-
+
if (testTenant != null) {
try {
tenantService.deleteTenant(testTenant.getItemId());
@@ -371,7 +366,35 @@ public abstract class BaseIT extends KarafTestSupport {
httpClient = null;
}
-
+
+ /**
+ * Create a LogChecker instance. Tests should override this method to add
+ * only the patterns they need, improving performance significantly.
+ *
+ * By default, only global patterns are included (e.g., BundleWatcher
warnings).
+ *
+ * IMPORTANT: Prefer literal strings over regex for better performance.
+ * Literal strings use fast contains() matching instead of regex.
+ *
+ * Example override for a test that needs specific substrings:
+ * <pre>
+ * {@literal @}Override
+ * protected LogChecker createLogChecker() {
+ * return LogChecker.builder()
+ * .addIgnoredSubstring("Response status code: 400")
// Single substring
+ * .addIgnoredMultiPart("Schema", "not found")
// Multi-part: sequential
+ * .build();
+ * }
+ * </pre>
+ *
+ * @return A configured LogChecker instance
+ */
+ protected LogChecker createLogChecker() {
+ // By default, only global patterns are included
+ // Individual tests should override this to add their specific patterns
+ return new LogChecker();
+ }
+
/**
* Check logs for unexpected errors and warnings since the last checkpoint
* This is called automatically after each test
@@ -380,20 +403,20 @@ public abstract class BaseIT extends KarafTestSupport {
if (logChecker == null) {
return;
}
-
+
try {
LogChecker.LogCheckResult result =
logChecker.checkLogsSinceLastCheckpoint();
-
+
if (result.hasUnexpectedIssues()) {
String summary = result.getSummary();
String testInfo = currentTestName != null ? "Test: " +
currentTestName + "\n" : "";
-
+
// Use System.err/out to avoid creating logs that would be
captured by InMemoryLogAppender
// This prevents a feedback loop where log checking creates
more logs to check
System.err.println("\n=== UNEXPECTED LOG ISSUES DETECTED ===");
System.err.println(testInfo + summary);
System.err.println("=======================================\n");
-
+
// Add to JUnit test output by printing to System.out
(captured by JUnit)
System.out.println("\n=== SERVER-SIDE LOG ISSUES ===");
System.out.println(testInfo + summary);
@@ -405,7 +428,7 @@ public abstract class BaseIT extends KarafTestSupport {
e.printStackTrace(System.err);
}
}
-
+
/**
* Check if log checking is enabled
* Can be controlled via system property: it.unomi.log.checking.enabled
@@ -415,25 +438,25 @@ public abstract class BaseIT extends KarafTestSupport {
String enabled = System.getProperty(ENABLE_LOG_CHECKING_PROPERTY,
"true");
return Boolean.parseBoolean(enabled);
}
-
+
/**
- * Add a pattern to ignore for log checking
+ * Add a substring to ignore for log checking
* Useful for tests that expect certain errors/warnings
- * @param pattern Regex pattern to match against log messages
+ * @param substring Literal substring or regex pattern to match against
log messages
*/
- protected void addIgnoredLogPattern(String pattern) {
+ protected void addIgnoredLogSubstring(String substring) {
if (logChecker != null) {
- logChecker.addIgnoredPattern(pattern);
+ logChecker.addIgnoredSubstring(substring);
}
}
-
+
/**
- * Add multiple patterns to ignore for log checking
- * @param patterns List of regex patterns
+ * Add multiple substrings to ignore for log checking
+ * @param substrings List of substrings (literal or regex)
*/
- protected void addIgnoredLogPatterns(List<String> patterns) {
+ protected void addIgnoredLogSubstrings(List<String> substrings) {
if (logChecker != null) {
- logChecker.addIgnoredPatterns(patterns);
+ logChecker.addIgnoredSubstrings(substrings);
}
}
@@ -698,11 +721,11 @@ public abstract class BaseIT extends KarafTestSupport {
LOGGER.info("Configuring in-memory log appender for log checking");
// Configure the appender in Log4j2
// The appender is already available via the log4j-extension
fragment bundle
-
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
+
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
"log4j2.appender.inMemory.type", "InMemoryLogAppender"));
-
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
+
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
"log4j2.appender.inMemory.name", "InMemoryLogAppender"));
-
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
+
karafOptions.add(editConfigurationFilePut("etc/org.ops4j.pax.logging.cfg",
"log4j2.rootLogger.appenderRef.inMemory.ref",
"InMemoryLogAppender"));
}
@@ -1421,138 +1444,17 @@ public abstract class BaseIT extends KarafTestSupport {
return response;
}
- /**
- * Fixes the default_template created by elasticsearch-maven-plugin that
overrides all user templates.
- * The plugin creates a template with index_patterns: ["*"] and very high
priority (2147483520),
- * which in ES 8/9 overrides all other templates since composable
templates don't merge.
- * This method detects and deletes the template if it has a very high
priority.
- * This must be called before Unomi starts since the ES persistence
service isn't available yet.
- */
- private void fixDefaultTemplateIfNeeded() {
- String templateName = "default_template";
- String esBaseUrl = "http://localhost:" + getSearchPort();
- String templateUrl = esBaseUrl + "/_index_template/" + templateName;
-
- CloseableHttpClient tempHttpClient = null;
- try {
- // Create a temporary HTTP client for ES requests
- tempHttpClient = initHttpClient(null);
-
- // Check if default_template exists using HEAD request
- HttpHead headRequest = new HttpHead(templateUrl);
- CloseableHttpResponse headResponse = null;
- try {
- headResponse = tempHttpClient.execute(headRequest);
- int statusCode = headResponse.getStatusLine().getStatusCode();
-
- if (statusCode == 404) {
- // Template doesn't exist, nothing to fix
- LOGGER.debug("default_template does not exist, no action
needed");
- return;
- } else if (statusCode != 200) {
- // Unexpected status, log and continue
- LOGGER.warn("Unexpected status code {} when checking for
default_template, skipping fix", statusCode);
- return;
- }
- } finally {
- if (headResponse != null) {
- headResponse.close();
- }
- }
-
- // Template exists, get its details to check priority
- HttpGet getRequest = new HttpGet(templateUrl);
- CloseableHttpResponse getResponse = null;
- try {
- getResponse = tempHttpClient.execute(getRequest);
- int statusCode = getResponse.getStatusLine().getStatusCode();
-
- if (statusCode == 200) {
- String responseBody =
IOUtils.toString(getResponse.getEntity().getContent(), "UTF-8");
- JsonNode jsonNode = objectMapper.readTree(responseBody);
-
- // Parse the template response
- // ES API returns: {"index_templates": [{"name":
"default_template", "index_template": {...}}]}
- if (jsonNode.has("index_templates") &&
jsonNode.get("index_templates").isArray()) {
- JsonNode templates = jsonNode.get("index_templates");
- for (JsonNode template : templates) {
- if (template.has("name") &&
templateName.equals(template.get("name").asText())) {
- JsonNode indexTemplate =
template.get("index_template");
- if (indexTemplate != null &&
indexTemplate.has("priority")) {
- Long priority =
indexTemplate.get("priority").asLong();
-
- // Check if priority is very high (>=
2147480000, near Integer.MAX_VALUE)
- // This indicates it's the problematic
template from elasticsearch-maven-plugin
- if (priority >= 2147480000L) {
- LOGGER.warn("Detected default_template
with very high priority ({}). " +
- "This template from
elasticsearch-maven-plugin overrides all user templates in ES 8/9. " +
- "Deleting it to allow user
templates to work correctly.", priority);
-
- // Delete the template
- HttpDelete deleteRequest = new
HttpDelete(templateUrl);
- CloseableHttpResponse deleteResponse =
null;
- try {
- deleteResponse =
tempHttpClient.execute(deleteRequest);
- int deleteStatusCode =
deleteResponse.getStatusLine().getStatusCode();
-
- if (deleteStatusCode == 200) {
- // Parse delete response to
check acknowledged
- String deleteResponseBody =
IOUtils.toString(deleteResponse.getEntity().getContent(), "UTF-8");
- JsonNode deleteJsonNode =
objectMapper.readTree(deleteResponseBody);
- boolean acknowledged =
deleteJsonNode.has("acknowledged") &&
-
deleteJsonNode.get("acknowledged").asBoolean();
-
- if (acknowledged) {
- LOGGER.info("Successfully
deleted default_template. User templates will now work correctly.");
- } else {
- LOGGER.warn("Failed to
delete default_template - not acknowledged. User templates may not work
correctly.");
- }
- } else {
- LOGGER.warn("Failed to delete
default_template - status code: {}. User templates may not work correctly.",
deleteStatusCode);
- }
- } finally {
- if (deleteResponse != null) {
- deleteResponse.close();
- }
- }
- } else {
- LOGGER.debug("default_template exists
but has normal priority ({}), no action needed.", priority);
- }
- break; // Found the template, no need to
continue
- }
- }
- }
- }
- } else {
- LOGGER.warn("Failed to get default_template details -
status code: {}, skipping fix", statusCode);
- }
- } finally {
- if (getResponse != null) {
- getResponse.close();
- }
- }
- } catch (Exception e) {
- // Log but don't fail startup - this is a best-effort fix for
integration tests
- LOGGER.warn("Failed to check/fix default_template: {}. This may
affect template application in integration tests.",
- e.getMessage(), e);
- } finally {
- if (tempHttpClient != null) {
- closeHttpClient(tempHttpClient);
- }
- }
- }
-
/**
* Enables Camel tracing and debug logging if requested via system
property.
* This provides visibility into Camel operations during test execution
without modifying production code.
- *
+ *
* To enable: Set system property -Dit.unomi.camel.debug=true
- *
+ *
* This will:
* - Enable Camel tracing (logs detailed message flow, body content,
headers as messages traverse routes)
* Tracing is useful for understanding WHAT is happening in routes
(message content, transformations)
* - Enable DEBUG logging for Camel packages (configured in config()
method)
- *
+ *
* Note: Tracing provides different information than route status checking:
* - Tracing: Shows message flow and content (useful for debugging message
transformations)
* - Route Status API: Shows if routes are running, exchange counts,
processing times (useful for verifying execution)
@@ -1576,7 +1478,7 @@ public abstract class BaseIT extends KarafTestSupport {
* Gets the Camel context from the router Camel context service.
* Uses the interface method which returns Object to avoid exposing Camel
dependency.
* Based on official Camel API: https://camel.apache.org/manual/
- *
+ *
* @return The CamelContext instance, or null if not available
*/
protected CamelContext getCamelContext() {
@@ -1593,7 +1495,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Checks if a Camel route with the given route ID exists.
* Uses official Camel API: CamelContext.getRoute(String routeId)
- *
+ *
* @param routeId The route ID to check (typically the import
configuration itemId)
* @return true if the route exists, false otherwise
*/
@@ -1610,7 +1512,7 @@ public abstract class BaseIT extends KarafTestSupport {
* Gets the status of a Camel route.
* Uses Camel 2.23.1 API directly.
* Returns ServiceStatus enum: Started, Stopped, Suspended, etc.
- *
+ *
* @param routeId The route ID to get status for
* @return The route status, or null if route doesn't exist or status
unavailable
*/
@@ -1637,7 +1539,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Checks if a Camel route is started (running).
* Uses official Camel API to check route status.
- *
+ *
* @param routeId The route ID to check
* @return true if the route exists and is started, false otherwise
*/
@@ -1649,7 +1551,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Gets detailed information about a Camel route including status,
endpoints, and configuration.
* Uses Camel 2.23.1 API to inspect route definitions and endpoints.
- *
+ *
* @param routeId The route ID to get information for
* @return A string describing the route status, endpoints, and
configuration, or error message if route doesn't exist
*/
@@ -1663,10 +1565,10 @@ public abstract class BaseIT extends KarafTestSupport {
if (route == null) {
return "Route '" + routeId + "' does not exist";
}
-
+
StringBuilder info = new StringBuilder();
info.append("Route '").append(routeId).append("': ");
-
+
// Get route status using official API
ServiceStatus status = getCamelRouteStatus(routeId);
if (status != null) {
@@ -1674,7 +1576,7 @@ public abstract class BaseIT extends KarafTestSupport {
} else {
info.append("status=unknown");
}
-
+
// Get route definition to inspect endpoints and configuration
try {
org.apache.camel.model.RouteDefinition routeDefinition =
camelContext.getRouteDefinition(routeId);
@@ -1687,7 +1589,7 @@ public abstract class BaseIT extends KarafTestSupport {
info.append(", from=").append(from.getUri());
}
}
-
+
// Get output endpoints (to)
java.util.List<org.apache.camel.model.ProcessorDefinition<?>> outputs =
routeDefinition.getOutputs();
if (outputs != null && !outputs.isEmpty()) {
@@ -1714,10 +1616,10 @@ public abstract class BaseIT extends KarafTestSupport {
// Route definition inspection failed, that's okay
LOGGER.debug("Could not get route definition for {}: {}",
routeId, e.getMessage());
}
-
+
// Note: Management statistics (exchange counts, processing times)
require camel-management dependency.
// For test visibility, route status and endpoint information are
the most useful.
-
+
return info.toString();
} catch (Exception e) {
return "Error getting route info for '" + routeId + "': " +
e.getMessage();
@@ -1727,7 +1629,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Waits for a Camel route to be created and started.
* This is useful for tests that need to verify the route was created by
the timer.
- *
+ *
* @param routeId The route ID to wait for
* @param timeoutMs Timeout in milliseconds between retries
* @param maxRetries Maximum number of retries
@@ -1751,7 +1653,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Gets a list of all Camel route IDs with their statuses.
* Uses official Camel API: CamelContext.getRoutes()
- *
+ *
* @return Map of route ID to status, or empty map if CamelContext is not
available
*/
protected java.util.Map<String, ServiceStatus>
getAllCamelRoutesWithStatus() {
@@ -1779,7 +1681,7 @@ public abstract class BaseIT extends KarafTestSupport {
/**
* Gets a list of all Camel route IDs.
- *
+ *
* @return List of route IDs, or empty list if CamelContext is not
available
*/
protected java.util.List<String> getAllCamelRouteIds() {
diff --git
a/itests/src/test/java/org/apache/unomi/itests/CopyPropertiesActionIT.java
b/itests/src/test/java/org/apache/unomi/itests/CopyPropertiesActionIT.java
index 6acde9fe2..6ff09cee9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CopyPropertiesActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CopyPropertiesActionIT.java
@@ -22,6 +22,7 @@ import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.Profile;
import org.apache.unomi.api.PropertyType;
import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.itests.tools.LogChecker;
import org.apache.unomi.persistence.spi.CustomObjectMapper;
import org.junit.After;
import org.junit.Assert;
@@ -36,13 +37,7 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
/**
* Created by amidani on 12/10/2017.
@@ -60,6 +55,16 @@ public class CopyPropertiesActionIT extends BaseIT {
public static final String PROPERTY_TO_MAP = "PropertyToMap";
public static final String MAPPED_PROPERTY = "MappedProperty";
+ /**
+ * Configure LogChecker with substrings for expected property copy errors
in this test.
+ */
+ @Override
+ protected LogChecker createLogChecker() {
+ return LogChecker.builder()
+ .addIgnoredSubstring("Impossible to copy the property")
+ .build();
+ }
+
@Before
public void setUp() throws InterruptedException {
Profile profile = new Profile();
diff --git
a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
index 4f78231f5..316ca5354 100644
--- a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
@@ -24,8 +24,11 @@ import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;
import org.apache.unomi.api.Scope;
+import org.apache.unomi.itests.tools.LogChecker;
import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
-import org.junit.*;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
import org.junit.runner.RunWith;
import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
@@ -52,6 +55,30 @@ public class InputValidationIT extends BaseIT {
private final static String ERROR_MESSAGE_INVALID_DATA_RECEIVED = "Request
rejected by the server because: Invalid received data";
public static final String DUMMY_SCOPE = "dummy_scope";
+ /**
+ * Configure LogChecker with substrings for expected validation errors in
this test.
+ * These are errors that are intentionally triggered to test validation
logic.
+ */
+ @Override
+ protected LogChecker createLogChecker() {
+ return LogChecker.builder()
+ // InvalidRequestExceptionMapper errors (expected when testing
invalid requests)
+ .addIgnoredSubstring("InvalidRequestExceptionMapper")
+ .addIgnoredSubstring("Invalid parameter")
+ .addIgnoredSubstring("Invalid Context request object")
+ .addIgnoredSubstring("Invalid events collector object")
+ .addIgnoredSubstring("Invalid profile ID format in cookie")
+ .addIgnoredSubstring("events collector cannot be empty")
+ .addIgnoredSubstring("Unable to deserialize object because")
+ // RequestValidatorInterceptor warnings (expected when testing
request size limits)
+ .addIgnoredSubstring("RequestValidatorInterceptor")
+ .addIgnoredSubstring("has thrown exception, unwinding now")
+ .addIgnoredSubstring("exceeding maximum bytes size")
+ .addIgnoredSubstring("Incoming POST request blocked because
exceeding maximum bytes size")
+ .addIgnoredSubstring("Response status code: 400")
+ .build();
+ }
+
@Before
public void setUp() throws InterruptedException {
TestUtils.createScope(DUMMY_SCOPE, "Dummy scope", scopeService);
diff --git a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
index d0504f29c..e37c23cc8 100644
--- a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java
@@ -25,6 +25,7 @@ import org.apache.http.util.EntityUtils;
import org.apache.unomi.api.Event;
import org.apache.unomi.api.Scope;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.itests.tools.LogChecker;
import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
import org.apache.unomi.schema.api.JsonSchemaWrapper;
import org.apache.unomi.schema.api.ValidationError;
@@ -58,6 +59,36 @@ public class JSONSchemaIT extends BaseIT {
private static final int DEFAULT_TRYING_TRIES = 30;
public static final String DUMMY_SCOPE = "dummy_scope";
+ /**
+ * Configure LogChecker with substrings for expected schema-related errors
in this test.
+ * These are errors that are intentionally triggered to test schema
validation logic.
+ */
+ @Override
+ protected LogChecker createLogChecker() {
+ return LogChecker.builder()
+ // Schema not found errors (expected when testing with missing
schemas)
+ .addIgnoredSubstring("Schema not found for event type: dummy")
+ .addIgnoredSubstring("Schema not found for event type: flattened")
+ .addIgnoredSubstring("Couldn't find schema")
+ .addIgnoredSubstring("Failed to load json schema")
+ // Schema validation errors (expected when testing invalid events)
+ .addIgnoredSubstring("Schema validation found")
+ .addIgnoredSubstring("Validation error")
+ .addIgnoredSubstring("does not match the regex pattern")
+ .addIgnoredSubstring("There are unevaluated properties")
+ .addIgnoredSubstring("Unknown scope value")
+ .addIgnoredSubstring("may only have a maximum of")
+ .addIgnoredSubstring("string found, number expected")
+ // Schema-related exceptions (expected during schema operations)
+ .addIgnoredSubstring("JsonSchemaException")
+ .addIgnoredSubstring("InvocationTargetException")
+ .addIgnoredSubstring("IOException")
+ .addIgnoredSubstring("Error executing system operation: Test
exception")
+ .addIgnoredSubstring("Couldn't find persona")
+ .addIgnoredSubstring("Unable to save schema")
+ .build();
+ }
+
@Before
public void setUp() throws InterruptedException {
keepTrying("Couldn't find json schema endpoint", () ->
get(JSONSCHEMA_URL, List.class), Objects::nonNull, DEFAULT_TRYING_TIMEOUT,
diff --git a/itests/src/test/java/org/apache/unomi/itests/tools/LogChecker.java
b/itests/src/test/java/org/apache/unomi/itests/tools/LogChecker.java
index 50bdacad8..dada4a5bb 100644
--- a/itests/src/test/java/org/apache/unomi/itests/tools/LogChecker.java
+++ b/itests/src/test/java/org/apache/unomi/itests/tools/LogChecker.java
@@ -27,18 +27,41 @@ import java.util.regex.Pattern;
/**
* Utility class to check logs for unexpected errors and warnings using an
in-memory appender.
* This replaces the file-based log checker and works with PaxExam/Karaf
integration tests.
+ *
+ * PERFORMANCE: To avoid checking 43,000+ log entries against many patterns,
each test class
+ * should add only the patterns it needs. Prefer literal strings over regex
for better performance.
+ *
+ * Example usage in a test class:
+ * <pre>
+ * {@literal @}Override
+ * protected LogChecker createLogChecker() {
+ * return LogChecker.builder()
+ * .addIgnoredSubstring("Response status code: 400") //
Single substring (fast)
+ * .addIgnoredMultiPart("Schema", "not found") //
Multi-part: "Schema" then "not found"
+ * .addIgnoredMultiPart("Invalid", "parameter", "format") //
Multi-part: all must appear in order
+ * .build();
+ * }
+ * </pre>
+ *
+ * IMPORTANT: All substrings are literal (no regex). Uses fast hierarchical
prefix-based matching
+ * with tree structure for multi-part patterns. Only checks subsequent parts
if first part matches,
+ * avoiding backtracking and multiple passes. Optimized for processing 43,000+
log entries.
*/
public class LogChecker {
-
+
private int checkpointIndex = 0;
- private final Set<Pattern> ignoredPatterns;
- private final Map<Pattern, java.util.regex.Matcher> matcherCache;
- private final List<String> literalPatterns; // Fast path for literal
string patterns (case-insensitive)
+ private final LiteralPatternMatcher literalSubstringMatcher; //
Hierarchical prefix-based matcher for literal substrings
private final int errorContextLinesBefore;
private final int errorContextLinesAfter;
private final int warningContextLinesBefore;
private final int warningContextLinesAfter;
-
+
+ // Maximum length of candidate string for pattern matching to prevent
processing extremely long strings
+ private static final int MAX_CANDIDATE_LENGTH = 10000; // 10KB limit
+
+ // Prefix length for hierarchical matching - balances between selectivity
and overhead
+ private static final int PREFIX_LENGTH = 4;
+
/**
* Simple data class to hold context event information (avoids storing
Log4j2 core classes)
*/
@@ -48,7 +71,7 @@ public class LogChecker {
final String thread;
final String logger;
final String message;
-
+
ContextEvent(String timestamp, String level, String thread, String
logger, String message) {
this.timestamp = timestamp;
this.level = level;
@@ -56,13 +79,13 @@ public class LogChecker {
this.logger = logger;
this.message = message;
}
-
+
String format(LogChecker checker) {
- return String.format("%s [%s] %s - %s",
+ return String.format("%s [%s] %s - %s",
checker.formatTimestamp(timestamp), level,
checker.shortenLogger(logger), checker.truncateMessage(message, 100));
}
}
-
+
/**
* Represents a log entry with its details including context
*/
@@ -76,7 +99,7 @@ public class LogChecker {
private final List<String> stacktrace;
private final List<ContextEvent> contextBefore;
private final List<ContextEvent> contextAfter;
-
+
public LogEntry(String timestamp, String level, String thread, String
logger, String message, long lineNumber) {
this.timestamp = timestamp;
this.level = level;
@@ -88,7 +111,7 @@ public class LogChecker {
this.contextBefore = new ArrayList<>();
this.contextAfter = new ArrayList<>();
}
-
+
public String getTimestamp() { return timestamp; }
public String getLevel() { return level; }
public String getThread() { return thread; }
@@ -98,26 +121,26 @@ public class LogChecker {
public List<String> getStacktrace() { return stacktrace; }
public List<ContextEvent> getContextBefore() { return contextBefore; }
public List<ContextEvent> getContextAfter() { return contextAfter; }
-
+
public void addStacktraceLine(String line) {
stacktrace.add(line);
}
-
+
public void addContextBefore(ContextEvent event) {
contextBefore.add(event);
}
-
+
public void addContextAfter(ContextEvent event) {
contextAfter.add(event);
}
-
+
public String getFullMessage() {
if (stacktrace.isEmpty()) {
return message;
}
return message + "\n" + String.join("\n", stacktrace);
}
-
+
public String getFullContext() {
StringBuilder sb = new StringBuilder();
appendContextBefore(sb);
@@ -126,7 +149,7 @@ public class LogChecker {
appendContextAfter(sb);
return sb.toString();
}
-
+
private void appendContextBefore(StringBuilder sb) {
if (!contextBefore.isEmpty()) {
sb.append("--- Context before (")
@@ -136,20 +159,20 @@ public class LogChecker {
}
}
}
-
+
private void appendIssueLine(StringBuilder sb) {
String headerLevel = (level != null) ? level : "LOG";
LogChecker checker = LogChecker.this;
-
+
// Extract source location from stack trace
String sourceLocation = checker.extractSourceLocation(stacktrace);
-
+
// Compact format: time [level] thread L{logLine} ->
sourceLocation: message
String time = checker.formatTimestamp(timestamp);
String shortThread = checker.shortenThread(thread);
String shortLogger = checker.shortenLogger(logger);
String truncatedMsg = checker.truncateMessage(message, 200);
-
+
// Format: time [level] thread L{logLine} -> ClassName:line:
message
if (sourceLocation != null && !sourceLocation.isEmpty()) {
sb.append(String.format("%s [%s] %s L%d -> %s: %s",
@@ -159,7 +182,7 @@ public class LogChecker {
time, headerLevel, shortThread, lineNumber, shortLogger,
truncatedMsg));
}
}
-
+
private void appendStackTrace(StringBuilder sb) {
if (!stacktrace.isEmpty()) {
sb.append("\n");
@@ -168,7 +191,7 @@ public class LogChecker {
}
}
}
-
+
private void appendContextAfter(StringBuilder sb) {
if (!contextAfter.isEmpty()) {
sb.append("\n--- Context after (")
@@ -178,14 +201,14 @@ public class LogChecker {
}
}
}
-
+
@Override
public String toString() {
- return String.format("[%s] %s [%s] %s - %s (line %d)",
+ return String.format("[%s] %s [%s] %s - %s (line %d)",
timestamp, level, thread, logger, message, lineNumber);
}
}
-
+
/**
* Result of a log check
*/
@@ -193,17 +216,17 @@ public class LogChecker {
private final List<LogEntry> errors;
private final List<LogEntry> warnings;
private final boolean hasUnexpectedIssues;
-
+
public LogCheckResult(List<LogEntry> errors, List<LogEntry> warnings) {
this.errors = errors != null ? errors : Collections.emptyList();
this.warnings = warnings != null ? warnings :
Collections.emptyList();
this.hasUnexpectedIssues = !this.errors.isEmpty() ||
!this.warnings.isEmpty();
}
-
+
public List<LogEntry> getErrors() { return errors; }
public List<LogEntry> getWarnings() { return warnings; }
public boolean hasUnexpectedIssues() { return hasUnexpectedIssues; }
-
+
public String getSummary() {
if (!hasUnexpectedIssues) {
return "No unexpected errors or warnings found in logs.";
@@ -213,7 +236,7 @@ public class LogChecker {
appendWarningsSummary(sb);
return sb.toString();
}
-
+
private void appendErrorsSummary(StringBuilder sb) {
if (!errors.isEmpty()) {
sb.append(String.format("Found %d error(s):", errors.size()));
@@ -227,7 +250,7 @@ public class LogChecker {
}
}
}
-
+
private void appendWarningsSummary(StringBuilder sb) {
if (!warnings.isEmpty()) {
sb.append(String.format("\nFound %d warning(s):",
warnings.size()));
@@ -242,7 +265,7 @@ public class LogChecker {
}
}
}
-
+
/**
* Create a new LogChecker with default context lines:
* - Errors: 10 lines before and after
@@ -251,212 +274,463 @@ public class LogChecker {
public LogChecker() {
this(10, 10, 0, 0);
}
-
+
/**
- * Create a new LogChecker with custom context line settings
+ * Create a new LogChecker with custom context line settings.
+ * Only includes truly global patterns that occur in all tests.
+ *
* @param errorContextLinesBefore Number of lines to capture before each
error
* @param errorContextLinesAfter Number of lines to capture after each
error
* @param warningContextLinesBefore Number of lines to capture before each
warning
* @param warningContextLinesAfter Number of lines to capture after each
warning
*/
- public LogChecker(int errorContextLinesBefore, int errorContextLinesAfter,
+ public LogChecker(int errorContextLinesBefore, int errorContextLinesAfter,
int warningContextLinesBefore, int
warningContextLinesAfter) {
- this.ignoredPatterns = new HashSet<>();
- this.matcherCache = new HashMap<>();
- this.literalPatterns = new ArrayList<>();
+ this.literalSubstringMatcher = new LiteralPatternMatcher();
this.errorContextLinesBefore = errorContextLinesBefore;
this.errorContextLinesAfter = errorContextLinesAfter;
this.warningContextLinesBefore = warningContextLinesBefore;
this.warningContextLinesAfter = warningContextLinesAfter;
- addDefaultIgnoredPatterns();
+ // No global substrings needed - BundleWatcher is handled by fast path
check
}
-
+
/**
- * Add a pattern to ignore (expected errors/warnings)
- * @param pattern Regex pattern to match against log messages, or literal
string (no regex special chars)
+ * Hierarchical prefix-based matcher for literal substrings with support
for multi-part matching.
+ *
+ * Supports both:
+ * - Single substrings: "Schema not found"
+ * - Multi-part substrings: ["Schema", "not found"] - must appear in
sequence
+ *
+ * Strategy:
+ * 1. Group by first substring's prefix (first PREFIX_LENGTH chars, or
full string if shorter)
+ * 2. Build tree: first substring -> list of remaining parts
+ * 3. When matching: only check subsequent parts if first part matches
+ * 4. Single pass through candidate string, no backtracking
+ *
+ * This avoids checking every pattern against every string position,
+ * and avoids checking subsequent parts unless the first part matches.
*/
- public void addIgnoredPattern(String pattern) {
- // Check if pattern is a literal string (no regex special characters
except . which we allow)
- if (isLiteralPattern(pattern)) {
- // Use fast string contains() for literal patterns
- literalPatterns.add(pattern.toLowerCase());
- } else {
- // Optimize regex pattern: use possessive quantifiers to prevent
backtracking
- String optimizedPattern = optimizePattern(pattern);
- Pattern compiledPattern = Pattern.compile(optimizedPattern,
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
- ignoredPatterns.add(compiledPattern);
- // Pre-create matcher for this pattern to avoid allocation during
matching
- matcherCache.put(compiledPattern, compiledPattern.matcher(""));
+ private static class LiteralPatternMatcher {
+ /**
+ * Represents a multi-part substring match requirement.
+ * First part must match, then subsequent parts must appear in order
after it.
+ */
+ private static class MultiPartMatch {
+ final String firstPart; // First substring to match
+ final List<String> remainingParts; // Subsequent substrings (in
order, after first)
+
+ MultiPartMatch(String firstPart, List<String> remainingParts) {
+ this.firstPart = firstPart;
+ this.remainingParts = remainingParts != null ? remainingParts
: Collections.emptyList();
+ }
+ }
+
+ // Map from prefix to list of multi-part matches
+ // For patterns with first part >= PREFIX_LENGTH: prefix is first
PREFIX_LENGTH chars
+ // For patterns with first part < PREFIX_LENGTH: prefix is the entire
first part
+ private final Map<String, List<MultiPartMatch>> matchesByPrefix = new
HashMap<>();
+ // Set of first characters of all prefixes (for quick filtering to
skip most positions)
+ private final Set<Character> prefixFirstChars = new HashSet<>();
+
+ /**
+ * Add a single substring to match
+ */
+ void addPattern(String substring) {
+ addMultiPartPattern(Collections.singletonList(substring));
+ }
+
+ /**
+ * Add a multi-part substring pattern (substrings must appear in
sequence).
+ *
+ * @param parts List of substrings that must appear in order
+ */
+ void addMultiPartPattern(List<String> parts) {
+ if (parts == null || parts.isEmpty()) {
+ return;
+ }
+
+ // Convert all parts to lowercase for case-insensitive matching
+ List<String> lowerParts = new ArrayList<>(parts.size());
+ for (String part : parts) {
+ if (part != null && !part.isEmpty()) {
+ lowerParts.add(part.toLowerCase());
+ }
+ }
+
+ if (lowerParts.isEmpty()) {
+ return;
+ }
+
+ String firstPart = lowerParts.get(0);
+ List<String> remainingParts = lowerParts.size() > 1
+ ? lowerParts.subList(1, lowerParts.size())
+ : Collections.emptyList();
+
+ MultiPartMatch match = new MultiPartMatch(firstPart,
remainingParts);
+
+ // Always use prefix-based structure, even for short first parts
+ // This ensures multi-part patterns are handled correctly
+ if (firstPart.length() < PREFIX_LENGTH) {
+ // Short first part - use entire first part as prefix for
grouping
+ String prefix = firstPart; // Use full first part as prefix
+ matchesByPrefix.computeIfAbsent(prefix, k -> {
+ // Track first character for quick filtering
+ if (prefix.length() > 0) {
+ prefixFirstChars.add(prefix.charAt(0));
+ }
+ return new ArrayList<>();
+ }).add(match);
+ } else {
+ // Group by prefix of first part
+ String prefix = firstPart.substring(0, PREFIX_LENGTH);
+ matchesByPrefix.computeIfAbsent(prefix, k -> {
+ // Track first character for quick filtering
+ prefixFirstChars.add(prefix.charAt(0));
+ return new ArrayList<>();
+ }).add(match);
+ }
}
+
+ /**
+ * Check if candidate string contains any of the patterns.
+ * Optimized with character-by-character comparison to avoid substring
creation.
+ *
+ * Strategy:
+ * 1. First-character filtering: O(1) HashSet lookup skips ~95%+ of
positions
+ * 2. Character-by-character prefix matching: avoids substring
allocation
+ * 3. Only check subsequent parts if first part matches (tree pruning)
+ * 4. Early exit on first match
+ *
+ * @param candidateLower Lowercase candidate string to check
+ * @return true if any pattern matches (should be ignored)
+ */
+ boolean containsAny(String candidateLower) {
+ int candidateLen = candidateLower.length();
+ if (candidateLen == 0) {
+ return false;
+ }
+
+ // For prefix-based patterns: check all possible positions
+ // Handle both standard PREFIX_LENGTH prefixes and shorter
prefixes (for multi-part patterns)
+ int maxCheckPos = candidateLen - 1;
+ if (maxCheckPos < 0) {
+ return false; // Candidate too short
+ }
+
+ // Prefix-based matching with first-character filtering
+ // Strategy: filter by first character to skip most positions,
then use character-by-character comparison
+ for (int i = 0; i <= maxCheckPos; i++) {
+ char c0 = candidateLower.charAt(i);
+
+ // Quick filter: skip if first character doesn't match any
prefix
+ if (!prefixFirstChars.contains(c0)) {
+ continue;
+ }
+
+ // Character-by-character prefix matching to avoid substring
creation
+ // Try to find matching prefix - check all possible prefix
lengths
+ List<MultiPartMatch> matchesWithPrefix = null;
+ String matchedPrefix = null;
+ int maxPrefixLen = Math.min(PREFIX_LENGTH, candidateLen - i);
+
+ // Iterate through all prefixes and compare
character-by-character
+ for (Map.Entry<String, List<MultiPartMatch>> entry :
matchesByPrefix.entrySet()) {
+ String prefix = entry.getKey();
+ int prefixLen = prefix.length();
+
+ // Skip if prefix doesn't start with matching character or
is too long
+ if (prefixLen > maxPrefixLen || prefix.charAt(0) != c0) {
+ continue;
+ }
+
+ // Check if we have enough characters remaining
+ if (i + prefixLen > candidateLen) {
+ continue;
+ }
+
+ // Character-by-character comparison (avoids substring
creation)
+ boolean prefixMatches = true;
+ for (int j = 1; j < prefixLen; j++) {
+ if (candidateLower.charAt(i + j) != prefix.charAt(j)) {
+ prefixMatches = false;
+ break;
+ }
+ }
+
+ if (prefixMatches) {
+ matchesWithPrefix = entry.getValue();
+ matchedPrefix = prefix;
+ break; // Found match, no need to check others
+ }
+ }
+
+ if (matchesWithPrefix != null && matchedPrefix != null) {
+ int prefixLen = matchedPrefix.length();
+ // Prefix matches - check multi-part matches (only this
subset)
+ for (MultiPartMatch match : matchesWithPrefix) {
+ // Find first part - prefix matches at position i, so
pattern could start at i or before
+ int patternLen = match.firstPart.length();
+ int firstPartPos = -1;
+
+ // Fast path: check if pattern starts at position i
(most common case)
+ // Since prefix is at the start of pattern, pattern
most likely starts at i
+ if (i + patternLen <= candidateLen) {
+ boolean matchesAtI = true;
+ // Only need to check characters after the prefix
(already matched)
+ int checkStart = Math.min(prefixLen, patternLen);
+ for (int j = checkStart; j < patternLen; j++) {
+ if (candidateLower.charAt(i + j) !=
match.firstPart.charAt(j)) {
+ matchesAtI = false;
+ break;
+ }
+ }
+ if (matchesAtI) {
+ firstPartPos = i;
+ }
+ }
+
+ // If fast path didn't match, use indexOf to search
backwards
+ // (pattern could start before i if prefix appears
elsewhere in pattern)
+ if (firstPartPos < 0) {
+ int searchStart = Math.max(0, i - patternLen +
Math.min(patternLen, PREFIX_LENGTH));
+ firstPartPos =
candidateLower.indexOf(match.firstPart, searchStart);
+ // Pattern can't start after position i (prefix is
at start of pattern)
+ if (firstPartPos > i) {
+ firstPartPos = -1;
+ }
+ }
+
+ if (firstPartPos >= 0) {
+ // First part found - now check remaining parts in
sequence
+ if (match.remainingParts.isEmpty()) {
+ // Single-part match - we're done
+ return true;
+ }
+
+ // Check remaining parts appear in order after
first part
+ int currentPos = firstPartPos + patternLen;
+ boolean allPartsMatch = true;
+
+ for (String remainingPart : match.remainingParts) {
+ int nextPos =
candidateLower.indexOf(remainingPart, currentPos);
+ if (nextPos < 0) {
+ // This part not found after previous part
- prune this branch
+ allPartsMatch = false;
+ break;
+ }
+ // Move position forward for next part
+ currentPos = nextPos + remainingPart.length();
+ }
+
+ if (allPartsMatch) {
+ return true; // All parts matched in sequence
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if any patterns are configured
+ */
+ boolean isEmpty() {
+ return matchesByPrefix.isEmpty();
+ }
+ }
+
+ /**
+ * Create a builder for configuring LogChecker with specific patterns.
+ * This is the recommended way to create LogChecker instances for better
performance.
+ *
+ * Example:
+ * <pre>
+ * LogChecker checker = LogChecker.builder()
+ * .addIgnoredSubstring("Response status code: 400") //
Single substring
+ * .addIgnoredMultiPart("Schema", "not found") //
Multi-part: sequential matching
+ * .build();
+ * </pre>
+ *
+ * IMPORTANT: All substrings are literal (no regex). Uses hierarchical
prefix-based matching with
+ * tree structure. Multi-part patterns only check subsequent parts if
first part matches.
+ *
+ * @return A LogCheckerBuilder instance
+ */
+ public static LogCheckerBuilder builder() {
+ return new LogCheckerBuilder();
}
-
+
/**
- * Check if a pattern is a literal string (no regex special characters)
+ * Builder for creating LogChecker instances with specific substrings to
ignore.
+ * This allows tests to only add the substrings they need, significantly
improving performance.
*/
- private boolean isLiteralPattern(String pattern) {
- // Check for regex special characters (excluding . which is common in
literal strings)
- // We consider it literal if it only contains alphanumeric, spaces,
and common punctuation
- for (int i = 0; i < pattern.length(); i++) {
- char c = pattern.charAt(i);
- if (c == '*' || c == '+' || c == '?' || c == '^' || c == '$' ||
- c == '[' || c == ']' || c == '{' || c == '}' || c == '(' || c
== ')' ||
- c == '|' || c == '\\') {
- return false;
+ public static class LogCheckerBuilder {
+ private int errorContextLinesBefore = 10;
+ private int errorContextLinesAfter = 10;
+ private int warningContextLinesBefore = 0;
+ private int warningContextLinesAfter = 0;
+ private final List<Object> substrings = new ArrayList<>(); // Can be
String or MultiPartSubstring
+
+ /**
+ * Set context lines for errors
+ */
+ public LogCheckerBuilder withErrorContext(int before, int after) {
+ this.errorContextLinesBefore = before;
+ this.errorContextLinesAfter = after;
+ return this;
+ }
+
+ /**
+ * Set context lines for warnings
+ */
+ public LogCheckerBuilder withWarningContext(int before, int after) {
+ this.warningContextLinesBefore = before;
+ this.warningContextLinesAfter = after;
+ return this;
+ }
+
+ /**
+ * Add a single substring to ignore.
+ *
+ * @param substring Literal substring to match (case-insensitive)
+ * @return This builder for method chaining
+ */
+ public LogCheckerBuilder addIgnoredSubstring(String substring) {
+ this.substrings.add(substring);
+ return this;
+ }
+
+ /**
+ * Add a multi-part substring pattern (substrings must appear in
sequence).
+ * This allows matching complex patterns without regex.
+ *
+ * Example: addIgnoredMultiPart("Schema", "not found") matches
"Schema" followed by "not found"
+ *
+ * @param parts Substrings that must appear in order
+ * @return This builder for method chaining
+ */
+ public LogCheckerBuilder addIgnoredMultiPart(String... parts) {
+ if (parts != null && parts.length > 0) {
+ this.substrings.add(new
MultiPartSubstring(Arrays.asList(parts)));
}
+ return this;
+ }
+
+ /**
+ * Add multiple substrings to ignore
+ *
+ * @param substrings Array of substrings to add
+ * @return This builder for method chaining
+ */
+ public LogCheckerBuilder addIgnoredSubstrings(String... substrings) {
+ Collections.addAll(this.substrings, substrings);
+ return this;
+ }
+
+ /**
+ * Add multiple substrings to ignore
+ *
+ * @param substrings List of substrings to add
+ * @return This builder for method chaining
+ */
+ public LogCheckerBuilder addIgnoredSubstrings(List<String> substrings)
{
+ if (substrings != null) {
+ this.substrings.addAll(substrings);
+ }
+ return this;
+ }
+
+ /**
+ * Marker class to distinguish multi-part substrings from single
substrings
+ */
+ private static class MultiPartSubstring {
+ final List<String> parts;
+ MultiPartSubstring(List<String> parts) {
+ this.parts = parts;
+ }
+ }
+
+ /**
+ * Build the LogChecker instance
+ */
+ public LogChecker build() {
+ LogChecker checker = new LogChecker(
+ errorContextLinesBefore, errorContextLinesAfter,
+ warningContextLinesBefore, warningContextLinesAfter
+ );
+ // Add all substrings specified by the builder
+ for (Object substring : substrings) {
+ if (substring instanceof MultiPartSubstring) {
+ checker.addIgnoredMultiPart(((MultiPartSubstring)
substring).parts);
+ } else if (substring instanceof String) {
+ checker.addIgnoredSubstring((String) substring);
+ }
+ }
+ return checker;
}
- return true;
}
-
+
/**
- * Optimize regex pattern to prevent catastrophic backtracking
- * - Replace .* with .*+ (possessive quantifier) to prevent backtracking
- * - This makes greedy matching non-backtracking, which is safe for
"contains" matching
+ * Add a single literal substring to ignore (expected errors/warnings).
+ *
+ * @param substring Literal substring to match against log messages
(case-insensitive)
+ *
+ * IMPORTANT: All substrings are literal (no regex). This uses fast
hierarchical prefix-based matching
+ * for optimal performance.
*/
- private String optimizePattern(String pattern) {
- // Replace .* with .*+ (possessive quantifier) to prevent backtracking
- // This is safe because we're using find(), not matches(), so we just
need to find the pattern anywhere
- // Possessive quantifiers prevent backtracking which can cause
exponential time complexity
- return pattern.replace(".*", ".*+");
+ public void addIgnoredSubstring(String substring) {
+ if (substring != null && !substring.isEmpty()) {
+ literalSubstringMatcher.addPattern(substring);
+ }
}
-
+
/**
- * Add multiple patterns to ignore
- * @param patterns List of regex patterns
+ * Add a multi-part substring pattern to ignore (substrings must appear in
sequence).
+ * This allows matching complex patterns without regex or backtracking.
+ *
+ * Example: addIgnoredMultiPart("Schema", "not found") will match "Schema"
followed by "not found"
+ * anywhere in the log message, but only checks "not found" if "Schema" is
found first.
+ *
+ * @param parts List of substrings that must appear in order
(case-insensitive)
*/
- public void addIgnoredPatterns(List<String> patterns) {
- if (patterns != null) {
- for (String pattern : patterns) {
- addIgnoredPattern(pattern);
- }
+ public void addIgnoredMultiPart(List<String> parts) {
+ if (parts != null && !parts.isEmpty()) {
+ literalSubstringMatcher.addMultiPartPattern(parts);
}
}
-
+
/**
- * Add default ignored patterns for common expected errors
+ * Add a multi-part substring pattern to ignore (substrings must appear in
sequence).
+ *
+ * @param parts Array of substrings that must appear in order
(case-insensitive)
*/
- private void addDefaultIgnoredPatterns() {
- // BundleWatcher warnings (common during startup)
- addIgnoredPattern("BundleWatcher.*WARN");
- // Old-style feature file deprecation warnings
- addIgnoredPattern("DEPRECATED.*feature.*file");
- // Segment condition recommendations
- addIgnoredPattern("segment.*condition.*recommendation");
- // KarafTestWatcher FAILED messages (just echoes of test failures)
- addIgnoredPattern("KarafTestWatcher.*FAILED:");
- // Dynamic test conditions (expected during test runs)
- addIgnoredPattern("loginEventCondition for rule testLogin");
- // Deprecated legacy query builder warnings
- addIgnoredPattern("DEPRECATED.*Using legacy queryBuilderId");
- // Test migration script intentional failures (expected in migration
recovery tests)
- addIgnoredPattern("failingMigration.*Intentional failure");
- addIgnoredPattern("Error executing migration
script.*failingMigration");
-
- // InvalidRequestExceptionMapper errors (expected in InputValidationIT
tests)
- addIgnoredPattern("InvalidRequestExceptionMapper.*Invalid parameter");
- addIgnoredPattern("InvalidRequestExceptionMapper.*Invalid Context
request object");
- addIgnoredPattern("InvalidRequestExceptionMapper.*Invalid events
collector object");
- addIgnoredPattern("InvalidRequestExceptionMapper.*Invalid profile ID
format in cookie");
- addIgnoredPattern("InvalidRequestExceptionMapper.*events collector
cannot be empty");
- addIgnoredPattern("InvalidRequestExceptionMapper.*Unable to
deserialize object because");
- addIgnoredPattern("InvalidRequestExceptionMapper.*Incoming POST
request blocked because exceeding maximum bytes size");
- // RequestValidatorInterceptor warnings (expected when testing request
size limits)
- addIgnoredPattern("RequestValidatorInterceptor.*Incoming POST request
blocked because exceeding maximum bytes size");
- addIgnoredPattern("RequestValidatorInterceptor.*has thrown exception,
unwinding now");
- addIgnoredPattern("RequestValidatorInterceptor.*Interceptor for.*has
thrown exception, unwinding now");
-
addIgnoredPattern("org\\.apache\\.unomi\\.rest\\.validation\\.request\\.RequestValidatorInterceptor.*has
thrown exception");
- addIgnoredPattern("InvalidRequestException.*Incoming POST request
blocked because exceeding maximum bytes size");
- addIgnoredPattern(".*Incoming POST request blocked because exceeding
maximum bytes size allowed on: /cxs/eventcollector");
- // More general patterns that match regardless of logger name format
- addIgnoredPattern(".*has thrown exception, unwinding now.*Incoming
POST request blocked because exceeding maximum bytes size");
- addIgnoredPattern(".*Interceptor for.*has thrown exception, unwinding
now.*exceeding maximum bytes size");
- addIgnoredPattern(".*exceeding maximum bytes size allowed on:
/cxs/eventcollector.*limit: 200000");
- addIgnoredPattern(".*exceeding maximum bytes size.*limit:
200000.*request size: 210940");
- // Match the exact error message format from the exception (with
escaped parentheses)
- addIgnoredPattern(".*Incoming POST request blocked because exceeding
maximum bytes size allowed on: /cxs/eventcollector \\(limit: 200000, request
size: 210940\\)");
- // Very general pattern that matches the key error message parts
- addIgnoredPattern(".*blocked because exceeding maximum bytes
size.*/cxs/eventcollector");
- // Simple pattern matching just the key error message
- addIgnoredPattern(".*exceeding maximum bytes size allowed on:
/cxs/eventcollector");
- // Very simple patterns that match the core error message parts
- addIgnoredPattern(".*exceeding maximum bytes
size.*/cxs/eventcollector.*limit: 200000");
- addIgnoredPattern(".*blocked because exceeding maximum bytes size");
- // Match if we see both key parts anywhere in the log entry
- addIgnoredPattern(".*has thrown exception.*exceeding maximum bytes
size");
- addIgnoredPattern(".*TestEndPoint.*exceeding maximum bytes size");
-
- // Test-related schema errors (expected in JSONSchemaIT and other
tests)
- addIgnoredPattern("Schema not found for event type: dummy");
- addIgnoredPattern("Schema not found for event type: flattened");
- addIgnoredPattern("Error executing system operation: Test exception");
- addIgnoredPattern("Error executing system
operation:.*ValidationException.*Schema not found");
- addIgnoredPattern("Couldn't find persona");
- addIgnoredPattern("Unable to save schema");
- addIgnoredPattern("SchemaServiceImpl.*Couldn't find schema");
- addIgnoredPattern("JsonSchemaFactory.*Failed to load json schema");
- addIgnoredPattern("Failed to load json schema!");
- addIgnoredPattern("Couldn't find schema.*vendor.test.com");
- addIgnoredPattern("JsonSchemaException.*Couldn't find schema");
- addIgnoredPattern("InvocationTargetException.*JsonSchemaException");
- addIgnoredPattern("InvocationTargetException.*Couldn't find schema");
-
addIgnoredPattern(".*InvocationTargetException.*JsonSchemaException.*Couldn't
find schema");
- addIgnoredPattern("IOException.*Couldn't find schema");
- addIgnoredPattern("ValidatorTypeCode.*InvocationTargetException");
-
addIgnoredPattern("ValidatorTypeCode.*Error:.*InvocationTargetException");
- // Schema validation warnings (expected during schema validation tests)
- addIgnoredPattern("SchemaServiceImpl.*Schema validation found.*errors
while validating");
- addIgnoredPattern("SchemaServiceImpl.*Validation error.*does not match
the regex pattern");
- addIgnoredPattern("SchemaServiceImpl.*An error occurred during the
validation of your event");
- addIgnoredPattern("SchemaServiceImpl.*Validation error: There are
unevaluated properties");
- addIgnoredPattern("SchemaServiceImpl.*Validation error: Unknown scope
value");
- addIgnoredPattern("SchemaServiceImpl.*Validation error:.*may only have
a maximum of.*properties");
- addIgnoredPattern("SchemaServiceImpl.*Validation error:.*string found,
number expected");
-
- // Action type resolution warnings (expected in tests with missing
action types)
- addIgnoredPattern("ParserHelper.*Couldn't resolve action type");
- addIgnoredPattern("ResolverServiceImpl.*Marked rules.*as invalid:
Unresolved action type");
-
- // Test-related property copy errors (expected in
CopyPropertiesActionIT)
- addIgnoredPattern("Impossible to copy the property");
-
- // Expected HTTP response codes in tests
- addIgnoredPattern("Response status code: 204");
- addIgnoredPattern("Response status code: 400");
-
- // Shutdown-related errors (expected during test teardown)
- addIgnoredPattern("FrameworkEvent ERROR");
- addIgnoredPattern("EventDispatcher: Error during dispatch.*Blueprint
container is being or has been destroyed");
-
- // Test query errors (expected when testing invalid queries/scroll IDs)
- addIgnoredPattern("Error while executing in class
loader.*scrollIdentifier=dummyScrollId");
- addIgnoredPattern("Error while executing in class loader.*Error
loading itemType");
- addIgnoredPattern("Error while executing in class loader.*Error
continuing scrolling query");
- addIgnoredPattern("OpenSearchPersistenceServiceImpl.*Error while
executing in class loader");
- addIgnoredPattern("OpenSearchPersistenceServiceImpl\\.\\d+.*Error
while executing in class loader");
- addIgnoredPattern("OpenSearchPersistenceServiceImpl\\.\\d+:\\d+.*Error
while executing in class loader");
- addIgnoredPattern("ElasticSearchPersistenceServiceImpl.*Error while
executing in class loader");
- addIgnoredPattern("Error continuing scrolling
query.*scrollIdentifier=dummyScrollId");
- addIgnoredPattern(".*Error continuing scrolling query for
itemType=org\\.apache\\.unomi\\.api\\.Profile.*scrollIdentifier=dummyScrollId");
- addIgnoredPattern("Error continuing scrolling query for
itemType.*scrollIdentifier=dummyScrollId");
- addIgnoredPattern("java\\.lang\\.Exception.*Error continuing scrolling
query.*scrollIdentifier=dummyScrollId");
- addIgnoredPattern("Cannot parse scroll id");
- addIgnoredPattern("Request failed:.*illegal_argument_exception.*Cannot
parse scroll id");
-
- // Index and mapping errors (expected during test setup/teardown or
when testing mapping scenarios)
- addIgnoredPattern("Could not find index.*could not register item
type");
- addIgnoredPattern("mapper_parsing_exception.*tried to parse field.*as
object, but found a concrete value");
-
- // Condition validation errors (expected in tests with invalid
conditions)
- addIgnoredPattern("Failed to validate condition");
- addIgnoredPattern("Error executing condition
evaluator.*pastEventConditionEvaluator");
+ public void addIgnoredMultiPart(String... parts) {
+ if (parts != null && parts.length > 0) {
+ literalSubstringMatcher.addMultiPartPattern(Arrays.asList(parts));
+ }
}
-
+
+ /**
+ * Add multiple substrings to ignore
+ * @param substrings List of literal substrings
+ */
+ public void addIgnoredSubstrings(List<String> substrings) {
+ if (substrings != null) {
+ for (String substring : substrings) {
+ addIgnoredSubstring(substring);
+ }
+ }
+ }
+
/**
* Mark the current log position as the starting point for the next check
*/
public void markCheckpoint() {
checkpointIndex = InMemoryLogAppender.getEventCount();
}
-
+
/**
* Check logs since the last checkpoint for errors and warnings
* @return LogCheckResult containing any errors/warnings found
@@ -466,7 +740,7 @@ public class LogChecker {
List<Object> events = getEventsSince(checkpointIndex);
return processEvents(events, checkpointIndex);
}
-
+
/**
* Get events since checkpoint using reflection to avoid direct LogEvent
dependency
* Converts List<LogEvent> to List<Object> by copying elements
@@ -479,7 +753,7 @@ public class LogChecker {
if (eventsList == null) {
return Collections.emptyList();
}
-
+
// Create a new ArrayList<Object> and copy all elements
List<Object> result = new ArrayList<>();
if (eventsList instanceof List) {
@@ -495,7 +769,7 @@ public class LogChecker {
return Collections.emptyList();
}
}
-
+
/**
* Process log events and extract errors/warnings with context
* Uses reflection to extract data from LogEvent objects without importing
Log4j2 core classes
@@ -503,47 +777,47 @@ public class LogChecker {
private LogCheckResult processEvents(List<Object> events, int baseIndex) {
List<LogEntry> errors = new ArrayList<>();
List<LogEntry> warnings = new ArrayList<>();
-
+
for (int i = 0; i < events.size(); i++) {
Object event = events.get(i);
EventData eventData = extractEventData(event);
-
+
if (eventData == null) {
continue;
}
-
+
// Only process ERROR, WARN, and FATAL levels
if (isErrorOrWarningLevel(eventData.level)) {
LogEntry entry = createLogEntry(eventData, baseIndex + i + 1);
-
+
if (shouldIncludeEntry(entry)) {
// Determine context lengths based on log level
boolean isError = isErrorLevel(eventData.level);
int contextBefore = isError ? errorContextLinesBefore :
warningContextLinesBefore;
int contextAfter = isError ? errorContextLinesAfter :
warningContextLinesAfter;
-
+
// Capture context before
int startBefore = Math.max(0, i - contextBefore);
for (int j = startBefore; j < i; j++) {
EventData contextData =
extractEventData(events.get(j));
if (contextData != null) {
entry.addContextBefore(new ContextEvent(
- contextData.timestamp, contextData.level,
+ contextData.timestamp, contextData.level,
contextData.thread, contextData.logger,
contextData.message));
}
}
-
+
// Capture context after
int endAfter = Math.min(events.size(), i + 1 +
contextAfter);
for (int j = i + 1; j < endAfter; j++) {
EventData contextData =
extractEventData(events.get(j));
if (contextData != null) {
entry.addContextAfter(new ContextEvent(
- contextData.timestamp, contextData.level,
+ contextData.timestamp, contextData.level,
contextData.thread, contextData.logger,
contextData.message));
}
}
-
+
// Add stack trace if present
if (eventData.throwable != null) {
String[] stackTrace =
getStackTrace(eventData.throwable);
@@ -551,15 +825,15 @@ public class LogChecker {
entry.addStacktraceLine(line);
}
}
-
+
addEntryToResults(entry, errors, warnings);
}
}
}
-
+
return new LogCheckResult(errors, warnings);
}
-
+
/**
* Data extracted from a LogEvent (avoids storing LogEvent directly)
*/
@@ -570,7 +844,7 @@ public class LogChecker {
final String logger;
final String message;
final Throwable throwable;
-
+
EventData(String timestamp, String level, String thread, String
logger, String message, Throwable throwable) {
this.timestamp = timestamp;
this.level = level;
@@ -580,7 +854,7 @@ public class LogChecker {
this.throwable = throwable;
}
}
-
+
/**
* Extract data from a LogEvent using reflection to avoid direct dependency
*/
@@ -588,23 +862,23 @@ public class LogChecker {
try {
// Use reflection to access LogEvent methods without importing the
class
Class<?> eventClass = event.getClass();
-
+
// Get level
Object levelObj = eventClass.getMethod("getLevel").invoke(event);
String level = levelObj != null ? levelObj.toString() : "UNKNOWN";
-
+
// Get instant/timestamp and format it
Object instantObj =
eventClass.getMethod("getInstant").invoke(event);
String timestamp = formatInstant(instantObj);
-
+
// Get thread name
String thread = (String)
eventClass.getMethod("getThreadName").invoke(event);
if (thread == null) thread = "";
-
+
// Get logger name
String logger = (String)
eventClass.getMethod("getLoggerName").invoke(event);
if (logger == null) logger = "";
-
+
// Get message
Object messageObj =
eventClass.getMethod("getMessage").invoke(event);
String message = "";
@@ -614,10 +888,10 @@ public class LogChecker {
message = formattedMsg.toString();
}
}
-
+
// Get throwable
Throwable throwable = (Throwable)
eventClass.getMethod("getThrown").invoke(event);
-
+
return new EventData(timestamp, level, thread, logger, message,
throwable);
} catch (Exception e) {
// Use System.err to avoid creating logs that would be captured by
InMemoryLogAppender
@@ -626,22 +900,22 @@ public class LogChecker {
return null;
}
}
-
+
/**
* Check if level is ERROR, WARN, or FATAL
*/
private boolean isErrorOrWarningLevel(String level) {
return "ERROR".equals(level) || "WARN".equals(level) ||
"FATAL".equals(level);
}
-
+
/**
* Create a LogEntry from extracted event data
*/
private LogEntry createLogEntry(EventData eventData, long lineNumber) {
- return new LogEntry(eventData.timestamp, eventData.level,
eventData.thread,
+ return new LogEntry(eventData.timestamp, eventData.level,
eventData.thread,
eventData.logger, eventData.message, lineNumber);
}
-
+
/**
* Get stack trace as array of strings
*/
@@ -654,7 +928,7 @@ public class LogChecker {
throwable.printStackTrace(pw);
return sw.toString().split("\n");
}
-
+
/**
* Add a log entry to the appropriate result list (errors or warnings)
*/
@@ -666,61 +940,65 @@ public class LogChecker {
warnings.add(entry);
}
}
-
+
/**
* Check if a log level represents an error
*/
private boolean isErrorLevel(String level) {
return "ERROR".equals(level) || "FATAL".equals(level);
}
-
+
/**
* Check if a log entry should be included (not ignored)
+ *
+ * CRITICAL PERFORMANCE: This method is called for every ERROR/WARN/FATAL
log entry (43,000+).
+ * Optimized for minimal operations and single-pass processing:
+ * - Early exit if no patterns configured
+ * - Avoids expensive operations (getFullMessage, toLowerCase) unless
needed
+ * - Single-pass string building with length limit
+ * - Early exit on first substring match
+ * - No regex: uses fast hierarchical prefix-based matching
+ *
+ * Package-private for testing purposes.
*/
- private boolean shouldIncludeEntry(LogEntry entry) {
- // Default ignores based on level/logger (fast path)
+ boolean shouldIncludeEntry(LogEntry entry) {
+ // Fast path: default ignores based on level/logger (no string
building needed)
if ("WARN".equals(entry.getLevel()) && entry.getLogger() != null &&
entry.getLogger().contains("BundleWatcher")) {
return false;
}
-
- // Build a rich candidate string for matching custom ignored patterns
- StringBuilder candidateBuilder = new StringBuilder();
- candidateBuilder.append(entry.getLevel() != null ? entry.getLevel() :
"")
- .append(' ')
- .append(entry.getLogger() != null ? entry.getLogger() : "")
- .append(' ')
- .append(entry.getMessage() != null ? entry.getMessage() : "")
- .append(' ')
- .append(entry.getFullMessage() != null ?
entry.getFullMessage() : "");
+
+ // Early exit: if no substrings configured, include all entries
+ if (literalSubstringMatcher.isEmpty()) {
+ return true;
+ }
+
+ // Build candidate string in single pass with length limit
+ // Prefer message over fullMessage (which includes stack trace) for
performance
+ String level = entry.getLevel() != null ? entry.getLevel() : "";
+ String logger = entry.getLogger() != null ? entry.getLogger() : "";
+ String message = entry.getMessage() != null ? entry.getMessage() : "";
+
+ // Build candidate: level + logger + message (most common case)
+ // No need to include fullMessage since we only use literal substrings
+ StringBuilder candidateBuilder = new
StringBuilder(Math.min(level.length() + logger.length() + message.length() +
10, MAX_CANDIDATE_LENGTH));
+ candidateBuilder.append(level).append(' ').append(logger).append('
').append(message);
+
+ // Ensure we don't exceed the limit (safety check)
String candidate = candidateBuilder.toString();
- String candidateLower = candidate.toLowerCase(); // For
case-insensitive literal matching
-
- // Fast path: check literal patterns first (much faster than regex)
- for (String literal : literalPatterns) {
- if (candidateLower.contains(literal)) {
- return false;
- }
+ if (candidate.length() > MAX_CANDIDATE_LENGTH) {
+ candidate = candidate.substring(0, MAX_CANDIDATE_LENGTH);
}
-
- // Slower path: check regex patterns (reuse cached matchers)
- for (Pattern pattern : ignoredPatterns) {
- java.util.regex.Matcher matcher = matcherCache.get(pattern);
- if (matcher != null) {
- // Reset the matcher with the new candidate string (reuses the
Matcher object)
- matcher.reset(candidate);
- if (matcher.find()) {
- return false;
- }
- } else {
- // Fallback if matcher not in cache (shouldn't happen, but be
safe)
- if (pattern.matcher(candidate).find()) {
- return false;
- }
- }
+
+ // Check literal substrings using hierarchical prefix-based matching
+ // This minimizes character comparisons by checking prefixes first
+ String candidateLower = candidate.toLowerCase();
+ if (literalSubstringMatcher.containsAny(candidateLower)) {
+ return false; // Early exit on first match
}
+
return true;
}
-
+
/**
* Format an Instant object to a compact timecode (HH:mm:ss.SSS)
*/
@@ -730,7 +1008,7 @@ public class LogChecker {
}
try {
Instant instant = null;
-
+
// If it's already an Instant, use it directly
if (instantObj instanceof Instant) {
instant = (Instant) instantObj;
@@ -756,7 +1034,7 @@ public class LogChecker {
Pattern nanoPattern = Pattern.compile("nano=(\\d+)");
java.util.regex.Matcher epochMatcher =
epochPattern.matcher(instantStr);
java.util.regex.Matcher nanoMatcher =
nanoPattern.matcher(instantStr);
-
+
if (epochMatcher.find()) {
long epochSeconds =
Long.parseLong(epochMatcher.group(1));
long nanos = 0;
@@ -767,13 +1045,13 @@ public class LogChecker {
}
}
}
-
+
if (instant != null) {
// Format as compact timecode: HH:mm:ss.SSS
return DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
.format(instant.atZone(ZoneId.systemDefault()));
}
-
+
// Fallback to original string if we can't parse it
return instantObj.toString();
} catch (Exception e) {
@@ -781,7 +1059,7 @@ public class LogChecker {
return instantObj.toString();
}
}
-
+
/**
* Format a timestamp string (already extracted) to compact timecode
format (HH:mm:ss.SSS)
* This is only called for ContextEvent timestamps which are already
strings from formatInstant()
@@ -801,7 +1079,7 @@ public class LogChecker {
Pattern nanoPattern = Pattern.compile("nano=(\\d+)");
java.util.regex.Matcher epochMatcher =
epochPattern.matcher(timestamp);
java.util.regex.Matcher nanoMatcher =
nanoPattern.matcher(timestamp);
-
+
if (epochMatcher.find()) {
long epochSeconds = Long.parseLong(epochMatcher.group(1));
long nanos = 0;
@@ -818,7 +1096,7 @@ public class LogChecker {
// Return as-is for any other format
return timestamp;
}
-
+
/**
* Shorten logger name to just the class name (remove package)
*/
@@ -832,7 +1110,7 @@ public class LogChecker {
}
return logger;
}
-
+
/**
* Shorten thread name for compact display (keep last part if it contains
useful info)
*/
@@ -848,7 +1126,7 @@ public class LogChecker {
}
return thread;
}
-
+
/**
* Truncate message if it's too long
*/
@@ -861,7 +1139,7 @@ public class LogChecker {
}
return message.substring(0, maxLength - 3) + "...";
}
-
+
/**
* Extract source location (class:line) from stack trace, skipping logging
framework classes
*/
@@ -869,47 +1147,47 @@ public class LogChecker {
if (stacktrace == null || stacktrace.isEmpty()) {
return null;
}
-
+
// Patterns to skip (logging framework classes)
Pattern skipPattern = Pattern.compile(
".*(org\\.apache\\.logging|org\\.slf4j|ch\\.qos\\.logback|org\\.log4j|" +
"java\\.util\\.logging|sun\\.reflect|jdk\\.internal\\.reflect).*"
);
-
+
// Pattern to match stack trace lines: at
package.ClassName.methodName(FileName.java:lineNumber)
// Group 1: full qualified name (package.ClassName.methodName)
// Group 2: line number
Pattern stackTracePattern = Pattern.compile(
"\\s*at\\s+([\\w.$<>]+)\\([\\w.]+\\.java:(\\d+)\\)"
);
-
+
for (String line : stacktrace) {
if (line == null || line.trim().isEmpty()) {
continue;
}
-
+
// Skip logging framework classes
if (skipPattern.matcher(line).matches()) {
continue;
}
-
+
// Try to match stack trace pattern
java.util.regex.Matcher matcher = stackTracePattern.matcher(line);
if (matcher.find()) {
String fullQualifiedName = matcher.group(1);
String lineNumber = matcher.group(2);
-
+
// Extract class name from full qualified name
(package.ClassName.methodName)
// Remove method name by finding the last dot before method
name
// For inner classes, we want the outer class name
String className = fullQualifiedName;
-
+
// Remove generic type parameters if present
int genericStart = className.indexOf('<');
if (genericStart > 0) {
className = className.substring(0, genericStart);
}
-
+
// Extract class name (everything up to the last dot before
method name)
// Method names typically start with lowercase, but we'll use
a simpler approach:
// Take the part before the last dot that contains the class
@@ -918,25 +1196,25 @@ public class LogChecker {
// Check if the part after last dot looks like a method
(starts with lowercase or is a common method pattern)
String afterDot = className.substring(lastDot + 1);
// If it's all uppercase or contains $, it might be a
class, otherwise assume it's a method
- if (afterDot.length() > 0 &&
Character.isLowerCase(afterDot.charAt(0)) &&
+ if (afterDot.length() > 0 &&
Character.isLowerCase(afterDot.charAt(0)) &&
!afterDot.contains("$")) {
// Likely a method name, get the class name before it
className = className.substring(0, lastDot);
}
}
-
+
// Extract just the simple class name (last part)
lastDot = className.lastIndexOf('.');
String simpleClassName = (lastDot >= 0) ?
className.substring(lastDot + 1) : className;
-
+
// Remove inner class markers ($)
simpleClassName = simpleClassName.replace('$', '.');
-
+
// Return compact format: ClassName:lineNumber
return simpleClassName + ":" + lineNumber;
}
}
-
+
return null;
}
}
diff --git
a/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java
b/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java
new file mode 100644
index 000000000..6fcd48c86
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java
@@ -0,0 +1,396 @@
+/*
+ * 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.unomi.itests.tools;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Comprehensive unit tests for LogChecker substring matching functionality.
+ * Tests validate the hierarchical prefix-based matching algorithm, multi-part
substring matching,
+ * edge cases, and performance characteristics.
+ */
+public class LogCheckerTest {
+
+ private LogChecker logChecker;
+
+ @Before
+ public void setUp() {
+ logChecker = LogChecker.builder()
+ .withErrorContext(0, 0)
+ .withWarningContext(0, 0)
+ .build();
+ }
+
+ @Test
+ public void testSingleSubstringMatch() {
+ logChecker.addIgnoredSubstring("error occurred");
+
+ assertFalse("Should ignore message with substring", shouldInclude("An
error occurred in the system"));
+ assertTrue("Should include message without substring",
shouldInclude("This is a normal log message"));
+ }
+
+ @Test
+ public void testSingleSubstringCaseInsensitive() {
+ logChecker.addIgnoredSubstring("ERROR OCCURRED");
+
+ assertFalse("Should match case-insensitively", shouldInclude("An error
occurred in the system"));
+ assertFalse("Should match case-insensitively", shouldInclude("An ERROR
OCCURRED in the system"));
+ }
+
+ @Test
+ public void testMultiPartSubstringMatch() {
+ logChecker.addIgnoredMultiPart("Schema", "not found");
+
+ assertFalse("Should match multi-part in sequence",
shouldInclude("Schema not found for event type"));
+ assertFalse("Should match with text between parts",
shouldInclude("Schema validation not found"));
+ assertTrue("Should not match if second part missing",
shouldInclude("Schema validation found"));
+ assertTrue("Should not match if order is wrong", shouldInclude("not
found Schema"));
+ }
+
+ @Test
+ public void testMultiPartSubstringThreeParts() {
+ logChecker.addIgnoredMultiPart("Invalid", "parameter", "format");
+
+ assertFalse("Should match all three parts in order",
shouldInclude("Invalid parameter format detected"));
+ assertFalse("Should match with text between", shouldInclude("Invalid
request parameter format error"));
+ assertTrue("Should not match if third part missing",
shouldInclude("Invalid parameter"));
+ assertTrue("Should not match if order is wrong",
shouldInclude("Invalid format parameter"));
+ }
+
+ @Test
+ public void testMultipleSubstrings() {
+ logChecker.addIgnoredSubstring("specific error");
+ logChecker.addIgnoredSubstring("warning message");
+ logChecker.addIgnoredMultiPart("Schema", "not found");
+
+ assertFalse("Should match first substring", shouldInclude("A specific
error occurred"));
+ assertFalse("Should match second substring", shouldInclude("A warning
message was issued"));
+ assertFalse("Should match multi-part", shouldInclude("Schema not
found"));
+ assertTrue("Should not match any pattern", shouldInclude("Normal log
message"));
+ }
+
+ @Test
+ public void testPrefixOptimization() {
+ // Add multiple substrings with same prefix to test prefix grouping
+ logChecker.addIgnoredSubstring("Schema not found");
+ logChecker.addIgnoredSubstring("Schema validation");
+ logChecker.addIgnoredSubstring("Schema error");
+
+ assertFalse("Should match first", shouldInclude("Schema not found for
event"));
+ assertFalse("Should match second", shouldInclude("Schema validation
failed"));
+ assertFalse("Should match third", shouldInclude("Schema error
occurred"));
+ assertTrue("Should not match", shouldInclude("No schema issues"));
+ }
+
+ @Test
+ public void testShortSubstrings() {
+ logChecker.addIgnoredSubstring("err");
+ logChecker.addIgnoredSubstring("warn");
+
+ assertFalse("Should match short substring", shouldInclude("An error
occurred"));
+ assertFalse("Should match short substring", shouldInclude("A warning
was issued"));
+ }
+
+ @Test
+ public void testEmptySubstrings() {
+ // Should handle empty/null gracefully - they are filtered out and
don't match
+ // Test with completely empty patterns only
+ LogChecker emptyChecker = LogChecker.builder().withErrorContext(0,
0).withWarningContext(0, 0).build();
+ emptyChecker.addIgnoredSubstring("");
+ emptyChecker.addIgnoredSubstring(null);
+ emptyChecker.addIgnoredMultiPart();
+
+ LogChecker.LogEntry entry = emptyChecker.new LogEntry(
+ "10:00:00.000", "ERROR", "test-thread", "TestLogger", "Any
message", 1L
+ );
+ assertTrue("Completely empty patterns should not match",
emptyChecker.shouldIncludeEntry(entry));
+
+ // Test that filtering empty parts from multi-part works correctly
+ logChecker.addIgnoredMultiPart("", "test");
+ // Empty string is filtered, leaving just "test" as single-part
+ assertFalse("Filtered multi-part leaves 'test' which matches",
shouldInclude("test message"));
+ }
+
+ @Test
+ public void testSubstringAtStart() {
+ logChecker.addIgnoredSubstring("Start");
+
+ assertFalse("Should match at start", shouldInclude("Start of
message"));
+ assertFalse("Should match anywhere (substring matching)",
shouldInclude("Message Start here"));
+ }
+
+ @Test
+ public void testSubstringAtEnd() {
+ logChecker.addIgnoredSubstring("End");
+
+ assertFalse("Should match at end", shouldInclude("Message ends with
End"));
+ assertFalse("Should match anywhere (substring matching)",
shouldInclude("End is in the middle"));
+ }
+
+ @Test
+ public void testSubstringInMiddle() {
+ logChecker.addIgnoredSubstring("middle");
+
+ assertFalse("Should match in middle", shouldInclude("Start middle
end"));
+ assertFalse("Should match at start", shouldInclude("middle end"));
+ assertFalse("Should match at end", shouldInclude("Start middle"));
+ }
+
+ @Test
+ public void testOverlappingSubstrings() {
+ logChecker.addIgnoredSubstring("abc");
+ logChecker.addIgnoredSubstring("bcd");
+ logChecker.addIgnoredSubstring("cde");
+
+ assertFalse("Should match first", shouldInclude("abc found"));
+ assertFalse("Should match second", shouldInclude("bcd found"));
+ assertFalse("Should match third", shouldInclude("cde found"));
+ assertFalse("Should match overlapping", shouldInclude("abcde found"));
+ }
+
+ @Test
+ public void testVeryLongSubstring() {
+ StringBuilder longPattern = new StringBuilder(200);
+ for (int i = 0; i < 50; i++) {
+ longPattern.append("word").append(i).append(" ");
+ }
+ logChecker.addIgnoredSubstring(longPattern.toString().trim());
+
+ assertFalse("Should match long substring", shouldInclude("Prefix " +
longPattern.toString().trim() + " suffix"));
+ assertTrue("Should not match partial", shouldInclude("word1 word2
word3"));
+ }
+
+ @Test
+ public void testMultiPartWithOverlapping() {
+ logChecker.addIgnoredMultiPart("abc", "def", "ghi");
+
+ assertFalse("Should match all parts", shouldInclude("abc then def then
ghi"));
+ assertFalse("Should match with text between", shouldInclude("abc def
ghi"));
+ assertTrue("Should not match if parts missing (ghi missing)",
shouldInclude("abc def"));
+ assertTrue("Should not match if order wrong", shouldInclude("def abc
ghi"));
+ assertTrue("Should not match if only first part", shouldInclude("abc
only"));
+ }
+
+ @Test
+ public void testMultiPartWithSamePart() {
+ // Use a more specific pattern to avoid matching "test" in "TestLogger"
+ logChecker.addIgnoredMultiPart("part", "part", "part");
+
+ assertFalse("Should match all three parts", shouldInclude("part part
part"));
+ assertFalse("Should match all three parts with extra",
shouldInclude("part part part extra"));
+
+ // "part part" should NOT match "part part part" pattern (missing
third part)
+ LogChecker.LogEntry entry1 = logChecker.new LogEntry(
+ "10:00:00.000", "ERROR", "test-thread", "TestLogger", "part part",
1L
+ );
+ assertTrue("Entry with only two 'part' should not match three-part
pattern",
+ logChecker.shouldIncludeEntry(entry1));
+
+ assertTrue("Should not match if only one part", shouldInclude("part
only"));
+ }
+
+ @Test
+ public void testMultiPartWithManyParts() {
+ logChecker.addIgnoredMultiPart("part1", "part2", "part3", "part4",
"part5");
+
+ assertFalse("Should match all parts in sequence",
+ shouldInclude("part1 then part2 then part3 then part4 then
part5"));
+ assertTrue("Should not match if not all parts present",
+ shouldInclude("part1 then part2 then part3"));
+ }
+
+ @Test
+ public void testCaseSensitivity() {
+ logChecker.addIgnoredSubstring("CaseSensitive");
+
+ assertFalse("Should match exact case", shouldInclude("CaseSensitive
match"));
+ assertFalse("Should match lowercase", shouldInclude("casesensitive
match"));
+ assertFalse("Should match uppercase", shouldInclude("CASESENSITIVE
match"));
+ assertFalse("Should match mixed case", shouldInclude("CaSeSeNsItIvE
match"));
+ }
+
+ @Test
+ public void testSpecialCharacters() {
+ logChecker.addIgnoredSubstring("[email protected]");
+ logChecker.addIgnoredSubstring("path/to/file");
+ logChecker.addIgnoredSubstring("value=123");
+
+ assertFalse("Should match email", shouldInclude("Contact
[email protected] for help"));
+ assertFalse("Should match path", shouldInclude("File at path/to/file
found"));
+ assertFalse("Should match equals", shouldInclude("Setting value=123"));
+ }
+
+ @Test
+ public void testUnicodeCharacters() {
+ logChecker.addIgnoredSubstring("café");
+ logChecker.addIgnoredSubstring("naïve");
+
+ assertFalse("Should match unicode", shouldInclude("Visit the café"));
+ assertFalse("Should match unicode", shouldInclude("A naïve approach"));
+ }
+
+ @Test
+ public void testWhitespaceHandling() {
+ logChecker.addIgnoredSubstring("test message");
+ logChecker.addIgnoredSubstring(" spaced ");
+
+ assertFalse("Should match with single space", shouldInclude("This is a
test message here"));
+ assertFalse("Should match with multiple spaces", shouldInclude("This
has spaced in it"));
+ }
+
+ @Test
+ public void testNoSubstringsConfigured() {
+ // With no substrings, all entries should be included
+ assertTrue("Should include when no substrings configured",
shouldInclude("Any message"));
+ assertTrue("Should include error messages", shouldInclude("ERROR
occurred"));
+ }
+
+ @Test
+ public void testBundleWatcherFastPath() {
+ // BundleWatcher warnings are handled by fast path (no substring
matching needed)
+ LogChecker.LogEntry warnEntry = logChecker.new LogEntry(
+ "10:00:00.000", "WARN", "test-thread",
+ "org.apache.unomi.lifecycle.BundleWatcher", "Some warning", 1L
+ );
+
+ assertFalse("BundleWatcher warnings should be ignored",
logChecker.shouldIncludeEntry(warnEntry));
+ }
+
+ @Test
+ public void testCandidateStringIncludesLevelAndLogger() {
+ // Verify that matching works across level + logger + message
+ logChecker.addIgnoredSubstring("ERROR");
+
+ // ERROR appears in level, should match
+ assertFalse("Should match ERROR in level", shouldInclude("Some
message"));
+
+ // Reset and test logger
+ logChecker = LogChecker.builder().withErrorContext(0,
0).withWarningContext(0, 0).build();
+ logChecker.addIgnoredSubstring("TestLogger");
+
+ assertFalse("Should match logger name", shouldInclude("Some message"));
+ }
+
+ @Test
+ public void testPerformanceWithManySubstrings() {
+ // Add many substrings to test performance
+ for (int i = 0; i < 100; i++) {
+ logChecker.addIgnoredSubstring("pattern" + i);
+ }
+
+ // Should still match quickly
+ long start = System.nanoTime();
+ assertFalse("Should match pattern50", shouldInclude("This message
contains pattern50 in it"));
+ long duration = System.nanoTime() - start;
+
+ // Should complete in reasonable time (< 1ms for this test)
+ assertTrue("Matching should be fast: " + duration + " ns", duration <
1_000_000);
+ }
+
+ @Test
+ public void testPerformanceWithLongString() {
+ logChecker.addIgnoredSubstring("target");
+
+ // Create a long string (simulating a log entry with stack trace)
+ // Put target near the beginning to ensure it's within
MAX_CANDIDATE_LENGTH
+ StringBuilder longString = new StringBuilder(10000);
+ longString.append("target "); // Put target at start
+ for (int i = 0; i < 1000; i++) {
+ longString.append("This is line ").append(i).append(" of a very
long log message. ");
+ }
+
+ long start = System.nanoTime();
+ assertFalse("Should match target in long string",
shouldInclude(longString.toString()));
+ long duration = System.nanoTime() - start;
+
+ // Should complete quickly even with long string (< 10ms)
+ assertTrue("Matching should be fast even with long strings: " +
duration + " ns", duration < 10_000_000);
+ }
+
+ @Test
+ public void testPerformanceStressTest() {
+ // Comprehensive performance test with multiple patterns and long
strings
+ // Should complete in under 2 seconds
+ long overallStart = System.currentTimeMillis();
+
+ // Add many diverse patterns
+ for (int i = 0; i < 50; i++) {
+ logChecker.addIgnoredSubstring("pattern" + i);
+ logChecker.addIgnoredSubstring("error" + i);
+ logChecker.addIgnoredMultiPart("part" + i, "sub" + i);
+ }
+
+ // Test many candidate strings
+ for (int i = 0; i < 1000; i++) {
+ String candidate = "Test message " + i + " with pattern" + (i %
50) + " in it";
+ logChecker.shouldIncludeEntry(logChecker.new LogEntry(
+ "10:00:00.000", "ERROR", "test-thread", "TestLogger",
candidate, 1L
+ ));
+ }
+
+ long overallDuration = System.currentTimeMillis() - overallStart;
+
+ // Should complete in under 2 seconds
+ assertTrue("Performance stress test should complete quickly: " +
overallDuration + " ms",
+ overallDuration < 2000);
+ }
+
+ @Test
+ public void testTruncatedCandidateString() {
+ // Test that matching works even when candidate is truncated to
MAX_CANDIDATE_LENGTH
+ logChecker.addIgnoredSubstring("early");
+
+ // Create a very long message that will be truncated
+ StringBuilder veryLongMessage = new StringBuilder(20000);
+ veryLongMessage.append("early "); // Put target at start
+ for (int i = 0; i < 2000; i++) {
+ veryLongMessage.append("This is a very long line
").append(i).append(". ");
+ }
+
+ assertFalse("Should match even in truncated string",
shouldInclude(veryLongMessage.toString()));
+ }
+
+ @Test
+ public void testPrefixLengthBoundary() {
+ // Test patterns at the PREFIX_LENGTH boundary (4 characters)
+ logChecker.addIgnoredSubstring("test"); // Exactly 4 chars
+ logChecker.addIgnoredSubstring("tes"); // 3 chars (short)
+ logChecker.addIgnoredSubstring("test1"); // 5 chars (prefix-based)
+
+ assertFalse("Should match 4-char pattern", shouldInclude("This is a
test message"));
+ assertFalse("Should match 3-char pattern", shouldInclude("This has tes
in it"));
+ assertFalse("Should match 5-char pattern", shouldInclude("This has
test1 in it"));
+ }
+
+ /**
+ * Helper method to test if a message should be included (not ignored)
+ */
+ private boolean shouldInclude(String message) {
+ // Create a minimal log entry for testing
+ LogChecker.LogEntry entry = logChecker.new LogEntry(
+ "10:00:00.000", "ERROR", "test-thread",
+ "TestLogger", message, 1L
+ );
+
+ // shouldIncludeEntry is package-private, so we can call it directly
+ return logChecker.shouldIncludeEntry(entry);
+ }
+}