feat: add agent status and observability status endpoints
Implements AgentStatusService (TDD) that proxies cameleer3-server agent registry API and queries ClickHouse for trace counts. Gracefully degrades to UNKNOWN state when server is unreachable or DataSource is absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package net.siegeln.cameleer.saas.observability;
|
||||
|
||||
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apps/{appId}")
|
||||
public class AgentStatusController {
|
||||
|
||||
private final AgentStatusService agentStatusService;
|
||||
|
||||
public AgentStatusController(AgentStatusService agentStatusService) {
|
||||
this.agentStatusService = agentStatusService;
|
||||
}
|
||||
|
||||
@GetMapping("/agent-status")
|
||||
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
||||
try {
|
||||
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/observability-status")
|
||||
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||
try {
|
||||
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package net.siegeln.cameleer.saas.observability;
|
||||
|
||||
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AgentStatusService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentStatusService.class);
|
||||
|
||||
private final AppRepository appRepository;
|
||||
private final EnvironmentRepository environmentRepository;
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
private final RestClient restClient;
|
||||
|
||||
@Autowired(required = false)
|
||||
@Qualifier("clickHouseDataSource")
|
||||
private DataSource clickHouseDataSource;
|
||||
|
||||
public AgentStatusService(AppRepository appRepository,
|
||||
EnvironmentRepository environmentRepository,
|
||||
RuntimeConfig runtimeConfig) {
|
||||
this.appRepository = appRepository;
|
||||
this.environmentRepository = environmentRepository;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
public AgentStatusResponse getAgentStatus(UUID appId) {
|
||||
var app = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> agents = restClient.get()
|
||||
.uri("/api/v1/agents")
|
||||
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
|
||||
if (agents == null) {
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
}
|
||||
|
||||
for (Map<String, Object> agent : agents) {
|
||||
String agentAppId = (String) agent.get("applicationId");
|
||||
String agentEnvId = (String) agent.get("environmentId");
|
||||
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
|
||||
String state = (String) agent.getOrDefault("state", "UNKNOWN");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> routeIds = (List<String>) agent.getOrDefault("routeIds", Collections.emptyList());
|
||||
return new AgentStatusResponse(
|
||||
true,
|
||||
state,
|
||||
null,
|
||||
routeIds,
|
||||
app.getSlug(),
|
||||
env.getSlug()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch agent status from cameleer3-server: {}", e.getMessage());
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
}
|
||||
}
|
||||
|
||||
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
|
||||
var app = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||
|
||||
if (clickHouseDataSource == null) {
|
||||
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||
}
|
||||
|
||||
try (var conn = clickHouseDataSource.getConnection()) {
|
||||
String sql = "SELECT count() as cnt, max(start_time) as last_trace " +
|
||||
"FROM executions " +
|
||||
"WHERE application_id = ? AND environment_id = ? " +
|
||||
"AND start_time >= now() - INTERVAL 24 HOUR";
|
||||
try (var stmt = conn.prepareStatement(sql)) {
|
||||
stmt.setString(1, app.getSlug());
|
||||
stmt.setString(2, env.getSlug());
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long count = rs.getLong("cnt");
|
||||
Timestamp lastTrace = rs.getTimestamp("last_trace");
|
||||
boolean hasTraces = count > 0;
|
||||
return new ObservabilityStatusResponse(
|
||||
hasTraces,
|
||||
false,
|
||||
false,
|
||||
hasTraces && lastTrace != null ? lastTrace.toInstant() : null,
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to query ClickHouse for observability status: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||
}
|
||||
|
||||
private AgentStatusResponse unknownStatus(String applicationId, String environmentId) {
|
||||
return new AgentStatusResponse(false, "UNKNOWN", null, Collections.emptyList(), applicationId, environmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.observability.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record AgentStatusResponse(
|
||||
boolean registered,
|
||||
String state,
|
||||
Instant lastHeartbeat,
|
||||
List<String> routeIds,
|
||||
String applicationId,
|
||||
String environmentId
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.siegeln.cameleer.saas.observability.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ObservabilityStatusResponse(
|
||||
boolean hasTraces,
|
||||
boolean hasMetrics,
|
||||
boolean hasDiagrams,
|
||||
Instant lastTraceAt,
|
||||
long traceCount24h
|
||||
) {}
|
||||
@@ -0,0 +1,92 @@
|
||||
package net.siegeln.cameleer.saas.observability;
|
||||
|
||||
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AgentStatusServiceTest {
|
||||
|
||||
@Mock private AppRepository appRepository;
|
||||
@Mock private EnvironmentRepository environmentRepository;
|
||||
@Mock private RuntimeConfig runtimeConfig;
|
||||
|
||||
private AgentStatusService agentStatusService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://localhost:9999");
|
||||
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgentStatus_appNotFound_shouldThrow() {
|
||||
when(appRepository.findById(any())).thenReturn(Optional.empty());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> agentStatusService.getAgentStatus(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgentStatus_shouldReturnUnknownWhenServerUnreachable() {
|
||||
var appId = UUID.randomUUID();
|
||||
var envId = UUID.randomUUID();
|
||||
|
||||
var app = new AppEntity();
|
||||
app.setId(appId);
|
||||
app.setEnvironmentId(envId);
|
||||
app.setSlug("my-app");
|
||||
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||
|
||||
var env = new EnvironmentEntity();
|
||||
env.setId(envId);
|
||||
env.setSlug("default");
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
|
||||
// Server at localhost:9999 won't be running — should return UNKNOWN gracefully
|
||||
var result = agentStatusService.getAgentStatus(appId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(result.registered());
|
||||
assertEquals("UNKNOWN", result.state());
|
||||
assertEquals("my-app", result.applicationId());
|
||||
assertEquals("default", result.environmentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getObservabilityStatus_shouldReturnEmptyWhenClickHouseUnavailable() {
|
||||
var appId = UUID.randomUUID();
|
||||
var envId = UUID.randomUUID();
|
||||
|
||||
var app = new AppEntity();
|
||||
app.setId(appId);
|
||||
app.setEnvironmentId(envId);
|
||||
app.setSlug("my-app");
|
||||
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||
|
||||
var env = new EnvironmentEntity();
|
||||
env.setId(envId);
|
||||
env.setSlug("default");
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
|
||||
// No ClickHouse DataSource injected — should return empty status
|
||||
var result = agentStatusService.getObservabilityStatus(appId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(result.hasTraces());
|
||||
assertEquals(0, result.traceCount24h());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user