DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Adding Micrometer Metrics to Spring Boot Schedulers (Without Over-Engineering It)

This article was originally published on Jo4 Blog.

Our campaign enrichment scheduler had been running for months with zero observability. It processed batches every 15 minutes, ran a watchdog every 15 minutes, and cleaned up stale data daily at 4 AM. If something broke, we found out from user reports.

That's not a metrics strategy. That's hope.

TL;DR: We added spring-boot-starter-actuator to a Spring Boot 3.4 app, registered four Micrometer metrics (two counters, one more counter, one timer) in the constructor, and verified everything with SimpleMeterRegistry in unit tests. Total time: under an hour. Zero YAML configuration.


Starting Point: Zero Metrics Infrastructure

The app had no actuator dependency, no metrics endpoint, no Prometheus scraping. The scheduler looked like this:

@Slf4j
@Service
@RequiredArgsConstructor
public class CampaignEnrichmentScheduler {

    private final CampaignEnrichmentService service;

    @Scheduled(fixedRate = 900_000) // 15 minutes
    public void processBatch() {
        int processed = service.processBatch(50);
        log.info("Enriched {} campaigns", processed);
    }

    @Scheduled(fixedRate = 900_000)
    public void watchdog() {
        int recovered = service.recoverStuckCampaigns();
        if (recovered > 0) {
            log.warn("Recovered {} stuck campaigns", recovered);
        }
    }

    @Scheduled(cron = "0 0 4 * * *") // Daily 4 AM
    public void cleanup() {
        service.cleanupExpiredEnrichments();
    }
}
Enter fullscreen mode Exit fullscreen mode

Logs only. If you wanted to know how many campaigns were processed in the last hour, you'd grep CloudWatch. Fun.

Step 1: Add the Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

That's it. Spring Boot auto-configures a MeterRegistry bean. No application.yml changes needed for basic metrics. If you're exporting to Prometheus, you'd add micrometer-registry-prometheus too, but that's a separate concern.

Step 2: Pick Your Metrics

Before writing code, decide what you actually need. We went with four:

Metric Type Why
campaign.enrichment.processed Counter Total campaigns enriched (throughput)
campaign.enrichment.failed Counter Total failures across batch + watchdog
campaign.enrichment.recovered Counter Campaigns rescued by watchdog (health signal)
campaign.enrichment.batch.duration Timer How long each batch takes (latency)

Four metrics. Not forty. You can always add more later.

Notice the shared failed counter across batch processing and the watchdog. At our scale, a single failure counter is fine. If we needed per-method failure rates, we'd add a method tag. But we don't need that yet, and unused tags are just noise in your dashboards.

Step 3: Replace @RequiredArgsConstructor with an Explicit Constructor

This is the part most tutorials skip. With @RequiredArgsConstructor, Lombok generates a constructor with all final fields. But Micrometer metrics are registered at construction time, and you need the MeterRegistry to create them. Counters and timers aren't fields you inject — they're fields you build from an injected registry.

@Slf4j
@Service
public class CampaignEnrichmentScheduler {

    private final CampaignEnrichmentService service;
    private final Counter processedCounter;
    private final Counter failedCounter;
    private final Counter recoveredCounter;
    private final Timer batchTimer;

    public CampaignEnrichmentScheduler(
            CampaignEnrichmentService service,
            MeterRegistry meterRegistry) {
        this.service = service;
        this.processedCounter = meterRegistry.counter("campaign.enrichment.processed");
        this.failedCounter = meterRegistry.counter("campaign.enrichment.failed");
        this.recoveredCounter = meterRegistry.counter("campaign.enrichment.recovered");
        this.batchTimer = meterRegistry.timer("campaign.enrichment.batch.duration");
    }
}
Enter fullscreen mode Exit fullscreen mode

Why register in the constructor?

  1. Metrics exist immediately — no lazy initialization surprises.
  2. The MeterRegistry is the only injected dependency (besides your service). Clean.
  3. The counter/timer fields are final. Immutable after construction.

Drop @RequiredArgsConstructor. An explicit constructor with two parameters is not boilerplate — it's clarity.

Step 4: Instrument the Methods

processBatch: Timer + Counter

Timer.record(Callable<T>) is the key API here. It wraps your callable, times it, and returns the result. No manual stopwatch:

@Scheduled(fixedRate = 900_000)
public void processBatch() {
    try {
        int processed = batchTimer.record(() -> service.processBatch(50));
        processedCounter.increment(processed);
        log.info("Enriched {} campaigns", processed);
    } catch (Exception e) {
        failedCounter.increment();
        log.error("Batch processing failed", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • batchTimer.record(() -> service.processBatch(50)) returns the lambda's return value. The timer records the duration. You get both in one call.
  • processedCounter.increment(processed) — Micrometer's Counter.increment() accepts a double. Internally, it's backed by a LongAdder, so it's thread-safe and performant. Passing an int works fine.
  • The catch block increments failedCounter. If batch processing throws, we count it and log it. The scheduler keeps running on the next tick.

watchdog: Counter Only

@Scheduled(fixedRate = 900_000)
public void watchdog() {
    try {
        int recovered = service.recoverStuckCampaigns();
        if (recovered > 0) {
            recoveredCounter.increment(recovered);
            log.warn("Recovered {} stuck campaigns", recovered);
        }
    } catch (Exception e) {
        failedCounter.increment();
        log.error("Watchdog failed", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Same failedCounter as processBatch. At our volume, we don't need to distinguish where failures come from — we just need to know they're happening. If the counter spikes, we check logs.

cleanup: Just Logging

@Scheduled(cron = "0 0 4 * * *")
public void cleanup() {
    try {
        service.cleanupExpiredEnrichments();
        log.info("Cleanup completed");
    } catch (Exception e) {
        failedCounter.increment();
        log.error("Cleanup failed", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

No dedicated metric for cleanup. It runs once a day. If it fails, the shared counter catches it. Over-instrumenting a daily job that takes 2 seconds would be pure vanity metrics.

Step 5: Test It

This is where SimpleMeterRegistry shines. It's an in-memory registry — no Prometheus, no actuator endpoint, no running server. Just a plain object you construct in tests:

@ExtendWith(MockitoExtension.class)
class CampaignEnrichmentSchedulerTest {

    @Mock
    private CampaignEnrichmentService service;

    private MeterRegistry meterRegistry;
    private CampaignEnrichmentScheduler scheduler;

    @BeforeEach
    void setUp() {
        meterRegistry = new SimpleMeterRegistry();
        scheduler = new CampaignEnrichmentScheduler(service, meterRegistry);
    }

    @Test
    void processBatch_incrementsProcessedCounter() {
        when(service.processBatch(50)).thenReturn(10);

        scheduler.processBatch();

        assertThat(meterRegistry.counter("campaign.enrichment.processed").count())
            .isEqualTo(10.0);
    }

    @Test
    void processBatch_recordsBatchDuration() {
        when(service.processBatch(50)).thenReturn(5);

        scheduler.processBatch();

        Timer timer = meterRegistry.timer("campaign.enrichment.batch.duration");
        assertThat(timer.count()).isEqualTo(1);
        assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isGreaterThan(0);
    }

    @Test
    void processBatch_incrementsFailedCounterOnException() {
        when(service.processBatch(50)).thenThrow(new RuntimeException("db down"));

        scheduler.processBatch();

        assertThat(meterRegistry.counter("campaign.enrichment.failed").count())
            .isEqualTo(1.0);
    }

    @Test
    void watchdog_incrementsRecoveredCounter() {
        when(service.recoverStuckCampaigns()).thenReturn(3);

        scheduler.watchdog();

        assertThat(meterRegistry.counter("campaign.enrichment.recovered").count())
            .isEqualTo(3.0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the explicit constructor: new CampaignEnrichmentScheduler(service, meterRegistry). This is why we dropped @RequiredArgsConstructor. We can inject a SimpleMeterRegistry without spinning up a Spring context. Pure unit tests, fast and deterministic.

The counter assertions use .count() which returns a double. Micrometer counters are always doubles internally, even when you increment by int values. So isEqualTo(10.0) not isEqualTo(10).

What We Deliberately Did Not Do

  • No custom tags. Tags like method=processBatch or status=success are useful at scale. We have one scheduler with three methods. Tags would add cardinality for no value.
  • No Gauge for "currently processing." Tempting, but our batches take seconds. A gauge that's 1 for 3 seconds and 0 for 897 seconds is useless.
  • No @Timed annotation. Micrometer provides @Timed for auto-instrumenting methods. It works, but it requires the TimedAspect bean, AOP configuration, and you lose control over what gets recorded on exceptions. Explicit Timer.record() is three lines and crystal clear.
  • No Prometheus/Grafana setup in this post. That's infrastructure, not instrumentation. Get the metrics right first.

The Complete Scheduler

@Slf4j
@Service
public class CampaignEnrichmentScheduler {

    private final CampaignEnrichmentService service;
    private final Counter processedCounter;
    private final Counter failedCounter;
    private final Counter recoveredCounter;
    private final Timer batchTimer;

    public CampaignEnrichmentScheduler(
            CampaignEnrichmentService service,
            MeterRegistry meterRegistry) {
        this.service = service;
        this.processedCounter = meterRegistry.counter("campaign.enrichment.processed");
        this.failedCounter = meterRegistry.counter("campaign.enrichment.failed");
        this.recoveredCounter = meterRegistry.counter("campaign.enrichment.recovered");
        this.batchTimer = meterRegistry.timer("campaign.enrichment.batch.duration");
    }

    @Scheduled(fixedRate = 900_000)
    public void processBatch() {
        try {
            int processed = batchTimer.record(() -> service.processBatch(50));
            processedCounter.increment(processed);
            log.info("Enriched {} campaigns", processed);
        } catch (Exception e) {
            failedCounter.increment();
            log.error("Batch processing failed", e);
        }
    }

    @Scheduled(fixedRate = 900_000)
    public void watchdog() {
        try {
            int recovered = service.recoverStuckCampaigns();
            if (recovered > 0) {
                recoveredCounter.increment(recovered);
                log.warn("Recovered {} stuck campaigns", recovered);
            }
        } catch (Exception e) {
            failedCounter.increment();
            log.error("Watchdog failed", e);
        }
    }

    @Scheduled(cron = "0 0 4 * * *")
    public void cleanup() {
        try {
            service.cleanupExpiredEnrichments();
            log.info("Cleanup completed");
        } catch (Exception e) {
            failedCounter.increment();
            log.error("Cleanup failed", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Four metrics. One explicit constructor. Tests that run in milliseconds. No YAML. No aspect magic. No over-engineering.


What metrics do you track on your scheduled tasks? If the answer is "just logs," I get it — we were there too.

Building jo4.io — a URL shortener with analytics and an affiliate marketplace. Our schedulers now have actual observability.

Top comments (0)