diff --git a/README.md b/README.md index ab3aed2..7ff6273 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ With Spring Boot Actuator and a Prometheus registry (`micrometer-registry-promet | Property | Default | Description | |----------|---------|-------------| | `okapi.metrics.refresh-interval` | `PT15S` (15s) | How often gauge metrics poll the outbox store. Each refresh runs one transaction with two queries. | +| `okapi.metrics.enabled` | `true` | Set `false` to disable `okapi-micrometer` entirely — no counters, timers, gauges, or store polling. Logs a startup warning when disabled. | ### Multi-instance deployments diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt index d4b4037..e323d5c 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt @@ -5,6 +5,7 @@ import com.softwaremill.okapi.micrometer.MicrometerOutboxListener import com.softwaremill.okapi.micrometer.MicrometerOutboxMetrics import com.softwaremill.okapi.micrometer.OutboxMetricsRefresher import io.micrometer.core.instrument.MeterRegistry +import org.slf4j.LoggerFactory import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.autoconfigure.AutoConfiguration @@ -12,8 +13,10 @@ import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate import java.time.Clock @@ -56,43 +59,72 @@ import java.time.Clock @ConditionalOnBean(MeterRegistry::class) @EnableConfigurationProperties(OkapiMetricsProperties::class, OkapiProperties::class) class OkapiMicrometerAutoConfiguration { - @Bean - @ConditionalOnMissingBean - fun micrometerOutboxListener(registry: MeterRegistry): MicrometerOutboxListener = MicrometerOutboxListener(registry) - @Bean - @ConditionalOnMissingBean - fun micrometerOutboxMetrics( - store: OutboxStore, - registry: MeterRegistry, - transactionManager: ObjectProvider, - clock: ObjectProvider, - beanFactory: BeanFactory, - okapiProperties: OkapiProperties, - ): MicrometerOutboxMetrics { - // The PTM is optional here (metrics work fine without a read-only runner), so we can't reuse - // OutboxAutoConfiguration.resolvePlatformTransactionManager() as-is — it throws when no PTM is - // found. When a qualifier is set it still must be honoured (explicit user config), via the - // shared resolvePlatformTransactionManagerByQualifier(); otherwise fall back to - // getIfUnique(), which returns null instead of throwing when multiple PTMs are present. This - // fixes issue #80: getIfAvailable() throws NoUniqueBeanDefinitionException with 2+ PTM beans, - // even when okapi.transaction-manager-qualifier disambiguates which one to use. - val ptm = okapiProperties.transactionManagerQualifier?.let { qualifier -> - OutboxAutoConfiguration.resolvePlatformTransactionManagerByQualifier(beanFactory, qualifier) - } ?: transactionManager.getIfUnique() - val readOnlyRunner = ptm?.let { tm -> - SpringTransactionRunner(TransactionTemplate(tm).apply { isReadOnly = true }) + /** + * Registers okapi's Micrometer beans on @ConditionalOnProperty(`okapi.metrics.enabled=true`). + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "okapi.metrics", name = ["enabled"], havingValue = "true", matchIfMissing = true) + class MetricsConfiguration { + @Bean + @ConditionalOnMissingBean + fun micrometerOutboxListener(registry: MeterRegistry): MicrometerOutboxListener = MicrometerOutboxListener(registry) + + @Bean + @ConditionalOnMissingBean + fun micrometerOutboxMetrics( + store: OutboxStore, + registry: MeterRegistry, + transactionManager: ObjectProvider, + clock: ObjectProvider, + beanFactory: BeanFactory, + okapiProperties: OkapiProperties, + ): MicrometerOutboxMetrics { + // The PTM is optional here (metrics work fine without a read-only runner), so we can't reuse + // OutboxAutoConfiguration.resolvePlatformTransactionManager() as-is — it throws when no PTM is + // found. When a qualifier is set it still must be honoured (explicit user config), via the + // shared resolvePlatformTransactionManagerByQualifier(); otherwise fall back to + // getIfUnique(), which returns null instead of throwing when multiple PTMs are present. This + // fixes issue #80: getIfAvailable() throws NoUniqueBeanDefinitionException with 2+ PTM beans, + // even when okapi.transaction-manager-qualifier disambiguates which one to use. + val ptm = okapiProperties.transactionManagerQualifier?.let { qualifier -> + OutboxAutoConfiguration.resolvePlatformTransactionManagerByQualifier(beanFactory, qualifier) + } ?: transactionManager.getIfUnique() + val readOnlyRunner = ptm?.let { tm -> + SpringTransactionRunner(TransactionTemplate(tm).apply { isReadOnly = true }) + } + return MicrometerOutboxMetrics( + store = store, + registry = registry, + transactionRunner = readOnlyRunner, + clock = clock.getIfAvailable { Clock.systemUTC() }, + ) + } + + @Bean(initMethod = "start", destroyMethod = "close") + @ConditionalOnMissingBean + fun outboxMetricsRefresher(metrics: MicrometerOutboxMetrics, properties: OkapiMetricsProperties): OutboxMetricsRefresher = + OutboxMetricsRefresher(metrics, properties.refreshInterval) + } + + /** + * Logs a single startup warning when okapi's Micrometer auto-config is explicitly opted out + * (`okapi.metrics.enabled=false`). Without this, the opt-out was previously silently ignored + * entirely (issue #92). + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "okapi.metrics", name = ["enabled"], havingValue = "false") + class MetricsDisabledNotice { + init { + logger.warn( + "okapi.metrics.enabled=false. Okapi will NOT register MicrometerOutboxListener, " + + "MicrometerOutboxMetrics, or OutboxMetricsRefresher. No okapi.* counters, timers, " + + "or gauges will be published, and the outbox store will not be polled for them.", + ) } - return MicrometerOutboxMetrics( - store = store, - registry = registry, - transactionRunner = readOnlyRunner, - clock = clock.getIfAvailable { Clock.systemUTC() }, - ) } - @Bean(initMethod = "start", destroyMethod = "close") - @ConditionalOnMissingBean - fun outboxMetricsRefresher(metrics: MicrometerOutboxMetrics, properties: OkapiMetricsProperties): OutboxMetricsRefresher = - OutboxMetricsRefresher(metrics, properties.refreshInterval) + companion object { + private val logger = LoggerFactory.getLogger(OkapiMicrometerAutoConfiguration::class.java) + } } diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfigurationTest.kt index dbf6378..efd4fbf 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfigurationTest.kt @@ -1,5 +1,9 @@ package com.softwaremill.okapi.springboot +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import com.softwaremill.okapi.core.OutboxStatus import com.softwaremill.okapi.core.OutboxStore import com.softwaremill.okapi.micrometer.MicrometerOutboxListener @@ -12,6 +16,7 @@ import io.kotest.matchers.shouldBe import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.simple.SimpleMeterRegistry import org.h2.jdbcx.JdbcDataSource +import org.slf4j.LoggerFactory import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.FilteredClassLoader @@ -22,6 +27,7 @@ import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Clock import javax.sql.DataSource +import io.kotest.matchers.string.shouldContain as stringShouldContain /** * Regression coverage for issue #80: `micrometerOutboxMetrics()` used a bare @@ -118,7 +124,7 @@ class OkapiMicrometerAutoConfigurationTest : FunSpec({ return emptyMap() } } - val metrics = OkapiMicrometerAutoConfiguration().micrometerOutboxMetrics( + val metrics = OkapiMicrometerAutoConfiguration.MetricsConfiguration().micrometerOutboxMetrics( store = store, registry = SimpleMeterRegistry(), transactionManager = beanCtx.beanFactory.getBeanProvider(PlatformTransactionManager::class.java), @@ -146,7 +152,7 @@ class OkapiMicrometerAutoConfigurationTest : FunSpec({ return emptyMap() } } - val metrics = OkapiMicrometerAutoConfiguration().micrometerOutboxMetrics( + val metrics = OkapiMicrometerAutoConfiguration.MetricsConfiguration().micrometerOutboxMetrics( store = store, registry = SimpleMeterRegistry(), transactionManager = beanCtx.beanFactory.getBeanProvider(PlatformTransactionManager::class.java), @@ -178,7 +184,7 @@ class OkapiMicrometerAutoConfigurationTest : FunSpec({ return emptyMap() } } - val metrics = OkapiMicrometerAutoConfiguration().micrometerOutboxMetrics( + val metrics = OkapiMicrometerAutoConfiguration.MetricsConfiguration().micrometerOutboxMetrics( store = store, registry = SimpleMeterRegistry(), transactionManager = beanCtx.beanFactory.getBeanProvider(PlatformTransactionManager::class.java), @@ -228,6 +234,95 @@ class OkapiMicrometerAutoConfigurationTest : FunSpec({ } } } + + context("okapi.metrics.enabled property") { + test("unset → defaults to enabled (matchIfMissing=true), no MetricsDisabledNotice") { + baseRunner.run { ctx -> + ctx.startupFailure.shouldBeNull() + ctx.getBeansOfType(MicrometerOutboxListener::class.java).isEmpty() shouldBe false + ctx.getBeansOfType(OkapiMicrometerAutoConfiguration.MetricsDisabledNotice::class.java) + .isEmpty() shouldBe true + } + } + + test("=true → explicit opt-in path registers the beans, no MetricsDisabledNotice") { + // Pins that the explicit string "true" is parsed and treated identically to the + // matchIfMissing=true default path exercised by the test above. + baseRunner + .withPropertyValues("okapi.metrics.enabled=true") + .run { ctx -> + ctx.startupFailure.shouldBeNull() + ctx.getBeansOfType(MicrometerOutboxListener::class.java).isEmpty() shouldBe false + ctx.getBeansOfType(OkapiMicrometerAutoConfiguration.MetricsDisabledNotice::class.java) + .isEmpty() shouldBe true + } + } + + test("=false → no okapi-micrometer beans are registered (previously silently ignored)") { + // Pre-fix, this property didn't exist at all: OkapiMetricsProperties only had + // refreshInterval, and ignoreUnknownFields defaults to true, so setting + // okapi.metrics.enabled=false bound nothing and changed nothing. + baseRunner + .withPropertyValues("okapi.metrics.enabled=false") + .run { ctx -> + ctx.startupFailure.shouldBeNull() + ctx.getBeansOfType(MicrometerOutboxListener::class.java).isEmpty() shouldBe true + ctx.getBeansOfType(MicrometerOutboxMetrics::class.java).isEmpty() shouldBe true + ctx.getBeansOfType(OutboxMetricsRefresher::class.java).isEmpty() shouldBe true + ctx.getBeansOfType(OkapiMicrometerAutoConfiguration.MetricsDisabledNotice::class.java) + .isEmpty() shouldBe false + } + } + + test("=false → MetricsDisabledNotice IS registered AND logs the actionable WARN") { + // Without this assertion a future cleanup pass that "removes the unused class" or + // replaces the init {} block with something that never runs would silently delete + // the operability promise the class exists to fulfil (mirrors the analogous + // LiquibaseDisabledNotice pin in LiquibaseAutoConfigurationTest). + val notice = LoggerFactory.getLogger( + "com.softwaremill.okapi.springboot.OkapiMicrometerAutoConfiguration", + ) as Logger + val appender = ListAppender().apply { start() } + notice.addAppender(appender) + + try { + baseRunner + .withPropertyValues("okapi.metrics.enabled=false") + .run { ctx -> + ctx.getBeansOfType(OkapiMicrometerAutoConfiguration.MetricsDisabledNotice::class.java) + .size shouldBe 1 + } + + val warnEvents = appender.list.filter { it.level == Level.WARN } + warnEvents.size shouldBe 1 + val message = warnEvents.single().formattedMessage + message stringShouldContain "okapi.metrics.enabled=false" + message stringShouldContain "MicrometerOutboxListener" + } finally { + notice.detachAppender(appender) + } + } + + test("=garbage → matches neither havingValue, so metrics are off but MetricsDisabledNotice does NOT fire") { + // Unlike okapi.liquibase.enabled, okapi.metrics.enabled is intentionally NOT also a + // typed field on a @ConfigurationProperties class (the issue explicitly asks for + // "a condition rather than a field", matching okapi.processor.enabled / + // okapi.purger.enabled) -- so there is no Spring binder to reject an invalid string, + // only two @ConditionalOnProperty(havingValue=...) string-equality checks. "garbage" + // matches neither "true" nor "false": context still starts, no okapi-micrometer beans + // register, but MetricsDisabledNotice's warning is also skipped since its own + // havingValue="false" doesn't match either. This is the same (pre-existing, accepted) + // behavior okapi.processor.enabled=garbage / okapi.purger.enabled=garbage already have. + baseRunner + .withPropertyValues("okapi.metrics.enabled=garbage") + .run { ctx -> + ctx.startupFailure.shouldBeNull() + ctx.getBeansOfType(MicrometerOutboxListener::class.java).isEmpty() shouldBe true + ctx.getBeansOfType(OkapiMicrometerAutoConfiguration.MetricsDisabledNotice::class.java) + .isEmpty() shouldBe true + } + } + } }) private fun h2DataSource(): DataSource = JdbcDataSource().apply {