diff --git a/CLAUDE.md b/CLAUDE.md
index 57c2c73c..c39469dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -110,6 +110,9 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
- `PrometheusLabelBuilder` — generates Prometheus Docker labels (`prometheus.scrape/path/port`) per runtime type for `docker_sd_configs` auto-discovery
- `DisabledRuntimeOrchestrator` — no-op when runtime not enabled
+**metrics/** — Prometheus observability
+- `ServerMetrics` — centralized business metrics: gauges (agents by state, SSE connections, buffer depths), counters (ingestion drops, agent transitions, deployment outcomes, auth failures), timers (flush duration, deployment duration). Exposed via `/api/v1/prometheus`.
+
**storage/** — PostgreSQL repositories (JdbcTemplate)
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
@@ -300,7 +303,7 @@ In SaaS mode, each tenant's server and its deployed apps are isolated at the Doc
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **cameleer3-server** (5968 symbols, 15141 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **cameleer3-server** (5987 symbols, 15177 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml
index 74817c04..70594769 100644
--- a/cameleer3-server-app/pom.xml
+++ b/cameleer3-server-app/pom.xml
@@ -31,6 +31,10 @@
org.springframework.boot
spring-boot-starter-actuator
+
+ io.micrometer
+ micrometer-registry-prometheus
+
org.springframework.boot
spring-boot-starter-jdbc
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
index c3f726e7..d2cec6c6 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
@@ -1,5 +1,6 @@
package com.cameleer3.server.app.agent;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
@@ -26,11 +27,14 @@ public class AgentLifecycleMonitor {
private final AgentRegistryService registryService;
private final AgentEventService agentEventService;
+ private final ServerMetrics serverMetrics;
public AgentLifecycleMonitor(AgentRegistryService registryService,
- AgentEventService agentEventService) {
+ AgentEventService agentEventService,
+ ServerMetrics serverMetrics) {
this.registryService = registryService;
this.agentEventService = agentEventService;
+ this.serverMetrics = serverMetrics;
}
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
@@ -53,6 +57,7 @@ public class AgentLifecycleMonitor {
if (eventType != null) {
agentEventService.recordEvent(agent.instanceId(), agent.applicationId(), eventType,
agent.displayName() + " " + before + " -> " + agent.state());
+ serverMetrics.recordAgentTransition(eventType);
}
}
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
index fd8d09eb..65506d26 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
@@ -131,6 +131,10 @@ public class SseConnectionManager implements AgentEventListener {
/**
* Check if an agent has an active SSE connection.
*/
+ public int getConnectionCount() {
+ return emitters.size();
+ }
+
public boolean isConnected(String agentId) {
return emitters.containsKey(agentId);
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java
index 3f9233af..e203db2b 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java
@@ -1,5 +1,6 @@
package com.cameleer3.server.app.config;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.app.search.ClickHouseLogStore;
import com.cameleer3.server.app.storage.ClickHouseAgentEventRepository;
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
@@ -113,9 +114,10 @@ public class StorageBeanConfig {
ClickHouseExecutionStore executionStore,
ClickHouseLogStore logStore,
ChunkAccumulator accumulator,
- IngestionConfig config) {
+ IngestionConfig config,
+ ServerMetrics serverMetrics) {
return new ExecutionFlushScheduler(executionBuffer, processorBatchBuffer,
- logBuffer, executionStore, logStore, accumulator, config);
+ logBuffer, executionStore, logStore, accumulator, config, serverMetrics);
}
@Bean
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogIngestionController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogIngestionController.java
index fa2acf68..21f580cd 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogIngestionController.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogIngestionController.java
@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.common.model.LogEntry;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.core.ingestion.BufferedLogEntry;
import java.util.List;
import com.cameleer3.server.core.ingestion.WriteBuffer;
@@ -32,13 +33,16 @@ public class LogIngestionController {
private final WriteBuffer logBuffer;
private final AgentRegistryService registryService;
private final TenantProperties tenantProperties;
+ private final ServerMetrics serverMetrics;
public LogIngestionController(WriteBuffer logBuffer,
AgentRegistryService registryService,
- TenantProperties tenantProperties) {
+ TenantProperties tenantProperties,
+ ServerMetrics serverMetrics) {
this.logBuffer = logBuffer;
this.registryService = registryService;
this.tenantProperties = tenantProperties;
+ this.serverMetrics = serverMetrics;
}
@PostMapping("/logs")
@@ -49,6 +53,7 @@ public class LogIngestionController {
String instanceId = extractAgentId();
if (instanceId == null || instanceId.isBlank()) {
log.warn("Log ingestion rejected: no agent identity in request (unauthenticated or missing principal)");
+ serverMetrics.recordIngestionDrop("no_identity");
return ResponseEntity.accepted().build();
}
@@ -61,6 +66,7 @@ public class LogIngestionController {
if (agent == null) {
log.warn("Log ingestion from instance={}: agent not found in registry (not registered or expired). {} entries dropped.",
instanceId, entries.size());
+ serverMetrics.recordIngestionDrops("no_agent", entries.size());
return ResponseEntity.accepted().build();
}
@@ -89,6 +95,7 @@ public class LogIngestionController {
if (dropped > 0) {
log.warn("Log buffer full: accepted={}, dropped={} from instance={}, app={}",
accepted, dropped, instanceId, applicationId);
+ serverMetrics.recordIngestionDrops("buffer_full", dropped);
} else {
log.debug("Accepted {} log entries from instance={}, app={}", accepted, instanceId, applicationId);
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ExecutionFlushScheduler.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ExecutionFlushScheduler.java
index a9813508..4d1e341d 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ExecutionFlushScheduler.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ExecutionFlushScheduler.java
@@ -12,6 +12,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.scheduling.annotation.Scheduled;
+import com.cameleer3.server.app.metrics.ServerMetrics;
+
+import java.time.Duration;
import java.util.List;
/**
@@ -33,6 +36,7 @@ public class ExecutionFlushScheduler implements SmartLifecycle {
private final ClickHouseExecutionStore executionStore;
private final ClickHouseLogStore logStore;
private final ChunkAccumulator accumulator;
+ private final ServerMetrics serverMetrics;
private final int batchSize;
private volatile boolean running = false;
@@ -42,7 +46,8 @@ public class ExecutionFlushScheduler implements SmartLifecycle {
ClickHouseExecutionStore executionStore,
ClickHouseLogStore logStore,
ChunkAccumulator accumulator,
- IngestionConfig config) {
+ IngestionConfig config,
+ ServerMetrics serverMetrics) {
this.executionBuffer = executionBuffer;
this.processorBuffer = processorBuffer;
this.logBuffer = logBuffer;
@@ -50,14 +55,18 @@ public class ExecutionFlushScheduler implements SmartLifecycle {
this.logStore = logStore;
this.accumulator = accumulator;
this.batchSize = config.getBatchSize();
+ this.serverMetrics = serverMetrics;
}
- @Scheduled(fixedDelayString = "${ingestion.flush-interval-ms:1000}")
+ @Scheduled(fixedDelayString = "${cameleer.server.ingestion.flushintervalms:1000}")
public void flush() {
try {
List executions = executionBuffer.drain(batchSize);
if (!executions.isEmpty()) {
+ long start = System.nanoTime();
executionStore.insertExecutionBatch(executions);
+ serverMetrics.recordFlushDuration("execution",
+ Duration.ofNanos(System.nanoTime() - start));
log.debug("Flushed {} executions to ClickHouse", executions.size());
}
} catch (Exception e) {
@@ -67,7 +76,10 @@ public class ExecutionFlushScheduler implements SmartLifecycle {
try {
List batches = processorBuffer.drain(batchSize);
if (!batches.isEmpty()) {
+ long start = System.nanoTime();
executionStore.insertProcessorBatches(batches);
+ serverMetrics.recordFlushDuration("processor",
+ Duration.ofNanos(System.nanoTime() - start));
log.debug("Flushed {} processor batches to ClickHouse", batches.size());
}
} catch (Exception e) {
@@ -77,7 +89,10 @@ public class ExecutionFlushScheduler implements SmartLifecycle {
try {
List logEntries = logBuffer.drain(batchSize);
if (!logEntries.isEmpty()) {
+ long start = System.nanoTime();
logStore.insertBufferedBatch(logEntries);
+ serverMetrics.recordFlushDuration("log",
+ Duration.ofNanos(System.nanoTime() - start));
log.debug("Flushed {} log entries to ClickHouse", logEntries.size());
}
} catch (Exception e) {
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/metrics/ServerMetrics.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/metrics/ServerMetrics.java
new file mode 100644
index 00000000..534a4de2
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/metrics/ServerMetrics.java
@@ -0,0 +1,190 @@
+package com.cameleer3.server.app.metrics;
+
+import com.cameleer3.server.app.agent.SseConnectionManager;
+import com.cameleer3.server.core.agent.AgentRegistryService;
+import com.cameleer3.server.core.agent.AgentState;
+import com.cameleer3.server.core.ingestion.BufferedLogEntry;
+import com.cameleer3.server.core.ingestion.ChunkAccumulator;
+import com.cameleer3.server.core.ingestion.MergedExecution;
+import com.cameleer3.server.core.ingestion.WriteBuffer;
+import com.cameleer3.server.core.storage.model.MetricsSnapshot;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class ServerMetrics {
+
+ private final MeterRegistry registry;
+
+ // Counters
+ private final Counter ingestionDropsBufferFull;
+ private final Counter ingestionDropsNoAgent;
+ private final Counter ingestionDropsNoIdentity;
+ private final Counter transitionsWentStale;
+ private final Counter transitionsWentDead;
+ private final Counter transitionsRecovered;
+ private final Counter deploymentsRunning;
+ private final Counter deploymentsFailed;
+ private final Counter deploymentsDegraded;
+ private final Counter authFailuresInvalidToken;
+ private final Counter authFailuresRevoked;
+ private final Counter authFailuresOidc;
+
+ // Timers
+ private final Timer flushExecutionTimer;
+ private final Timer flushProcessorTimer;
+ private final Timer flushLogTimer;
+ private final Timer deploymentDurationTimer;
+
+ public ServerMetrics(MeterRegistry registry,
+ AgentRegistryService registryService,
+ SseConnectionManager sseManager,
+ WriteBuffer executionBuffer,
+ WriteBuffer processorBuffer,
+ WriteBuffer logBuffer,
+ WriteBuffer metricsBuffer,
+ ChunkAccumulator accumulator) {
+ this.registry = registry;
+
+ // ── Gauges (auto-polled) ────────────────────────────────────────
+
+ for (AgentState state : AgentState.values()) {
+ Gauge.builder("cameleer.agents.connected", registryService,
+ r -> r.findByState(state).size())
+ .tag("state", state.name().toLowerCase())
+ .register(registry);
+ }
+
+ Gauge.builder("cameleer.agents.sse.active", sseManager,
+ SseConnectionManager::getConnectionCount)
+ .register(registry);
+
+ Gauge.builder("cameleer.ingestion.buffer.size", executionBuffer, WriteBuffer::size)
+ .tag("type", "execution")
+ .register(registry);
+ Gauge.builder("cameleer.ingestion.buffer.size", processorBuffer, WriteBuffer::size)
+ .tag("type", "processor")
+ .register(registry);
+ Gauge.builder("cameleer.ingestion.buffer.size", logBuffer, WriteBuffer::size)
+ .tag("type", "log")
+ .register(registry);
+ Gauge.builder("cameleer.ingestion.buffer.size", metricsBuffer, WriteBuffer::size)
+ .tag("type", "metrics")
+ .register(registry);
+
+ Gauge.builder("cameleer.ingestion.accumulator.pending", accumulator,
+ ChunkAccumulator::getPendingCount)
+ .register(registry);
+
+ // ── Counters ────────────────────────────────────────────────────
+
+ ingestionDropsBufferFull = Counter.builder("cameleer.ingestion.drops")
+ .tag("reason", "buffer_full").register(registry);
+ ingestionDropsNoAgent = Counter.builder("cameleer.ingestion.drops")
+ .tag("reason", "no_agent").register(registry);
+ ingestionDropsNoIdentity = Counter.builder("cameleer.ingestion.drops")
+ .tag("reason", "no_identity").register(registry);
+
+ transitionsWentStale = Counter.builder("cameleer.agents.transitions")
+ .tag("transition", "went_stale").register(registry);
+ transitionsWentDead = Counter.builder("cameleer.agents.transitions")
+ .tag("transition", "went_dead").register(registry);
+ transitionsRecovered = Counter.builder("cameleer.agents.transitions")
+ .tag("transition", "recovered").register(registry);
+
+ deploymentsRunning = Counter.builder("cameleer.deployments.outcome")
+ .tag("status", "running").register(registry);
+ deploymentsFailed = Counter.builder("cameleer.deployments.outcome")
+ .tag("status", "failed").register(registry);
+ deploymentsDegraded = Counter.builder("cameleer.deployments.outcome")
+ .tag("status", "degraded").register(registry);
+
+ authFailuresInvalidToken = Counter.builder("cameleer.auth.failures")
+ .tag("reason", "invalid_token").register(registry);
+ authFailuresRevoked = Counter.builder("cameleer.auth.failures")
+ .tag("reason", "revoked").register(registry);
+ authFailuresOidc = Counter.builder("cameleer.auth.failures")
+ .tag("reason", "oidc_rejected").register(registry);
+
+ // ── Timers ──────────────────────────────────────────────────────
+
+ flushExecutionTimer = Timer.builder("cameleer.ingestion.flush.duration")
+ .tag("type", "execution").register(registry);
+ flushProcessorTimer = Timer.builder("cameleer.ingestion.flush.duration")
+ .tag("type", "processor").register(registry);
+ flushLogTimer = Timer.builder("cameleer.ingestion.flush.duration")
+ .tag("type", "log").register(registry);
+
+ deploymentDurationTimer = Timer.builder("cameleer.deployments.duration")
+ .register(registry);
+ }
+
+ // ── Ingestion ───────────────────────────────────────────────────────
+
+ public void recordIngestionDrop(String reason) {
+ switch (reason) {
+ case "buffer_full" -> ingestionDropsBufferFull.increment();
+ case "no_agent" -> ingestionDropsNoAgent.increment();
+ case "no_identity" -> ingestionDropsNoIdentity.increment();
+ }
+ }
+
+ public void recordIngestionDrops(String reason, int count) {
+ switch (reason) {
+ case "buffer_full" -> ingestionDropsBufferFull.increment(count);
+ case "no_agent" -> ingestionDropsNoAgent.increment(count);
+ case "no_identity" -> ingestionDropsNoIdentity.increment(count);
+ }
+ }
+
+ // ── Agent lifecycle ─────────────────────────────────────────────────
+
+ public void recordAgentTransition(String transition) {
+ switch (transition) {
+ case "WENT_STALE" -> transitionsWentStale.increment();
+ case "WENT_DEAD" -> transitionsWentDead.increment();
+ case "RECOVERED" -> transitionsRecovered.increment();
+ }
+ }
+
+ // ── Deployments ─────────────────────────────────────────────────────
+
+ public void recordDeploymentOutcome(String status) {
+ switch (status) {
+ case "RUNNING" -> deploymentsRunning.increment();
+ case "FAILED" -> deploymentsFailed.increment();
+ case "DEGRADED" -> deploymentsDegraded.increment();
+ }
+ }
+
+ public void recordDeploymentDuration(long startMillis) {
+ deploymentDurationTimer.record(
+ System.currentTimeMillis() - startMillis, TimeUnit.MILLISECONDS);
+ }
+
+ // ── Flush timers ────────────────────────────────────────────────────
+
+ public void recordFlushDuration(String type, Duration duration) {
+ switch (type) {
+ case "execution" -> flushExecutionTimer.record(duration);
+ case "processor" -> flushProcessorTimer.record(duration);
+ case "log" -> flushLogTimer.record(duration);
+ }
+ }
+
+ // ── Auth ────────────────────────────────────────────────────────────
+
+ public void recordAuthFailure(String reason) {
+ switch (reason) {
+ case "invalid_token" -> authFailuresInvalidToken.increment();
+ case "revoked" -> authFailuresRevoked.increment();
+ case "oidc_rejected" -> authFailuresOidc.increment();
+ }
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
index 21266d68..a639465a 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
@@ -1,5 +1,6 @@
package com.cameleer3.server.app.runtime;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.app.storage.PostgresDeploymentRepository;
import com.cameleer3.server.core.runtime.*;
import org.slf4j.Logger;
@@ -67,6 +68,9 @@ public class DeploymentExecutor {
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
+ @Autowired
+ private ServerMetrics serverMetrics;
+
public DeploymentExecutor(RuntimeOrchestrator orchestrator,
DeploymentService deploymentService,
AppService appService,
@@ -82,6 +86,7 @@ public class DeploymentExecutor {
@Async("deploymentTaskExecutor")
public void executeAsync(Deployment deployment) {
+ long deployStart = System.currentTimeMillis();
try {
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
@@ -215,6 +220,8 @@ public class DeploymentExecutor {
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
deploymentService.markFailed(deployment.id(), "No replicas passed health check within " + healthCheckTimeout + "s");
+ serverMetrics.recordDeploymentOutcome("FAILED");
+ serverMetrics.recordDeploymentDuration(deployStart);
return;
}
@@ -245,6 +252,8 @@ public class DeploymentExecutor {
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
+ serverMetrics.recordDeploymentOutcome(finalStatus.name());
+ serverMetrics.recordDeploymentDuration(deployStart);
log.info("Deployment {} is {} ({}/{} replicas healthy)",
deployment.id(), finalStatus, healthyCount, config.replicas());
@@ -252,6 +261,8 @@ public class DeploymentExecutor {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
pgDeployRepo.updateDeployStage(deployment.id(), null);
deploymentService.markFailed(deployment.id(), e.getMessage());
+ serverMetrics.recordDeploymentOutcome("FAILED");
+ serverMetrics.recordDeploymentDuration(deployStart);
}
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
index 7e1004ea..5934a5d8 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
@@ -1,5 +1,6 @@
package com.cameleer3.server.app.security;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService;
@@ -45,15 +46,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AgentRegistryService agentRegistryService;
private final JwtDecoder oidcDecoder;
private final UserRepository userRepository;
+ private final ServerMetrics serverMetrics;
public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService,
JwtDecoder oidcDecoder,
- UserRepository userRepository) {
+ UserRepository userRepository,
+ ServerMetrics serverMetrics) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder;
this.userRepository = userRepository;
+ this.serverMetrics = serverMetrics;
}
@Override
@@ -85,6 +89,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
userRepository.findById(subject).ifPresent(user -> {
Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) {
+ serverMetrics.recordAuthFailure("revoked");
throw new com.cameleer3.server.core.security.InvalidTokenException("Token revoked");
}
});
@@ -117,6 +122,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("OIDC token validation failed: {}", e.getMessage());
+ serverMetrics.recordAuthFailure("oidc_rejected");
}
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
index ec98db49..63f98a39 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
@@ -1,5 +1,6 @@
package com.cameleer3.server.app.security;
+import com.cameleer3.server.app.metrics.ServerMetrics;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.UserRepository;
@@ -61,7 +62,8 @@ public class SecurityConfig {
AgentRegistryService registryService,
SecurityProperties securityProperties,
CorsConfigurationSource corsConfigurationSource,
- UserRepository userRepository) throws Exception {
+ UserRepository userRepository,
+ ServerMetrics serverMetrics) throws Exception {
JwtDecoder oidcDecoder = null;
String issuer = securityProperties.getOidc().getIssuerUri();
if (issuer != null && !issuer.isBlank()) {
@@ -83,6 +85,7 @@ public class SecurityConfig {
// Public endpoints
.requestMatchers(
"/api/v1/health",
+ "/api/v1/prometheus",
"/api/v1/branding/**",
"/api/v1/agents/register",
"/api/v1/agents/*/refresh",
@@ -142,7 +145,7 @@ public class SecurityConfig {
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.addFilterBefore(
- new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository),
+ new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository, serverMetrics),
UsernamePasswordAuthenticationFilter.class
);
diff --git a/cameleer3-server-app/src/main/resources/application.yml b/cameleer3-server-app/src/main/resources/application.yml
index d8742be5..3aa6d335 100644
--- a/cameleer3-server-app/src/main/resources/application.yml
+++ b/cameleer3-server-app/src/main/resources/application.yml
@@ -100,7 +100,7 @@ management:
web:
base-path: /api/v1
exposure:
- include: health
+ include: health,prometheus
endpoint:
health:
show-details: always