How to Add Observability to Spring Boot with Micrometer: A Beginner's Guide
Problem
I deployed my Spring Boot application to production. Then a user reported that the API was slow. I opened my logs and saw nothing useful. No metrics. No traces. No idea what was happening.
I realized I was flying blind.
User: "The checkout page is slow."Me: "Let me check the logs..."Logs: [Nothing useful - just INFO messages]Me: "I have no idea what's wrong."This is the problem observability solves. It gives you visibility into your running application.
Environment
- Spring Boot 3.x
- Java 17+
- Maven or Gradle
What is Observability?
Observability means you can understand what’s happening inside your application from the outside. It has three parts:
┌─────────────────────────────────────────────────────────────┐│ OBSERVABILITY │├───────────────┬───────────────┬─────────────────────────────┤│ METRICS │ LOGS │ TRACES ││ │ │ ││ "How many?" │ "What │ "Where did the request ││ │ happened?" │ go?" ││ │ │ ││ Counters │ Error msgs │ Spans across services ││ Gauges │ Info logs │ Timing per operation ││ Histograms │ Debug logs │ Request flow visualization │└───────────────┴───────────────┴─────────────────────────────┘Spring Boot uses Micrometer for metrics and OpenTelemetry for tracing. Let me show you how to set them up.
Step 1: Add Actuator and Micrometer
First, add the dependencies. I use Maven:
<dependencies> <!-- Spring Boot Actuator - provides /actuator endpoints --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
<!-- Micrometer Prometheus - exposes metrics in Prometheus format --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency></dependencies>If you use Gradle:
dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus'}Step 2: Configure Actuator Endpoints
By default, Actuator exposes only /actuator/health and /actuator/info. I need to enable more endpoints.
management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: when_authorized prometheus: enabled: true metrics: tags: application: ${spring.application.name}Now I can access:
/actuator/health- Application health status/actuator/metrics- List of available metrics/actuator/prometheus- Metrics in Prometheus format
Step 3: Check Default Metrics
Start the application and visit /actuator/metrics:
curl http://localhost:8080/actuator/metricsYou’ll see Spring Boot provides many metrics by default:
{ "names": [ "jvm.memory.used", "jvm.memory.max", "process.cpu.usage", "http.server.requests", "system.cpu.usage", "tomcat.sessions.active.current" ]}Let me check one metric:
curl http://localhost:8080/actuator/metrics/jvm.memory.used{ "name": "jvm.memory.used", "measurements": [ { "statistic": "VALUE", "value": 1.2345678E8 } ]}This tells me the JVM is using about 123 MB of memory. Useful!
Step 4: Add Custom Metrics
Default metrics are nice, but I need business metrics too. Let’s say I want to track orders.
Counter: Count Things
Counters only go up. Use them for things like order count, request count, error count.
@Servicepublic class OrderService { private final Counter orderCounter;
public OrderService(MeterRegistry registry) { this.orderCounter = Counter.builder("orders.created") .description("Total number of orders created") .tag("type", "standard") .register(registry); }
public void createOrder(Order order) { // ... business logic ... orderCounter.increment(); // +1 to the counter }}Timer: Measure Duration
Timers measure how long things take. Perfect for tracking API response times.
@Servicepublic class OrderService { private final Timer orderTimer;
public OrderService(MeterRegistry registry) { this.orderTimer = Timer.builder("orders.processing.time") .description("Time taken to process orders") .publishPercentiles(0.5, 0.95, 0.99) // p50, p95, p99 .register(registry); }
public void createOrder(Order order) { orderTimer.record(() -> { // ... business logic ... }); }}Gauge: Track Current Value
Gauges can go up or down. Use them for things like queue size, active connections.
@Servicepublic class OrderService { private final Queue<Order> pendingOrders = new LinkedList<>();
public OrderService(MeterRegistry registry) { registry.gauge("orders.pending.size", pendingOrders, Queue::size); }
public void addToQueue(Order order) { pendingOrders.add(order); }}Step 5: Expose Metrics to Prometheus
The /actuator/prometheus endpoint outputs metrics in Prometheus format:
curl http://localhost:8080/actuator/prometheus# HELP jvm_memory_used_bytes The amount of used memory# TYPE jvm_memory_used_bytes gaugejvm_memory_used_bytes{application="my-app",area="heap",} 1.2345678E8
# HELP orders_created_total Total number of orders created# TYPE orders_created_total counterorders_created_total{application="my-app",type="standard",} 42.0Prometheus can scrape this endpoint and store the data. Then Grafana can visualize it.
Step 6: Add Distributed Tracing
Metrics tell you WHAT is slow. Tracing tells you WHERE it’s slow.
Add Tracing Dependencies
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId></dependency><dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-zipkin</artifactId></dependency>Configure Tracing
management: tracing: enabled: true sampling: probability: 1.0 # 100% sampling (reduce in production!) zipkin: tracing: endpoint: http://localhost:9411/api/v2/spansCreate Custom Spans
Spans represent units of work. Spring can create them automatically with annotations:
@Servicepublic class PaymentService { private final Tracer tracer;
public PaymentService(Tracer tracer) { this.tracer = tracer; }
@NewSpan("process-payment") // Creates a span automatically public PaymentResult processPayment(PaymentRequest request) { Span span = tracer.currentSpan(); span.tag("payment.method", request.getMethod()); span.tag("payment.amount", String.valueOf(request.getAmount()));
// Business logic here return doProcess(request); }}Now when I call processPayment, it creates a span with timing information. I can see it in Zipkin or Jaeger.
Step 7: Correlate Logs with Traces
Here’s the magic part. I can connect logs to traces:
logging: pattern: level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"Now my logs include trace IDs:
INFO [my-app,abc123,def456] Processed payment successfullyWhen something fails, I can search logs by trace ID. No more hunting through thousands of log lines.
Step 8: Add Custom Health Checks
The /actuator/health endpoint shows if your app is healthy. You can add custom checks:
@Componentpublic class DatabaseHealthIndicator implements HealthIndicator { private final DataSource dataSource;
@Override public Health health() { try (Connection conn = dataSource.getConnection()) { if (conn.isValid(1)) { return Health.up() .withDetail("database", "PostgreSQL") .withDetail("validationQuery", "SELECT 1") .build(); } } catch (SQLException e) { return Health.down() .withException(e) .build(); } return Health.unknown().build(); }}Now /actuator/health shows:
{ "status": "UP", "components": { "database": { "status": "UP", "details": { "database": "PostgreSQL" } }, "diskSpace": { "status": "UP" } }}Common Mistakes to Avoid
1. Sampling Rate Too High
Don’t trace 100% of requests in production. It’s too much data.
# WRONG for productionmanagement: tracing: sampling: probability: 1.0 # 100%
# BETTER for productionmanagement: tracing: sampling: probability: 0.1 # 10%2. High Cardinality Tags
Don’t use unique values as tags. This creates too many time series.
// WRONG - user IDs are unique, creates millions of seriesCounter.builder("requests") .tag("user_id", userId) // BAD! .register(registry);
// BETTER - use bounded valuesCounter.builder("requests") .tag("region", userRegion) // OK - only a few regions .register(registry);3. Not Setting Up Alerts
Metrics are useless if nobody looks at them. Set up alerts:
groups:- name: spring-boot-alerts rules: - alert: HighErrorRate expr: | sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) > 0.05 for: 5m labels: severity: critical annotations: summary: "Error rate above 5%"Quick Setup Checklist
Here’s what you need for basic observability:
[ ] Add spring-boot-starter-actuator dependency[ ] Add micrometer-registry-prometheus dependency[ ] Configure application.yml to expose endpoints[ ] Add custom business metrics (counters, timers)[ ] Set up Prometheus to scrape /actuator/prometheus[ ] Create Grafana dashboards[ ] Add tracing dependencies for distributed tracing[ ] Configure log pattern with trace IDs[ ] Set up alerts for critical metricsSummary
In this post, I showed how to add observability to Spring Boot with Micrometer. The key steps are:
- Add Actuator and Micrometer dependencies
- Configure which endpoints to expose
- Add custom metrics for your business logic
- Set up Prometheus to collect metrics
- Add distributed tracing to see where requests go
- Correlate logs with traces for easier debugging
With these in place, you’ll never be flying blind in production again. When users report slowness, you’ll have the data to find the problem.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Boot Actuator Documentation
- 👨💻 Micrometer Documentation
- 👨💻 OpenTelemetry Java Agent Guide
- 👨💻 Prometheus Exporter Configuration
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments