RequestLoggingAutoConfiguration.kt

package eu.inqudium.limesium.servlet.logging

import eu.inqudium.limesium.common.CorrelationIdGenerator
import eu.inqudium.limesium.common.HeaderValueMasker
import eu.inqudium.limesium.common.NanoTimeSource
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
import jakarta.servlet.ServletRequestListener
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.core.Ordered

/**
 * Registers the [RequestLoggingFilter] in a servlet (Tomcat) Spring Boot application - drop the module on
 * the classpath and every exchange is logged; `endpoint-logging.enabled=false` removes it again.
 *
 * Every bean backs off to a host-provided one: a host may pin [NanoTimeSource] or
 * [CorrelationIdGenerator] (tests do), replace the [HeaderValueMasker] (a keyed fingerprint for a
 * compliance regime), or define its own [RequestLoggingFilter] bean to take over registration entirely.
 */
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(prefix = "endpoint-logging", name = ["enabled"], havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(RequestLoggingProperties::class)
class RequestLoggingAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean
    fun requestLoggingNanoTimeSource(): NanoTimeSource = NanoTimeSource.SYSTEM

    @Bean
    @ConditionalOnMissingBean
    fun requestLoggingCorrelationIdGenerator(): CorrelationIdGenerator = CorrelationIdGenerator.DEFAULT

    /** How masked header values render - a host pins a keyed or fixed masker; both twins take the same bean. */
    @Bean
    @ConditionalOnMissingBean
    fun requestLoggingHeaderValueMasker(properties: RequestLoggingProperties): HeaderValueMasker = HeaderValueMasker.forKey(properties.maskingKey.value)

    /**
     * The filter as its own bean, so a host can replace it while keeping the registration wiring below.
     *
     * The meter registry arrives as an [ObjectProvider] and is CONSUMED, never exported: a logging
     * library must not define the host's `MeterRegistry`. A host without one - no actuator - gets a
     * private [SimpleMeterRegistry]: the fail-open counters then count unexported, and the module works
     * unchanged.
     */
    @Bean
    @ConditionalOnMissingBean
    fun requestLoggingFilter(
        properties: RequestLoggingProperties,
        nanoTime: NanoTimeSource,
        correlationIds: CorrelationIdGenerator,
        masker: HeaderValueMasker,
        meterRegistry: ObjectProvider<MeterRegistry>,
    ): RequestLoggingFilter = RequestLoggingFilter(properties, nanoTime, correlationIds, meterRegistry.getIfAvailable { SimpleMeterRegistry() }, masker)

    /**
     * Runs very early (but not first) in the chain, so the request id is in the MDC before other
     * filters log; the offset leaves room for infrastructure that must precede logging (metrics,
     * request-context setup). Referencing the filter bean here keeps Boot from ALSO auto-registering the
     * bare `Filter` bean - a registration bean claims its filter.
     *
     * Trace identity does NOT depend on this order: the filter parses the incoming `traceparent` header
     * itself (ADR-0002).
     * `RequestLoggingFilterTomcatTracingIntegrationTest` pins that contract beside a live bridge, so a
     * Boot upgrade that lets the bridge displace the parsed context breaks the build.
     */
    @Bean
    fun requestLoggingFilterRegistration(filter: RequestLoggingFilter): FilterRegistrationBean<RequestLoggingFilter> =
        FilterRegistrationBean(filter).apply {
            order = Ordered.HIGHEST_PRECEDENCE + 10
        }

    /**
     * The emission point: the filter's completion listener, fired by the container at request destruction
     * - after the error dispatch and after async completion - so the logged status is the response's
     * FINAL one, not a pre-rendering value. See the emission-point section of [RequestLoggingFilter].
     */
    @Bean
    fun requestLoggingExchangeCompletionListener(filter: RequestLoggingFilter): ServletListenerRegistrationBean<ServletRequestListener> = ServletListenerRegistrationBean(filter.exchangeCompletionListener())
}