feat(alerting): ClickHouseLogStore.countLogs for log-pattern evaluator

Adds countLogs(LogSearchRequest) — no FINAL, no cursor/sort/limit —
reusing the same WHERE-clause logic as search() for tenant, env, app,
level, q, logger, source, exchangeId, and time-range filters.
Also extends ClickHouseTestHelper with executeInitSqlWithProjections()
and makes the script runner non-fatal for ADD/MATERIALIZE PROJECTION.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:18:41 +02:00
parent 59354fae18
commit 44e91ccdb5
3 changed files with 229 additions and 2 deletions

View File

@@ -14,7 +14,16 @@ public final class ClickHouseTestHelper {
private ClickHouseTestHelper() {}
public static void executeInitSql(JdbcTemplate jdbc) throws IOException {
String sql = new ClassPathResource("clickhouse/init.sql")
executeScript(jdbc, "clickhouse/init.sql");
}
public static void executeInitSqlWithProjections(JdbcTemplate jdbc) throws IOException {
executeScript(jdbc, "clickhouse/init.sql");
executeScript(jdbc, "clickhouse/alerting_projections.sql");
}
private static void executeScript(JdbcTemplate jdbc, String classpathResource) throws IOException {
String sql = new ClassPathResource(classpathResource)
.getContentAsString(StandardCharsets.UTF_8);
for (String statement : sql.split(";")) {
String trimmed = statement.trim();
@@ -24,7 +33,20 @@ public final class ClickHouseTestHelper {
.filter(line -> !line.isEmpty())
.reduce("", (a, b) -> a + b);
if (!withoutComments.isEmpty()) {
jdbc.execute(trimmed);
String upper = withoutComments.toUpperCase();
boolean isBestEffort = upper.contains("MATERIALIZE PROJECTION")
|| upper.contains("ADD PROJECTION");
try {
jdbc.execute(trimmed);
} catch (Exception e) {
if (isBestEffort) {
// ADD PROJECTION on ReplacingMergeTree requires a session setting unavailable
// via JDBC pool; MATERIALIZE can fail on empty tables — both non-fatal in tests.
System.err.println("Projection DDL skipped (non-fatal): " + e.getMessage());
} else {
throw e;
}
}
}
}
}

View File

@@ -0,0 +1,127 @@
package com.cameleer.server.app.search;
import com.cameleer.common.model.LogEntry;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import com.cameleer.server.core.search.LogSearchRequest;
import com.cameleer.server.app.ClickHouseTestHelper;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseLogStoreCountIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseLogStore store;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE logs");
store = new ClickHouseLogStore("default", jdbc);
}
/** Seed a log row with explicit environment via insertBufferedBatch. */
private void seed(String tenantId, String environment, String appId, String instanceId,
Instant ts, String level, String message) {
LogEntry entry = new LogEntry(ts, level, "com.example.Foo", message, "main", null, null);
store.insertBufferedBatch(List.of(
new BufferedLogEntry(tenantId, environment, instanceId, appId, entry)));
}
@Test
void countLogs_respectsLevelAndPattern() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
// 3 ERROR rows with "TimeoutException" message
for (int i = 0; i < 3; i++) {
seed("default", "dev", "orders", "agent-1", base.plusSeconds(i),
"ERROR", "TimeoutException occurred");
}
// 2 non-matching INFO rows
for (int i = 0; i < 2; i++) {
seed("default", "dev", "orders", "agent-1", base.plusSeconds(10 + i),
"INFO", "Health check OK");
}
long count = store.countLogs(new LogSearchRequest(
"TimeoutException",
List.of("ERROR"),
"orders",
null,
null,
null,
"dev",
List.of(),
base.minusSeconds(10),
base.plusSeconds(30),
null,
100,
"desc"));
assertThat(count).isEqualTo(3);
}
@Test
void countLogs_noMatchReturnsZero() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
seed("default", "dev", "orders", "agent-1", base, "INFO", "all good");
long count = store.countLogs(new LogSearchRequest(
null,
List.of("ERROR"),
"orders",
null,
null,
null,
"dev",
List.of(),
base.minusSeconds(10),
base.plusSeconds(30),
null,
100,
"desc"));
assertThat(count).isZero();
}
@Test
void countLogs_environmentFilter_isolatesEnvironments() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
// 2 rows in "dev"
seed("default", "dev", "orders", "agent-1", base, "ERROR", "err");
seed("default", "dev", "orders", "agent-1", base.plusSeconds(1), "ERROR", "err");
// 1 row in "prod" — should not be counted
seed("default", "prod", "orders", "agent-2", base.plusSeconds(5), "ERROR", "err");
long devCount = store.countLogs(new LogSearchRequest(
null, List.of("ERROR"), "orders", null, null, null,
"dev", List.of(),
base.minusSeconds(1), base.plusSeconds(60),
null, 100, "desc"));
assertThat(devCount).isEqualTo(2);
}
}