Skip to content

spring.batch.job.launch.count is silently missing: the JobOperator keeps ObservationRegistry.NOOP on most configuration paths #5463

Description

@benelog

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.

  1. 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.
  2. 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).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions