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.
Mental model
Section titled “Mental model”What one Log.info(...) call does:
- Level filter. Below the minimum level, the call returns before allocating anything. The default minimum is
Info, sodebuglines cost one comparison. - Listener check. With the console off and no sinks, the entry is never built.
- Entry:
{ Scope, Level, LevelName, Message, Args, Clock }.Argsis the remaining arguments packed into a table andClockisos.clock(). - Console, synchronously. The line is
[Scope] message arg1 arg2, each argument throughtostring. With the structured console on, the line goes toLogService:Info(Debug, Info) orLogService: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 throughwarn()and Debug and Info throughprint(). - Custom sinks, each through
task.spawnon 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 underpcall. 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).
Levels
Section titled “Levels”| 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 closureslocal Log = Logger.scope("Shop")
Log.info("purchase", "Chloe", 42) -- [Shop] purchase Chloe 42Log.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 endend)
-- The returned function removes the sinkRemoveAlerts()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 = 200local Ring = table.create(Capacity)local Head = 0
Logger.addSink(function(entry) Head = (Head % Capacity) + 1 Ring[Head] = entry -- keep the structured entry; format on demandend)
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 LinesendA 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 consoleend)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.
Structured console
Section titled “Structured console”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.
Capture
Section titled “Capture”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
releaserestores the capture that was active before it, and calling areleasetwice 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 OutputRelease()API reference
Section titled “API reference”| 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.
How the framework tags its output
Section titled “How the framework tags its output”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)Performance notes
Section titled “Performance notes”- A filtered-out line costs one comparison, so
Log.debugin 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
Argstable. 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.