Skip to content

See what your game is doing

Output that nobody can read is the same as no output. A live server produces hundreds of lines a minute, and the ones that matter are buried under prints that were useful for an afternoon three months ago.

Two modules cover this. Log gives every message a scope and a severity, so the noise can be turned down without deleting anything. Error catches the failures nobody wrote a handler for.

Make it once, keep it for the lifetime of that system, and never build one per call.

ServerScriptService/Services/ShopService
local Twill = require("@game/ServerScriptService/Twill")
-- The scope is what makes a line findable later. Name the system, not the file.
local logger = Twill.Log.new("Shop")
local ShopService = {}
function ShopService.Start()
logger:Info("ready") --> [Shop] ready
end
return ShopService

The levels rank Debug < Info < Warn < Error, and the choice decides what survives when you turn the volume down.

LevelFor
DebugDetail useful only while investigating. The first thing silenced.
InfoProgress worth seeing in a healthy session.
WarnA recoverable problem that did not stop the operation.
ErrorA failure. Reaches the installed handler as well as the output.
logger:Debug("catalogue reloaded", #items)
logger:Info("ready")
logger:Warn(`unknown item '{itemId}' requested`)
logger:Error(`could not reach the catalogue: {reason}`)

One call sets the floor for every logger on that side, Twill’s own included.

ServerScriptService/Main
local RunService = game:GetService("RunService")
-- Studio sees everything. A live server keeps only what someone would act on.
Twill.Log.SetLevel(if RunService:IsStudio() then "Debug" else "Info")

Nothing is deleted by doing this. A Debug line left in the code costs a comparison when it is silenced, so leave the investigation aids where they are and raise the floor instead of stripping them out before release.

Warn and Error report the nearest line outside Twill, so a complaint about a bad call points at the call rather than at the framework that noticed it.

[Twill.Data] (from MyGame.Services.Shop:42) main.Home for 123 holds Vector3; run it through Twill.Serialize.Encode first

That is usually enough to skip opening a stack trace at all. Only a bounded stretch of the stack is examined, so a failure raised through many layers of native code reports no call-site rather than a misleading one.

Nobody reads a live server’s output. Route reported failures to whatever you actually watch.

function ObservabilityService.Init()
Twill.Log.SetErrorHandler(function(scope, ...)
-- Runs on its own thread, so a slow sink cannot hold up the reporter,
-- and it is allowed to yield.
sendToYourSink(scope, ...)
end)
end

One handler at a time. Installing a second replaces the first, so install it once during Init rather than from each system that wants reporting.

Log.SetErrorHandler only sees failures somebody deliberately reported. The interesting ones are the failures nobody expected at all.

function ObservabilityService.Init()
local errors = Twill.Error.Install(Twill.Log.new("Unhandled"))
-- It is a thing that must be cleaned up, like anything else.
Twill.Scope.Framework():Add(errors, "Destroy")
end

Every script error the runtime reports now leaves a record carrying its trace. Nothing is re-thrown: the error already happened, and raising it again would only report it twice.

local errors = Twill.Error.Install(logger, {
Webhook = webhookUrl,
})

Posting happens only on the server, where the URL cannot be read by a client, and it is throttled so a burst of failures cannot bury the channel. A burst is dropped rather than queued, because the point is to be told that something broke, not to receive every instance of it.

Discord refuses webhook requests from Roblox game servers, and Install warns when the URL points straight at Discord. Post through a proxy you control.

  • A scope on every line, so a search finds one system rather than one word.
  • Info for things that happened, not for things that are about to.
  • Warn reserved for something a person could act on. If nobody would act, it is Debug.
  • No line inside a hot path or a per-frame loop. Logging is not free, and a line written every frame is the first thing to drown everything else.