Observability

This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

A batch job that runs at 02:00 is only as good as what it reports. Spring Batch instruments itself with Micrometer and the Observation API, so job, step and chunk timings reach the same monitoring stack as the rest of the application.

Micrometer meters

Spring Batch publishes these meters under the spring.batch prefix:

Meter Type Records

spring.batch.job

Timer

Duration of a whole job execution. Tags: spring.batch.job.name, spring.batch.job.status.

spring.batch.job.active

LongTaskTimer

Currently running job executions and how long each has been running — the meter that answers "is last night’s job still going?".

spring.batch.step

Timer

Duration of a step execution. Tags: spring.batch.step.name, spring.batch.step.job.name, spring.batch.step.status.

spring.batch.item.read

Timer

Duration of a single read().

spring.batch.item.process

Timer

Duration of a single process().

spring.batch.chunk.write

Timer

Duration of one write(chunk) — per chunk, not per item.

The split between item-level and chunk-level timers is what makes a slow step diagnosable: if spring.batch.item.process dominates, the processor is the problem; if spring.batch.chunk.write dominates, the writer or the database is. That is the same question Profiling & tuning starts from, answered continuously instead of in a profiling session.

Under Spring Boot, a MeterRegistry on the classpath is enough — the meters are registered automatically:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    tags:
      application: batch-app

Outside Spring Boot, register a global registry explicitly:

@Configuration
public class MetricsConfiguration {

    @Bean
    public MeterRegistry meterRegistry() {
        PrometheusMeterRegistry registry =
                new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
        Metrics.addRegistry(registry);      // Spring Batch publishes to the global registry
        return registry;
    }
}

Business-level counters belong alongside them — a rejected-record counter incremented from a SkipListener (Fault tolerance: skip & retry) is often the most actionable metric a batch job has:

@Component
public class MeteredSkipListener implements SkipListener<TradeCsv, Trade> {

    private final Counter rejected;

    public MeteredSkipListener(MeterRegistry registry) {
        this.rejected = Counter.builder("batch.trades.rejected")
                .description("trades skipped during the load step")
                .register(registry);
    }

    @Override
    public void onSkipInProcess(TradeCsv item, Throwable throwable) {
        rejected.increment();
    }
}

A useful alerting rule set: job duration above its usual band, spring.batch.job.active still non-zero past a deadline, any execution with status=FAILED, and a rejection counter above a threshold.

Tracing with the Observation API

Every meter above is produced by an Observation, so the same instrumentation also emits spans when a tracer is present: one observation per job execution, one per step execution, and one per chunk. A job that calls downstream services therefore appears in a trace as a job span containing step spans containing the outgoing HTTP or messaging spans.

@Configuration
public class ObservabilityConfiguration {

    @Bean
    public ObservationRegistry observationRegistry(MeterRegistry meterRegistry) {
        ObservationRegistry registry = ObservationRegistry.create();
        registry.observationConfig()
                .observationHandler(new DefaultMeterObservationHandler(meterRegistry));
        return registry;
    }
}

Under Spring Boot, spring-boot-starter-actuator plus a tracer bridge (micrometer-tracing-bridge-otel and an exporter) is all that is needed; the general Micrometer / OpenTelemetry / Prometheus / Grafana setup is covered on Metrics & Observability and is not restated here.

Sampling deserves a thought for batch: a chunk observation per 500 items is fine, but item-level spans on a ten-million-row job are not. Sample aggressively, or rely on the timers rather than on spans for item-level detail.

Java Flight Recorder events

Spring Batch 6.0 emits JFR events for job, step and chunk execution. Because JFR is built into the JVM and cheap enough to leave on, this gives a full picture of a production run — framework phases interleaved with GC, allocation, JDBC and thread activity — without attaching anything:

java -XX:StartFlightRecording=filename=endofday.jfr,settings=profile,dumponexit=true \
     -jar batch-app.jar --spring.batch.job.name=endOfDayJob run.date=2026-01-31
jfr summary endofday.jfr
jfr print --events SpringBatchJobExecution,SpringBatchStepExecution endofday.jfr

This is the tool of choice for "the job took three hours last night and nobody was watching": the recording is written by the run itself and analysed afterwards in JDK Mission Control.

What Actuator adds

With spring-boot-starter-actuator on the classpath, a batch application also gets:

  • /actuator/metrics/spring.batch.job and the other meters, browsable per tag;

  • /actuator/prometheus for scraping;

  • /actuator/health — worth extending with a custom indicator that reports the last execution’s status, so an orchestrator can tell a failed job from a finished one;

  • /actuator/loggers to raise a package’s log level on a running job without restarting it.

@Component
public class LastRunHealthIndicator implements HealthIndicator {

    private final JobRepository jobRepository;

    public LastRunHealthIndicator(JobRepository jobRepository) {
        this.jobRepository = jobRepository;
    }

    @Override
    public Health health() {
        JobInstance instance = jobRepository.getLastJobInstance("endOfDayJob");
        if (instance == null) {
            return Health.unknown().withDetail("reason", "never run").build();
        }
        JobExecution execution = jobRepository.getLastJobExecution(instance);
        return (execution.getStatus() == BatchStatus.COMPLETED ? Health.up() : Health.down())
                .withDetail("executionId", execution.getId())
                .withDetail("status", execution.getStatus())
                .withDetail("endTime", execution.getEndTime())
                .build();
    }
}

The metadata tables remain the durable record behind all of this — see The job repository & metadata schema.

Further reading