Custom Health Status
Customize how Spring Boot Admin Server retrieves and interprets health status from instances.
Overview
The Admin Server monitors instance health by querying the /actuator/health endpoint. You can customize:
- StatusUpdater - How health status is retrieved and parsed
- InfoUpdater - How instance info is retrieved
- Status interpretation - Custom status codes and logic
Default Behavior
StatusUpdater
By default, StatusUpdater queries the health endpoint:
StatusUpdater.java (simplified):
protected Mono<Instance> doUpdateStatus(Instance instance) {
return instanceWebClient.instance(instance)
.get()
.uri(Endpoint.HEALTH)
.exchangeToMono(this::convertStatusInfo)
.timeout(Duration.ofSeconds(10))
.onErrorResume(this::handleError)
.map(instance::withStatusInfo);
}
protected StatusInfo getStatusInfoFromStatus(HttpStatusCode httpStatus, Map<String, ?> body) {
if (httpStatus.is2xxSuccessful()) {
return StatusInfo.ofUp();
}
// Return DOWN with error details
return StatusInfo.ofDown(details);
}
private Mono<StatusInfo> handleError(Throwable ex) {
Map<String, Object> details = new HashMap<>();
details.put("message", ex.getMessage());
details.put("exception", ex.getClass().getName());
return Mono.just(StatusInfo.ofOffline(details));
}
Status Mapping:
- HTTP 2xx →
UP - HTTP 4xx/5xx →
DOWN - Network error →
OFFLINE
StatusInfo
Built-in Status Codes
public static final String STATUS_UNKNOWN = "UNKNOWN";
public static final String STATUS_OUT_OF_SERVICE = "OUT_OF_SERVICE";
public static final String STATUS_UP = "UP";
public static final String STATUS_DOWN = "DOWN";
public static final String STATUS_OFFLINE = "OFFLINE";
public static final String STATUS_RESTRICTED = "RESTRICTED";
Status Priority (highest to lowest):
DOWNOUT_OF_SERVICEOFFLINEUNKNOWNRESTRICTEDUP
Creating StatusInfo
// UP status
StatusInfo.ofUp();
StatusInfo.ofUp(Map.of("version", "1.0.0"));
// DOWN status
StatusInfo.ofDown();
StatusInfo.ofDown(Map.of("error", "Database unreachable"));
// OFFLINE status
StatusInfo.ofOffline();
StatusInfo.ofOffline(Map.of("message", "Connection timeout"));
// Custom status
StatusInfo.valueOf("DEGRADED", Map.of("reason", "High latency"));
Custom StatusUpdater
Example: Custom Timeout
package com.example.admin;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
@Configuration
public class StatusUpdaterConfig {
@Bean
public StatusUpdater statusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
return new StatusUpdater(repository, instanceWebClient, apiMediaTypeHandler)
.timeout(Duration.ofSeconds(30)); // Increase timeout
}
}
Example: Custom Status Interpretation
package com.example.admin;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatusCode;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
@Configuration
public class StatusUpdaterConfig {
@Bean
public StatusUpdater statusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
return new CustomStatusUpdater(repository, instanceWebClient, apiMediaTypeHandler);
}
static class CustomStatusUpdater extends StatusUpdater {
public CustomStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected StatusInfo getStatusInfoFromStatus(HttpStatusCode httpStatus, Map<String, ?> body) {
// Custom logic: 503 Service Unavailable = OUT_OF_SERVICE
if (httpStatus.value() == 503) {
return StatusInfo.valueOf("OUT_OF_SERVICE", body);
}
// Custom logic: 429 Too Many Requests = RESTRICTED
if (httpStatus.value() == 429) {
return StatusInfo.valueOf("RESTRICTED",
Map.of("reason", "Rate limited"));
}
// Delegate to default behavior
return super.getStatusInfoFromStatus(httpStatus, body);
}
}
}
Example: Custom Health Endpoint
Query a different health endpoint:
package com.example.admin;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class CustomHealthEndpointStatusUpdater extends StatusUpdater {
public CustomHealthEndpointStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected Mono<Instance> doUpdateStatus(Instance instance) {
if (!instance.isRegistered()) {
return Mono.empty();
}
// Query custom health endpoint based on metadata
String customHealthPath = instance.getRegistration()
.getMetadata()
.getOrDefault("health-path", "/actuator/health");
return instanceWebClient.instance(instance)
.get()
.uri(customHealthPath)
.exchangeToMono(this::convertStatusInfo)
.timeout(getTimeoutWithMargin())
.onErrorResume(this::handleError)
.map(instance::withStatusInfo);
}
}
Client Configuration:
spring:
boot:
admin:
client:
instance:
metadata:
health-path: /custom/health
Example: Combine Multiple Health Checks
package com.example.admin;
import java.util.Map;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class AggregatedStatusUpdater extends StatusUpdater {
public AggregatedStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected Mono<Instance> doUpdateStatus(Instance instance) {
if (!instance.isRegistered()) {
return Mono.empty();
}
// Check both health and readiness
Mono<StatusInfo> health = instanceWebClient.instance(instance)
.get()
.uri("/actuator/health")
.exchangeToMono(this::convertStatusInfo)
.onErrorResume(ex -> Mono.just(StatusInfo.ofOffline()));
Mono<StatusInfo> readiness = instanceWebClient.instance(instance)
.get()
.uri("/actuator/health/readiness")
.exchangeToMono(this::convertStatusInfo)
.onErrorResume(ex -> Mono.just(StatusInfo.ofUp()));
return Mono.zip(health, readiness, this::combineStatus)
.map(instance::withStatusInfo);
}
private StatusInfo combineStatus(StatusInfo health, StatusInfo readiness) {
// If either is DOWN, overall is DOWN
if (health.isDown() || readiness.isDown()) {
return StatusInfo.ofDown(Map.of(
"health", health.getStatus(),
"readiness", readiness.getStatus()
));
}
// If either is OFFLINE, overall is OFFLINE
if (health.isOffline() || readiness.isOffline()) {
return StatusInfo.ofOffline(Map.of(
"health", health.getStatus(),
"readiness", readiness.getStatus()
));
}
// Otherwise UP
return StatusInfo.ofUp(Map.of(
"health", health.getStatus(),
"readiness", readiness.getStatus()
));
}
}
Custom InfoUpdater
Default Behavior
InfoUpdater queries /actuator/info:
protected Mono<Instance> doUpdateInfo(Instance instance) {
if (instance.getStatusInfo().isOffline() || instance.getStatusInfo().isUnknown()) {
return Mono.empty(); // Skip if offline
}
if (!instance.getEndpoints().isPresent(Endpoint.INFO)) {
return Mono.empty(); // Skip if no info endpoint
}
return instanceWebClient.instance(instance)
.get()
.uri(Endpoint.INFO)
.exchangeToMono(response -> convertInfo(instance, response))
.onErrorResume(ex -> Mono.just(convertInfo(instance, ex)))
.map(instance::withInfo);
}
Example: Custom Info Endpoint
package com.example.admin;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.Info;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.InfoUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class CustomInfoUpdater extends InfoUpdater {
public CustomInfoUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected Mono<Instance> doUpdateInfo(Instance instance) {
if (instance.getStatusInfo().isOffline() || instance.getStatusInfo().isUnknown()) {
return Mono.empty();
}
// Query custom info endpoint
String infoPath = instance.getRegistration()
.getMetadata()
.getOrDefault("info-path", "/actuator/info");
return instanceWebClient.instance(instance)
.get()
.uri(infoPath)
.exchangeToMono(response -> convertInfo(instance, response))
.onErrorResume(ex -> Mono.just(Info.empty()))
.map(instance::withInfo);
}
}
Example: Enrich Info with Metadata
package com.example.admin;
import java.util.HashMap;
import java.util.Map;
import reactor.core.publisher.Mono;
import org.springframework.web.reactive.function.client.ClientResponse;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.Info;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.InfoUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class EnrichedInfoUpdater extends InfoUpdater {
public EnrichedInfoUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected Mono<Info> convertInfo(Instance instance, ClientResponse response) {
return super.convertInfo(instance, response)
.map(info -> enrichInfo(instance, info));
}
private Info enrichInfo(Instance instance, Info originalInfo) {
Map<String, Object> enriched = new HashMap<>(originalInfo.getValues());
// Add metadata to info
enriched.put("metadata", instance.getRegistration().getMetadata());
// Add custom fields
enriched.put("registrationTime",
instance.getRegistration().getTimestamp().toString());
enriched.put("instanceId", instance.getId().getValue());
return Info.from(enriched);
}
}
Custom Status Codes
Define custom status codes for specific scenarios:
package com.example.admin;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatusCode;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
@Configuration
public class CustomStatusConfig {
@Bean
public StatusUpdater statusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
return new CustomStatusCodeUpdater(repository, instanceWebClient, apiMediaTypeHandler);
}
static class CustomStatusCodeUpdater extends StatusUpdater {
public CustomStatusCodeUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected StatusInfo getStatusInfoFromStatus(HttpStatusCode httpStatus, Map<String, ?> body) {
// Custom status codes
if (body.containsKey("status")) {
String status = body.get("status").toString();
return switch (status) {
case "DEGRADED" -> StatusInfo.valueOf("DEGRADED",
Map.of("details", "Service running with reduced capacity"));
case "MAINTENANCE" -> StatusInfo.valueOf("OUT_OF_SERVICE",
Map.of("reason", "Under maintenance"));
case "WARMING_UP" -> StatusInfo.valueOf("RESTRICTED",
Map.of("reason", "Service is warming up"));
default -> super.getStatusInfoFromStatus(httpStatus, body);
};
}
return super.getStatusInfoFromStatus(httpStatus, body);
}
}
}
Client Health Indicator:
package com.example.client;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class CustomHealthIndicator implements HealthIndicator {
private boolean warming = true;
@Override
public Health health() {
if (warming) {
return Health.status("WARMING_UP")
.withDetail("progress", "50%")
.build();
}
return Health.up().build();
}
}
Advanced Scenarios
Scenario 1: External Health Check
Query an external monitoring service:
package com.example.admin;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class ExternalHealthCheckStatusUpdater extends StatusUpdater {
private final WebClient externalMonitor;
public ExternalHealthCheckStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler,
WebClient.Builder webClientBuilder) {
super(repository, instanceWebClient, apiMediaTypeHandler);
this.externalMonitor = webClientBuilder
.baseUrl("https://monitoring-service.company.com")
.build();
}
@Override
protected Mono<Instance> doUpdateStatus(Instance instance) {
String serviceName = instance.getRegistration().getName();
// Query external monitoring service
Mono<StatusInfo> externalStatus = externalMonitor.get()
.uri("/health/{service}", serviceName)
.retrieve()
.bodyToMono(ExternalHealthResponse.class)
.map(this::convertExternalHealth)
.onErrorResume(ex -> super.doUpdateStatus(instance)
.map(Instance::getStatusInfo));
return externalStatus.map(instance::withStatusInfo);
}
private StatusInfo convertExternalHealth(ExternalHealthResponse response) {
return StatusInfo.valueOf(response.getStatus(), response.getDetails());
}
record ExternalHealthResponse(String status, Map<String, Object> details) {
public String getStatus() { return status; }
public Map<String, Object> getDetails() { return details; }
}
}
Scenario 2: Synthetic Monitoring
Perform synthetic transactions:
package com.example.admin;
import java.util.Map;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class SyntheticMonitoringStatusUpdater extends StatusUpdater {
public SyntheticMonitoringStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected Mono<Instance> doUpdateStatus(Instance instance) {
// 1. Check health endpoint
Mono<StatusInfo> healthCheck = super.doUpdateStatus(instance)
.map(Instance::getStatusInfo);
// 2. Perform synthetic transaction
Mono<Boolean> syntheticCheck = performSyntheticTransaction(instance);
return Mono.zip(healthCheck, syntheticCheck,
(health, synthetic) -> {
if (health.isDown()) {
return health; // Already down
}
if (!synthetic) {
return StatusInfo.valueOf("DEGRADED",
Map.of("reason", "Synthetic transaction failed"));
}
return health;
})
.map(instance::withStatusInfo);
}
private Mono<Boolean> performSyntheticTransaction(Instance instance) {
// Example: Try to fetch a known endpoint
return instanceWebClient.instance(instance)
.get()
.uri("/api/health-check")
.retrieve()
.toBodilessEntity()
.map(response -> response.getStatusCode().is2xxSuccessful())
.onErrorReturn(false);
}
}
Scenario 3: Database-Backed Status
Store and retrieve status from database:
package com.example.admin;
import reactor.core.publisher.Mono;
import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
public class DatabaseBackedStatusUpdater extends StatusUpdater {
private final HealthStatusRepository healthStatusRepository;
public DatabaseBackedStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler,
HealthStatusRepository healthStatusRepository) {
super(repository, instanceWebClient, apiMediaTypeHandler);
this.healthStatusRepository = healthStatusRepository;
}
@Override
protected Mono<Instance> doUpdateStatus(Instance instance) {
return super.doUpdateStatus(instance)
.flatMap(updatedInstance -> {
// Save status to database
HealthStatus status = new HealthStatus(
instance.getId().getValue(),
updatedInstance.getStatusInfo().getStatus(),
updatedInstance.getStatusInfo().getDetails()
);
return healthStatusRepository.save(status)
.thenReturn(updatedInstance);
});
}
}
interface HealthStatusRepository {
Mono<HealthStatus> save(HealthStatus status);
}
record HealthStatus(String instanceId, String status, Map<String, Object> details) {}
Debugging
Enable Debug Logging
logging:
level:
de.codecentric.boot.admin.server.services.StatusUpdater: DEBUG
de.codecentric.boot.admin.server.services.InfoUpdater: DEBUG
Log Output:
DEBUG StatusUpdater - Update status for Instance{id=abc123, name=my-service}
DEBUG StatusUpdater - Status updated: UP
Monitor Status Updates
Listen to InstanceStatusChangedEvent:
package com.example.admin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
@Component
public class StatusChangeLogger {
private static final Logger log = LoggerFactory.getLogger(StatusChangeLogger.class);
@EventListener
public void onStatusChanged(InstanceStatusChangedEvent event) {
log.info("Status changed for instance {}: {} -> {}",
event.getInstance(),
event.getStatusInfo().getStatus(),
event.getInstance().getStatusInfo().getStatus());
}
}
Complete Example
package com.example.admin;
import java.time.Duration;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatusCode;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
import de.codecentric.boot.admin.server.services.ApiMediaTypeHandler;
import de.codecentric.boot.admin.server.services.StatusUpdater;
import de.codecentric.boot.admin.server.web.client.InstanceWebClient;
@Configuration
public class CustomMonitoringConfig {
@Bean
public StatusUpdater statusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
return new EnhancedStatusUpdater(repository, instanceWebClient, apiMediaTypeHandler)
.timeout(Duration.ofSeconds(15));
}
static class EnhancedStatusUpdater extends StatusUpdater {
public EnhancedStatusUpdater(
InstanceRepository repository,
InstanceWebClient instanceWebClient,
ApiMediaTypeHandler apiMediaTypeHandler) {
super(repository, instanceWebClient, apiMediaTypeHandler);
}
@Override
protected StatusInfo getStatusInfoFromStatus(HttpStatusCode httpStatus, Map<String, ?> body) {
// Support custom status codes
if (body.containsKey("status")) {
String status = body.get("status").toString().toUpperCase();
return switch (status) {
case "DEGRADED" -> StatusInfo.valueOf("DEGRADED", body);
case "MAINTENANCE" -> StatusInfo.valueOf("OUT_OF_SERVICE", body);
case "STARTING" -> StatusInfo.valueOf("RESTRICTED",
Map.of("reason", "Service starting"));
default -> StatusInfo.valueOf(status, body);
};
}
// HTTP 503 = OUT_OF_SERVICE
if (httpStatus.value() == 503) {
return StatusInfo.valueOf("OUT_OF_SERVICE", body);
}
// HTTP 429 = RESTRICTED
if (httpStatus.value() == 429) {
return StatusInfo.valueOf("RESTRICTED",
Map.of("reason", "Rate limited"));
}
return super.getStatusInfoFromStatus(httpStatus, body);
}
}
}