Logger
Logger is the framework’s structured logging module: every line carries a scope, a level, a message, and its arguments as data — not a pre-formatted string. The defining decision is the sink: the console is just the built-in sink, and anything else (analytics, an in-game console, a webhook, log aggregation) registers alongside it and receives the same structured entries. Call sites never change when the destinations do.
Logger is module-global — one shared level, one sink list, one console toggle per VM. The server and client each run their own copy (each VM requires its own module instance), but within a VM every scope writes to the same stream. That is deliberate: a log stream you can tee is more useful than per-module loggers you have to hunt down.
Mental model
Section titled “Mental model”The pipeline for one Log.info(...) call:
- Level filter, first and cheapest. Below the minimum level,
emitreturns before allocating anything. Default minimum isInfo, sodebuglines cost a comparison and nothing else. - Listener check. With the console disabled and zero sinks, the entry is never built.
- Entry construction —
{ Scope, Level, LevelName, Message, Args, Clock }.Argsis the varargs packed into a table;Clockisos.clock(). - Console sink runs synchronously: formats
[Scope] message arg1 arg2 …and routesWarn/Errorthroughwarn()(orange, with source attribution in Studio) andDebug/Infothroughprint(). - Custom sinks run on a reused worker thread, one
task.spawneach — the same zero-allocation thread-reuse pattern as Signal. GC pressure matters most exactly when logging volume spikes, so the steady state allocates no coroutines. Each sink call is wrapped inpcall: a broken sink never breaks the caller, or the other sinks — it prints a[Logger] sink errored:warning (when the console is enabled) and moves on.
Treat sink delivery as asynchronous: it happens on that separate thread, and the spec yields once before asserting delivery. A sink that yields keeps the shared thread — it defers only its own completion, never the caller — and the next delivery allocates a fresh runner, forfeiting the reuse.
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; entries strictly below it are dropped before reaching 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 closure table.local Log = Logger.scope("Shop")
Log.info("purchase", PlayerName, ItemId) -- [Shop] purchase Chloe 42Log.warn("stock low", ItemId) -- via warn(): orange in the consoleLog.debug("cart state", CartSize) -- silent unless setLevel(Logger.Level.Debug)Scoped loggers are plain function tables — call them with a dot (Log.info(...)), not a colon. The functions close over the scope name; there is no self.
Tee the stream without touching a single call site:
-- Errors to a webhook, everything to analytics:local RemoveAlerts = Logger.addSink(function(entry) if entry.Level >= Logger.Level.Error then alertWebhook(`[{entry.Scope}] {entry.Message}`) endend)
local RemoveAnalytics = Logger.addSink(function(entry) Analytics:track(entry.Scope, entry.Message, entry.Args)end)
-- Sinks remove themselves via the returned function:RemoveAlerts()A ring-buffer sink is the standard recipe for “attach the last N log lines to a bug report” — bounded memory, no formatting cost until someone actually 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 -- store the structured entry; format only 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 LinesendHeadless contexts (a custom in-game console, log shipping where double-printing is noise) can silence the built-in console and keep sinks:
Logger.setConsoleEnabled(false)Logger.addSink(sendToInGameConsole)The TestKit Logger spec does exactly this — disables the console for its own run and restores the prior state after, because a skipped restore would mute the whole VM’s logging for the rest of the session.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Logger.scope(name) → ScopedLogger |
Returns { debug, info, warn, error }, each (message: string, ...any) → (). Dot-call; safe to create per module |
Logger.Level |
{ Debug = 1, Info = 2, Warn = 3, Error = 4 } |
Logger.setLevel(level) |
Global minimum; entries below it are dropped before entry construction. Default Info |
Logger.addSink(fn) → () -> () |
Registers fn(entry); returns its removal function. Crash-isolated, delivered asynchronously on a reused thread |
Logger.setConsoleEnabled(enabled) |
Toggles the built-in console sink (default true) |
Logger.isConsoleEnabled() → boolean |
Current console state — read it before toggling so you can restore |
Every sink receives the same structure:
| Field | Type | Meaning |
|---|---|---|
Scope |
string |
The Logger.scope name |
Level |
number |
1–4; compare against Logger.Level.* |
LevelName |
string |
"DEBUG", "INFO", "WARN", "ERROR" |
Message |
string |
The first argument to the scoped call |
Args |
{ any } |
Remaining arguments, unformatted — sinks decide how to render or index them |
Clock |
number |
os.clock() at emit time |
The console sink renders Args by tostring-joining them after the message. Keeping Message a stable string and pushing variable data into Args is what makes entries aggregatable — a sink can count “purchase” events without parsing interpolated strings.
How the framework tags its output
Section titled “How the framework tags its output”The console format [Scope] message is the framework-wide convention, and everything in the boot output follows it:
[TestKit] PASS — 259/259 tests across 23 specs[ChloeKernel 0.5.0] Server booted 0 services in 0.01msFramework modules with ongoing narratives log through scopes — Logger.scope("ErrorWatch") in ErrorWatch, Logger.scope("Simulation") and Logger.scope("SimLoad") in the simulation tooling, Logger.scope("Panel") in the debug panels. A handful of hard-wired lines — the kernel’s boot banner and scheduled-task-error warnings ([ChloeKernel]), TestKit’s pass/fail summary ([TestKit]), and NetDriver’s security warnings ([NetDriver]) — print directly with the same bracket prefix. Those bypass the sink pipeline on purpose: a fail-closed security warning must reach the console even if a game has disabled it or broken a sink. The net effect either way is one grep-able convention: filter the console (or your aggregated logs) by [NetDriver] and you have that module’s story.
Give your own systems the same treatment — one scope per system, named like the module:
local Log = Logger.scope("Matchmaking")Log.info("queued", Player.Name)Performance notes
Section titled “Performance notes”- A filtered-out line costs one comparison. Leaving
Log.debugcalls in hot paths is fine — but their arguments still evaluate.Log.debug("state", serializeBoard(Board))pays for the serialization even when Debug is off; guard genuinely expensive argument construction on the level yourself, or don’t log it per-frame. - Every delivered entry allocates the entry table plus the
Argspack. That is nothing for events (purchases, joins, violations) and too much for per-frame telemetry — frame-cadence numbers belong in the Scheduler stats and diagnostics attributes, not the log stream. - Sinks should return quickly and never assume delivery order relative to the call site — they run on their own thread. Batch outbound work (webhooks, HTTP) inside the sink rather than yielding per entry, both for rate limits and to keep the reused thread reusable.