Skip to content

Logger

Structured, scoped logging with crash-isolated sinks, a structured console mode and capture, so the console, analytics and tests all read one stream.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Logger
Since
v0.1.0
Source
Logger.luau

Logger is the framework’s structured logging module. Every line carries a scope, a level, a message and its arguments as data rather than one pre-formatted string. The module is built around sinks: the console is one built-in sink, and anything else (analytics, an in-game console, a webhook, log aggregation) registers beside it and receives the same entries. Call sites never change when the destinations do. Since 0.9.0 the framework’s own warnings go through Logger too, so sinks, the level filter, the console switch and TestKit’s warning capture cover them. Health numbers that change every frame belong in Scheduler stats and diagnostics instead.

Logger is module-global: one level, one sink list, one console switch and one capture per VM. The server and each client run their own copy, and within a VM every scope writes to the same stream. One stream you can tee is more useful than per-module loggers you have to hunt down.

What one Log.info(...) call does:

  1. Level filter. Below the minimum level, the call returns before allocating anything. The default minimum is Info, so debug lines cost one comparison.
  2. Listener check. With the console off and no sinks, the entry is never built.
  3. Entry: { Scope, Level, LevelName, Message, Args, Clock }. Args is the remaining arguments packed into a table and Clock is os.clock().
  4. Console, synchronously. The line is [Scope] message arg1 arg2, each argument through tostring. With the structured console on, the line goes to LogService:Info (Debug, Info) or LogService:Warn (Warn, Error) with structured context. Otherwise, or if that call fails, a capture handler takes the line when one is installed, and else Warn and Error go through warn() and Debug and Info through print().
  5. Custom sinks, each through task.spawn on a reused worker thread: the same thread-reuse pattern as Signal, so the steady state allocates no coroutines, which matters most when log volume spikes. Each sink call runs under pcall. A broken sink never breaks the caller or the other sinks; it produces a [Logger] sink errored: console line (when the console is on) and delivery moves on.

A sink that does not yield finishes before the logging call returns. A sink that yields keeps the worker thread and finishes later, and the next delivery allocates a new worker. Sinks should not assume they run before or after the code that follows the call site.

The sink list is copy-on-write: addSink and a sink’s removal replace the list instead of editing it. An emit already walking the old list is unaffected, so a sink that removes itself (or another sink) during an emit no longer makes the walk skip the next sink (fixed in 0.9.0).

Level Value Console routing
Logger.Level.Debug 1 print, hidden by default
Logger.Level.Info 2 print
Logger.Level.Warn 3 warn
Logger.Level.Error 4 warn

Logger.setLevel(level) moves the minimum, and entries below it are dropped before they reach any sink. The default is Info.

local Logger = require(game:GetService("ReplicatedStorage").ChloeKernel.Logger)
-- Cache the scoped logger at module top; scope() builds a small table of closures
local Log = Logger.scope("Shop")
Log.info("purchase", "Chloe", 42) -- [Shop] purchase Chloe 42
Log.warn("stock low", 42) -- through warn()
Log.debug("cart size", 3) -- silent unless setLevel(Logger.Level.Debug)

Scoped loggers are plain function tables, called with a dot: Log.info(...).

Tee the stream without touching a call site:

local HttpService = game:GetService("HttpService")
local AlertUrl = "https://example.com/alerts"
local RemoveAlerts = Logger.addSink(function(entry)
if entry.Level >= Logger.Level.Error then
local Body = HttpService:JSONEncode({ Scope = entry.Scope, Message = entry.Message })
pcall(HttpService.PostAsync, HttpService, AlertUrl, Body) -- yields on this sink's own thread
end
end)
-- The returned function removes the sink
RemoveAlerts()

A ring buffer is the usual way to attach the last N lines to a bug report. Memory stays bounded and nothing is formatted until someone asks for the dump:

local Capacity = 200
local Ring = table.create(Capacity)
local Head = 0
Logger.addSink(function(entry)
Head = (Head % Capacity) + 1
Ring[Head] = entry -- keep the structured entry; format on demand
end)
local function dumpRecentLines(): { string }
local Lines = {}
for Offset = Capacity - 1, 0, -1 do
local Entry = Ring[((Head - Offset - 1) % Capacity) + 1]
if Entry then
table.insert(Lines, `{Entry.LevelName} [{Entry.Scope}] {Entry.Message}`)
end
end
return Lines
end

A headless context (an in-game console, log shipping where double printing is noise) turns the built-in console off and keeps its sinks:

Logger.setConsoleEnabled(false)
Logger.addSink(function(entry)
print(`{entry.LevelName} {entry.Scope}: {entry.Message}`) -- stands in for the in-game console
end)

Read Logger.isConsoleEnabled() first when you change it temporarily, and restore it after. A skipped restore mutes the whole VM for the rest of the session.

Logger.setStructuredConsole(true) sends console output through LogService:Info and LogService:Warn with a context table instead of print and warn (0.9.0). The context is flat: Scope, Level (the level name) and Arg1, Arg2 and so on, where strings, numbers and booleans pass through and anything else is converted with tostring. The Developer Console shows it as data.

Logger.setStructuredConsole(true)
Log.info("bought", "Sword", 3)
-- LogService:Info("[Shop] bought Sword 3", { Scope = "Shop", Level = "INFO", Arg1 = "Sword", Arg2 = 3 })

Error entries go to LogService:Warn, never LogService:Error, which throws. If the structured call fails, the line falls back to print or warn, so logging never breaks the caller. setStructuredConsole(false) switches back. The optional second argument injects a stand-in for LogService, which the specs use.

Logger.capture(handler) -> release hands every line the console would write to handler(line, level) instead of the Output window, until release() runs (0.9.0). It exists for tests: TestKit holds each test’s warnings this way, so a spec declares the warnings it provokes with T.expectWarning(fragment), and an undeclared warning or a declared one that never came fails the test.

  • Captures nest. Each release restores the capture that was active before it, and calling a release twice does nothing the second time.
  • Logger.capture(nil) writes to the Output window again until its release runs.
  • Capture replaces only the console output. Sinks still receive every entry.
  • With the structured console pointed at the engine’s LogService, the capture takes the line instead. With an injected structured logger, the structured call still happens.
  • The [Logger] sink errored: line goes through the same path, so a capture takes it too.
local Held = {}
local Release = Logger.capture(function(line: string, level: number)
table.insert(Held, line)
end)
Log.warn("low stock") -- goes to Held as "[Shop] low stock", not to Output
Release()
Member Description
Logger.scope(name): ScopedLogger Returns { debug, info, warn, error }, each (message: string, ...any) -> (). Dot-called.
Logger.Level { Debug = 1, Info = 2, Warn = 3, Error = 4 }.
Logger.setLevel(level) Global minimum. Entries below it are dropped before the entry is built. Default Info.
Logger.addSink(fn): () -> () Registers fn(entry) and returns its removal function. Crash-isolated and run on a reused thread.
Logger.setConsoleEnabled(enabled) Turns the built-in console sink on or off. Default on.
Logger.isConsoleEnabled(): boolean Current console state, for restoring it after a change.
Logger.setStructuredConsole(enabled, logService?) Routes console output through LogService:Info and LogService:Warn with structured context.
Logger.capture(handler?): () -> () Sends console lines to handler(line, level) until the returned release runs. Nests.

Every sink receives the same structure:

Field Type Meaning
Scope string The Logger.scope name.
Level number 1 to 4; compare against Logger.Level.
LevelName string "DEBUG", "INFO", "WARN" or "ERROR".
Message string The first argument of the scoped call.
Args { any } The remaining arguments, unformatted. Sinks decide how to render or index them.
Clock number os.clock() at emit time.

Keep Message a stable string and put the variable data in Args. A sink can then count “purchase” events without parsing interpolated text.

The console format [Scope] message is the framework-wide convention. Since 0.9.0 framework modules warn through Logger.scope(<Module>) instead of raw warn: [ChloeKernel] for scheduled task errors, [ServiceManager] for failed stops, [HookRegistry] for crashing hook handlers, [ChloeKernelServer] for the close phase and unguarded channels, [NetDriver], [Scope], [Zones], [DataDriver <Name>] and the rest. Sinks, setLevel, setConsoleEnabled and capture therefore apply to framework warnings, the F8 SERVER log shows them, and a spec can expect them. Directive CK016 in the directive linter fails any framework module that calls raw warn(); the Packet port and the ActorPool worker script are the exceptions.

A few lines still print directly with the same bracket prefix: the kernel boot line ([ChloeKernel 0.9.1] Server booted ...), the [ChloeKernelServer] security audit: clean line and TestKit’s pass or fail summary. Filtering the console or aggregated logs by [NetDriver] gives that module’s story either way.

Game code does the same: one scope per system, named like the module, so its specs can expect its warnings.

local Log = Logger.scope("Matchmaking")
Log.info("queued", Player.Name)
  • A filtered-out line costs one comparison, so Log.debug in a hot path is fine, but its arguments are still evaluated. Log.debug("state", serializeBoard(Board)) pays for the serialization even with Debug off. Guard expensive argument construction yourself.
  • Every delivered entry allocates the entry table and the Args table. That is nothing for events (purchases, joins, violations) and too much for per-frame telemetry.
  • Sinks should return quickly. Batch outbound work (webhooks, HTTP) inside the sink instead of yielding per entry, for rate limits and so the reused thread stays reusable.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.