diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java index eac4e351..636e4e5d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java @@ -61,7 +61,8 @@ public class LogPatternEvaluator implements ConditionEvaluator instanceIdList = List.of(); + if (instanceIds != null && !instanceIds.isEmpty()) { + instanceIdList = Arrays.stream(instanceIds.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + Instant fromInstant = from != null ? Instant.parse(from) : null; Instant toInstant = to != null ? Instant.parse(to) : null; LogSearchRequest request = new LogSearchRequest( searchText, levels, application, instanceId, exchangeId, - logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort); + logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort, + instanceIdList); LogSearchResponse result = logIndex.search(request); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java index 1cf95fa3..ce932c3e 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseLogStore.java @@ -122,6 +122,14 @@ public class ClickHouseLogStore implements LogIndex { baseParams.add(request.instanceId()); } + if (request.instanceIds() != null && !request.instanceIds().isEmpty()) { + String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?")); + baseConditions.add("instance_id IN (" + placeholders + ")"); + for (String id : request.instanceIds()) { + baseParams.add(id); + } + } + if (request.exchangeId() != null && !request.exchangeId().isEmpty()) { baseConditions.add("(exchange_id = ?" + " OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" + @@ -281,6 +289,14 @@ public class ClickHouseLogStore implements LogIndex { params.add(request.instanceId()); } + if (request.instanceIds() != null && !request.instanceIds().isEmpty()) { + String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?")); + conditions.add("instance_id IN (" + placeholders + ")"); + for (String id : request.instanceIds()) { + params.add(id); + } + } + if (request.exchangeId() != null && !request.exchangeId().isEmpty()) { conditions.add("(exchange_id = ?" + " OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" + diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreCountIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreCountIT.java index a363417b..1f70bf36 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreCountIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreCountIT.java @@ -79,7 +79,8 @@ class ClickHouseLogStoreCountIT { base.plusSeconds(30), null, 100, - "desc")); + "desc", + null)); assertThat(count).isEqualTo(3); } @@ -102,7 +103,8 @@ class ClickHouseLogStoreCountIT { base.plusSeconds(30), null, 100, - "desc")); + "desc", + null)); assertThat(count).isZero(); } @@ -120,7 +122,7 @@ class ClickHouseLogStoreCountIT { null, List.of("ERROR"), "orders", null, null, null, "dev", List.of(), base.minusSeconds(1), base.plusSeconds(60), - null, 100, "desc")); + null, 100, "desc", null)); assertThat(devCount).isEqualTo(2); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreIT.java index fb0a213e..958d2959 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreIT.java @@ -53,7 +53,7 @@ class ClickHouseLogStoreIT { } private LogSearchRequest req(String application) { - return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc"); + return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc", null); } // ── Tests ───────────────────────────────────────────────────────────── @@ -99,7 +99,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc")); + null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).level()).isEqualTo("ERROR"); @@ -116,7 +116,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc")); + null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(2); } @@ -130,7 +130,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - "order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc")); + "order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).contains("order #12345"); @@ -147,7 +147,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc")); + null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).isEqualTo("msg with exchange"); @@ -170,7 +170,7 @@ class ClickHouseLogStoreIT { Instant to = Instant.parse("2026-03-31T13:00:00Z"); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc")); + null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).isEqualTo("noon"); @@ -188,7 +188,7 @@ class ClickHouseLogStoreIT { // No application filter — should return both LogSearchResponse result = store.search(new LogSearchRequest( - null, null, null, null, null, null, null, null, null, null, null, 100, "desc")); + null, null, null, null, null, null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(2); } @@ -202,7 +202,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc")); + null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).loggerName()).contains("OrderProcessor"); @@ -221,7 +221,7 @@ class ClickHouseLogStoreIT { // Page 1: limit 2 LogSearchResponse page1 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc", null)); assertThat(page1.data()).hasSize(2); assertThat(page1.hasMore()).isTrue(); @@ -230,7 +230,7 @@ class ClickHouseLogStoreIT { // Page 2: use cursor LogSearchResponse page2 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc", null)); assertThat(page2.data()).hasSize(2); assertThat(page2.hasMore()).isTrue(); @@ -238,7 +238,7 @@ class ClickHouseLogStoreIT { // Page 3: last page LogSearchResponse page3 = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc")); + null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc", null)); assertThat(page3.data()).hasSize(1); assertThat(page3.hasMore()).isFalse(); @@ -257,7 +257,7 @@ class ClickHouseLogStoreIT { // Filter for ERROR only, but counts should include all levels LogSearchResponse result = store.search(new LogSearchRequest( - null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc")); + null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.levelCounts()).containsEntry("INFO", 2L); @@ -275,7 +275,7 @@ class ClickHouseLogStoreIT { )); LogSearchResponse result = store.search(new LogSearchRequest( - null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc")); + null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc", null)); assertThat(result.data()).hasSize(3); assertThat(result.data().get(0).message()).isEqualTo("msg-1"); @@ -340,7 +340,7 @@ class ClickHouseLogStoreIT { LogSearchResponse result = store.search(new LogSearchRequest( null, null, "my-app", null, null, null, null, - List.of("container"), null, null, null, 100, "desc")); + List.of("container"), null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(1); assertThat(result.data().get(0).message()).isEqualTo("container msg"); @@ -365,7 +365,7 @@ class ClickHouseLogStoreIT { LogSearchResponse result = store.search(new LogSearchRequest( null, null, "my-app", null, null, null, null, - List.of("app", "container"), null, null, null, 100, "desc")); + List.of("app", "container"), null, null, null, 100, "desc", null)); assertThat(result.data()).hasSize(2); assertThat(result.data()).extracting(LogEntryResult::message) @@ -388,7 +388,7 @@ class ClickHouseLogStoreIT { for (int page = 0; page < 10; page++) { LogSearchResponse resp = store.search(new LogSearchRequest( null, null, "my-app", null, null, null, null, null, - null, null, cursor, 2, "desc")); + null, null, cursor, 2, "desc", null)); for (LogEntryResult r : resp.data()) { assertThat(seen.add(r.message())).as("duplicate row returned: " + r.message()).isTrue(); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreInstanceIdsIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreInstanceIdsIT.java new file mode 100644 index 00000000..93cecfcf --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/ClickHouseLogStoreInstanceIdsIT.java @@ -0,0 +1,196 @@ +package com.cameleer.server.app.search; + +import com.cameleer.server.core.ingestion.BufferedLogEntry; +import com.cameleer.server.core.search.LogSearchRequest; +import com.cameleer.server.core.search.LogSearchResponse; +import com.cameleer.common.model.LogEntry; +import com.cameleer.server.app.ClickHouseTestHelper; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for the {@code instanceIds} multi-value filter on + * {@link ClickHouseLogStore#search(LogSearchRequest)}. + * + *

Three rows are seeded with distinct {@code instance_id} values: + *

+ */ +@Testcontainers +class ClickHouseLogStoreInstanceIdsIT { + + @Container + static final ClickHouseContainer clickhouse = + new ClickHouseContainer("clickhouse/clickhouse-server:24.12"); + + private JdbcTemplate jdbc; + private ClickHouseLogStore store; + + private static final String TENANT = "default"; + private static final String ENV = "prod"; + private static final String APP = "app1"; + private static final String INST_A = "prod-app1-0-aaa11111"; + private static final String INST_B = "prod-app1-1-aaa11111"; + private static final String INST_C = "prod-app1-0-bbb22222"; + + @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(TENANT, jdbc); + + Instant base = Instant.parse("2026-04-23T09:00:00Z"); + seedLog(INST_A, base, "msg-from-replica-0-gen-aaa"); + seedLog(INST_B, base.plusSeconds(1), "msg-from-replica-1-gen-aaa"); + seedLog(INST_C, base.plusSeconds(2), "msg-from-replica-0-gen-bbb"); + } + + @AfterEach + void tearDown() { + jdbc.execute("TRUNCATE TABLE logs"); + } + + private void seedLog(String instanceId, Instant ts, String message) { + LogEntry entry = new LogEntry(ts, "INFO", "com.example.Svc", message, "main", null, null); + store.insertBufferedBatch(List.of( + new BufferedLogEntry(TENANT, ENV, instanceId, APP, entry))); + } + + // ── Tests ───────────────────────────────────────────────────────────── + + @Test + void search_instanceIds_returnsOnlyMatchingInstances() { + LogSearchResponse result = store.search(new LogSearchRequest( + null, + List.of(), + APP, + null, + null, + null, + ENV, + List.of(), + null, + null, + null, + 100, + "desc", + List.of(INST_A, INST_B))); + + assertThat(result.data()).hasSize(2); + assertThat(result.data()) + .extracting(r -> r.instanceId()) + .containsExactlyInAnyOrder(INST_A, INST_B); + assertThat(result.data()) + .extracting(r -> r.instanceId()) + .doesNotContain(INST_C); + } + + @Test + void search_emptyInstanceIds_returnsAllRows() { + LogSearchResponse result = store.search(new LogSearchRequest( + null, + List.of(), + APP, + null, + null, + null, + ENV, + List.of(), + null, + null, + null, + 100, + "desc", + List.of())); + + assertThat(result.data()).hasSize(3); + } + + @Test + void search_nullInstanceIds_returnsAllRows() { + LogSearchResponse result = store.search(new LogSearchRequest( + null, + List.of(), + APP, + null, + null, + null, + ENV, + List.of(), + null, + null, + null, + 100, + "desc", + null)); + + assertThat(result.data()).hasSize(3); + } + + @Test + void search_instanceIds_singleValue_filtersToOneReplica() { + LogSearchResponse result = store.search(new LogSearchRequest( + null, + List.of(), + APP, + null, + null, + null, + ENV, + List.of(), + null, + null, + null, + 100, + "desc", + List.of(INST_C))); + + assertThat(result.data()).hasSize(1); + assertThat(result.data().get(0).instanceId()).isEqualTo(INST_C); + assertThat(result.data().get(0).message()).isEqualTo("msg-from-replica-0-gen-bbb"); + } + + @Test + void search_instanceIds_doesNotConflictWithSingularInstanceId() { + // Singular instanceId=INST_A AND instanceIds=[INST_B] → intersection = empty + // (both conditions apply: instance_id = A AND instance_id IN (B)) + LogSearchResponse result = store.search(new LogSearchRequest( + null, + List.of(), + APP, + INST_A, // singular + null, + null, + ENV, + List.of(), + null, + null, + null, + 100, + "desc", + List.of(INST_B))); // plural — no overlap + + assertThat(result.data()).isEmpty(); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java index e6a45315..de5afac1 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java @@ -9,7 +9,7 @@ import java.util.List; * @param q free-text search across message and stack trace * @param levels log level filter (e.g. ["WARN","ERROR"]), OR-joined * @param application application ID filter (nullable = all apps) - * @param instanceId agent instance ID filter + * @param instanceId agent instance ID filter (single value; coexists with instanceIds) * @param exchangeId Camel exchange ID filter * @param logger logger name substring filter * @param environment optional environment filter (e.g. "dev", "staging", "prod") @@ -19,6 +19,9 @@ import java.util.List; * @param cursor ISO timestamp cursor for keyset pagination * @param limit page size (1-500, default 100) * @param sort sort direction: "asc" or "desc" (default "desc") + * @param instanceIds multi-value instance ID filter (IN clause); scopes logs to one deployment's + * replicas when provided. Both instanceId and instanceIds may coexist — both + * conditions apply (AND). Empty/null means no additional filtering. */ public record LogSearchRequest( String q, @@ -33,7 +36,8 @@ public record LogSearchRequest( Instant to, String cursor, int limit, - String sort + String sort, + List instanceIds ) { private static final int DEFAULT_LIMIT = 100; @@ -45,5 +49,6 @@ public record LogSearchRequest( if (sort == null || !"asc".equalsIgnoreCase(sort)) sort = "desc"; if (levels == null) levels = List.of(); if (sources == null) sources = List.of(); + if (instanceIds == null) instanceIds = List.of(); } }