BoundedBodyCapture.kt

package eu.inqudium.limesium.servlet.logging

import eu.inqudium.limesium.common.BodyReadState
import eu.inqudium.limesium.common.MeasuredBody
import eu.inqudium.limesium.common.decodeTruncated
import java.io.ByteArrayOutputStream
import java.nio.charset.Charset

/**
 * A bounded tee target: the capturing wrappers copy every body byte that actually flows through the
 * exchange into this buffer, up to [maxBytes]; beyond the cap bytes are only counted.
 *
 * The capture is a passive copy of the live stream - it never buffers, replays, or withholds bytes - so
 * unlike a replaying body cache there is no `IN_PROGRESS`/`COMPLETE` lifecycle to manage and nothing to
 * mark complete: at the moment the exchange line is written, whatever has flowed is what gets logged.
 *
 * Single-writer, single-late-reader concurrency model: the container serializes body I/O (one writer at
 * a time), and the emission reads once, at request destruction. Visibility across that handoff is
 * established by THIS class, not borrowed from container internals: [totalBytes] is `@Volatile` and is
 * written LAST in every mutation, so the reader's initial [totalBytes] read publishes all preceding
 * buffer writes (a piggybacked happens-before edge; relying on the async state machine's incidental
 * synchronization instead proved unsafe).
 *
 * With `maxBytes = 0` the capture runs in COUNT-ONLY mode: nothing is buffered, [totalBytes] still
 * counts every byte - the mode the body-size metrics use when body logging is off.
 *
 * Besides the bytes, the capture records HOW FAR the application consumed the body ([readState]): the
 * tee mirrors consumption, not transmission, so a body the application never read - or stopped reading
 * half-way - is invisible in the byte count alone. The request tee marks the start of consumption and
 * the end of the stream; the emitter turns the state into the `endpoint.request.body.read` counter.
 */
internal class BoundedBodyCapture(
    private val maxBytes: Int,
) : MeasuredBody {
    private val buffer = ByteArrayOutputStream()

    /**
     * How far the application consumed the body - see [BodyReadState]. Volatile for the same
     * writer-to-reader handoff as [totalBytes]; it is a separate fact (a zero-byte body can be read to
     * its end), so it has its own field rather than being derived from the count.
     */
    @Volatile
    override var readState: BodyReadState = BodyReadState.UNREAD
        private set

    /**
     * Every byte that flowed, including those beyond the capture limit - the size metrics' source.
     * Volatile, and always the LAST write of a mutation: its write publishes the buffer state to the
     * destruction-time reader (see the class KDoc), and readers must read it FIRST.
     */
    @Volatile
    override var totalBytes: Long = 0
        private set

    fun capture(b: Int) {
        if (buffer.size() < maxBytes) {
            buffer.write(b)
        }
        totalBytes += 1
    }

    fun capture(
        bytes: ByteArray,
        offset: Int,
        length: Int,
    ) {
        val room = maxBytes - buffer.size()
        if (room > 0) {
            buffer.write(bytes, offset, minOf(length, room))
        }
        totalBytes += length
    }

    /** The application selected the body stream or reader: from now on the body counts as (at least) partially read. */
    fun markStarted() {
        if (readState == BodyReadState.UNREAD) {
            readState = BodyReadState.PARTIAL
        }
    }

    /** The application observed the end of the stream: the body was consumed completely. */
    fun markCompleted() {
        readState = BodyReadState.COMPLETE
    }

    /**
     * Discards everything captured so far. Called by [CapturingResponseWrapper] when the application
     * resets an UNCOMMITTED response (`reset()`/`resetBuffer()`): nothing written before the reset ever
     * left the container's buffer, so dropping it keeps the logged body and the size metric aligned with
     * what actually went out through the write path.
     */
    fun clear() {
        buffer.reset()
        totalBytes = 0
    }

    /**
     * The captured bytes decoded with [charset], suffixed with a truncation note when the body was larger
     * than the capture limit. Returns `null` for a body of zero bytes, so the log emission can omit the
     * key entirely instead of logging an empty string.
     */
    fun loggedValue(charset: Charset): String? {
        if (totalBytes == 0L) {
            return null
        }
        return if (totalBytes > buffer.size()) {
            "${decodeTruncated(buffer.toByteArray(), charset)}... [truncated, $totalBytes bytes total]"
        } else {
            buffer.toString(charset)
        }
    }
}