nupp.log

nupp.log is leveled logging whose disabled path is the one it is designed around. A severity call in statement position is lowered rather than called, so a filtered line evaluates none of its arguments.

nupp.log.error("cannot open %s: %s", path, reason)
nupp.log.warn("retrying in %dms", delay)
nupp.log.info("loaded %d entities", count)
nupp.log.debug("state=%s", state)

Each severity accepts a string.format directive string and the arguments it calls for. The directives and their argument types are checked where the call is written, by the same machinery that checks string.format, so a missing argument or a %d handed a string is a compile error rather than a line that fails at run time.

nupp.log.error("id %d")
  error: NUPP2006: omitted argument 2 supplies nil, not number

Because %s accepts anything, nothing needs tostring and a nil argument prints as nil rather than raising. Lines go to standard error until a host says otherwise.

%? is Nupp's debug directive. It requires nupp.Debug, calls the value's debug() method, and formats the returned string as %s:

nupp.log.debug("state=%?", state)

At lowered call sites that method call is inside the level guard, so filtered logs do not render the value. Named loggers perform the same check in their already-lazy writer.

Severity calls are intrinsics#

A severity call in statement position whose format is a literal is lowered rather than called:

nupp.log.error("id %d", id)
const __nuppModule = _G.nupp.log.forModule("amb");   -- once, in the prologue
if __nuppModule.on[1] then __nuppModule.emit(1,47,string.format("id %d",id)) end

Three properties follow from that shape, and together they are the reason this is a compiler intrinsic rather than a library.

Filtered calls evaluate nothing#

The level test stands at the call site, so the arguments of a suppressed line are never computed. nupp.log.debug("%s", render(state)) does not call render when debug is off. No library can decline to evaluate its own arguments.

Module name and line are constants#

The compiler is generating the file, so it writes both in directly: the module once in the prologue, the line at each site. Nothing is recovered at run time, nothing depends on the debug library, and there is no per-module boilerplate to write or to keep in step with a rename.

Filtered call cost#

A filtered call costs an upvalue read, an array index and a branch. The view is bound once per module, and on is an array indexed by severity, shared with every other module so a level change is seen everywhere at once.

Forms that stay calls#

Every other form keeps an ordinary call meaning exactly the same thing, only slower. The module shows as ?, and the arguments are evaluated:

Form Lowered Reason
nupp.log.info("id %d", id) yes
nupp.log.info(format, id) no the format is not a literal
local f = nupp.log.info no a value, not a call
x = nupp.log.info("hi") no not statement position
local nupp = ... no not the compiler-provided nupp
logger:info("id %d", id) no a named logger, not the path

Levels#

level reads the threshold, and moves it when given one:

nupp.log.level("debug") -- set, answers the previous one
nupp.log.level() -- read

The five levels are "off", "error", "warn", "info" and "debug", each admitting itself and everything above it, so "warn" emits warnings and errors. The default is "warn".

The parameter is that literal union, so a string from outside the program has to be narrowed to one of the five before it can be passed. os.getenv("LOG_LEVEL") or "warn" is string, and string is not one of them:

local wanted = os.getenv("LOG_LEVEL")
if wanted == "debug" or wanted == "info" or wanted == "warn" then
    nupp.log.level(wanted)
end

A level that is not one of the five is a compile error where it is a literal and an ordinary raise where it is not.

nupp.log.enabled(level) answers whether a level would emit. Use it to guard preparation spanning more than one call, which no single lowered site can elide:

if nupp.log.enabled("debug") then
    local report = summarize(world)
    nupp.log.debug("world: %s", report)
end

Swapping the back end#

A host that logs through its own facility installs a sink function and takes over completely. It receives the parts, not a rendered line, and pays for no formatting it would discard:

nupp.log.sink(function(level: integer, module: string, line: integer, message: string): nil
    sdl.logMessage(CATEGORY, PRIORITY[level], ("%s:%d %s"):format(module, line, message))
end)

level is 1 error, 2 warn, 3 info, 4 debug; nupp.log.levelName turns one back into its name. line is 0 for a line the compiler could not attribute, which is every line from a named logger.

Passing anything file-like instead keeps the built-in rendering and only moves where it goes:

local file = assert(io.open("game.log", "a"))
nupp.log.sink(file)

io.open answers LuaFile?, and sink takes a target rather than a maybe, so the assert is what turns one into the other.

A file-like target renders through the formatter, which is replaceable on its own:

nupp.log.formatter(function(level: integer, module: string, line: integer, message: string, stamp: string): string
    return ("%s[%s] %s"):format(stamp, nupp.log.levelName(level), message)
end)

Both setters answer the value they replaced, so a host can restore what it found.

Timestamps#

nupp.log.timestamp() answers the current time formatted, recomputed at most once per wall-clock second and shared by every logger. It is a pull rather than something pushed to sinks, so a host that stamps its own lines never pays for one.

nupp.log.timestampFormat("%H:%M:%S ") -- set, answers the previous format
nupp.log.timestampFormat("") -- off
Reading the second through the FFI

os.time is NYI in LuaJIT and stitches the trace it stands on, so the second is read through the FFI instead. os.time is still called once at startup to validate the symbol, because some Windows CRTs inline time to _time64 or give it a 32-bit time_t, and a symbol that resolves but is the wrong width answers nonsense rather than failing to resolve.

Named loggers#

named answers a logger carrying a fixed name, with a method per severity:

local physics = nupp.log.named("physics")
physics:warn("step %d took %.2fms", step, elapsed)

Use one for a subsystem that does not correspond to a module, and for a call site the intrinsic cannot reach. Repeating a name answers the same logger. Their methods are replaced when the level or target changes, so a filtered call reaches an empty function rather than a test, but the arguments are still evaluated, which is the cost of a name chosen at run time.

Cost#

The installer lands only in modules that reach nupp.log, like every other compiler-provided facility. A module that never logs carries nothing.

Module contents

Types

TypeKindDescription
FormattertypeRenders one line for a file-like destination.
LeveltypeThe threshold, from silent to most verbose.
LoggerrecordA logger carrying a fixed name, for subsystems and for call sites the intrinsic cannot rewrite.
SeveritytypeA level as a sink sees it: 1 error, 2 warn, 3 info, 4 debug.
SinktypeReceives one emitted line, already formatted.
TargettypeWhere lines go: a sink function, or anything file-like to write to.

Functions

FunctionKindDescription
debugfunctionLogs at debug.
enabledfunctionWhether a level is currently admitted.
errorfunctionLogs at error.
formatterfunctionReads or replaces the line formatter.
forModulefunctionThe view a generated chunk binds once, so a site carries only severity and line.
infofunctionLogs at info.
levelfunctionReads or moves the threshold.
levelNamefunctionThe name for a sink's numeric level.
namedfunctionA logger with a fixed name.
sinkfunctionReads or replaces the destination.
timestampfunctionThe stamp a line written now would carry.
timestampFormatfunctionReads or replaces the timestamp format.
warnfunctionLogs at warn.

Types#

Formattertype#

type log.Formatter = function(
    level: log.Severity,
    module: string,
    line: integer,
    message: string,
    stamp: string
): string

Renders one line for a file-like destination. Only consulted when the target is file-like; a sink function formats however it likes.

Leveltype#

type log.Level = "off" | "error" | "warn" | "info" | "debug"

The threshold, from silent to most verbose. Each level admits itself and everything above it, so "warn" emits warnings and errors.

Loggerrecord#

record log.Logger
    readonly name: string
    debug: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil
    info: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil
    warn: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil
    error: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil
    enabled: function(self: log.Logger, level: log.Level): boolean
end

A logger carrying a fixed name, for subsystems and for call sites the intrinsic cannot rewrite. Changing the level or target restamps every logger, so a filtered call reaches an empty function rather than a test.

Methods

debug#
debug: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at debug. Accepts string.format directives.

Arguments
NameTypeDescription
selflog.Logger
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)
Returns
TypeDescription
nil
info#
info: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at info. Accepts string.format directives.

Arguments
NameTypeDescription
selflog.Logger
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)
Returns
TypeDescription
nil
warn#
warn: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at warn. Accepts string.format directives.

Arguments
NameTypeDescription
selflog.Logger
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)
Returns
TypeDescription
nil
error#
error: function<F is string>(self: log.Logger, fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at error. Accepts string.format directives.

Arguments
NameTypeDescription
selflog.Logger
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)
Returns
TypeDescription
nil
enabled#
enabled: function(self: log.Logger, level: log.Level): boolean

Whether this logger would emit at level.

Arguments
NameTypeDescription
selflog.Logger
levellog.Level
Returns
TypeDescription
boolean

Fields

name#
name: string

The name every line from this logger carries.

Severitytype#

type log.Severity = integer

A level as a sink sees it: 1 error, 2 warn, 3 info, 4 debug.

Sinktype#

type log.Sink = function(level: log.Severity, module: string, line: integer, message: string): nil

Receives one emitted line, already formatted.

Replacing this replaces the back end, so a host logging through its own facility pays for nothing it discards. No timestamp is passed, because a sink that wants one asks.

Targettype#

type log.Target = log.Sink | LuaFile

Where lines go: a sink function, or anything file-like to write to.

Functions#

log.debugfunction#

function log.debug<F is string>(fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at debug. Accepts string.format directives.

Type parameters

NameDescription
F

Arguments

NameTypeDescription
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)

Returns

TypeDescription
nil

log.enabledfunction#

function log.enabled(level: log.Level): boolean

Whether a level is currently admitted.

Arguments

NameTypeDescription
levellog.Level

the level to test

Returns

TypeDescription
boolean

whether a call at that level would emit

log.errorfunction#

function log.error<F is string>(fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at error. Accepts string.format directives.

Type parameters

NameDescription
F

Arguments

NameTypeDescription
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)

Returns

TypeDescription
nil

log.formatterfunction#

function log.formatter(next: log.Formatter?): log.Formatter?

Reads or replaces the line formatter.

Arguments

NameTypeDescription
nextlog.Formatter?

the formatter, or nil to read the current one

Returns

TypeDescription
log.Formatter?

the formatter this call replaced

Raises

  • when next is not a function

log.forModulefunction#

function log.forModule(module: string): any

The view a generated chunk binds once, so a site carries only severity and line.

Generated code calls this in a module's prologue. Nothing written by hand needs it: a severity call in statement position is already lowered to a test against the view's on array around a direct emit.

Arguments

NameTypeDescription
modulestring

the module name its lines carry

Returns

TypeDescription
any

the view, whose on a site indexes and whose emit it calls

log.infofunction#

function log.info<F is string>(fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at info. Accepts string.format directives.

Type parameters

NameDescription
F

Arguments

NameTypeDescription
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)

Returns

TypeDescription
nil

log.levelfunction#

function log.level(level: log.Level?): log.Level

Reads or moves the threshold. Each level admits itself and everything above it.

Arguments

NameTypeDescription
levellog.Level?

the new threshold, or nil to read the current one

Returns

TypeDescription
log.Level

the threshold this call replaced

log.levelNamefunction#

function log.levelName(severity: log.Severity): log.Level

The name for a sink's numeric level.

Arguments

NameTypeDescription
severitylog.Severity

the numeric level a sink received

Returns

TypeDescription
log.Level

the level's name

log.namedfunction#

function log.named(name: string): log.Logger

A logger with a fixed name. Repeating a name answers the same logger.

Reach for one where the intrinsic cannot rewrite the call: a format built at run time, a call in expression position, a subsystem that wants its own name on every line. The methods are restamped whenever the level or the target moves, so a filtered call reaches an empty function rather than a test.

const log = nupp.log.named("renderer")
log:info("loaded %d entities", count)

Arguments

NameTypeDescription
namestring

the name its lines carry

Returns

TypeDescription
log.Logger

the logger

Raises

  • when name is not a string

log.sinkfunction#

function log.sink(next: log.Target?): log.Target

Reads or replaces the destination.

Arguments

NameTypeDescription
nextlog.Target?

a sink function, a file, or nil to read the current destination

Returns

TypeDescription
log.Target

the destination this call replaced

Raises

  • when next is neither a sink function nor a file

log.timestampfunction#

function log.timestamp(): string

The stamp a line written now would carry.

Returns

TypeDescription
string

the rendered timestamp, or "" when stamping is off

log.timestampFormatfunction#

function log.timestampFormat(next: string?): string

Reads or replaces the timestamp format. An empty format stops stamping.

Arguments

NameTypeDescription
nextstring?

the os.date format, or nil to read the current one

Returns

TypeDescription
string

the format this call replaced

Raises

  • when next is not a string

log.warnfunction#

function log.warn<F is string>(fmt: F, ...: unpackof __NuppFormatArguments(F, nupp.Debug)): nil

Logs at warn. Accepts string.format directives.

Type parameters

NameDescription
F

Arguments

NameTypeDescription
fmtF
...unpackof __NuppFormatArguments(F, nupp.Debug)

Returns

TypeDescription
nil