Skip to content

Logging.kt

src/jvmMain/kotlin/afield/ai/diagnostics/Logging.kt · 429 lines · cited by Logging: channels, sinks and the console

package afield.ai.diagnostics

import afield.ai.conversation.AiToastPolicy
import afield.app.notifications.notify
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.time.Instant
import java.util.concurrent.CopyOnWriteArrayList
import java.util.UUID
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

The channel-tagged log events the AI tooling writes, and the sinks that receive them.

A LogEvent carries a channel (a comma-separated set of tags such as info,ai.toolio), a message, and optional block indent and boundary markers. LogDispatcher fans each event out to every installed LogSink; LogDispatcher.withSink attaches one for the length of a block.

Sinks

ChannelMatch is the rule all channel selectors share: a selector matches when each of its tags is among the channel's.

File traces

TraceLogger appends sectioned, truncated text to a trace file when afield.ai4.trace (or AFIELD_AI4_TRACE) is set; 1/true selects ~/.afield/logs/ai4_trace.log. AiToolIoLogger writes one JSONL file per tool session under ~/.afield/logs/ai4_tool_io, on unless afield.ai4.toolIoLog (or AFIELD_AI4_TOOL_IO_LOG) is 0, false or off.

data class LogEvent

One log line: its channel tags, message, time, and the block indent and boundary it sits in.

data class LogEvent(
    val channel: String,
    val message: String,
    val timestamp: Instant = Instant.now(),
    val indent: String = "",
    val blockId: String? = null,
    val boundary: BlockBoundary? = null
)

enum class BlockBoundary { BEGIN, END }

fun interface LogSink

fun log(event: LogEvent)

fun LogSink.log

fun LogSink.log(
    channel: String,
    message: String,
    indent: String = "",
    blockId: String? = null,
    boundary: BlockBoundary? = null
) = log(LogEvent(channel = channel, message = message, indent = indent, blockId = blockId, boundary = boundary))

object LogDispatcher

object LogDispatcher {
    private val sinks = CopyOnWriteArrayList<LogSink>().apply { add(StdoutLog) }

fun setSinks(newSinks: List<LogSink>)

    fun setSinks(newSinks: List<LogSink>) {
        sinks.clear()
        sinks.addAll(newSinks)
    }

fun addSink(sink: LogSink)

    fun addSink(sink: LogSink) {
        sinks.add(sink)
    }

fun removeSink(sink: LogSink)

    fun removeSink(sink: LogSink) {
        sinks.remove(sink)
    }

fun log(event: LogEvent)

    fun log(event: LogEvent) {
        sinks.forEach { sink ->
            runCatching { sink.log(event) }
        }
    }

fun log

    fun log(
        channel: String,
        message: String,
        indent: String = "",
        blockId: String? = null,
        boundary: BlockBoundary? = null
    ) = log(LogEvent(channel = channel, message = message, indent = indent, blockId = blockId, boundary = boundary))
}

inline fun <R> LogDispatcher.withSink(sink: LogSink, block: () -> R): R {

Runs block with sink attached, and detaches it again however block ends — normally, by an early return, or on a throw.

Prefer this to a bare LogDispatcher.addSink/LogDispatcher.removeSink pair. The sink list is process-wide and outlives any one box, so a sink leaked by a path that skipped its own cleanup does not go quiet: it keeps receiving every event on every channel for the life of the JVM, and the box that installed it is long gone. Worse, LogDispatcher.log wraps every sink in runCatching, so a leaked sink that throws fails silently and forever. Making the removal part of the call is the difference between a rule the caller has to remember and one they cannot get wrong.

The same bracketing shape, and the same reason for it, as afield.app.diagnostics.logging.lblock.

Top-level rather than a member of LogDispatcher because Kotlin permits contract only on top-level functions, and the callsInPlace contract is what lets a caller assign a val inside the block. The call reads identically either way.

The example writes the channel as a variable on purpose: a literal here would be counted as a production lprintln call site by ProseNumeralContractTest's channel census, which reads source and does not exclude KDoc — so a doc snippet would silently move a statistic about real logging.

val seen = LogDispatcher.withSink(probe) {
    lprintln(channel, "a plain line")
    probe.count
}
@OptIn(ExperimentalContracts::class)
inline fun <R> LogDispatcher.withSink(sink: LogSink, block: () -> R): R {
    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
    addSink(sink)
    try {
        return block()
    } finally {
        removeSink(sink)
    }
}

object ChannelMatch

The one rule every channel selector follows — the console mute set, the pinned console and the LogStream filter: a channel is a comma-separated set of tags, and a selector matches when every one of its tags is among the channel's. So boxes matches info,boxes and boxes,debug; info,boxes matches boxes,info and info,boxes,slow; kotlin does not match kotlinjs. Tags compare exactly, case included, as written at the call site.

fun tags(channel: String): Set<String>

    fun tags(channel: String): Set<String> =
        channel.split(',').mapNotNullTo(HashSet()) { it.trim().takeIf(String::isNotEmpty) }

fun matches(channel: String, selector: String): Boolean

    fun matches(channel: String, selector: String): Boolean {
        val wanted = tags(selector)
        return wanted.isNotEmpty() && tags(channel).containsAll(wanted)
    }
}

object ConsoleMute

Channels suppressed from the console — both the plain StdoutLog and the interactive PinnedConsole honor this. Muted events still reach every other sink (e.g. the LogStream web widget), so nothing is lost; they're just too chatty for the terminal. Default mutes the chatty background channels (info,boxes, info,persist, info,ai.boxnames, debug,boxes). Override with -Dafield.log.mute=... or AFIELD_LOG_MUTE=... (semicolon-separated selectors, matched by ChannelMatch: boxes mutes every channel tagged boxes); set to a blank/empty value to mute nothing.

val channels: Set<String>

    val channels: Set<String> = run {
        val raw = System.getProperty("afield.log.mute") ?: System.getenv("AFIELD_LOG_MUTE")
        if (raw != null) {
            raw.split(';').map { it.trim() }.filter { it.isNotEmpty() }.toSet()
        } else {
            setOf("info,boxes", "info,persist", "info,ai.boxnames", "debug,boxes")
        }
    }

fun isMuted(channel: String): Boolean = channels.any { ChannelMatch.matches(channel, it) }

object StdoutLog : LogSink

object StdoutLog : LogSink {
    private const val ANSI_RESET = "\u001B[0m"
    private const val ANSI_DARK_GRAY = "\u001B[90m"
    private const val ANSI_ITALIC = "\u001B[3m"

override fun log(event: LogEvent)

    override fun log(event: LogEvent) {
        if (ConsoleMute.isMuted(event.channel)) return
        val prefix = ""
        val channelPart = if (event.channel.isNotBlank()) {
            // Render channel tags as dark gray + italic for easier skimming in stdout
            "$ANSI_DARK_GRAY$ANSI_ITALIC[${event.channel}]$ANSI_RESET "
        } else ""
        val boundaryMarker = when (event.boundary) {
            BlockBoundary.BEGIN -> ">|> "
            BlockBoundary.END -> "<|< "
            null -> ""
        }
        val full = prefix + channelPart + depthIndent(event.indent) + boundaryMarker + event.message
        println(full)
    }

internal fun depthIndent(indent: String): String

An lblock indent is one "<block uuid>\t" token per open block — the structured sinks use the ids. A terminal wants the depth: two spaces per level, not 36 characters of UUID.

    internal fun depthIndent(indent: String): String =
        if (indent.isEmpty()) "" else "  ".repeat(indent.count { it == '\t' }.coerceAtLeast(1))
}

class NotifyLog

class NotifyLog(
    private val label: String = "AI4",
    private val prefix: String = "AI4",
    private val maxLen: Int = 200,
    private val dedupeWindowMs: Long = 1000
) : LogSink {
    private val policy = AiToastPolicy(dedupeWindowMs = dedupeWindowMs)

override fun log(event: LogEvent)

    override fun log(event: LogEvent) {
        // Keep the complete diagnostic event in the log, even when it has no toast.
        StdoutLog.log(event)
        val text = policy.message(event, System.currentTimeMillis()) ?: return
        val clipped = text.take(maxLen.coerceAtLeast(1))
        notify(clipped, label.ifBlank { prefix.ifBlank { "AI4" } })
    }
}

object SilentLog : LogSink

override fun log(event: LogEvent)

    override fun log(event: LogEvent) {
        // intentionally quiet
    }
}

class TraceLogger private constructor

class TraceLogger private constructor(
    private val path: Path,
    private val maxChars: Int
) {
    private val lock = Any()

fun log(section: String, payload: String)

    fun log(section: String, payload: String) {
        val clipped = if (payload.length > maxChars) {
            payload.take(maxChars) + "\n... [truncated ${payload.length - maxChars} chars]"
        } else {
            payload
        }
        val entry = buildString {
            append("---- ")
            append(section)
            append(" ")
            append(Instant.now().toString())
            append("\n")
            append(clipped)
            append("\n\n")
        }
        LogDispatcher.log(CHANNEL, "$section: ${payload.length} chars -> $path")
        synchronized(lock) {
            Files.createDirectories(path.parent)
            Files.newBufferedWriter(
                path,
                StandardCharsets.UTF_8,
                StandardOpenOption.CREATE,
                StandardOpenOption.APPEND
            ).use { it.write(entry) }
        }
    }

companion object

    companion object {

const val CHANNEL = "info,ai.trace"

The channel each trace entry is announced on (one line; the payload stays in the file).

fun fromEnv(): TraceLogger?

body — 42 lines, from line 264
        fun fromEnv(): TraceLogger? {
            val raw = System.getProperty("afield.ai4.trace")
                ?: System.getProperty("afield.ai4.trace.path")
                ?: System.getenv("AFIELD_AI4_TRACE")
                ?: System.getenv("AFIELD_AI4_TRACE_PATH")
                ?: return null
            val normalized = raw.trim()
            if (normalized.isEmpty()) return null
            if (normalized.equals("0", ignoreCase = true) ||
                normalized.equals("false", ignoreCase = true) ||
                normalized.equals("off", ignoreCase = true)
            ) {
                return null
            }
            val defaultPath = Paths.get(System.getProperty("user.home"), ".afield", "logs", "ai4_trace.log")
            val path = if (
                normalized.equals("1", ignoreCase = true) ||
                normalized.equals("true", ignoreCase = true) ||
                normalized.equals("yes", ignoreCase = true) ||
                normalized.equals("on", ignoreCase = true)
            ) {
                defaultPath
            } else {
                val p = Paths.get(normalized)
                if (p.isAbsolute) p else Paths.get(System.getProperty("user.dir")).resolve(p)
            }
            val max = (
                System.getProperty("afield.ai4.trace.max")
                    ?: System.getenv("AFIELD_AI4_TRACE_MAX")
            )?.toIntOrNull()?.coerceAtLeast(1000) ?: 20000
            return TraceLogger(path, max).also {
                it.log(
                    "TRACE_INIT",
                    buildString {
                        appendLine("path=$path")
                        appendLine("maxChars=$max")
                    }
                )
            }
        }
    }
}

class AiToolIoLogger private constructor

class AiToolIoLogger private constructor(
    private val root: Path,
    private val maxChars: Int,
    private val log: LogSink
) {

data class Session

    data class Session(
        val id: String,
        val path: Path
    )

    private val lock = Any()

fun begin(fields: Map<String, Any?>): Session

    fun begin(fields: Map<String, Any?>): Session {
        val now = Instant.now()
        val id = UUID.randomUUID().toString()
        val fileStamp = now.toString()
            .replace(":", "")
            .replace(".", "-")
        val path = root.resolve("$fileStamp-${id.take(8)}.jsonl")
        val session = Session(id = id, path = path)
        event(session, "session_start", fields + ("logPath" to path.toString()))
        log.log("info,ai.toolio", "log=${path.toAbsolutePath()}")
        return session
    }

fun event(session: Session, event: String, fields: Map<String, Any?> = emptyMap())

body — 63 lines, from line 332
    fun event(session: Session, event: String, fields: Map<String, Any?> = emptyMap()) {
        val record = linkedMapOf<String, Any?>(
            "timestamp" to Instant.now().toString(),
            "sessionId" to session.id,
            "event" to event
        )
        record.putAll(fields)
        append(session.path, jsonValue(record) + "\n")
    }

    private fun append(path: Path, text: String) {
        synchronized(lock) {
            Files.createDirectories(path.parent)
            Files.newBufferedWriter(
                path,
                StandardCharsets.UTF_8,
                StandardOpenOption.CREATE,
                StandardOpenOption.APPEND
            ).use { it.write(text) }
        }
    }

    private fun clipped(text: String): String =
        if (text.length <= maxChars) text else text.take(maxChars) + "\n... [truncated ${text.length - maxChars} chars]"

    private fun jsonValue(value: Any?): String = when (value) {
        null -> "null"
        is Boolean -> value.toString()
        is Byte, is Short, is Int, is Long -> value.toString()
        is Float -> if (value.isFinite()) value.toString() else jsonString(value.toString())
        is Double -> if (value.isFinite()) value.toString() else jsonString(value.toString())
        is String -> jsonString(clipped(value))
        is Map<*, *> -> value.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
            "${jsonString(k?.toString() ?: "null")}:${jsonValue(v)}"
        }
        is Iterable<*> -> value.joinToString(prefix = "[", postfix = "]") { jsonValue(it) }
        is Array<*> -> value.joinToString(prefix = "[", postfix = "]") { jsonValue(it) }
        else -> jsonString(clipped(value.toString()))
    }

    private fun jsonString(value: String): String = buildString {
        append('"')
        value.forEach { ch ->
            when (ch) {
                '\\' -> append("\\\\")
                '"' -> append("\\\"")
                '\b' -> append("\\b")
                '\u000C' -> append("\\f")
                '\n' -> append("\\n")
                '\r' -> append("\\r")
                '\t' -> append("\\t")
                else -> {
                    if (ch.code < 0x20) {
                        append("\\u")
                        append(ch.code.toString(16).padStart(4, '0'))
                    } else {
                        append(ch)
                    }
                }
            }
        }
        append('"')
    }

companion object

fun fromEnvOrDefault(log: LogSink): AiToolIoLogger?

        fun fromEnvOrDefault(log: LogSink): AiToolIoLogger? {
            val enabledRaw = System.getProperty("afield.ai4.toolIoLog")
                ?: System.getenv("AFIELD_AI4_TOOL_IO_LOG")
            if (enabledRaw != null) {
                val normalized = enabledRaw.trim()
                if (normalized.equals("0", ignoreCase = true) ||
                    normalized.equals("false", ignoreCase = true) ||
                    normalized.equals("off", ignoreCase = true)
                ) {
                    return null
                }
            }

            val rootRaw = System.getProperty("afield.ai4.toolIoLog.path")
                ?: System.getenv("AFIELD_AI4_TOOL_IO_LOG_PATH")
            val defaultRoot = Paths.get(System.getProperty("user.home"), ".afield", "logs", "ai4_tool_io")
            val root = rootRaw
                ?.trim()
                ?.takeIf { it.isNotBlank() }
                ?.let {
                    val path = Paths.get(it)
                    if (path.isAbsolute) path else Paths.get(System.getProperty("user.dir")).resolve(path)
                }
                ?: defaultRoot
            val max = (
                System.getProperty("afield.ai4.toolIoLog.max")
                    ?: System.getenv("AFIELD_AI4_TOOL_IO_LOG_MAX")
            )?.toIntOrNull()?.coerceAtLeast(1000) ?: 200_000
            return AiToolIoLogger(root, max, log)
        }
    }
}