29 KiB
29 KiB
Spring Boot Actuator Examples
Complete Application Example
Application Configuration
@SpringBootApplication
public class MonitoringApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(MonitoringApplication.class);
// Enable startup tracking
app.setApplicationStartup(new BufferingApplicationStartup(2048));
app.run(args);
}
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config()
.commonTags("application", "order-service", "environment", "production");
}
}
Application Properties
spring:
application:
name: order-service
info:
app:
name: ${spring.application.name}
description: Order Processing Service
version: "@project.version@"
encoding: "@project.build.sourceEncoding@"
java:
version: "@java.version@"
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus,startup"
base-path: "/actuator"
endpoint:
health:
show-details: when-authorized
show-components: always
probes:
enabled: true
group:
liveness:
include: "ping,diskSpace"
readiness:
include: "readinessState,db,redis,externalApi"
show-details: always
status:
order: "fatal,down,out-of-service,warning,unknown,up"
http-mapping:
down: 503
fatal: 503
warning: 500
info:
enabled: true
metrics:
enabled: true
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
region: eu-west-1
info:
git:
mode: full
build:
enabled: true
Health Indicators Examples
Database Health Indicator
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private static final Logger log = LoggerFactory.getLogger(DatabaseHealthIndicator.class);
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
long startTime = System.currentTimeMillis();
boolean valid = connection.isValid(1000);
long responseTime = System.currentTimeMillis() - startTime;
if (!valid) {
return Health.down()
.withDetail("database", "Connection not valid")
.build();
}
DatabaseMetaData metaData = connection.getMetaData();
Health.Builder builder = Health.up()
.withDetail("database", metaData.getDatabaseProductName())
.withDetail("version", metaData.getDatabaseProductVersion())
.withDetail("responseTime", responseTime + "ms");
if (responseTime > 500) {
builder.status("WARNING")
.withDetail("warning", "Slow database connection");
}
return builder.build();
} catch (SQLException ex) {
log.error("Database health check failed", ex);
return Health.down()
.withDetail("error", ex.getMessage())
.withException(ex)
.build();
}
}
}
External API Health Indicator with Circuit Breaker
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final RestTemplate restTemplate;
private final CircuitBreaker circuitBreaker;
public PaymentGatewayHealthIndicator(
RestTemplate restTemplate,
@Qualifier("paymentCircuitBreaker") CircuitBreaker circuitBreaker) {
this.restTemplate = restTemplate;
this.circuitBreaker = circuitBreaker;
}
@Override
public Health health() {
CircuitBreaker.State state = circuitBreaker.getState();
Health.Builder builder = Health.up()
.withDetail("circuitBreaker", state.toString())
.withDetail("service", "Payment Gateway");
if (state == CircuitBreaker.State.OPEN) {
return builder
.down()
.withDetail("reason", "Circuit breaker is open")
.build();
}
if (state == CircuitBreaker.State.HALF_OPEN) {
builder.status("WARNING")
.withDetail("reason", "Circuit breaker is testing");
}
try {
long startTime = System.currentTimeMillis();
ResponseEntity<Map> response = restTemplate.getForEntity(
"https://api.payment.com/health",
Map.class
);
long responseTime = System.currentTimeMillis() - startTime;
return builder
.withDetail("responseTime", responseTime + "ms")
.withDetail("statusCode", response.getStatusCode().value())
.build();
} catch (Exception ex) {
return builder
.down()
.withDetail("error", ex.getMessage())
.build();
}
}
}
Cache Health Indicator
@Component
public class CacheHealthIndicator implements HealthIndicator {
private final CacheManager cacheManager;
public CacheHealthIndicator(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public Health health() {
Collection<String> cacheNames = cacheManager.getCacheNames();
Map<String, Object> cacheDetails = new HashMap<>();
boolean allHealthy = true;
for (String cacheName : cacheNames) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
try {
// Test cache operations
cache.put("health-check", "test");
String value = cache.get("health-check", String.class);
cache.evict("health-check");
cacheDetails.put(cacheName, "UP");
} catch (Exception ex) {
cacheDetails.put(cacheName, "DOWN: " + ex.getMessage());
allHealthy = false;
}
}
}
Health.Builder builder = allHealthy ? Health.up() : Health.down();
return builder
.withDetail("caches", cacheDetails)
.withDetail("totalCaches", cacheNames.size())
.build();
}
}
Reactive Health Indicator
@Component
public class ReactiveExternalServiceHealthIndicator implements ReactiveHealthIndicator {
private final WebClient webClient;
public ReactiveExternalServiceHealthIndicator(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder
.baseUrl("https://api.example.com")
.build();
}
@Override
public Mono<Health> health() {
return webClient
.get()
.uri("/health")
.retrieve()
.toBodilessEntity()
.map(response -> Health.up()
.withDetail("statusCode", response.getStatusCode().value())
.withDetail("service", "External API")
.build())
.timeout(Duration.ofSeconds(2))
.onErrorResume(TimeoutException.class, ex ->
Mono.just(Health.down()
.withDetail("error", "Timeout after 2 seconds")
.build()))
.onErrorResume(ex ->
Mono.just(Health.down()
.withDetail("error", ex.getMessage())
.build()));
}
}
Custom Endpoints Examples
Application Statistics Endpoint
@Component
@Endpoint(id = "appstats")
public class AppStatisticsEndpoint {
private final UserRepository userRepository;
private final OrderRepository orderRepository;
private final MeterRegistry meterRegistry;
public AppStatisticsEndpoint(
UserRepository userRepository,
OrderRepository orderRepository,
MeterRegistry meterRegistry) {
this.userRepository = userRepository;
this.orderRepository = orderRepository;
this.meterRegistry = meterRegistry;
}
@ReadOperation
public Map<String, Object> getStatistics() {
Map<String, Object> stats = new HashMap<>();
// User statistics
stats.put("users", Map.of(
"total", userRepository.count(),
"active", userRepository.countByStatus("ACTIVE"),
"inactive", userRepository.countByStatus("INACTIVE")
));
// Order statistics
stats.put("orders", Map.of(
"total", orderRepository.count(),
"pending", orderRepository.countByStatus("PENDING"),
"completed", orderRepository.countByStatus("COMPLETED"),
"cancelled", orderRepository.countByStatus("CANCELLED")
));
// JVM statistics
stats.put("jvm", Map.of(
"memoryUsed", getMetricValue("jvm.memory.used"),
"memoryMax", getMetricValue("jvm.memory.max"),
"threadCount", getMetricValue("jvm.threads.live")
));
stats.put("timestamp", Instant.now());
return stats;
}
@ReadOperation
public Map<String, Object> getStatisticsByType(@Selector String type) {
return switch (type.toLowerCase()) {
case "users" -> Map.of(
"total", userRepository.count(),
"byStatus", userRepository.countByStatusGrouped()
);
case "orders" -> Map.of(
"total", orderRepository.count(),
"byStatus", orderRepository.countByStatusGrouped()
);
default -> Map.of("error", "Unknown type: " + type);
};
}
private Double getMetricValue(String meterName) {
return meterRegistry.find(meterName)
.gauge()
.map(Gauge::value)
.orElse(0.0);
}
}
Feature Flags Endpoint
@Component
@Endpoint(id = "features")
public class FeatureFlagsEndpoint {
private final Map<String, FeatureFlag> features = new ConcurrentHashMap<>();
private final ApplicationEventPublisher eventPublisher;
public FeatureFlagsEndpoint(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
initializeDefaultFeatures();
}
private void initializeDefaultFeatures() {
features.put("dark-mode", new FeatureFlag(true, "Dark mode UI"));
features.put("new-checkout", new FeatureFlag(false, "New checkout flow"));
features.put("ai-recommendations", new FeatureFlag(false, "AI-powered recommendations"));
}
@ReadOperation
public Map<String, FeatureFlag> getAllFeatures() {
return features;
}
@ReadOperation
public FeatureFlag getFeature(@Selector String name) {
return features.get(name);
}
@WriteOperation
public void updateFeature(
@Selector String name,
@Nullable Boolean enabled,
@Nullable String description) {
features.compute(name, (key, existing) -> {
if (existing == null) {
existing = new FeatureFlag(false, "");
}
if (enabled != null) {
existing.setEnabled(enabled);
}
if (description != null) {
existing.setDescription(description);
}
return existing;
});
eventPublisher.publishEvent(new FeatureFlagChangedEvent(name, features.get(name)));
}
@DeleteOperation
public void deleteFeature(@Selector String name) {
FeatureFlag removed = features.remove(name);
if (removed != null) {
eventPublisher.publishEvent(new FeatureFlagDeletedEvent(name));
}
}
public static class FeatureFlag {
private boolean enabled;
private String description;
private Instant lastModified = Instant.now();
public FeatureFlag() {}
public FeatureFlag(boolean enabled, String description) {
this.enabled = enabled;
this.description = description;
}
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) {
this.enabled = enabled;
this.lastModified = Instant.now();
}
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Instant getLastModified() { return lastModified; }
}
}
Cache Management Endpoint
@Component
@Endpoint(id = "caches")
public class CacheManagementEndpoint {
private final CacheManager cacheManager;
public CacheManagementEndpoint(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@ReadOperation
public Map<String, Object> getCaches() {
Collection<String> cacheNames = cacheManager.getCacheNames();
Map<String, Object> result = new HashMap<>();
result.put("totalCaches", cacheNames.size());
result.put("caches", cacheNames);
return result;
}
@ReadOperation
public Map<String, Object> getCache(@Selector String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
return Map.of("error", "Cache not found: " + cacheName);
}
return Map.of(
"name", cacheName,
"type", cache.getClass().getSimpleName()
);
}
@DeleteOperation
public void clearCache(@Selector String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
}
@WriteOperation
public void clearAllCaches() {
cacheManager.getCacheNames()
.forEach(name -> {
Cache cache = cacheManager.getCache(name);
if (cache != null) {
cache.clear();
}
});
}
}
Custom Info Contributors
Detailed Application Info
@Component
public class DetailedApplicationInfoContributor implements InfoContributor {
private final Environment environment;
private final UserRepository userRepository;
private final OrderRepository orderRepository;
public DetailedApplicationInfoContributor(
Environment environment,
UserRepository userRepository,
OrderRepository orderRepository) {
this.environment = environment;
this.userRepository = userRepository;
this.orderRepository = orderRepository;
}
@Override
public void contribute(Info.Builder builder) {
// Runtime information
Runtime runtime = Runtime.getRuntime();
builder.withDetail("runtime", Map.of(
"processors", runtime.availableProcessors(),
"freeMemory", runtime.freeMemory(),
"totalMemory", runtime.totalMemory(),
"maxMemory", runtime.maxMemory(),
"uptime", ManagementFactory.getRuntimeMXBean().getUptime()
));
// Active profiles
builder.withDetail("profiles", List.of(environment.getActiveProfiles()));
// Database statistics
builder.withDetail("database", Map.of(
"users", Map.of(
"total", userRepository.count(),
"active", userRepository.countByStatus("ACTIVE")
),
"orders", Map.of(
"total", orderRepository.count(),
"pending", orderRepository.countByStatus("PENDING"),
"completed", orderRepository.countByStatus("COMPLETED")
)
));
// Deployment information
builder.withDetail("deployment", Map.of(
"environment", environment.getProperty("app.environment", "unknown"),
"region", environment.getProperty("app.region", "unknown"),
"instance", getHostname()
));
}
private String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
return "unknown";
}
}
}
Dependency Version Info
@Component
public class DependencyVersionInfoContributor implements InfoContributor {
@Override
public void contribute(Info.Builder builder) {
Map<String, String> versions = new HashMap<>();
// Spring versions
versions.put("spring-boot", SpringBootVersion.getVersion());
versions.put("spring-framework", SpringVersion.getVersion());
// Java version
versions.put("java", System.getProperty("java.version"));
versions.put("java-vendor", System.getProperty("java.vendor"));
// Other dependencies (if available)
addVersionIfPresent(versions, "hibernate", "org.hibernate.Version", "getVersionString");
addVersionIfPresent(versions, "jackson", "com.fasterxml.jackson.core.Version", "versionString");
builder.withDetail("dependencies", versions);
}
private void addVersionIfPresent(Map<String, String> versions, String key,
String className, String methodName) {
try {
Class<?> clazz = Class.forName(className);
Object versionInstance = clazz.getDeclaredConstructor().newInstance();
String version = (String) clazz.getMethod(methodName).invoke(versionInstance);
versions.put(key, version);
} catch (Exception e) {
// Dependency not present or version not accessible
}
}
}
Metrics Examples
Service Metrics
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final MeterRegistry meterRegistry;
private final Counter orderCreatedCounter;
private final Counter orderFailedCounter;
private final Timer orderProcessingTimer;
private final DistributionSummary orderAmountSummary;
public OrderService(OrderRepository orderRepository, MeterRegistry meterRegistry) {
this.orderRepository = orderRepository;
this.meterRegistry = meterRegistry;
// Counters
this.orderCreatedCounter = Counter.builder("orders.created")
.description("Total number of orders created")
.tag("service", "order")
.register(meterRegistry);
this.orderFailedCounter = Counter.builder("orders.failed")
.description("Total number of failed orders")
.tag("service", "order")
.register(meterRegistry);
// Timer for processing duration
this.orderProcessingTimer = Timer.builder("order.processing.time")
.description("Order processing duration")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
// Distribution summary for order amounts
this.orderAmountSummary = DistributionSummary.builder("order.amount")
.description("Order amount distribution")
.baseUnit("EUR")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
// Gauge for pending orders
Gauge.builder("orders.pending", orderRepository, repo -> repo.countByStatus("PENDING"))
.description("Number of pending orders")
.register(meterRegistry);
}
public Order createOrder(OrderRequest request) {
return orderProcessingTimer.record(() -> {
try {
Order order = processOrder(request);
orderCreatedCounter.increment();
orderAmountSummary.record(order.getTotalAmount());
// Tag by payment method
Counter.builder("orders.created.by.payment")
.tag("paymentMethod", order.getPaymentMethod())
.register(meterRegistry)
.increment();
return order;
} catch (Exception ex) {
orderFailedCounter.increment();
throw ex;
}
});
}
private Order processOrder(OrderRequest request) {
// Implementation
return new Order();
}
}
Custom Metrics with Tags
@Service
public class MetricsService {
private final MeterRegistry registry;
public MetricsService(MeterRegistry registry) {
this.registry = registry;
}
public void recordHttpRequest(String method, String endpoint, int statusCode, long duration) {
Timer.builder("http.requests")
.tag("method", method)
.tag("endpoint", endpoint)
.tag("status", String.valueOf(statusCode))
.register(registry)
.record(duration, TimeUnit.MILLISECONDS);
}
public void recordDatabaseQuery(String query, long duration, boolean success) {
Timer.builder("db.queries")
.tag("query", query)
.tag("success", String.valueOf(success))
.register(registry)
.record(duration, TimeUnit.MILLISECONDS);
}
public void trackCacheHit(String cacheName, boolean hit) {
Counter.builder("cache.operations")
.tag("cache", cacheName)
.tag("result", hit ? "hit" : "miss")
.register(registry)
.increment();
}
public void recordBusinessMetric(String metricName, double value, Map<String, String> tags) {
DistributionSummary.Builder builder = DistributionSummary.builder(metricName);
tags.forEach(builder::tag);
builder.register(registry).record(value);
}
}
Security Configuration Examples
Complete Security Setup
@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfiguration {
@Bean
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
// Public health check (for load balancers)
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
// Info endpoint for authenticated users
.requestMatchers(EndpointRequest.to(InfoEndpoint.class)).authenticated()
// Read-only metrics for monitoring role
.requestMatchers(HttpMethod.GET, "/actuator/metrics/**")
.hasAnyRole("MONITOR", "ADMIN")
// Prometheus endpoint for monitoring tools
.requestMatchers(EndpointRequest.to("prometheus"))
.hasRole("MONITOR")
// Write operations only for admin
.requestMatchers(HttpMethod.POST, "/actuator/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/actuator/**").hasRole("ADMIN")
// Everything else requires admin
.anyRequest().hasRole("ADMIN")
)
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
public UserDetailsService actuatorUsers() {
UserDetails monitor = User.builder()
.username("monitor")
.password("{noop}monitor-password")
.roles("MONITOR")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{noop}admin-password")
.roles("ADMIN", "MONITOR")
.build();
return new InMemoryUserDetailsManager(monitor, admin);
}
}
IP-Based Access Control
@Configuration
public class IpBasedActuatorSecurity {
@Bean
public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(request ->
isFromAllowedIp(request.getRemoteAddr())
).permitAll()
.anyRequest().denyAll()
)
.build();
}
private boolean isFromAllowedIp(String remoteAddr) {
// Allow localhost and specific IPs
return remoteAddr.equals("127.0.0.1") ||
remoteAddr.equals("0:0:0:0:0:0:0:1") ||
remoteAddr.startsWith("10.0.0.");
}
}
Testing Examples
Health Indicator Tests
@SpringBootTest
class DatabaseHealthIndicatorTest {
@Autowired
private DatabaseHealthIndicator healthIndicator;
@MockBean
private DataSource dataSource;
@Test
void shouldReturnUpWhenDatabaseIsHealthy() throws Exception {
Connection connection = mock(Connection.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.isValid(1000)).thenReturn(true);
DatabaseMetaData metaData = mock(DatabaseMetaData.class);
when(connection.getMetaData()).thenReturn(metaData);
when(metaData.getDatabaseProductName()).thenReturn("PostgreSQL");
Health health = healthIndicator.health();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health.getDetails()).containsKey("database");
}
@Test
void shouldReturnDownWhenDatabaseConnectionFails() throws Exception {
when(dataSource.getConnection()).thenThrow(new SQLException("Connection failed"));
Health health = healthIndicator.health();
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
assertThat(health.getDetails()).containsKey("error");
}
}
Endpoint Tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class ActuatorEndpointIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void healthEndpointShouldBeAccessible() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void metricsEndpointShouldListAvailableMetrics() throws Exception {
mockMvc.perform(get("/actuator/metrics"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.names").isArray())
.andExpect(jsonPath("$.names[*]", hasItem("jvm.memory.used")));
}
@Test
void customEndpointShouldWork() throws Exception {
mockMvc.perform(get("/actuator/features"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
@Test
@WithMockUser(roles = "ADMIN")
void securedEndpointShouldRequireAuthentication() throws Exception {
mockMvc.perform(post("/actuator/features/test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":true}"))
.andExpect(status().isOk());
}
}
Kubernetes Integration Example
Deployment with Probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: order-service:1.0.0
ports:
- containerPort: 8080
name: http
- containerPort: 8081
name: management
env:
- name: MANAGEMENT_SERVER_PORT
value: "8081"
- name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE
value: "health,info,prometheus"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: management
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- name: http
port: 8080
targetPort: http
- name: management
port: 8081
targetPort: management
ServiceMonitor for Prometheus Operator
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: order-service-metrics
labels:
app: order-service
spec:
selector:
matchLabels:
app: order-service
endpoints:
- port: management
path: /actuator/prometheus
interval: 30s
scrapeTimeout: 10s