From 08b87edd6eacf2527e9c8444c7350b14d07af1ce Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:01:43 +0200 Subject: [PATCH] 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 --- .../observability/AgentStatusController.java | 37 +++++ .../observability/AgentStatusService.java | 136 ++++++++++++++++++ .../dto/AgentStatusResponse.java | 13 ++ .../dto/ObservabilityStatusResponse.java | 11 ++ .../observability/AgentStatusServiceTest.java | 92 ++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java new file mode 100644 index 0000000..cb5e732 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java @@ -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 getAgentStatus(@PathVariable UUID appId) { + try { + return ResponseEntity.ok(agentStatusService.getAgentStatus(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/observability-status") + public ResponseEntity getObservabilityStatus(@PathVariable UUID appId) { + try { + return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java new file mode 100644 index 0000000..b738057 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java @@ -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> 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 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 routeIds = (List) 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); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java b/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java new file mode 100644 index 0000000..2749aff --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java @@ -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 routeIds, + String applicationId, + String environmentId +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java b/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java new file mode 100644 index 0000000..cfbb574 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java @@ -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 +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java new file mode 100644 index 0000000..18fab7e --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java @@ -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()); + } +}