Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ 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
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
Expand Down Expand Up @@ -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<PlatformTransactionManager>,
clock: ObjectProvider<Clock>,
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<PlatformTransactionManager>,
clock: ObjectProvider<Clock>,
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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<ILoggingEvent>().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 {
Expand Down