Chapter 11: Using Actuator
- Chapter 11: Using Actuator
Spring Boot Actuator is one of the most valuable features of the Spring Boot ecosystem, providing production-ready monitoring, metrics, and operational insights out of the box. In this chapter, we’ll explore how to effectively use Actuator in Kotlin-based Spring Boot applications to gain deep visibility into your application’s health, performance, and behavior.
Actuator transforms your application from a black box into an observable system that can be monitored, diagnosed, and managed in production environments. Whether you’re debugging performance issues, monitoring resource usage, or ensuring your application is healthy, Actuator provides the tools you need.
We’ll start with basic setup and configuration, explore the rich set of built-in endpoints, and then dive deep into customization options. By the end of this chapter, you’ll understand how to leverage Actuator for comprehensive application observability and how to extend it with custom metrics and health checks tailored to your specific needs.
11.1 Dependency and Setup
Setting up Actuator in a Kotlin Spring Boot application is straightforward, but proper configuration is crucial for both security and functionality.
Basic Dependency Configuration
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security") // Recommended for production
// Optional: For enhanced metrics
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.micrometer:micrometer-registry-jmx")
// Optional: For tracing (if using distributed tracing)
implementation("io.micrometer:micrometer-tracing-bridge-brave")
implementation("io.zipkin.reporter2:zipkin-reporter-brave")
// Development and testing
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Basic Application Configuration
Let’s start with a comprehensive Actuator configuration that balances functionality with security:
# application.yml
server:
port: 8080
management:
# Use a different port for actuator endpoints in production
server:
port: 8081
ssl:
enabled: false # Set to true in production with proper certificates
# Configure which endpoints to expose
endpoints:
web:
exposure:
# Be very careful with 'include: "*"' in production
include: health,info,metrics,env,beans,mappings,prometheus
base-path: /actuator
cors:
allowed-origins: "*"
allowed-methods: GET,POST
jmx:
exposure:
include: "*"
# Endpoint-specific configuration
endpoint:
health:
show-details: when-authorized
show-components: always
probes:
enabled: true
info:
enabled: true
metrics:
enabled: true
env:
show-values: when-authorized
beans:
enabled: true
shutdown:
enabled: false # Never enable in production without proper security
# Metrics configuration
metrics:
enabled: true
export:
prometheus:
enabled: true
step: PT1M
jmx:
enabled: true
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5,0.95,0.99
slo:
http.server.requests: 10ms,50ms,100ms,200ms,500ms
# Tracing configuration (if using distributed tracing)
tracing:
sampling:
probability: 0.1 # 10% sampling rate
# Application-specific info
info:
app:
name: ${spring.application.name:My Kotlin Spring Boot App}
description: A comprehensive Spring Boot application with Kotlin
version: ${app.version:1.0.0}
encoding: ${file.encoding:UTF-8}
java:
version: ${java.version}
build:
time: ${app.build.time:unknown}
artifact: ${project.artifactId:app}
group: ${project.groupId:com.example}
Security Configuration for Actuator
Security is crucial when exposing Actuator endpoints. Here’s a comprehensive security configuration:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class ActuatorSecurityConfiguration {
@Bean
@Order(1)
fun actuatorSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests { requests ->
requests
// Public endpoints - no authentication required
.requestMatchers(EndpointRequest.to(HealthEndpoint::class.java)).permitAll()
.requestMatchers(EndpointRequest.to(InfoEndpoint::class.java)).permitAll()
// Prometheus metrics - typically accessed by monitoring systems
.requestMatchers(EndpointRequest.to("prometheus")).hasRole("METRICS_READER")
// Sensitive endpoints - require admin privileges
.requestMatchers(
EndpointRequest.to(
EnvironmentEndpoint::class.java,
BeansEndpoint::class.java,
ConfigurableEnvironmentEndpoint::class.java
)
).hasRole("ACTUATOR_ADMIN")
// All other actuator endpoints require monitoring role
.anyRequest().hasRole("ACTUATOR_USER")
}
.httpBasic { }
.csrf { csrf -> csrf.disable() }
.build()
}
@Bean
@Order(2)
fun applicationSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.requestMatcher(AnyRequestMatcher.INSTANCE)
.authorizeHttpRequests { requests ->
requests
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { }
}
.build()
}
@Bean
fun actuatorUserDetailsService(): UserDetailsService {
val actuatorUser = User.builder()
.username("actuator-user")
.password("{noop}actuator-password") // Use proper password encoding in production
.roles("ACTUATOR_USER")
.build()
val metricsReader = User.builder()
.username("metrics-reader")
.password("{noop}metrics-password")
.roles("METRICS_READER")
.build()
val actuatorAdmin = User.builder()
.username("actuator-admin")
.password("{noop}admin-password")
.roles("ACTUATOR_ADMIN", "ACTUATOR_USER", "METRICS_READER")
.build()
return InMemoryUserDetailsManager(actuatorUser, metricsReader, actuatorAdmin)
}
}
Production-Ready Configuration
For production environments, you’ll want more sophisticated configuration:
@Configuration
@EnableConfigurationProperties(ActuatorConfiguration.ActuatorProperties::class)
class ActuatorConfiguration(
private val actuatorProperties: ActuatorProperties
) {
@ConfigurationProperties(prefix = "app.actuator")
data class ActuatorProperties(
val enabled: Boolean = true,
val security: SecurityProperties = SecurityProperties(),
val metrics: MetricsProperties = MetricsProperties()
) {
data class SecurityProperties(
val enabled: Boolean = true,
val allowedIps: List<String> = emptyList(),
val requireSsl: Boolean = false
)
data class MetricsProperties(
val export: ExportProperties = ExportProperties()
) {
data class ExportProperties(
val prometheus: PrometheusProperties = PrometheusProperties(),
val jmx: JmxProperties = JmxProperties()
) {
data class PrometheusProperties(
val enabled: Boolean = false,
val step: Duration = Duration.ofMinutes(1),
val descriptions: Boolean = true
)
data class JmxProperties(
val enabled: Boolean = false,
val domain: String = "metrics"
)
}
}
}
@Bean
@ConditionalOnProperty(value = ["app.actuator.enabled"], havingValue = "true", matchIfMissing = true)
fun actuatorCustomizer(): WebEndpointProperties.Exposure {
val exposure = WebEndpointProperties.Exposure()
when {
isProduction() -> {
// Minimal exposure in production
exposure.include = setOf("health", "info", "metrics", "prometheus")
}
isStaging() -> {
// More endpoints in staging for debugging
exposure.include = setOf("health", "info", "metrics", "env", "beans", "prometheus")
}
else -> {
// All endpoints in development
exposure.include = setOf("*")
}
}
return exposure
}
@Bean
@ConditionalOnProperty(value = ["app.actuator.security.enabled"], havingValue = "true", matchIfMissing = true)
fun actuatorIpFilter(): FilterRegistrationBean<ActuatorIpFilter> {
val registration = FilterRegistrationBean<ActuatorIpFilter>()
registration.filter = ActuatorIpFilter(actuatorProperties.security.allowedIps)
registration.addUrlPatterns("/actuator/*")
registration.order = Ordered.HIGHEST_PRECEDENCE
return registration
}
private fun isProduction(): Boolean =
System.getProperty("spring.profiles.active")?.contains("prod") == true
private fun isStaging(): Boolean =
System.getProperty("spring.profiles.active")?.contains("staging") == true
}
// IP filtering for actuator endpoints
class ActuatorIpFilter(
private val allowedIps: List<String>
) : OncePerRequestFilter() {
private val logger = LoggerFactory.getLogger(ActuatorIpFilter::class.java)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
if (allowedIps.isEmpty()) {
// If no IPs configured, allow all (useful for development)
filterChain.doFilter(request, response)
return
}
val clientIp = getClientIpAddress(request)
if (isIpAllowed(clientIp)) {
filterChain.doFilter(request, response)
} else {
logger.warn("Access to actuator endpoint denied for IP: {}", clientIp)
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access denied")
}
}
private fun getClientIpAddress(request: HttpServletRequest): String {
val xForwardedFor = request.getHeader("X-Forwarded-For")
if (xForwardedFor != null && xForwardedFor.isNotEmpty()) {
return xForwardedFor.split(",")[0].trim()
}
val xRealIp = request.getHeader("X-Real-IP")
if (xRealIp != null && xRealIp.isNotEmpty()) {
return xRealIp
}
return request.remoteAddr
}
private fun isIpAllowed(ip: String): Boolean {
return allowedIps.any { allowedIp ->
when {
allowedIp == ip -> true
allowedIp.endsWith("*") -> ip.startsWith(allowedIp.dropLast(1))
allowedIp.contains("/") -> isIpInCidr(ip, allowedIp)
else -> false
}
}
}
private fun isIpInCidr(ip: String, cidr: String): Boolean {
// Simple CIDR implementation - you might want to use a library for production
try {
val parts = cidr.split("/")
val networkIp = parts[0]
val prefixLength = parts[1].toInt()
// This is a simplified implementation
// Consider using Apache Commons Net or similar for production
return ip.startsWith(networkIp.substringBeforeLast("."))
} catch (e: Exception) {
return false
}
}
}
11.2 Built-in Endpoints
Actuator provides a rich set of built-in endpoints. Let’s explore the most important ones and how to use them effectively.
Health Endpoint
The health endpoint is crucial for monitoring application health:
// Custom health indicators
@Component
class DatabaseHealthIndicator(
private val dataSource: DataSource
) : HealthIndicator {
private val logger = LoggerFactory.getLogger(DatabaseHealthIndicator::class.java)
override fun health(): Health {
return try {
dataSource.connection.use { connection ->
val isValid = connection.isValid(5)
if (isValid) {
// Test a simple query to ensure database is responsive
connection.prepareStatement("SELECT 1").use { statement ->
statement.executeQuery().use { resultSet ->
if (resultSet.next()) {
Health.up()
.withDetail("database", "PostgreSQL")
.withDetail("connection_pool", getConnectionPoolInfo())
.withDetail("validation_query", "SELECT 1")
.withDetail("response_time", measureResponseTime(connection))
.build()
} else {
Health.down()
.withDetail("error", "Query returned no results")
.build()
}
}
}
} else {
Health.down()
.withDetail("error", "Connection validation failed")
.build()
}
}
} catch (ex: SQLException) {
logger.error("Database health check failed", ex)
Health.down(ex)
.withDetail("error", ex.message)
.withDetail("error_code", ex.errorCode)
.build()
} catch (ex: Exception) {
logger.error("Unexpected error during database health check", ex)
Health.down(ex).build()
}
}
private fun getConnectionPoolInfo(): Map<String, Any> {
if (dataSource is HikariDataSource) {
val hikariPool = dataSource.hikariPoolMXBean
return mapOf(
"active_connections" to hikariPool.activeConnections,
"idle_connections" to hikariPool.idleConnections,
"total_connections" to hikariPool.totalConnections,
"threads_waiting" to hikariPool.threadsAwaitingConnection
)
}
return mapOf("type" to dataSource.javaClass.simpleName)
}
private fun measureResponseTime(connection: Connection): String {
val startTime = System.currentTimeMillis()
try {
connection.prepareStatement("SELECT 1").use { it.executeQuery() }
val responseTime = System.currentTimeMillis() - startTime
return "${responseTime}ms"
} catch (ex: Exception) {
return "N/A"
}
}
}
@Component
class RedisHealthIndicator(
private val redisTemplate: RedisTemplate<String, String>
) : HealthIndicator {
private val logger = LoggerFactory.getLogger(RedisHealthIndicator::class.java)
override fun health(): Health {
return try {
val startTime = System.currentTimeMillis()
val ping = redisTemplate.execute { connection: RedisConnection ->
connection.ping()
}
val responseTime = System.currentTimeMillis() - startTime
if (ping == "PONG") {
Health.up()
.withDetail("redis", "Available")
.withDetail("response_time", "${responseTime}ms")
.withDetail("connection_info", getRedisConnectionInfo())
.build()
} else {
Health.down()
.withDetail("error", "Unexpected ping response: $ping")
.build()
}
} catch (ex: Exception) {
logger.error("Redis health check failed", ex)
Health.down(ex)
.withDetail("error", ex.message)
.build()
}
}
private fun getRedisConnectionInfo(): Map<String, Any?> {
return try {
redisTemplate.execute { connection: RedisConnection ->
val info = connection.info()
mapOf(
"version" to info?.getProperty("redis_version"),
"mode" to info?.getProperty("redis_mode"),
"connected_clients" to info?.getProperty("connected_clients")
)
} ?: emptyMap()
} catch (ex: Exception) {
emptyMap<String, Any?>()
}
}
}
// External service health indicator
@Component
class ExternalApiHealthIndicator(
private val webClient: WebClient,
@Value("\${app.external-api.health-check-url}") private val healthCheckUrl: String
) : HealthIndicator {
private val logger = LoggerFactory.getLogger(ExternalApiHealthIndicator::class.java)
override fun health(): Health {
return try {
val startTime = System.currentTimeMillis()
val response = webClient
.get()
.uri(healthCheckUrl)
.retrieve()
.toBodilessEntity()
.timeout(Duration.ofSeconds(5))
.block()
val responseTime = System.currentTimeMillis() - startTime
if (response?.statusCode?.is2xxSuccessful == true) {
Health.up()
.withDetail("external_api", "Available")
.withDetail("url", healthCheckUrl)
.withDetail("response_time", "${responseTime}ms")
.withDetail("status_code", response.statusCode.value())
.build()
} else {
Health.down()
.withDetail("external_api", "Unavailable")
.withDetail("url", healthCheckUrl)
.withDetail("status_code", response?.statusCode?.value())
.build()
}
} catch (ex: TimeoutException) {
logger.warn("External API health check timed out")
Health.down()
.withDetail("error", "Health check timed out")
.withDetail("url", healthCheckUrl)
.build()
} catch (ex: Exception) {
logger.error("External API health check failed", ex)
Health.down(ex)
.withDetail("url", healthCheckUrl)
.build()
}
}
}
// Application-specific health indicator
@Component
class ApplicationHealthIndicator(
private val cacheManager: CacheManager,
private val taskExecutor: TaskExecutor
) : HealthIndicator {
override fun health(): Health {
val details = mutableMapOf<String, Any>()
var overallStatus = Status.UP
// Check cache status
try {
val cacheNames = cacheManager.cacheNames
details["cache"] = mapOf(
"available_caches" to cacheNames.size,
"cache_names" to cacheNames
)
} catch (ex: Exception) {
details["cache"] = mapOf("error" to ex.message)
overallStatus = Status.DOWN
}
// Check thread pool status (if using ThreadPoolTaskExecutor)
if (taskExecutor is ThreadPoolTaskExecutor) {
val poolDetails = mapOf(
"core_pool_size" to taskExecutor.corePoolSize,
"max_pool_size" to taskExecutor.maxPoolSize,
"active_count" to taskExecutor.activeCount,
"pool_size" to taskExecutor.poolSize,
"queue_size" to taskExecutor.threadPoolExecutor?.queue?.size
)
details["thread_pool"] = poolDetails
// Consider unhealthy if queue is too large
val queueSize = taskExecutor.threadPoolExecutor?.queue?.size ?: 0
if (queueSize > 100) { // Configurable threshold
overallStatus = Status.DOWN
details["thread_pool_warning"] = "Queue size is high: $queueSize"
}
}
return Health.Builder(overallStatus)
.withDetails(details)
.build()
}
}
Metrics Endpoint
The metrics endpoint provides detailed application metrics:
@Component
class CustomMetricsConfiguration {
@Bean
fun commonTags(
@Value("\${spring.application.name}") applicationName: String,
@Value("\${management.metrics.tags.environment:unknown}") environment: String
): MeterRegistryCustomizer<MeterRegistry> {
return MeterRegistryCustomizer { registry ->
registry.config().commonTags(
"application", applicationName,
"environment", environment,
"instance", InetAddress.getLocalHost().hostName
)
}
}
@EventListener
@Async
fun handleApplicationEvent(event: ApplicationReadyEvent) {
// Record application startup time
Metrics.gauge("application.startup.time", System.currentTimeMillis())
}
}
// Custom metrics in services
@Service
class UserService(
private val userRepository: UserRepository,
private val meterRegistry: MeterRegistry
) {
private val userCreationCounter = Counter.builder("user.creation.total")
.description("Total number of users created")
.register(meterRegistry)
private val userCreationTimer = Timer.builder("user.creation.duration")
.description("Time taken to create a user")
.register(meterRegistry)
private val activeUsersGauge = Gauge.builder("user.active.count")
.description("Number of active users")
.register(meterRegistry) { userRepository.countByActiveTrue() }
fun createUser(request: CreateUserRequest): UserResponse {
return userCreationTimer.recordCallable {
try {
val user = performUserCreation(request)
userCreationCounter.increment(
Tags.of(
"status", "success",
"method", "api"
)
)
user
} catch (ex: Exception) {
userCreationCounter.increment(
Tags.of(
"status", "error",
"error_type", ex.javaClass.simpleName
)
)
throw ex
}
}!!
}
private fun performUserCreation(request: CreateUserRequest): UserResponse {
// Implementation details
TODO("Implementation")
}
}
// Method-level metrics with AOP
@Aspect
@Component
class MetricsAspect(private val meterRegistry: MeterRegistry) {
@Around("@annotation(timed)")
fun timeMethod(joinPoint: ProceedingJoinPoint, timed: Timed): Any? {
val methodName = joinPoint.signature.name
val className = joinPoint.signature.declaringType.simpleName
val timer = Timer.builder("method.execution.time")
.description("Method execution time")
.tag("class", className)
.tag("method", methodName)
.register(meterRegistry)
return timer.recordCallable { joinPoint.proceed() }
}
@Around("@annotation(counted)")
fun countMethod(joinPoint: ProceedingJoinPoint, counted: Counted): Any? {
val methodName = joinPoint.signature.name
val className = joinPoint.signature.declaringType.simpleName
val counter = Counter.builder("method.invocation.total")
.description("Method invocation count")
.tag("class", className)
.tag("method", methodName)
.register(meterRegistry)
return try {
val result = joinPoint.proceed()
counter.increment(Tags.of("status", "success"))
result
} catch (ex: Exception) {
counter.increment(Tags.of(
"status", "error",
"exception", ex.javaClass.simpleName
))
throw ex
}
}
}
// Custom annotations for metrics
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Timed(val value: String = "")
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Counted(val value: String = "")
Info Endpoint
The info endpoint provides application information:
@Component
class CustomInfoContributor : InfoContributor {
override fun contribute(builder: Info.Builder) {
builder
.withDetail("application") {
mapOf(
"name" to "Kotlin Spring Boot Application",
"description" to "A comprehensive example application",
"version" to getApplicationVersion(),
"build_time" to getBuildTime(),
"commit" to getGitCommitInfo()
)
}
.withDetail("system") {
mapOf(
"java_version" to System.getProperty("java.version"),
"java_vendor" to System.getProperty("java.vendor"),
"os_name" to System.getProperty("os.name"),
"os_version" to System.getProperty("os.version"),
"available_processors" to Runtime.getRuntime().availableProcessors(),
"max_memory" to "${Runtime.getRuntime().maxMemory() / 1024 / 1024} MB",
"total_memory" to "${Runtime.getRuntime().totalMemory() / 1024 / 1024} MB",
"free_memory" to "${Runtime.getRuntime().freeMemory() / 1024 / 1024} MB"
)
}
.withDetail("features") {
mapOf(
"database" to "PostgreSQL",
"cache" to "Redis",
"messaging" to "RabbitMQ",
"security" to "JWT + OAuth2",
"monitoring" to "Actuator + Prometheus"
)
}
}
private fun getApplicationVersion(): String {
// Try to read from manifest or properties
val implementationVersion = javaClass.`package`?.implementationVersion
return implementationVersion ?: "unknown"
}
private fun getBuildTime(): String {
// Try to read from build properties
return try {
val properties = Properties()
properties.load(javaClass.getResourceAsStream("/build-info.properties"))
properties.getProperty("build.time", "unknown")
} catch (ex: Exception) {
"unknown"
}
}
private fun getGitCommitInfo(): Map<String, String> {
return try {
val properties = Properties()
properties.load(javaClass.getResourceAsStream("/git.properties"))
mapOf(
"commit_id" to properties.getProperty("git.commit.id.abbrev", "unknown"),
"commit_time" to properties.getProperty("git.commit.time", "unknown"),
"branch" to properties.getProperty("git.branch", "unknown")
)
} catch (ex: Exception) {
mapOf("status" to "unavailable")
}
}
}
// Build info contribution
@Component
@ConditionalOnProperty(value = ["management.info.build.enabled"], havingValue = "true")
class BuildInfoContributor(
private val buildProperties: BuildProperties? = null
) : InfoContributor {
override fun contribute(builder: Info.Builder) {
buildProperties?.let { build ->
builder.withDetail("build") {
mapOf(
"artifact" to build.artifact,
"group" to build.group,
"name" to build.name,
"version" to build.version,
"time" to build.time.toString()
)
}
}
}
}
// Git info contribution
@Component
@ConditionalOnProperty(value = ["management.info.git.enabled"], havingValue = "true")
class GitInfoContributor(
private val gitProperties: GitProperties? = null
) : InfoContributor {
override fun contribute(builder: Info.Builder) {
gitProperties?.let { git ->
builder.withDetail("git") {
mapOf(
"branch" to git.branch,
"commit" to mapOf(
"id" to git.commitId,
"time" to git.commitTime.toString()
)
)
}
}
}
}
Environment Endpoint
The environment endpoint exposes configuration properties:
@Component
class EnvironmentPostProcessor : EnvironmentPostProcessor {
override fun postProcessEnvironment(
environment: ConfigurableEnvironment,
application: SpringApplication
) {
// Add custom property sources
val customProperties = MapPropertySource("custom", mapOf(
"app.environment.processor.enabled" to true,
"app.environment.processor.timestamp" to Instant.now().toString()
))
environment.propertySources.addLast(customProperties)
}
}
// Custom environment contributor
@Component
class CustomEnvironmentInfoContributor : InfoContributor {
@Autowired
private lateinit var environment: Environment
override fun contribute(builder: Info.Builder) {
val profiles = environment.activeProfiles.takeIf { it.isNotEmpty() }
?: environment.defaultProfiles
builder.withDetail("environment") {
mapOf(
"active_profiles" to profiles.toList(),
"default_profiles" to environment.defaultProfiles.toList(),
"configuration_classes" to getConfigurationClasses()
)
}
}
private fun getConfigurationClasses(): List<String> {
// This is a simplified implementation
// In a real application, you might want to inspect the application context
return listOf(
"ActuatorConfiguration",
"SecurityConfiguration",
"DatabaseConfiguration"
)
}
}
11.3 Customizing Actuator
Actuator’s real power comes from its extensibility. Let’s explore advanced customization options.
Custom Endpoints
Creating custom endpoints allows you to expose application-specific information:
// Custom endpoint for application statistics
@Component
@Endpoint(id = "application-stats")
class ApplicationStatsEndpoint(
private val userRepository: UserRepository,
private val orderRepository: OrderRepository,
private val cacheManager: CacheManager
) {
@ReadOperation
fun getApplicationStats(): ApplicationStats {
return ApplicationStats(
userStats = getUserStats(),
orderStats = getOrderStats(),
cacheStats = getCacheStats(),
systemStats = getSystemStats()
)
}
@ReadOperation
fun getApplicationStats(@Selector period: String): ApplicationStats {
val periodDays = when (period.toLowerCase()) {
"daily" -> 1
"weekly" -> 7
"monthly" -> 30
else -> throw IllegalArgumentException("Invalid period. Use: daily, weekly, monthly")
}
return getApplicationStatsForPeriod(periodDays)
}
@WriteOperation
fun refreshStats(): Map<String, Any> {
// Trigger stats refresh
return mapOf(
"status" to "refreshed",
"timestamp" to Instant.now()
)
}
@DeleteOperation
fun clearStats(): Map<String, Any> {
// Clear cached stats
return mapOf(
"status" to "cleared",
"timestamp" to Instant.now()
)
}
private fun getUserStats(): UserStats {
return UserStats(
totalUsers = userRepository.count(),
activeUsers = userRepository.countByActiveTrue(),
newUsersToday = userRepository.countUsersCreatedAfter(LocalDateTime.now().minusDays(1)),
newUsersThisWeek = userRepository.countUsersCreatedAfter(LocalDateTime.now().minusDays(7))
)
}
private fun getOrderStats(): OrderStats {
return OrderStats(
totalOrders = orderRepository.count(),
ordersToday = orderRepository.countOrdersCreatedAfter(LocalDateTime.now().minusDays(1)),
ordersThisWeek = orderRepository.countOrdersCreatedAfter(LocalDateTime.now().minusDays(7)),
totalRevenue = orderRepository.getTotalRevenue()
)
}
private fun getCacheStats(): CacheStats {
val cacheStatistics = cacheManager.cacheNames.associate { cacheName ->
val cache = cacheManager.getCache(cacheName)
cacheName to if (cache != null) {
mapOf(
"size" to getCacheSize(cache),
"hit_ratio" to getCacheHitRatio(cache)
)
} else {
mapOf("status" to "unavailable")
}
}
return CacheStats(
availableCaches = cacheManager.cacheNames.size,
cacheStatistics = cacheStatistics
)
}
private fun getSystemStats(): SystemStats {
val runtime = Runtime.getRuntime()
val memoryBean = ManagementFactory.getMemoryMXBean()
val gcBeans = ManagementFactory.getGarbageCollectorMXBeans()
return SystemStats(
heapMemoryUsage = memoryBean.heapMemoryUsage.let {
MemoryUsage(
used = it.used,
committed = it.committed,
max = it.max
)
},
nonHeapMemoryUsage = memoryBean.nonHeapMemoryUsage.let {
MemoryUsage(
used = it.used,
committed = it.committed,
max = it.max
)
},
garbageCollection = gcBeans.map { gc ->
GarbageCollectionStats(
name = gc.name,
collectionCount = gc.collectionCount,
collectionTime = gc.collectionTime
)
},
processorCount = runtime.availableProcessors(),
systemLoadAverage = ManagementFactory.getOperatingSystemMXBean().systemLoadAverage
)
}
private fun getApplicationStatsForPeriod(days: Int): ApplicationStats {
// Implementation for period-specific stats
val cutoffDate = LocalDateTime.now().minusDays(days.toLong())
return ApplicationStats(
userStats = UserStats(
totalUsers = userRepository.count(),
activeUsers = userRepository.countByActiveTrue(),
newUsersToday = userRepository.countUsersCreatedAfter(cutoffDate),
newUsersThisWeek = userRepository.countUsersCreatedAfter(cutoffDate)
),
orderStats = getOrderStats(),
cacheStats = getCacheStats(),
systemStats = getSystemStats()
)
}
private fun getCacheSize(cache: Cache): Int {
// This is cache implementation specific
return when (cache) {
is CaffeineCache -> cache.nativeCache.estimatedSize().toInt()
else -> -1 // Unknown size
}
}
private fun getCacheHitRatio(cache: Cache): Double {
// This is cache implementation specific
return when (cache) {
is CaffeineCache -> {
val stats = cache.nativeCache.stats()
if (stats.requestCount() > 0) {
stats.hitRate()
} else {
0.0
}
}
else -> -1.0 // Unknown hit ratio
}
}
}
// Data classes for custom endpoint
data class ApplicationStats(
val userStats: UserStats,
val orderStats: OrderStats,
val cacheStats: CacheStats,
val systemStats: SystemStats
)
data class UserStats(
val totalUsers: Long,
val activeUsers: Long,
val newUsersToday: Long,
val newUsersThisWeek: Long
)
data class OrderStats(
val totalOrders: Long,
val ordersToday: Long,
val ordersThisWeek: Long,
val totalRevenue: BigDecimal
)
data class CacheStats(
val availableCaches: Int,
val cacheStatistics: Map<String, Map<String, Any>>
)
data class SystemStats(
val heapMemoryUsage: MemoryUsage,
val nonHeapMemoryUsage: MemoryUsage,
val garbageCollection: List<GarbageCollectionStats>,
val processorCount: Int,
val systemLoadAverage: Double
)
data class MemoryUsage(
val used: Long,
val committed: Long,
val max: Long
) {
val usagePercentage: Double
get() = if (max > 0) (used.toDouble() / max) * 100 else 0.0
}
data class GarbageCollectionStats(
val name: String,
val collectionCount: Long,
val collectionTime: Long
)
Custom Web Endpoint
For more complex operations that need HTTP method support:
@Component
@WebEndpoint(id = "application-management")
class ApplicationManagementWebEndpoint(
private val cacheManager: CacheManager,
private val taskScheduler: TaskScheduler
) {
private val logger = LoggerFactory.getLogger(ApplicationManagementWebEndpoint::class.java)
@ReadOperation
fun getManagementInfo(): ResponseEntity<ManagementInfo> {
return ResponseEntity.ok(ManagementInfo(
cacheInfo = getCacheInfo(),
scheduledTasks = getScheduledTasksInfo(),
maintenanceMode = isMaintenanceModeEnabled()
))
}
@WriteOperation
fun clearCache(@Selector cacheName: String?): ResponseEntity<Map<String, Any>> {
return try {
when (cacheName) {
null -> {
// Clear all caches
cacheManager.cacheNames.forEach { name ->
cacheManager.getCache(name)?.clear()
}
ResponseEntity.ok(mapOf(
"status" to "success",
"message" to "All caches cleared",
"timestamp" to Instant.now()
))
}
else -> {
val cache = cacheManager.getCache(cacheName)
if (cache != null) {
cache.clear()
ResponseEntity.ok(mapOf(
"status" to "success",
"message" to "Cache '$cacheName' cleared",
"timestamp" to Instant.now()
))
} else {
ResponseEntity.badRequest().body(mapOf(
"status" to "error",
"message" to "Cache '$cacheName' not found"
))
}
}
}
} catch (ex: Exception) {
logger.error("Failed to clear cache", ex)
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf(
"status" to "error",
"message" to "Failed to clear cache: ${ex.message}"
))
}
}
@WriteOperation
fun enableMaintenanceMode(): ResponseEntity<Map<String, Any>> {
// Implementation to enable maintenance mode
System.setProperty("app.maintenance.enabled", "true")
return ResponseEntity.ok(mapOf(
"status" to "success",
"message" to "Maintenance mode enabled",
"timestamp" to Instant.now()
))
}
@DeleteOperation
fun disableMaintenanceMode(): ResponseEntity<Map<String, Any>> {
// Implementation to disable maintenance mode
System.setProperty("app.maintenance.enabled", "false")
return ResponseEntity.ok(mapOf(
"status" to "success",
"message" to "Maintenance mode disabled",
"timestamp" to Instant.now()
))
}
private fun getCacheInfo(): Map<String, Any> {
return mapOf(
"available_caches" to cacheManager.cacheNames.toList(),
"total_caches" to cacheManager.cacheNames.size
)
}
private fun getScheduledTasksInfo(): Map<String, Any> {
// This is a simplified implementation
// In practice, you'd want to track your scheduled tasks
return mapOf(
"scheduler_type" to taskScheduler.javaClass.simpleName,
"status" to "running"
)
}
private fun isMaintenanceModeEnabled(): Boolean {
return System.getProperty("app.maintenance.enabled", "false").toBoolean()
}
}
data class ManagementInfo(
val cacheInfo: Map<String, Any>,
val scheduledTasks: Map<String, Any>,
val maintenanceMode: Boolean
)
Custom Health Indicators with Groups
Create health indicator groups for different purposes:
@Configuration
class HealthConfiguration {
@Bean
fun healthContributorRegistry(): HealthContributorRegistry {
val registry = DefaultHealthContributorRegistry()
// Group for infrastructure health
registry.registerContributor("infrastructure", CompositeHealthContributor.fromMap(
mapOf(
"database" to DatabaseHealthIndicator(dataSource),
"redis" to RedisHealthIndicator(redisTemplate),
"messaging" to RabbitMQHealthIndicator(rabbitTemplate)
)
))
// Group for external services
registry.registerContributor("external", CompositeHealthContributor.fromMap(
mapOf(
"payment-service" to ExternalServiceHealthIndicator("payment"),
"notification-service" to ExternalServiceHealthIndicator("notification")
)
))
return registry
}
}
// Configuration for health groups
# application.yml
management:
endpoint:
health:
group:
liveness:
include: livenessState,database
show-details: always
readiness:
include: readinessState,redis,external
show-details: when-authorized
infrastructure:
include: database,redis,messaging
show-details: always
Monitoring and Alerting Integration
Integration with monitoring systems:
@Component
class PrometheusMetricsCustomizer : MeterRegistryCustomizer<PrometheusMeterRegistry> {
override fun customize(registry: PrometheusMeterRegistry) {
registry.config()
.meterFilter(MeterFilter.deny { id ->
// Filter out sensitive metrics
val name = id.name
name.contains("password") ||
name.contains("secret") ||
name.contains("key")
})
.meterFilter(MeterFilter.denyNameStartsWith("jvm.threads.states"))
.commonTags(
"service", "kotlin-spring-boot-app",
"version", getApplicationVersion()
)
}
private fun getApplicationVersion(): String {
return javaClass.`package`?.implementationVersion ?: "unknown"
}
}
// Custom metrics for business KPIs
@Component
class BusinessMetrics(
private val meterRegistry: MeterRegistry
) {
private val orderTotalGauge = Gauge.builder("business.order.total")
.description("Total order value")
.register(meterRegistry, this) { getTotalOrderValue() }
private val activeUserGauge = Gauge.builder("business.users.active")
.description("Number of active users")
.register(meterRegistry, this) { getActiveUserCount() }
private fun getTotalOrderValue(): Double {
// Implementation to get total order value
return 0.0 // Placeholder
}
private fun getActiveUserCount(): Double {
// Implementation to get active user count
return 0.0 // Placeholder
}
}
// Alert configuration (for integration with alerting systems)
@ConfigurationProperties(prefix = "app.monitoring.alerts")
data class AlertingProperties(
val enabled: Boolean = true,
val thresholds: AlertThresholds = AlertThresholds()
) {
data class AlertThresholds(
val errorRate: Double = 0.05, // 5%
val responseTime: Duration = Duration.ofSeconds(2),
val memoryUsage: Double = 0.8, // 80%
val diskUsage: Double = 0.9 // 90%
)
}
@Component
@ConditionalOnProperty(value = ["app.monitoring.alerts.enabled"], havingValue = "true")
class AlertingService(
private val alertingProperties: AlertingProperties,
private val meterRegistry: MeterRegistry
) {
private val logger = LoggerFactory.getLogger(AlertingService::class.java)
@EventListener
fun handleMetricEvent(event: MeterRegistryEvent) {
// Check thresholds and send alerts
checkErrorRateThreshold()
checkMemoryUsageThreshold()
}
private fun checkErrorRateThreshold() {
val errorRate = getErrorRate()
if (errorRate > alertingProperties.thresholds.errorRate) {
sendAlert("High error rate detected: ${errorRate * 100}%")
}
}
private fun checkMemoryUsageThreshold() {
val memoryUsage = getMemoryUsagePercentage()
if (memoryUsage > alertingProperties.thresholds.memoryUsage) {
sendAlert("High memory usage detected: ${memoryUsage * 100}%")
}
}
private fun getErrorRate(): Double {
// Calculate error rate from metrics
return 0.0 // Placeholder
}
private fun getMemoryUsagePercentage(): Double {
// Calculate memory usage percentage
return 0.0 // Placeholder
}
private fun sendAlert(message: String) {
logger.warn("ALERT: {}", message)
// Implementation to send alert (email, Slack, PagerDuty, etc.)
}
}
11.4 Summary
Spring Boot Actuator transforms your Kotlin application into a fully observable and manageable system. Throughout this chapter, we’ve explored how to effectively leverage Actuator’s capabilities while maintaining security and performance.
Dependency and Setup forms the foundation of effective monitoring. We covered:
- Proper dependency configuration with security considerations
- Production-ready security setup with role-based access control
- IP filtering and SSL configuration for sensitive environments
- Environment-specific endpoint exposure strategies
Built-in Endpoints provide comprehensive insights into your application:
- Health endpoints with custom health indicators for databases, external services, and application-specific concerns
- Metrics endpoints with custom business metrics and performance monitoring
- Info endpoints with build information, Git details, and application metadata
- Environment endpoints for configuration inspection and troubleshooting
Customizing Actuator enables application-specific monitoring:
- Custom endpoints for domain-specific statistics and management operations
- Health indicator groups for different monitoring scenarios (liveness, readiness, infrastructure)
- Integration with monitoring systems like Prometheus and Grafana
- Custom metrics and alerting based on business KPIs
The key benefits of our comprehensive Actuator implementation include:
- Observability: Complete visibility into application health, performance, and behavior
- Operational Excellence: Tools for managing applications in production environments
- Troubleshooting: Rich diagnostic information for debugging and problem resolution
- Monitoring Integration: Seamless integration with modern monitoring stacks
- Security: Proper access controls and sensitive data protection
Best practices we’ve established:
- Always secure Actuator endpoints in production environments
- Use separate ports for management endpoints when possible
- Implement custom health checks for critical dependencies
- Create business-specific metrics and KPIs
- Filter sensitive information from exposed endpoints
- Integrate with centralized monitoring and alerting systems
Kotlin-specific advantages we’ve leveraged:
- Data classes for clean metric and health check models
- Extension functions for enhanced API usability
- Null safety for robust error handling
- Sealed classes for type-safe status representations
The monitoring and management capabilities provided by Actuator are essential for running Kotlin Spring Boot applications in production. The patterns and configurations demonstrated in this chapter provide a solid foundation for building observable, manageable applications that can be effectively monitored and maintained at scale.
In the next chapter, we’ll explore server-to-server communication patterns, including how to monitor and instrument external service calls using the Actuator metrics we’ve established. The monitoring foundation we’ve built here will be crucial for tracking the health and performance of distributed system interactions.
The investment in comprehensive Actuator configuration pays dividends in operational excellence, system reliability, and developer productivity. Well-instrumented applications are easier to debug, monitor, and maintain, leading to better user experiences and more robust systems.