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();
}
}
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>
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");
}
}
Why register in the constructor?
- Metrics exist immediately — no lazy initialization surprises.
- The
MeterRegistryis the only injected dependency (besides your service). Clean. - 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);
}
}
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'sCounter.increment()accepts adouble. Internally, it's backed by aLongAdder, so it's thread-safe and performant. Passing an int works fine. - The
catchblock incrementsfailedCounter. 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);
}
}
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);
}
}
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);
}
}
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=processBatchorstatus=successare 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
@Timedannotation. Micrometer provides@Timedfor auto-instrumenting methods. It works, but it requires theTimedAspectbean, AOP configuration, and you lose control over what gets recorded on exceptions. ExplicitTimer.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);
}
}
}
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)