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:
hsiegeln
2026-04-04 21:01:43 +02:00
parent 024780c01e
commit 08b87edd6e
5 changed files with 289 additions and 0 deletions

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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());
}
}