Spring Boot Monitoring Dashboard: From Zero to Production in 20 Minutes
A speed-run guide to setting up complete observability with Prometheus, Grafana, Loki, and Tempo — with pre-built dashboards you can copy-paste
I’ve seen too many teams deploy Spring Boot services to production with zero observability. No metrics, no dashboards, no alerting. Then when something breaks at 2 AM, it’s log-file archaeology with grep and prayers.
Here’s the thing: setting up proper monitoring for Spring Boot doesn’t take days. It takes 20 minutes if you know what you’re doing. After doing this for dozens of services across banking and e-commerce platforms in Singapore, I’ve distilled it to a repeatable recipe. Metrics, dashboards, alerts, logs, and traces — all connected, all copy-paste ready.
Let’s speed-run this.
What We’re Building
By the end of this post, you’ll have:
- Prometheus scraping metrics from your Spring Boot app
- Grafana with pre-built dashboards showing JVM stats, HTTP metrics, and business KPIs
- Alert rules that fire when response times spike or error rates climb
- Loki aggregating structured logs from your application
- Tempo capturing distributed traces with automatic span correlation
- All of it wired together so you can jump from a metric spike to related logs to the exact trace
Step 1: Spring Boot Dependencies (2 Minutes)
Add these to your pom.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<dependencies>
<!-- Actuator exposes metrics endpoint -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Prometheus metrics format -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Distributed tracing with OTLP export -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- Structured logging with Loki appender -->
<dependency>
<groupId>com.github.loki4j</groupId>
<artifactId>loki-logback-appender</artifactId>
<version>1.5.1</version>
</dependency>
</dependencies>
Step 2: Application Configuration (3 Minutes)
application.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
spring:
application:
name: order-service
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics,loggers
endpoint:
health:
show-details: when_authorized
probes:
enabled: true
metrics:
tags:
application: ${spring.application.name}
environment: ${ENVIRONMENT:local}
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.9, 0.95, 0.99
slo:
http.server.requests: 50ms, 100ms, 200ms, 500ms
tracing:
sampling:
probability: 1.0
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
Key configuration decisions:
- Percentile histograms — Enable these for proper p50/p90/p99 calculations in Prometheus
- SLO buckets — Define your latency SLOs upfront (50ms, 100ms, 200ms, 500ms)
- Trace correlation in logs — The logging pattern includes traceId and spanId so you can jump from log line to full trace
- 100% sampling — For local/staging. In production, use 10-20% or adaptive sampling
Step 3: Custom Business Metrics (3 Minutes)
Don’t just rely on auto-generated HTTP metrics. Add business-meaningful metrics:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Component
public class OrderMetrics {
private final Counter ordersCreated;
private final Counter ordersFailed;
private final Timer orderProcessingTime;
private final AtomicInteger activeOrders;
private final DistributionSummary orderValue;
public OrderMetrics(MeterRegistry registry) {
this.ordersCreated = Counter.builder("orders.created.total")
.description("Total orders created")
.tag("service", "order-service")
.register(registry);
this.ordersFailed = Counter.builder("orders.failed.total")
.description("Total orders that failed processing")
.tag("service", "order-service")
.register(registry);
this.orderProcessingTime = Timer.builder("orders.processing.duration")
.description("Time taken to process an order")
.publishPercentiles(0.5, 0.9, 0.95, 0.99)
.register(registry);
this.activeOrders = registry.gauge("orders.active.count",
new AtomicInteger(0));
this.orderValue = DistributionSummary.builder("orders.value")
.description("Distribution of order values")
.baseUnit("dollars")
.publishPercentiles(0.5, 0.75, 0.9)
.register(registry);
}
public void recordOrderCreated() {
ordersCreated.increment();
}
public void recordOrderFailed() {
ordersFailed.increment();
}
public Timer.Sample startProcessing() {
activeOrders.incrementAndGet();
return Timer.start();
}
public void stopProcessing(Timer.Sample sample) {
sample.stop(orderProcessingTime);
activeOrders.decrementAndGet();
}
public void recordOrderValue(double amount) {
orderValue.record(amount);
}
}
Usage in your service:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class OrderService {
private final OrderRepository repository;
private final OrderMetrics metrics;
public OrderService(OrderRepository repository, OrderMetrics metrics) {
this.repository = repository;
this.metrics = metrics;
}
public Order createOrder(CreateOrderRequest request) {
Timer.Sample sample = metrics.startProcessing();
try {
Order order = buildOrder(request);
Order saved = repository.save(order);
metrics.recordOrderCreated();
metrics.recordOrderValue(saved.getTotalAmount());
return saved;
} catch (Exception e) {
metrics.recordOrderFailed();
throw e;
} finally {
metrics.stopProcessing(sample);
}
}
}
Step 4: Structured Logging with Loki (3 Minutes)
Create logback-spring.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<!-- Console output for local development -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg %X{traceId:-} %n</pattern>
</encoder>
</appender>
<!-- Loki appender for log aggregation -->
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<http>
<url>http://localhost:3100/loki/api/v1/push</url>
</http>
<format>
<label>
<pattern>application=${appName},host=${HOSTNAME},level=%level</pattern>
</label>
<message>
<pattern>
{"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSS}","level":"%level","logger":"%logger","thread":"%thread","traceId":"%X{traceId:-none}","spanId":"%X{spanId:-none}","message":"%msg"}
</pattern>
</message>
<sortByTime>true</sortByTime>
</format>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="LOKI"/>
</root>
<!-- Reduce noise from libraries -->
<logger name="org.apache.kafka" level="WARN"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
</configuration>
The JSON-structured log messages mean you can query by any field in Grafana:
1
{application="order-service"} |= "traceId" | json | level="ERROR"
Step 5: Prometheus Configuration (2 Minutes)
docker/prometheus/prometheus.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alert-rules.yml"
scrape_configs:
- job_name: 'spring-boot-apps'
metrics_path: '/actuator/prometheus'
scrape_interval: 5s
static_configs:
- targets: ['host.docker.internal:8080']
labels:
application: 'order-service'
environment: 'local'
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
Step 6: Alert Rules (3 Minutes)
docker/prometheus/alert-rules.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
groups:
- name: spring-boot-alerts
rules:
# High error rate
- alert: HighErrorRate
expr: |
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
> 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "High error rate detected (> 5%)"
description: "Error rate is over the last 5 minutes"
# Slow response times
- alert: HighLatency
expr: |
histogram_quantile(0.95,
sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri)
) > 0.5
for: 3m
labels:
severity: warning
annotations:
summary: "P95 latency above 500ms"
description: "P95 latency for is s"
# JVM memory pressure
- alert: HighMemoryUsage
expr: |
jvm_memory_used_bytes{area="heap"}
/
jvm_memory_max_bytes{area="heap"}
> 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "JVM heap usage above 85%"
description: "Heap usage is "
# Application down
- alert: ApplicationDown
expr: up{job="spring-boot-apps"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Application is down"
description: " has been down for more than 1 minute"
# High order failure rate (business metric)
- alert: HighOrderFailureRate
expr: |
rate(orders_failed_total[5m])
/
(rate(orders_created_total[5m]) + rate(orders_failed_total[5m]))
> 0.1
for: 3m
labels:
severity: critical
annotations:
summary: "Order failure rate above 10%"
description: " of orders are failing"
Step 7: Grafana Dashboard (4 Minutes)
Here’s a pre-built Grafana dashboard JSON. Save this as docker/grafana/dashboards/spring-boot-overview.json:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
{
"dashboard": {
"title": "Spring Boot Application Overview",
"uid": "spring-boot-overview",
"timezone": "browser",
"refresh": "10s",
"panels": [
{
"title": "Request Rate",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m])) by (uri, method)",
"legendFormat": " "
}
]
},
{
"title": "Response Time (P95)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application=\"$application\"}[5m])) by (le, uri))",
"legendFormat": ""
}
]
},
{
"title": "Error Rate",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 8 },
"targets": [
{
"expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\", status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m]))"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"steps": [
{ "value": 0, "color": "green" },
{ "value": 0.01, "color": "yellow" },
{ "value": 0.05, "color": "red" }
]
}
}
}
},
{
"title": "JVM Heap Usage",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"targets": [
{
"expr": "jvm_memory_used_bytes{application=\"$application\", area=\"heap\"}",
"legendFormat": "Used"
},
{
"expr": "jvm_memory_max_bytes{application=\"$application\", area=\"heap\"}",
"legendFormat": "Max"
},
{
"expr": "jvm_memory_committed_bytes{application=\"$application\", area=\"heap\"}",
"legendFormat": "Committed"
}
],
"fieldConfig": {
"defaults": { "unit": "bytes" }
}
},
{
"title": "Active Threads",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"targets": [
{
"expr": "jvm_threads_live_threads{application=\"$application\"}",
"legendFormat": "Live"
},
{
"expr": "jvm_threads_peak_threads{application=\"$application\"}",
"legendFormat": "Peak"
}
]
},
{
"title": "Orders Created vs Failed",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
"targets": [
{
"expr": "rate(orders_created_total{application=\"$application\"}[5m])",
"legendFormat": "Created"
},
{
"expr": "rate(orders_failed_total{application=\"$application\"}[5m])",
"legendFormat": "Failed"
}
]
},
{
"title": "Database Connection Pool",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"targets": [
{
"expr": "hikaricp_connections_active{application=\"$application\"}",
"legendFormat": "Active"
},
{
"expr": "hikaricp_connections_idle{application=\"$application\"}",
"legendFormat": "Idle"
},
{
"expr": "hikaricp_connections_max{application=\"$application\"}",
"legendFormat": "Max"
}
]
}
],
"templating": {
"list": [
{
"name": "application",
"type": "query",
"query": "label_values(jvm_info, application)",
"current": { "text": "order-service", "value": "order-service" }
}
]
}
}
}
Grafana provisioning (docker/grafana/provisioning/datasources/datasources.yml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: true
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"traceId":"(\w+)"'
name: TraceID
url: '$${__value.raw}'
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
uid: tempo
editable: true
jsonData:
tracesToLogs:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: true
The magic here is the derivedFields configuration in Loki — it automatically creates clickable links from traceId values in your logs to the full trace in Tempo. And Tempo’s tracesToLogs does the reverse: from a trace span, you can jump directly to the related logs.
Step 8: Verify Everything Works (2 Minutes)
Start the infrastructure:
1
docker compose up -d prometheus grafana loki tempo
Start your Spring Boot app:
1
./mvnw spring-boot:run
Verify metrics are exposed:
1
curl http://localhost:8080/actuator/prometheus | head -20
Check Prometheus is scraping (open http://localhost:9090/targets):
- Status should show “UP” for your application target
Open Grafana at http://localhost:3000 (admin/admin):
- The Spring Boot Overview dashboard should already be available via provisioning
The Complete Observability Loop
Here’s how all the pieces connect in an incident scenario:
- Alert fires — Grafana shows “High Error Rate” alert from Prometheus
- Check the dashboard — You see the error spike on the timeseries panel, correlated with a latency increase
- Drill into logs — Click the time range, switch to Loki datasource, filter by
level="ERROR" - Find the trace — Click the traceId link in the log line, jumps to Tempo
- See the full picture — The trace shows which downstream service is slow or failing
This loop — metric to log to trace — is what separates “we have monitoring” from “we have observability.”
Production Checklist
Before deploying this to production, adjust these settings:
- Sampling rate — Reduce from 1.0 to 0.1 or 0.2 (10-20% of requests traced)
- Retention — Set Prometheus retention to 15-30 days, Loki to 7-14 days
- Storage — Use remote storage (S3/GCS) for Loki and Tempo in production
- Authentication — Enable auth on Grafana, use service accounts for Prometheus
- Resource limits — Prometheus needs 2-4GB RAM for 30-day retention with 10+ services
- Alert routing — Connect Alertmanager to Slack/PagerDuty for real notifications
Common Pitfalls
- Cardinality explosion — Don’t tag metrics with user IDs, request IDs, or any high-cardinality values. Your Prometheus will OOM.
- Missing histogram buckets — If your SLO is 200ms but all your buckets are above 1s, you can’t measure your SLO accurately.
- Log volume — Structured JSON logs are larger. Set Loki retention carefully or you’ll fill your disk.
- Trace context propagation — If you use RestTemplate or WebClient, trace context propagates automatically. If you use raw HttpClient, you need to propagate headers manually.
Final Thoughts
20 minutes. That’s all it takes to go from zero observability to a production-grade monitoring setup. The initial investment is minimal, but the payoff the first time something breaks at 2 AM is immeasurable.
The most important piece isn’t any single tool — it’s the correlation between metrics, logs, and traces. When you can jump from “error rate spiked” to “here’s the exact request that failed and why” in under 30 seconds, you’ve achieved real observability.