Version: Spring Batch 6.0.4 (Spring Boot 4.1.0)
The reference documentation lists spring.batch.job.launch.count among the provided metrics and shows an ObservationRegistry bean definition to enable metrics collection. In practice the JobOperator usually keeps ObservationRegistry.NOOP, so this one metric is silently missing while every other spring.batch.* metric is recorded. Whether it works is not something the application can state explicitly: with Spring Boot's batch auto-configuration it never works, and with @EnableBatchProcessing it depends on bean definition order.
DefaultBatchConfiguration#getObservationRegistry() returns a hardcoded ObservationRegistry.NOOP and passes it to JobOperatorFactoryBean. Spring Boot's SpringBootBatchDefaultConfiguration does not override it, so declaring an ObservationRegistry bean has no effect on the operator.
- The fallback
BatchObservabilityBeanPostProcessor checks bean instanceof TaskExecutorJobOperator, but the jobOperator bean is a JDK dynamic proxy created by JobOperatorFactoryBean, so the check is always false and the operator is silently skipped (DEBUG log only).
- With
@EnableBatchProcessing, BatchRegistrar adds the observationRegistry property reference only when a bean definition with the name from observationRegistryRef (default: observationRegistry) already exists while the registrar runs. Spring Boot's auto-configured ObservationRegistry is usually registered later (deferred import), so the reference is not added. This is an ordering condition rather than a hard failure, and it can go either way (see below).
Minimal reproducer: https://github.com/benelog/spring-batch-practice/tree/main/issue-batch-observability (./gradlew bootRun)
[DIAG] spring.batch meters recorded = [spring.batch.job, spring.batch.job.active, spring.batch.step, spring.batch.step.active]
[DIAG] spring.batch.job.launch.count present = false
[DIAG] jobOperator target class = ...TaskExecutorJobOperator, observationRegistry = ObservationRegistry.NOOP
The custom-registry profile uses the exact ObservationRegistry snippet from the reference documentation: launch.count is still missing, and as a side effect every spring.batch.* metric is recorded twice in a Boot application (the manually registered DefaultMeterObservationHandler plus the handler Boot attaches to every ObservationRegistry bean).
A plain Spring (non-Boot) reproducer in the same repository (issue-batch-observability-plain, ./gradlew run) shows that the ordering sensitivity of cause 3 exists without Spring Boot: the same ObservationRegistry bean declared in a separate configuration class is wired only when that class is registered before the @EnableBatchProcessing class.
The ordering condition can go either way
Because cause 3 is an ordering condition, the outcome differs between modules of the same application. In a multi-module Boot application of mine, removing the ObservationRegistry bean from the shared @EnableBatchProcessing class makes the metric disappear in a plain CLI module, while a web module in the same build still records it: the extra auto-configurations that module pulls in happen to register the observationRegistry bean definition before BatchRegistrar runs. This is what makes the defect easy to miss — checking one module can suggest the metric is fine.
Two things are worth knowing when reproducing this:
- The meter is created on the first job launch, so
/actuator/metrics right after startup does not list it either way. The check is only meaningful after a job has run.
- Adding a diagnostic
static BeanFactoryPostProcessor @Bean to the @EnableBatchProcessing class changes the registration order itself, which can flip the result while you are measuring it.
Workaround (application side)
Placing the ObservationRegistry bean definition from the reference documentation inside the @EnableBatchProcessing configuration class makes the wiring independent of registration order:
@Configuration
@EnableBatchProcessing
public class BatchConfig {
@Bean
public ObservationRegistry observationRegistry(MeterRegistry meterRegistry) {
ObservationRegistry observationRegistry = ObservationRegistry.create();
observationRegistry.observationConfig()
.observationHandler(new DefaultMeterObservationHandler(meterRegistry));
return observationRegistry;
}
}
- The bean definition is the documentation's snippet unchanged; what makes it reliable are two requirements the documentation does not mention (its snippet shows no surrounding class): the bean definition must already exist when
BatchRegistrar runs, which declaring it inside the @EnableBatchProcessing class guarantees, and the bean name must match observationRegistryRef (default: observationRegistry).
- In a Spring Boot application, the manual
DefaultMeterObservationHandler must be omitted (return ObservationRegistry.create(); alone is enough): Boot auto-configures a DefaultMeterObservationHandler bean wired to the auto-configured MeterRegistry (MetricsAutoConfiguration), and ObservationRegistryPostProcessor attaches it to every ObservationRegistry bean. With the handler-registering snippet above, the same handler class ends up attached twice and every spring.batch.* metric is recorded twice.
- I could not find a workaround for the Boot auto-configuration path (no
@EnableBatchProcessing): DefaultBatchConfiguration#getObservationRegistry() hardcodes NOOP regardless of user-defined beans.
Possible fixes
- Resolve the registry from the application context in
DefaultBatchConfiguration#getObservationRegistry() instead of hardcoding NOOP, e.g. getBeanProvider(ObservationRegistry.class).getIfUnique(() -> ObservationRegistry.NOOP). This would fix the Boot auto-configuration path as well.
- Make
BatchObservabilityBeanPostProcessor proxy-aware, e.g. unwrap with AopProxyUtils.getSingletonTarget(bean) before the instanceof checks, so the proxied jobOperator bean can be handled on every path. Because the post processor resolves the registry from the bean factory by type, this also removes the ordering and bean-name conditions.
- Documentation: note that in a Spring Boot application the
ObservationRegistry bean should be declared without manually registering DefaultMeterObservationHandler (Boot attaches handlers automatically); the current snippet leads to double-counted metrics.
Related: #4222, #4226
Version: Spring Batch 6.0.4 (Spring Boot 4.1.0)
The reference documentation lists
spring.batch.job.launch.countamong the provided metrics and shows anObservationRegistrybean definition to enable metrics collection. In practice theJobOperatorusually keepsObservationRegistry.NOOP, so this one metric is silently missing while every otherspring.batch.*metric is recorded. Whether it works is not something the application can state explicitly: with Spring Boot's batch auto-configuration it never works, and with@EnableBatchProcessingit depends on bean definition order.DefaultBatchConfiguration#getObservationRegistry()returns a hardcodedObservationRegistry.NOOPand passes it toJobOperatorFactoryBean. Spring Boot'sSpringBootBatchDefaultConfigurationdoes not override it, so declaring anObservationRegistrybean has no effect on the operator.BatchObservabilityBeanPostProcessorchecksbean instanceof TaskExecutorJobOperator, but thejobOperatorbean is a JDK dynamic proxy created byJobOperatorFactoryBean, so the check is always false and the operator is silently skipped (DEBUG log only).@EnableBatchProcessing,BatchRegistraradds theobservationRegistryproperty reference only when a bean definition with the name fromobservationRegistryRef(default:observationRegistry) already exists while the registrar runs. Spring Boot's auto-configuredObservationRegistryis usually registered later (deferred import), so the reference is not added. This is an ordering condition rather than a hard failure, and it can go either way (see below).Minimal reproducer: https://github.com/benelog/spring-batch-practice/tree/main/issue-batch-observability (
./gradlew bootRun)The
custom-registryprofile uses the exactObservationRegistrysnippet from the reference documentation:launch.countis still missing, and as a side effect everyspring.batch.*metric is recorded twice in a Boot application (the manually registeredDefaultMeterObservationHandlerplus the handler Boot attaches to everyObservationRegistrybean).A plain Spring (non-Boot) reproducer in the same repository (issue-batch-observability-plain,
./gradlew run) shows that the ordering sensitivity of cause 3 exists without Spring Boot: the sameObservationRegistrybean declared in a separate configuration class is wired only when that class is registered before the@EnableBatchProcessingclass.The ordering condition can go either way
Because cause 3 is an ordering condition, the outcome differs between modules of the same application. In a multi-module Boot application of mine, removing the
ObservationRegistrybean from the shared@EnableBatchProcessingclass makes the metric disappear in a plain CLI module, while a web module in the same build still records it: the extra auto-configurations that module pulls in happen to register theobservationRegistrybean definition beforeBatchRegistrarruns. This is what makes the defect easy to miss — checking one module can suggest the metric is fine.Two things are worth knowing when reproducing this:
/actuator/metricsright after startup does not list it either way. The check is only meaningful after a job has run.static BeanFactoryPostProcessor@Beanto the@EnableBatchProcessingclass changes the registration order itself, which can flip the result while you are measuring it.Workaround (application side)
Placing the
ObservationRegistrybean definition from the reference documentation inside the@EnableBatchProcessingconfiguration class makes the wiring independent of registration order:BatchRegistrarruns, which declaring it inside the@EnableBatchProcessingclass guarantees, and the bean name must matchobservationRegistryRef(default:observationRegistry).DefaultMeterObservationHandlermust be omitted (return ObservationRegistry.create();alone is enough): Boot auto-configures aDefaultMeterObservationHandlerbean wired to the auto-configuredMeterRegistry(MetricsAutoConfiguration), andObservationRegistryPostProcessorattaches it to everyObservationRegistrybean. With the handler-registering snippet above, the same handler class ends up attached twice and everyspring.batch.*metric is recorded twice.@EnableBatchProcessing):DefaultBatchConfiguration#getObservationRegistry()hardcodes NOOP regardless of user-defined beans.Possible fixes
DefaultBatchConfiguration#getObservationRegistry()instead of hardcoding NOOP, e.g.getBeanProvider(ObservationRegistry.class).getIfUnique(() -> ObservationRegistry.NOOP). This would fix the Boot auto-configuration path as well.BatchObservabilityBeanPostProcessorproxy-aware, e.g. unwrap withAopProxyUtils.getSingletonTarget(bean)before theinstanceofchecks, so the proxiedjobOperatorbean can be handled on every path. Because the post processor resolves the registry from the bean factory by type, this also removes the ordering and bean-name conditions.ObservationRegistrybean should be declared without manually registeringDefaultMeterObservationHandler(Boot attaches handlers automatically); the current snippet leads to double-counted metrics.Related: #4222, #4226