Skip to content

Logging module

Tecs provides a lightweight and fast logging module with a no-op disabled path. Disabled log levels are replaced with an empty function at runtime, avoiding branching, formatting, or allocation. Enabled levels write formatted messages to a configurable sink (defaults to stderr). Timestamps are second-resolution.

Basic usage

First, require the logging module:

teal
local logging <const> = require("tecs.utils.logging")

Next, create a logger by providing a name. This is typically the name of the module.

teal
local LOGGER <const> = logging.getLogger("mySystem")

Now you can log using debug, info, warn, and error methods. The first argument is the message, in string.format syntax, followed by zero or more string.format arguments.

teal
LOGGER:info("system started")
LOGGER:debug("loaded %d assets from %s", count, path)
LOGGER:warn("%.1f%% memory used", 85.3)
LOGGER:error("failed to load: %s", filename)

Pass raw values

Don't pre-format values yourself. Formatting only runs if the level is enabled, so passing raw values keeps disabled logs free. (Use %s for values that need tostring; numeric specifiers like %d and %f expect numbers and may error if given other types.)

Log levels

Levels from most to least verbose:

LevelDescription
DEBUGVerbose diagnostic info
INFOGeneral operational messages
WARNPotential issues that aren't fatal
ERRORFailures that need attention
OFFSuppresses all output

Setting a level enables that level and all less verbose levels below it. For example, WARN enables WARN and ERROR, but disables DEBUG and INFO.

Just log, don't check

Don't worry about ever checking if a log level is enabled because:

  1. Logging uses Lua varargs, so the logger itself does not allocate transient tables in the common case
  2. Disabled levels are an empty function call: no branching, formatting, or argument inspection
  3. Formatting on enabled levels only runs after the level check passes

Changing the log level

teal
logging.setLevel("DEBUG")   -- enable all levels
logging.setLevel("WARN")    -- only warnings and errors
logging.setLevel("OFF")     -- silence everything

setLevel updates all existing loggers immediately, in place, so code holding a reference to a logger sees the change.

Changing the output sink

By default, log messages are written to io.stderr. You can redirect to any file handle:

teal
local logFile = io.open("game.log", "a")
logging.setSink(logFile)

Like setLevel, this updates all existing loggers. The module does not close or flush sinks for you; the caller owns the file handle lifecycle.

Output format

Each log line is written as:

YYYY-MM-DD HH:MM:SS name LEVEL: message

For example:

2026-04-12 14:30:00 tecs2d.gfx.mesh ERROR: No render pipeline found in world resources
2026-04-12 14:30:00 mySystem INFO: entity 42 spawned at (100, 200)

Logger caching

getLogger returns the same logger instance for a given name. Names are used verbatim and are case-sensitive. Calling it repeatedly is essentially free (a table lookup), though best practice is to create a variable for a logger in each module that needs logging.

You can do this:

teal
-- These return the same object:
local a = logging.getLogger("physics")
local b = logging.getLogger("physics")
assert(a == b)

But you should do this:

teal
local LOGGER <const> = logging.getLogger("physics")

Module properties

PropertyDefaultDescription
level"INFO"The active log level (read only)
sinkio.stderrThe output file handle (read only)
dateFormat"%Y-%m-%d %H:%M:%S "The strftime format passed to os.date

Performance

An enabled log call measures at ~29 ns/call on LuaJIT 2.1 with a null sink on an Apple M1 (logger:info("loaded %d entities", n)). Real I/O adds its own cost on top; in practice io.stderr writes will dominate. Disabled levels have effectively no overhead: the method slot is replaced with a no-op function, avoiding branching, formatting, and timestamp lookup.

What makes it fast
  1. Shared writers, per-logger state. All loggers of a given level point at the same function object, and all per-logger state (sink, format, prefix) lives on the logger table. Call sites stay monomorphic no matter how many loggers rotate through them, so LuaJIT specializes a single trace.
  2. Per-second date cache. The default dateFormat is seconds-resolution, so a naive os.date call per log would allocate a fresh string every time (~500 ns, dominating the cost). The module caches the formatted timestamp once per wall-clock second, keyed on ffi.C.time (JIT-traceable; os.time is not). Effectively all enabled logs within a given second share one allocation.