Skip to content

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.

The pipeline for one Log.info(...) call:

  1. Level filter, first and cheapest. Below the minimum level, emit returns before allocating anything. Default minimum is Info, so debug lines cost a comparison and nothing else.
  2. Listener check. With the console disabled and zero sinks, the entry is never built.
  3. Entry construction{ Scope, Level, LevelName, Message, Args, Clock }. Args is the varargs packed into a table; Clock is os.clock().
  4. Console sink runs synchronously: formats [Scope] message arg1 arg2 … and routes Warn/Error through warn() (orange, with source attribution in Studio) and Debug/Info through print().
  5. Custom sinks run on a reused worker thread, one task.spawn each — 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 in pcall: 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.

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 42
Log.warn("stock low", ItemId) -- via warn(): orange in the console
Log.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}`)
end
end)
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 = 200
local 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 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

Headless 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.

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 14; 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.

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.01ms

Framework 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)
  • A filtered-out line costs one comparison. Leaving Log.debug calls 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 Args pack. 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.