feat(clickhouse): add ClickHouseLogStore with LogIndex interface
Extract LogIndex interface from OpenSearchLogIndex. Both ClickHouseLogStore and OpenSearchLogIndex implement it. Controllers now inject LogIndex. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.model.LogBatch;
|
||||
import com.cameleer3.server.app.search.OpenSearchLogIndex;
|
||||
import com.cameleer3.server.core.storage.LogIndex;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -24,10 +24,10 @@ public class LogIngestionController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LogIngestionController.class);
|
||||
|
||||
private final OpenSearchLogIndex logIndex;
|
||||
private final LogIndex logIndex;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public LogIngestionController(OpenSearchLogIndex logIndex,
|
||||
public LogIngestionController(LogIndex logIndex,
|
||||
AgentRegistryService registryService) {
|
||||
this.logIndex = logIndex;
|
||||
this.registryService = registryService;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||
import com.cameleer3.server.app.search.OpenSearchLogIndex;
|
||||
import com.cameleer3.server.core.storage.LogEntryResult;
|
||||
import com.cameleer3.server.core.storage.LogIndex;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -18,9 +19,9 @@ import java.util.List;
|
||||
@Tag(name = "Application Logs", description = "Query application logs stored in OpenSearch")
|
||||
public class LogQueryController {
|
||||
|
||||
private final OpenSearchLogIndex logIndex;
|
||||
private final LogIndex logIndex;
|
||||
|
||||
public LogQueryController(OpenSearchLogIndex logIndex) {
|
||||
public LogQueryController(LogIndex logIndex) {
|
||||
this.logIndex = logIndex;
|
||||
}
|
||||
|
||||
@@ -42,9 +43,14 @@ public class LogQueryController {
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
List<LogEntryResponse> entries = logIndex.search(
|
||||
List<LogEntryResult> results = logIndex.search(
|
||||
application, agentId, level, query, exchangeId, fromInstant, toInstant, limit);
|
||||
|
||||
List<LogEntryResponse> entries = results.stream()
|
||||
.map(r -> new LogEntryResponse(r.timestamp(), r.level(), r.loggerName(),
|
||||
r.message(), r.threadName(), r.stackTrace()))
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.cameleer3.server.app.search;
|
||||
|
||||
import com.cameleer3.common.model.LogEntry;
|
||||
import com.cameleer3.server.core.storage.LogEntryResult;
|
||||
import com.cameleer3.server.core.storage.LogIndex;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ClickHouse-backed implementation of {@link LogIndex}.
|
||||
* Stores application logs in the {@code logs} MergeTree table with
|
||||
* ngram bloom-filter indexes for efficient substring search.
|
||||
*/
|
||||
public class ClickHouseLogStore implements LogIndex {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClickHouseLogStore.class);
|
||||
private static final String TENANT = "default";
|
||||
private static final DateTimeFormatter ISO_FMT = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public ClickHouseLogStore(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void indexBatch(String agentId, String application, List<LogEntry> entries) {
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String sql = "INSERT INTO logs (tenant_id, timestamp, application, agent_id, level, " +
|
||||
"logger_name, message, thread_name, stack_trace, exchange_id, mdc) " +
|
||||
"VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
jdbc.batchUpdate(sql, entries, entries.size(), (ps, entry) -> {
|
||||
Instant ts = entry.getTimestamp() != null ? entry.getTimestamp() : Instant.now();
|
||||
ps.setTimestamp(1, Timestamp.from(ts));
|
||||
ps.setString(2, application);
|
||||
ps.setString(3, agentId);
|
||||
ps.setString(4, entry.getLevel() != null ? entry.getLevel() : "");
|
||||
ps.setString(5, entry.getLoggerName() != null ? entry.getLoggerName() : "");
|
||||
ps.setString(6, entry.getMessage() != null ? entry.getMessage() : "");
|
||||
ps.setString(7, entry.getThreadName() != null ? entry.getThreadName() : "");
|
||||
ps.setString(8, entry.getStackTrace() != null ? entry.getStackTrace() : "");
|
||||
|
||||
// Extract camel.exchangeId from MDC into top-level column
|
||||
Map<String, String> mdc = entry.getMdc() != null ? entry.getMdc() : Collections.emptyMap();
|
||||
String exchangeId = mdc.getOrDefault("camel.exchangeId", "");
|
||||
ps.setString(9, exchangeId);
|
||||
|
||||
// ClickHouse JDBC handles java.util.Map natively for Map columns
|
||||
ps.setObject(10, mdc);
|
||||
});
|
||||
|
||||
log.debug("Indexed {} log entries for agent={}, app={}", entries.size(), agentId, application);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LogEntryResult> search(String application, String agentId, String level,
|
||||
String query, String exchangeId,
|
||||
Instant from, Instant to, int limit) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT timestamp, level, logger_name, message, thread_name, stack_trace " +
|
||||
"FROM logs WHERE tenant_id = 'default' AND application = ?");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(application);
|
||||
|
||||
if (agentId != null && !agentId.isEmpty()) {
|
||||
sql.append(" AND agent_id = ?");
|
||||
params.add(agentId);
|
||||
}
|
||||
|
||||
if (level != null && !level.isEmpty()) {
|
||||
sql.append(" AND level = ?");
|
||||
params.add(level.toUpperCase());
|
||||
}
|
||||
|
||||
if (exchangeId != null && !exchangeId.isEmpty()) {
|
||||
sql.append(" AND (exchange_id = ? OR (mapContains(mdc, 'camel.exchangeId') AND mdc['camel.exchangeId'] = ?))");
|
||||
params.add(exchangeId);
|
||||
params.add(exchangeId);
|
||||
}
|
||||
|
||||
if (query != null && !query.isEmpty()) {
|
||||
sql.append(" AND message LIKE ?");
|
||||
params.add("%" + query + "%");
|
||||
}
|
||||
|
||||
if (from != null) {
|
||||
sql.append(" AND timestamp >= ?");
|
||||
params.add(Timestamp.from(from));
|
||||
}
|
||||
|
||||
if (to != null) {
|
||||
sql.append(" AND timestamp <= ?");
|
||||
params.add(Timestamp.from(to));
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
||||
params.add(limit);
|
||||
|
||||
return jdbc.query(sql.toString(), params.toArray(), (rs, rowNum) -> {
|
||||
Timestamp ts = rs.getTimestamp("timestamp");
|
||||
String timestampStr = ts != null
|
||||
? ts.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
|
||||
: null;
|
||||
return new LogEntryResult(
|
||||
timestampStr,
|
||||
rs.getString("level"),
|
||||
rs.getString("logger_name"),
|
||||
rs.getString("message"),
|
||||
rs.getString("thread_name"),
|
||||
rs.getString("stack_trace")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.cameleer3.server.app.search;
|
||||
|
||||
import com.cameleer3.common.model.LogEntry;
|
||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||
import com.cameleer3.server.core.storage.LogEntryResult;
|
||||
import com.cameleer3.server.core.storage.LogIndex;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.opensearch.client.json.JsonData;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
@@ -30,7 +31,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class OpenSearchLogIndex {
|
||||
public class OpenSearchLogIndex implements LogIndex {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpenSearchLogIndex.class);
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
@@ -100,9 +101,10 @@ public class OpenSearchLogIndex {
|
||||
}
|
||||
}
|
||||
|
||||
public List<LogEntryResponse> search(String application, String agentId, String level,
|
||||
String query, String exchangeId,
|
||||
Instant from, Instant to, int limit) {
|
||||
@Override
|
||||
public List<LogEntryResult> search(String application, String agentId, String level,
|
||||
String query, String exchangeId,
|
||||
Instant from, Instant to, int limit) {
|
||||
try {
|
||||
BoolQuery.Builder bool = new BoolQuery.Builder();
|
||||
bool.must(Query.of(q -> q.term(t -> t.field("application").value(FieldValue.of(application)))));
|
||||
@@ -137,12 +139,12 @@ public class OpenSearchLogIndex {
|
||||
.sort(so -> so.field(f -> f.field("@timestamp").order(SortOrder.Desc)))
|
||||
.size(limit), Map.class);
|
||||
|
||||
List<LogEntryResponse> results = new ArrayList<>();
|
||||
List<LogEntryResult> results = new ArrayList<>();
|
||||
for (var hit : response.hits().hits()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> src = (Map<String, Object>) hit.source();
|
||||
if (src == null) continue;
|
||||
results.add(new LogEntryResponse(
|
||||
results.add(new LogEntryResult(
|
||||
str(src, "@timestamp"),
|
||||
str(src, "level"),
|
||||
str(src, "loggerName"),
|
||||
@@ -162,6 +164,7 @@ public class OpenSearchLogIndex {
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void indexBatch(String agentId, String application, List<LogEntry> entries) {
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user