3637 words
18 minutes
Monitoring Spring Boot with Actuator, Micrometer, and Prometheus

Spring Boot is a common choice for enterprise applications. As services are split into microservices and deployed in containers, the number of running instances and service calls grows. Understanding what happens at runtime becomes essential.

A growing heap, a long garbage collection pause, or a blocked thread can become a production incident. Monitoring helps us see these problems before users feel their effects.

Micrometer provides a common API for application metrics. Together with Spring Boot Actuator, it exposes JVM and business metrics to monitoring systems such as Prometheus. This guide starts with endpoint configuration, then explains the metrics, queries, and alerts that matter in daily operations.

The examples follow the source document’s baseline: Spring Boot 3.5.3 with embedded Tomcat 10.1. Metric availability and labels depend on the JVM, libraries, and enabled instrumentation. Check your own /actuator/prometheus output before building dashboards.

1. Expose Metrics with Actuator#

Add the dependencies#

Add these dependencies to pom.xml. The Spring Boot parent manages their versions.

<!-- Production monitoring and management endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Prometheus registry for /actuator/prometheus -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Configure the endpoints#

The following configuration exposes health, application information, and Prometheus metrics. It also enables the Tomcat MBean registry, which is needed for Tomcat thread metrics.

server:
tomcat:
mbeanregistry:
enabled: true
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
show-details: when-authorized
status:
order: DOWN,OUT_OF_SERVICE,UNKNOWN,UP
http-mapping:
UP: 200
DOWN: 503
OUT_OF_SERVICE: 503
UNKNOWN: 503
info:
env:
enabled: true

The custom status order treats UNKNOWN as more severe than UP. This is useful if a custom health indicator reports UNKNOWN and you want the overall endpoint to return HTTP 503. Other indicators still take part in the aggregate status; this configuration does not give one indicator exclusive control.

The source document also exposes env and sets both health.show-details and env.show-values to always. Those settings can help during local debugging, but exposing environment values can reveal credentials. Enable them only in a controlled environment. In production, protect management endpoints with authentication and network access controls. Enabling management.info.env.enabled includes configured info.* properties in /actuator/info.

Export component health as a metric#

/actuator/health reports the health of dependencies such as databases and Redis. It does not automatically create the custom health_status metric used in this guide.

The following component registers one gauge for each top-level HealthIndicator. It reports 1 for UP and 0 for any other status, timeout, or failure.

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PreDestroy;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthContributorRegistry;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@Component
public class HealthMetricsExportBinder {
private static final int CHECK_TIMEOUT_MS = 200;
private final ThreadPoolExecutor healthCheckPool = new ThreadPoolExecutor(
6, 6, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(32),
new ThreadPoolExecutor.AbortPolicy());
private final HealthContributorRegistry contributors;
private final MeterRegistry meterRegistry;
public HealthMetricsExportBinder(
HealthContributorRegistry contributors,
MeterRegistry meterRegistry) {
this.contributors = contributors;
this.meterRegistry = meterRegistry;
}
@EventListener(ApplicationReadyEvent.class)
public void registerHealthGauges() {
contributors.stream().forEach(named -> {
if (named.getContributor() instanceof HealthIndicator indicator) {
Gauge.builder("health_status", indicator, this::healthValue)
.description("Component health: 1 for UP, 0 otherwise")
.tag("component", named.getName())
.register(meterRegistry);
}
});
}
private double healthValue(HealthIndicator indicator) {
Future<Health> future = null;
try {
future = healthCheckPool.submit(indicator::health);
Health health = future.get(CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS);
return Status.UP.equals(health.getStatus()) ? 1.0 : 0.0;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return 0.0;
} catch (TimeoutException | ExecutionException
| RejectedExecutionException ex) {
return 0.0;
} finally {
if (future != null && !future.isDone()) {
future.cancel(true);
}
}
}
@PreDestroy
public void destroy() {
healthCheckPool.shutdownNow();
}
}

The health checks run in a separate pool, but the scrape still waits for each result. The 200 ms timeout applies to each check, not to the entire scrape. Choose a timeout that suits your dependencies.

This example adds a bounded queue and cancellation to the source’s approach. Cancellation cannot guarantee that a blocked database or network call stops, so configure timeouts in the underlying clients too. For larger systems, refresh health in the background and expose cached values. Composite contributors require traversal of their child indicators; the example above deliberately handles only top-level indicators.

The gauge is independent of management.endpoint.health.show-details.

Verify the output#

Start the application and request:

Terminal window
curl http://localhost:8080/actuator/prometheus

With the custom binder installed, the response may include:

# HELP health_status Component health: 1 for UP, 0 otherwise
# TYPE health_status gauge
health_status{component="ping"} 1.0
health_status{component="diskSpace"} 1.0

Database and other component series appear when their indicators are registered and supported by the binder.

2. Know the Main Metric Groups#

GroupTypical metric names or prefixesWhat they describe
HTTP requestshttp_server_requests_seconds_*Traffic, latency, status codes, methods, and routes
Dependency healthhealth_statusCustom component health gauges
JVMjvm_*Memory, garbage collection, threads, class loading, and compilation
Web containertomcat_threads_*, jetty_threads_*Container thread usage and limits
Process and systemprocess_*, system_*CPU, file descriptors, uptime, and load
Database poolsjdbc_connections_*, hikaricp_*, custom druid_*Active, idle, and waiting connections
Logginglogback_events_totalLog events by severity
Cachescache_*Hits, misses, evictions, and size, where supported
Executorsexecutor_*Active threads, queued tasks, and completed work
Startupapplication_started_time_seconds, application_ready_time_secondsStartup and readiness duration
Business activityApplication-defined namesOrders, payments, and other domain events

In the queries below, job and instance are Prometheus target labels. svc is an optional service label that you must supply through your metrics or scrape configuration. Adjust grouping labels to match your deployment.

3. HTTP Requests#

Request volume and requests per second#

http_server_requests_seconds_count is a counter of recorded HTTP requests. Use rate() to calculate requests per second over a time window.

PromQL
# Requests per second by service
sum by (job, svc) (rate(http_server_requests_seconds_count[5m]))
# Requests per second by route and method
sum by (job, svc, uri, method) (rate(http_server_requests_seconds_count[5m]))
# The ten busiest routes
topk(10, sum by (job, svc, uri) (rate(http_server_requests_seconds_count[5m])))

Error rates#

These queries return percentages for error rates and requests per second for the status-code breakdown.

PromQL
# Server error rate: HTTP 5xx
sum by (job) (rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum by (job) (rate(http_server_requests_seconds_count[5m])) * 100
# Server error rate by route
sum by (job, svc, uri) (rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum by (job, svc, uri) (rate(http_server_requests_seconds_count[5m])) * 100
# Client error rate: HTTP 4xx
sum by (job) (rate(http_server_requests_seconds_count{status=~"4.."}[5m]))
/
sum by (job) (rate(http_server_requests_seconds_count[5m])) * 100
# Request rate by status code
sum by (job, status) (rate(http_server_requests_seconds_count[5m]))

If no matching error series exists, the error-rate query may return no result. If there is no traffic, division by zero can produce NaN. Account for these cases in dashboards and use a minimum traffic condition for percentage-based alerts.

Response time#

MetricTypeMeaning
http_server_requests_seconds_sumCounterTotal duration of recorded requests, in seconds
http_server_requests_seconds_countCounterNumber of recorded requests
http_server_requests_seconds_bucketHistogram bucket counterRequests within each le duration boundary; requires histogram configuration
http_server_requests_seconds_maxGaugeMaximum recorded duration within the recent statistics window

Count and total duration are available without histogram buckets. To calculate P50, P95, or P99 with histogram_quantile(), enable a histogram:

management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: true

Merge this into the existing management section. Histograms increase the number of time series, especially when a metric has many label combinations.

PromQL
# Average response time in seconds
sum by (job) (rate(http_server_requests_seconds_sum[5m]))
/
sum by (job) (rate(http_server_requests_seconds_count[5m]))
# Average response time by route, in milliseconds
sum by (job, svc, uri) (rate(http_server_requests_seconds_sum[5m]))
/
sum by (job, svc, uri) (rate(http_server_requests_seconds_count[5m])) * 1000
# Recent maximum response time for each recorded label combination
http_server_requests_seconds_max
# P95 response time in seconds; requires histogram buckets
histogram_quantile(
0.95,
sum by (le, job) (rate(http_server_requests_seconds_bucket[5m]))
)

The source also discusses http_server_requests_active_seconds_duration_sum and http_server_requests_active_seconds_active_count. Do not assume these exist in a standard Spring MVC application. They require suitable active-request instrumentation. If available, their ratio gives the mean elapsed time of requests still in progress:

PromQL
http_server_requests_active_seconds_duration_sum
/
http_server_requests_active_seconds_active_count

The total elapsed duration of currently active requests is a gauge: it can fall when requests finish. It is not a counter of completed request duration.

As initial thresholds, consider a warning above 500 ms average latency and a critical alert above one second. Adjust these values to the service’s requirements. Compare latency with request volume to find busy, slow routes, and use percentiles to reveal delays hidden by averages.

4. Dependency Health#

The custom health_status gauge uses a simple convention: 1 means healthy; 0 means the check did not report UP.

PromQL
# Unhealthy components
health_status == 0
# Healthy components
health_status == 1
# Number of unhealthy components per instance
count by (job, instance) (health_status == 0)
# Select instances with at least one unhealthy component
min by (job, instance) (health_status) != 1
# Equivalent selection using a count
count by (job, instance) (health_status == 0) > 0

PromQL comparisons filter series unless you add bool. For example, min(...) != 1 retains an unhealthy instance with its value of 0; it does not convert that value to 1. Alert rules activate when a result series is present. See the Prometheus comparison operator documentation.

Missing health data is a separate condition. Monitor scrape failures with up == 0 as well, since an unreachable application cannot report its own health.

5. JVM Metrics#

Memory#

JVM memory metrics distinguish two areas:

  • area="heap": memory for ordinary objects and arrays, managed by garbage collection and influenced by -Xms and -Xmx.
  • area="nonheap": memory used for JVM facilities such as class metadata and compiled code; it is not governed by the heap’s -Xmx limit.
AreaExample pool namesPurpose
HeapG1 Eden Space, PS Eden SpaceAllocation of new objects
HeapG1 Survivor Space, PS Survivor SpaceObjects that survive young-generation collection
HeapG1 Old Gen, PS Old Gen, Tenured GenLonger-lived objects
Non-heapMetaspaceClass metadata
Non-heapCompressed Class SpaceClass metadata associated with compressed class pointers
Non-heapCodeCache, CodeHeap...Machine code produced by the JIT compiler

Pool names depend on the collector and JDK. ZGC and Shenandoah may expose different layouts, so inspect the actual id labels before writing pool-specific queries.

MetricTypeMeaning
jvm_memory_used_bytesGaugeMemory currently used in a pool
jvm_memory_committed_bytesGaugeMemory committed for JVM use; not necessarily resident physical memory
jvm_memory_max_bytesGaugeReported pool limit; -1 means the limit is undefined

The source also lists jvm_memory_init_bytes. Verify its availability before using it; it is not part of every standard Micrometer export. Direct buffer memory is reported separately through metrics such as jvm_buffer_memory_used_bytes.

PromQL
# Heap usage by pool
jvm_memory_used_bytes{area="heap"}
# Usage percentage for pools with a defined positive limit
jvm_memory_used_bytes{area="heap"}
/
(jvm_memory_max_bytes{area="heap"} > 0) * 100
# Committed heap memory and reported limits
jvm_memory_committed_bytes{area="heap"}
jvm_memory_max_bytes{area="heap"}
# Metaspace usage
jvm_memory_used_bytes{area="nonheap", id="Metaspace"}
# Code cache usage; adapt the pool pattern to your JVM
jvm_memory_used_bytes{area="nonheap", id=~"Code.*"}
# Old-generation growth in bytes per second over one hour
deriv(jvm_memory_used_bytes{area="heap", id=~".*Old.*|Tenured Gen"}[1h])

The percentage query is per pool, not a universal whole-heap calculation. Some collectors report undefined limits for individual pools. Validate any whole-heap ratio against the collector’s memory layout.

Sustained usage above 90% deserves attention. An old generation that keeps growing after collection may indicate a leak, while growing Metaspace may point to repeated class generation or retained class loaders. Compare used, committed, and maximum memory before changing JVM settings. A rising trend alone does not prove a leak.

Garbage collection#

MetricTypeMeaning
jvm_gc_pause_seconds_countCounterRecorded GC pause events
jvm_gc_pause_seconds_sumCounterTotal recorded pause time in seconds
jvm_gc_pause_seconds_maxGaugeLargest pause in the recent statistics window
jvm_gc_memory_allocated_bytes_totalCounterEstimated allocated bytes from GC observations
jvm_gc_memory_promoted_bytes_totalCounterEstimated bytes promoted to the old generation
jvm_gc_live_data_size_bytesGaugeReported long-lived data size after relevant collection events
jvm_gc_max_data_size_bytesGaugeReported maximum size of the long-lived memory pool

The pause timer can support percentile queries when histogram buckets are enabled. The meaning and availability of GC measurements depend on the collector.

PromQL
# Pause events per second, retaining collector and cause labels
rate(jvm_gc_pause_seconds_count[5m])
# Average pause duration in seconds
rate(jvm_gc_pause_seconds_sum[5m])
/
rate(jvm_gc_pause_seconds_count[5m])
# Major collection events over the last hour, where this label is used
increase(jvm_gc_pause_seconds_count{action="end of major GC"}[1h])
# Total major collection pause time over the last hour
increase(jvm_gc_pause_seconds_sum{action="end of major GC"}[1h])
# Minor collection events over the last hour
increase(jvm_gc_pause_seconds_count{action="end of minor GC"}[1h])
# Recorded GC pause time as a percentage of elapsed time, per instance
sum by (job, instance) (rate(jvm_gc_pause_seconds_sum[5m])) * 100
# Allocation and promotion rates, in bytes per second
rate(jvm_gc_memory_allocated_bytes_total[5m])
rate(jvm_gc_memory_promoted_bytes_total[5m])

Subtracting cumulative promoted bytes from cumulative allocated bytes does not measure memory reclaimed by a collection. Use before-and-after heap observations or GC logs for that analysis.

GC labels add context:

LabelExample valueInterpretation
actionend of minor GCA young-generation collection event on collectors that use this label
actionend of major GCA major collection event; confirm its meaning for your collector
causeG1 Evacuation PauseA G1 evacuation pause
causeAllocation FailureAn allocation could not be satisfied without collection
causeMetadata GC ThresholdMetadata usage reached a collection threshold
causeSystem.gc()An explicit GC request
causeHeap Dump Initiated GCCollection initiated while taking a heap dump
causeErgonomicsCollection triggered by JVM adaptive policy
causeG1 Humongous AllocationAllocation of an object larger than half a G1 region
causeG1 Mixed GCA mixed collection, if exposed under this label
causeConcurrent Mode FailureA legacy CMS collector could not finish concurrent collection in time
causePromotion FailedObjects could not be promoted successfully
gcG1 Young Generation, G1 Old GenerationG1 collector names
gcPS Scavenge, PS MarkSweepParallel collector names
gcCMS, ZGC, ShenandoahOther collector names, depending on JDK and instrumentation

These are examples across JVM generations, not labels guaranteed in Spring Boot 3.5. CMS belongs to older JDKs.

The source proposes investigating more than one Full GC per hour, a Full GC pause above three seconds, or recorded pause time above 5% of elapsed time. Treat these as starting points. Frequent young collections may reflect allocation pressure; poor old-generation recovery after major collections may indicate retained objects. Tune alerts to the collector and the service’s latency requirements.

Threads#

MetricTypeMeaning
jvm_threads_live_threadsGaugeCurrent live thread count, including daemon threads
jvm_threads_daemon_threadsGaugeCurrent daemon thread count
jvm_threads_peak_threadsGaugePeak live thread count since startup or the last peak reset
jvm_threads_states_threadsGaugeThread count by the state label
jvm_threads_started_threads_totalCounterTotal threads started
PromQL
# Current, peak, and daemon thread counts
jvm_threads_live_threads
jvm_threads_peak_threads
jvm_threads_daemon_threads
# Distribution by thread state
jvm_threads_states_threads
# Growth in live threads over one hour
deriv(jvm_threads_live_threads[1h])
# Deadlocked threads, if a binder exports this metric
jvm_threads_deadlocked_threads

Look for sustained thread growth and unexpected changes in state distribution. Many runnable threads can indicate CPU pressure or busy loops. Blocked threads may indicate lock contention, but waiting threads are often normal in idle pools. Use a thread dump to investigate suspected deadlocks. Thread-state label values may be lowercase with hyphens, so copy them from the exported metrics.

Class loading#

jvm_classes_loaded_classes measures the number of classes currently loaded. jvm_classes_unloaded_classes_total counts classes unloaded since startup.

PromQL
# Currently loaded classes
jvm_classes_loaded_classes
# Classes unloaded per second
rate(jvm_classes_unloaded_classes_total[5m])

The source also uses jvm_classes_loaded_classes_total as a cumulative load counter. Do not assume that metric is exported by the standard binder. If your instrumentation provides it, query its rate:

PromQL
rate(jvm_classes_loaded_classes_total[5m])

A sudden rise in loaded classes can accompany generated proxies, dynamic bytecode, or reloads. Compare it with Metaspace growth. A stable unload counter is normal for many applications; it suggests a class-loader leak only when classes should be unloaded but remain retained.

JIT compilation#

Just-in-time compilation converts bytecode into machine code while the application runs. It lets the JVM optimize frequently executed code using runtime information.

jvm_compilation_time_seconds_total measures cumulative compilation time when the JVM supports compilation monitoring. Spring Boot’s JVM metrics include JIT compilation instrumentation; an extra library is not inherently required. See the Spring Boot JVM metrics documentation.

Compilation metrics are especially useful when investigating warmup, dynamic code generation, or changing class-loading behavior.

6. Tomcat Threads#

With embedded Tomcat, enable server.tomcat.mbeanregistry.enabled as shown earlier, then restart the application.

MetricTypeMeaning
tomcat_threads_busy_threadsGaugeThreads currently handling work
tomcat_threads_current_threadsGaugeCurrent thread count, including idle threads
tomcat_threads_config_max_threadsGaugeConfigured maximum thread count

The source also names tomcat_threads_config_min_spare_threads and tomcat_threads_queue_remaining_capacity. These require additional instrumentation; they are not standard metrics from the built-in binder. A worker task queue and the socket accept backlog are different resources and should be measured separately.

PromQL
# Tomcat worker utilization
tomcat_threads_busy_threads / tomcat_threads_config_max_threads

A ratio above 0.70 is a useful warning starting point; above 0.90 may warrant urgent investigation. High worker utilization does not necessarily mean high CPU use. Threads may be waiting on a database, an external HTTP call, or a lock.

As the pool fills, request latency and queueing can rise. Check downstream bottlenecks before increasing the maximum thread count.

7. Process and System Metrics#

CPU#

MetricTypeMeaning
process_cpu_usageGaugeRecent JVM process CPU utilization, normally expressed from 0 to 1
system_cpu_usageGaugeRecent system CPU utilization visible to the JVM
system_cpu_countGaugeAvailable processor count reported to the JVM
PromQL
# Recent process CPU utilization as a percentage
process_cpu_usage * 100
# Recent system CPU utilization as a percentage
system_cpu_usage * 100
# Processor count visible to the JVM
system_cpu_count

Do not divide process_cpu_usage by system_cpu_count as a general normalization step. The process gauge already comes from the JVM’s CPU-load calculation. Container limits and JDK behavior affect its interpretation.

For cumulative process CPU time, inspect the exported name and unit. The source uses process_cpu_time_seconds_total, but Micrometer’s binder registers process.cpu.time with a nanosecond base unit, so an export may instead use process_cpu_time_ns_total. Apply rate() or increase() to the counter actually present and convert its units as needed. See the Micrometer processor binder.

File descriptors#

process_files_open reports open file descriptors, including sockets. process_files_max reports the allowed limit on supported platforms.

PromQL
# File descriptor utilization
process_files_open / process_files_max

A rising ratio may indicate leaked files or connections. Investigate before the process reaches its limit.

Uptime and restarts#

MetricTypeMeaning
process_uptime_secondsGaugeJVM uptime in seconds
process_start_time_secondsGaugeProcess start time as a Unix timestamp
PromQL
# Process age in seconds
time() - process_start_time_seconds
# Select processes started within the last five minutes
time() - process_start_time_seconds < 300

The start timestamp normally lies in the past. A small process age indicates a recent start, which may be a restart, deployment, or newly added instance.

8. Database Connection Pools#

Metric names depend on the connection pool. Spring Boot provides generic jdbc.connections.* instrumentation for supported data sources, while HikariCP has its own hikaricp.* metrics.

The source example uses Druid. The following binder exposes a small set of Druid gauges; it does not reproduce the full Druid monitoring console.

Add Druid#

The source uses this dependency version:

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.23</version>
</dependency>

Register the gauges#

This version uses Spring bean names as labels so multiple unnamed data sources do not share the same metric identity.

import com.alibaba.druid.pool.DruidDataSource;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.util.Map;
@Component
public class DruidDataSourceMeterBinder implements MeterBinder {
private final Map<String, DataSource> dataSources;
public DruidDataSourceMeterBinder(Map<String, DataSource> dataSources) {
this.dataSources = dataSources;
}
@Override
public void bindTo(MeterRegistry registry) {
dataSources.forEach((name, dataSource) -> {
if (dataSource instanceof DruidDataSource druid) {
Gauge.builder("druid.connections.active", druid,
DruidDataSource::getActiveCount)
.description("Active Druid connections")
.tag("datasource", name)
.register(registry);
Gauge.builder("druid.connections.idle", druid,
DruidDataSource::getPoolingCount)
.description("Idle Druid connections")
.tag("datasource", name)
.register(registry);
Gauge.builder("druid.connections.pending", druid,
DruidDataSource::getWaitThreadCount)
.description("Threads waiting for a Druid connection")
.tag("datasource", name)
.register(registry);
Gauge.builder("druid.connections.max", druid,
DruidDataSource::getMaxActive)
.description("Maximum Druid connections")
.tag("datasource", name)
.register(registry);
Gauge.builder("druid.connections.min", druid,
DruidDataSource::getMinIdle)
.description("Minimum idle Druid connections")
.tag("datasource", name)
.register(registry);
}
});
}
}

The example handles direct DruidDataSource beans. Routing data sources and wrappers may need additional handling.

Prometheus metricDruid methodMeaning
druid_connections_activegetActiveCount()Connections currently in use
druid_connections_idlegetPoolingCount()Idle connections
druid_connections_pendinggetWaitThreadCount()Threads waiting for a connection
druid_connections_maxgetMaxActive()Maximum active connections
druid_connections_mingetMinIdle()Configured minimum idle connections

All five are gauges, with a datasource label to distinguish pools.

PromQL
# Pool utilization
druid_connections_active / druid_connections_max
# Ten most heavily used pools, as percentages
topk(10, druid_connections_active / druid_connections_max * 100)
# Threads waiting for connections
druid_connections_pending

Consider warning above 80% utilization and escalating above 90%, especially when pending requests also rise. Persistent saturation can result from slow queries, long transactions, leaked connections, or insufficient capacity. Increasing the pool is useful only if the database can handle the extra concurrency.

9. Logging#

logback_events_total counts logging events by severity. Use the label values actually exported by your binder; standard Micrometer Logback metrics use lowercase levels.

PromQL
# Error events per second
rate(logback_events_total{level="error"}[1m])
# Warning events per second
rate(logback_events_total{level="warn"}[1m])
# Error events during the last minute
increase(logback_events_total{level="error"}[1m])

These counters reveal changes in error volume. Use the logs themselves to understand the failures.

10. Caches#

The source’s Tomcat example includes these resource-cache counters:

# TYPE tomcat_cache_access_total counter
tomcat_cache_access_total 0.0
# TYPE tomcat_cache_hit_total counter
tomcat_cache_hit_total 0.0

Tomcat’s resource cache is separate from an application’s business cache. Application cache metrics under cache_* depend on the cache provider, enabled statistics, and registered instrumentation. Ehcache, Redis, and other providers need their own interpretation; this guide does not cover them in detail.

11. Executors and Scheduled Tasks#

Spring Boot can instrument supported ThreadPoolTaskExecutor and ThreadPoolTaskScheduler beans. Metrics use the executor_ prefix and a name label identifying the bean.

# TYPE executor_pool_max_threads gauge
executor_pool_max_threads{name="myTaskExecutor"} 10.0
MetricTypeMeaning
executor_active_threadsGaugeThreads executing tasks
executor_pool_size_threadsGaugeCurrent pool size
executor_pool_core_threadsGaugeConfigured core pool size
executor_pool_max_threadsGaugeConfigured maximum pool size
executor_queued_tasksGaugeTasks waiting in the queue
executor_queue_remaining_tasksGaugeRemaining queue capacity
executor_completed_tasks_totalCounterCompleted tasks

Inspect the exported set for each executor implementation. Rising queue depth shows that work is arriving faster than it completes. A nearly full bounded queue can lead to rejected tasks. Check task duration, downstream delays, and queue policy before changing pool size. An unbounded queue can accumulate work even when the configured maximum thread count looks generous.

12. Application Startup#

MetricMeaning
process_uptime_secondsTime since the JVM started; grows continuously
application_started_time_secondsApplication startup duration recorded at ApplicationStartedEvent
application_ready_time_secondsDuration until ApplicationReadyEvent, after application runners finish

The two application metrics are gauges recorded during startup. Their timing is based on application startup, which is distinct from JVM process uptime.

JVM starts
-> Process uptime begins
-> Spring application startup begins
-> Application context is refreshed
-> ApplicationStartedEvent records startup duration
-> CommandLineRunner and ApplicationRunner execute
-> ApplicationReadyEvent records readiness duration
PromQL
# Time between the started and ready milestones
application_ready_time_seconds - application_started_time_seconds

A large difference often points to warmup tasks, data loading, or remote calls in application runners. It can include other work between the two events, so it is not an exact profiler of runner execution.

Kubernetes routes traffic according to its configured readiness probe. Recording an application-ready metric does not, by itself, configure that probe.

13. Business Metrics#

Infrastructure metrics explain how a service runs. Business metrics explain whether it is doing useful work.

Examples include orders created, payments completed, and payment failures. Their design depends on the application, so the source leaves detailed instrumentation outside this guide’s scope.

14. Suggested Alert Thresholds#

The following rules are starting points from the source document. Tune them to normal traffic, service objectives, and workload behavior. The for duration specifies how long a condition must remain true before an alert fires.

AlertSeverityforSuggested condition
AppHighAvgResponseTimeWarning5mAverage response time above 500 ms
AppCriticalAvgResponseTimeCritical5mAverage response time above 1 second
AppHigh5xxErrorRateCritical5mServer error rate above 1%
AppHigh4xxErrorRateWarning5mClient error rate above 5%
AppQpsAnomalyWarning5mRequest rate changes by more than 50% against a defined comparable baseline
AppComponentUnhealthyCritical1mmin by (job, instance) (health_status) != 1
AppHeapUsageHighWarning5mValidated heap utilization above 80%
AppHeapUsageCriticalCritical5mValidated heap utilization above 90%
AppTomcatThreadsHighWarning5mBusy threads / maximum threads above 0.70
AppTomcatThreadsCriticalCritical5mBusy threads / maximum threads above 0.90
AppDruidPoolUsageHighWarning5mActive connections / maximum connections above 0.80
AppDruidPoolUsageCriticalCritical5mActive connections / maximum connections above 0.90

Keep instance and data-source labels where they help identify the failing resource. Avoid treating normal low-traffic variation as an incident, and handle missing data separately from healthy values.

15. Build a Useful Monitoring Practice#

Start with a small set of signals: request volume, 5xx error rate, heap usage, major GC activity, and database pool activity and waiters. Together, they cover traffic, availability, memory pressure, and database access. Problems in one area often spread to the others.

Expose only the Actuator endpoints you need, protect them in production, and use Micrometer and Prometheus to collect metrics. Build dashboards and alerts in Grafana or your chosen monitoring tools. Set thresholds early enough to act before users experience a serious failure. Continuous profiling can help explain slow calls and unexpected memory retention.

As the system matures, add business metrics, connect metrics with distributed traces, and keep alert rules in version control. Review those rules as the application changes so the monitoring continues to reflect the service it protects.

Monitoring Spring Boot with Actuator, Micrometer, and Prometheus
https://astro-nyc.pages.dev/posts/spring-boot-monitoring-actuator-micrometer-prometheus/
Author
Hari
Published at
2026-09-27
License
CC BY-NC-SA 4.0