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