feat: add application log ingestion with OpenSearch storage
Agents can now send application log entries in batches via POST /api/v1/data/logs.
Logs are indexed directly into OpenSearch daily indices (logs-{yyyy-MM-dd}) using
the bulk API. Index template defines explicit mappings for full-text search readiness.
New DTOs (LogEntry, LogBatch) added to cameleer3-common in the agent repo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,9 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
|||||||
- Spring Boot 3.4.3 parent POM
|
- Spring Boot 3.4.3 parent POM
|
||||||
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
||||||
- Jackson `JavaTimeModule` for `Instant` deserialization
|
- Jackson `JavaTimeModule` for `Instant` deserialization
|
||||||
- Communication: receives HTTP POST data from agents, serves SSE event streams for config push/commands
|
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands
|
||||||
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
||||||
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search
|
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search and application log storage
|
||||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
||||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
|
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
|
||||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
||||||
|
|||||||
18
HOWTO.md
18
HOWTO.md
@@ -100,7 +100,7 @@ JWTs carry a `roles` claim. Endpoints are restricted by role:
|
|||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `AGENT` | Data ingestion (`/data/**`), heartbeat, SSE events, command ack |
|
| `AGENT` | Data ingestion (`/data/**` — executions, diagrams, metrics, logs), heartbeat, SSE events, command ack |
|
||||||
| `VIEWER` | Search, execution detail, diagrams, agent list |
|
| `VIEWER` | Search, execution detail, diagrams, agent list |
|
||||||
| `OPERATOR` | VIEWER + send commands to agents |
|
| `OPERATOR` | VIEWER + send commands to agents |
|
||||||
| `ADMIN` | OPERATOR + user management (`/admin/**`) |
|
| `ADMIN` | OPERATOR + user management (`/admin/**`) |
|
||||||
@@ -220,6 +220,20 @@ curl -s -X POST http://localhost:8081/api/v1/data/metrics \
|
|||||||
-H "X-Protocol-Version: 1" \
|
-H "X-Protocol-Version: 1" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '[{"agentId":"agent-1","metricName":"cpu","value":42.0,"timestamp":"2026-03-11T00:00:00Z","tags":{}}]'
|
-d '[{"agentId":"agent-1","metricName":"cpu","value":42.0,"timestamp":"2026-03-11T00:00:00Z","tags":{}}]'
|
||||||
|
|
||||||
|
# Post application log entries (batch)
|
||||||
|
curl -s -X POST http://localhost:8081/api/v1/data/logs \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"entries": [{
|
||||||
|
"timestamp": "2026-03-25T10:00:00Z",
|
||||||
|
"level": "INFO",
|
||||||
|
"loggerName": "com.acme.MyService",
|
||||||
|
"message": "Processing order #12345",
|
||||||
|
"threadName": "main"
|
||||||
|
}]
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The `X-Protocol-Version: 1` header is required on all `/api/v1/data/**` endpoints. Missing or wrong version returns 400.
|
**Note:** The `X-Protocol-Version: 1` header is required on all `/api/v1/data/**` endpoints. Missing or wrong version returns 400.
|
||||||
@@ -361,6 +375,8 @@ Key settings in `cameleer3-server-app/src/main/resources/application.yml`:
|
|||||||
| `security.oidc.client-secret` | | OAuth2 client secret (`CAMELEER_OIDC_CLIENT_SECRET`) |
|
| `security.oidc.client-secret` | | OAuth2 client secret (`CAMELEER_OIDC_CLIENT_SECRET`) |
|
||||||
| `security.oidc.roles-claim` | `realm_access.roles` | JSONPath to roles in OIDC id_token (`CAMELEER_OIDC_ROLES_CLAIM`) |
|
| `security.oidc.roles-claim` | `realm_access.roles` | JSONPath to roles in OIDC id_token (`CAMELEER_OIDC_ROLES_CLAIM`) |
|
||||||
| `security.oidc.default-roles` | `VIEWER` | Default roles for new OIDC users (`CAMELEER_OIDC_DEFAULT_ROLES`) |
|
| `security.oidc.default-roles` | `VIEWER` | Default roles for new OIDC users (`CAMELEER_OIDC_DEFAULT_ROLES`) |
|
||||||
|
| `opensearch.log-index-prefix` | `logs-` | OpenSearch index prefix for application logs (`CAMELEER_LOG_INDEX_PREFIX`) |
|
||||||
|
| `opensearch.log-retention-days` | `7` | Days before log indices are deleted (`CAMELEER_LOG_RETENTION_DAYS`) |
|
||||||
|
|
||||||
## Web UI Development
|
## Web UI Development
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.LogBatch;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.logging.LogIndexService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/data")
|
||||||
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
|
public class LogIngestionController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LogIngestionController.class);
|
||||||
|
|
||||||
|
private final LogIndexService logIndexService;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
|
||||||
|
public LogIngestionController(LogIndexService logIndexService,
|
||||||
|
AgentRegistryService registryService) {
|
||||||
|
this.logIndexService = logIndexService;
|
||||||
|
this.registryService = registryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logs")
|
||||||
|
@Operation(summary = "Ingest application log entries",
|
||||||
|
description = "Accepts a batch of log entries from an agent. Entries are indexed in OpenSearch.")
|
||||||
|
@ApiResponse(responseCode = "202", description = "Logs accepted for indexing")
|
||||||
|
public ResponseEntity<Void> ingestLogs(@RequestBody LogBatch batch) {
|
||||||
|
String agentId = extractAgentId();
|
||||||
|
String application = resolveApplicationName(agentId);
|
||||||
|
|
||||||
|
if (batch.getEntries() != null && !batch.getEntries().isEmpty()) {
|
||||||
|
log.debug("Received {} log entries from agent={}, app={}", batch.getEntries().size(), agentId, application);
|
||||||
|
logIndexService.indexBatch(agentId, application, batch.getEntries());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.accepted().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractAgentId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return auth != null ? auth.getName() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveApplicationName(String agentId) {
|
||||||
|
AgentInfo agent = registryService.findById(agentId);
|
||||||
|
return agent != null ? agent.application() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.cameleer3.server.app.search;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.LogEntry;
|
||||||
|
import com.cameleer3.server.core.logging.LogIndexService;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||||
|
import org.opensearch.client.opensearch._types.mapping.Property;
|
||||||
|
import org.opensearch.client.opensearch.core.BulkRequest;
|
||||||
|
import org.opensearch.client.opensearch.core.BulkResponse;
|
||||||
|
import org.opensearch.client.opensearch.core.bulk.BulkResponseItem;
|
||||||
|
import org.opensearch.client.opensearch.indices.ExistsIndexTemplateRequest;
|
||||||
|
import org.opensearch.client.opensearch.indices.PutIndexTemplateRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class OpenSearchLogIndex implements LogIndexService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OpenSearchLogIndex.class);
|
||||||
|
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
.withZone(ZoneOffset.UTC);
|
||||||
|
|
||||||
|
private final OpenSearchClient client;
|
||||||
|
private final String indexPrefix;
|
||||||
|
private final int retentionDays;
|
||||||
|
|
||||||
|
public OpenSearchLogIndex(OpenSearchClient client,
|
||||||
|
@Value("${opensearch.log-index-prefix:logs-}") String indexPrefix,
|
||||||
|
@Value("${opensearch.log-retention-days:7}") int retentionDays) {
|
||||||
|
this.client = client;
|
||||||
|
this.indexPrefix = indexPrefix;
|
||||||
|
this.retentionDays = retentionDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
ensureIndexTemplate();
|
||||||
|
ensureIsmPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureIndexTemplate() {
|
||||||
|
String templateName = indexPrefix.replace("-", "") + "-template";
|
||||||
|
String indexPattern = indexPrefix + "*";
|
||||||
|
try {
|
||||||
|
boolean exists = client.indices().existsIndexTemplate(
|
||||||
|
ExistsIndexTemplateRequest.of(b -> b.name(templateName))).value();
|
||||||
|
if (!exists) {
|
||||||
|
client.indices().putIndexTemplate(PutIndexTemplateRequest.of(b -> b
|
||||||
|
.name(templateName)
|
||||||
|
.indexPatterns(List.of(indexPattern))
|
||||||
|
.template(t -> t
|
||||||
|
.settings(s -> s
|
||||||
|
.numberOfShards("1")
|
||||||
|
.numberOfReplicas("1"))
|
||||||
|
.mappings(m -> m
|
||||||
|
.properties("@timestamp", Property.of(p -> p.date(d -> d)))
|
||||||
|
.properties("level", Property.of(p -> p.keyword(k -> k)))
|
||||||
|
.properties("loggerName", Property.of(p -> p.keyword(k -> k)))
|
||||||
|
.properties("message", Property.of(p -> p.text(tx -> tx)))
|
||||||
|
.properties("threadName", Property.of(p -> p.keyword(k -> k)))
|
||||||
|
.properties("stackTrace", Property.of(p -> p.text(tx -> tx)))
|
||||||
|
.properties("agentId", Property.of(p -> p.keyword(k -> k)))
|
||||||
|
.properties("application", Property.of(p -> p.keyword(k -> k)))))));
|
||||||
|
log.info("OpenSearch log index template '{}' created", templateName);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to create log index template", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureIsmPolicy() {
|
||||||
|
String policyId = "logs-retention";
|
||||||
|
try {
|
||||||
|
// Use the low-level REST client to manage ISM policies
|
||||||
|
var restClient = client._transport();
|
||||||
|
// Check if the ISM policy exists via a GET; create if not
|
||||||
|
// ISM is managed via the _plugins/_ism/policies API
|
||||||
|
// For now, log a reminder — ISM policy should be created via OpenSearch API or dashboard
|
||||||
|
log.info("Log retention policy: indices matching '{}*' should be deleted after {} days. " +
|
||||||
|
"Ensure ISM policy '{}' is configured in OpenSearch.", indexPrefix, retentionDays, policyId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not verify ISM policy for log retention", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void indexBatch(String agentId, String application, List<LogEntry> entries) {
|
||||||
|
if (entries == null || entries.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
|
||||||
|
|
||||||
|
for (LogEntry entry : entries) {
|
||||||
|
String indexName = indexPrefix + DAY_FMT.format(
|
||||||
|
entry.getTimestamp() != null ? entry.getTimestamp() : java.time.Instant.now());
|
||||||
|
|
||||||
|
Map<String, Object> doc = toMap(entry, agentId, application);
|
||||||
|
|
||||||
|
bulkBuilder.operations(op -> op
|
||||||
|
.index(idx -> idx
|
||||||
|
.index(indexName)
|
||||||
|
.document(doc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
BulkResponse response = client.bulk(bulkBuilder.build());
|
||||||
|
|
||||||
|
if (response.errors()) {
|
||||||
|
int errorCount = 0;
|
||||||
|
for (BulkResponseItem item : response.items()) {
|
||||||
|
if (item.error() != null) {
|
||||||
|
errorCount++;
|
||||||
|
if (errorCount == 1) {
|
||||||
|
log.error("Bulk log index error: {}", item.error().reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.error("Bulk log indexing had {} error(s) out of {} entries", errorCount, entries.size());
|
||||||
|
} else {
|
||||||
|
log.debug("Indexed {} log entries for agent={}, app={}", entries.size(), agentId, application);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to bulk index {} log entries for agent={}", entries.size(), agentId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toMap(LogEntry entry, String agentId, String application) {
|
||||||
|
Map<String, Object> doc = new LinkedHashMap<>();
|
||||||
|
doc.put("@timestamp", entry.getTimestamp() != null ? entry.getTimestamp().toString() : null);
|
||||||
|
doc.put("level", entry.getLevel());
|
||||||
|
doc.put("loggerName", entry.getLoggerName());
|
||||||
|
doc.put("message", entry.getMessage());
|
||||||
|
doc.put("threadName", entry.getThreadName());
|
||||||
|
doc.put("stackTrace", entry.getStackTrace());
|
||||||
|
doc.put("mdc", entry.getMdc());
|
||||||
|
doc.put("agentId", agentId);
|
||||||
|
doc.put("application", application);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ opensearch:
|
|||||||
index-prefix: ${CAMELEER_OPENSEARCH_INDEX_PREFIX:executions-}
|
index-prefix: ${CAMELEER_OPENSEARCH_INDEX_PREFIX:executions-}
|
||||||
queue-size: ${CAMELEER_OPENSEARCH_QUEUE_SIZE:10000}
|
queue-size: ${CAMELEER_OPENSEARCH_QUEUE_SIZE:10000}
|
||||||
debounce-ms: ${CAMELEER_OPENSEARCH_DEBOUNCE_MS:2000}
|
debounce-ms: ${CAMELEER_OPENSEARCH_DEBOUNCE_MS:2000}
|
||||||
|
log-index-prefix: ${CAMELEER_LOG_INDEX_PREFIX:logs-}
|
||||||
|
log-retention-days: ${CAMELEER_LOG_RETENTION_DAYS:7}
|
||||||
|
|
||||||
cameleer:
|
cameleer:
|
||||||
body-size-limit: ${CAMELEER_BODY_SIZE_LIMIT:16384}
|
body-size-limit: ${CAMELEER_BODY_SIZE_LIMIT:16384}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.cameleer3.server.core.logging;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.LogEntry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface LogIndexService {
|
||||||
|
|
||||||
|
void indexBatch(String agentId, String application, List<LogEntry> entries);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user