ClientLoggingAutoConfiguration.kt

package eu.inqudium.legatium.webclient.logging

import eu.inqudium.legatium.common.ClientLoggingProperties
import eu.inqudium.legatium.common.CorrelationIdGenerator
import eu.inqudium.legatium.common.HeaderValueMasker
import eu.inqudium.legatium.common.NanoTimeSource
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.composite.CompositeMeterRegistry
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.AutoConfiguration
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.boot.webclient.WebClientCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order

/**
 * Registers the [ClientRequestLoggingFilter] in a Spring Boot application and attaches it to every
 * `WebClient` Boot builds - drop the module on the classpath and every outbound call is logged;
 * `adapter-logging.enabled=false` removes it again. The property namespace matches
 * legatium-restclient-logging's key for key; both modules may even live in one application (a servlet
 * host using `WebClient` for streaming calls), each logging the client it serves.
 *
 * Every bean backs off to a host-provided one - the time source, the id generator, the
 * [HeaderValueMasker] (a keyed fingerprint for a compliance regime), the filter itself. The meter
 * registry arrives as an [ObjectProvider] and is CONSUMED, never exported - a logging library must not
 * define the host's `MeterRegistry`; without one (no actuator) an empty [CompositeMeterRegistry] makes
 * every meter a no-op and the module works unchanged.
 *
 * The customizer lives in a nested configuration conditional on Boot's `spring-boot-webclient` module
 * (an optional dependency of this one): a host that builds its clients by hand keeps the filter bean
 * and adds it itself.
 */
@AutoConfiguration
@ConditionalOnProperty(prefix = "adapter-logging", name = ["enabled"], havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(ClientLoggingProperties::class)
class ClientLoggingAutoConfiguration {
    /** The system's monotonic clock, unless the host pins a time source. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingNanoTimeSource(): NanoTimeSource = NanoTimeSource.SYSTEM

    /** The counting default of ADR-0004, unless the host pins a generator. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingCorrelationIdGenerator(): CorrelationIdGenerator = CorrelationIdGenerator.DEFAULT

    /** The fingerprint the `masking-key` property selects, unless the host pins a masker. */
    @Bean
    @ConditionalOnMissingBean
    fun clientLoggingHeaderValueMasker(properties: ClientLoggingProperties): HeaderValueMasker = HeaderValueMasker.forKey(properties.maskingKey)

    /** The filter as its own bean, so a host can replace it while keeping the customizer below. */
    @Bean
    @ConditionalOnMissingBean
    fun clientRequestLoggingFilter(
        properties: ClientLoggingProperties,
        nanoTime: NanoTimeSource,
        correlationIds: CorrelationIdGenerator,
        masker: HeaderValueMasker,
        meterRegistry: ObjectProvider<MeterRegistry>,
    ): ClientRequestLoggingFilter = ClientRequestLoggingFilter(properties, nanoTime, correlationIds, meterRegistry.getIfAvailable { CompositeMeterRegistry() }, masker)

    /**
     * Attaches the filter to every `WebClient.Builder` Boot hands out (and to every HTTP service client
     * group built from one). Ordered LATE among the customizers, so the filter is appended behind the
     * filters of earlier customizers and runs INSIDE them - closest to the connector, once per attempt
     * of an outer retry ([ClientRequestLoggingFilter]).
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(WebClientCustomizer::class)
    class WebClientCustomization {
        @Bean
        @Order(CUSTOMIZER_ORDER)
        fun clientLoggingWebClientCustomizer(filter: ClientRequestLoggingFilter): WebClientCustomizer = WebClientCustomizer { builder -> builder.filter(filter) }
    }

    companion object {
        /**
         * Late, not last: room is left below for a host customizer that must see the fully configured
         * filter list (a metrics or diagnostics wrapper) - the same value as the RestClient twin's.
         */
        const val CUSTOMIZER_ORDER = Ordered.LOWEST_PRECEDENCE - 10
    }
}