log
import log from "log";
log.info("listening on port {port}");
log.warn("disk {used}% full");
log.debug("only shown with TAPE_LOG=debug");2026-10-04T09:15:02.071Z INFO listening on port 8080
2026-10-04T09:15:02.071Z WARN disk 93% fulllog writes one line per message: a UTC timestamp with milliseconds, the
level, and your message. It is a capability module (it writes to stderr and
reads the clock and the environment).
Levels
pub enum Level : u8 { Debug = 0; Info = 1; Warn = 2; Error = 3; Off = 4; }| Function | Does |
|---|---|
debug(msg), info(msg), warn(msg), error(msg) | Write msg at that level, if the level is enabled |
log(l: Level, msg) | The same with the level as a value |
set_level(l: Level) | Write messages at l and above; Off writes nothing |
level() -> Level | The current level |
enabled(l: Level) -> bool | Whether a message at l would be written |
parse_level(s) -> Level or LogError | "debug", "info", "warn", "error" or "off", any case |
The level starts from the TAPE_LOG environment variable, read the first
time you log, and is info when it isn’t set (or isn’t a level name).
set_level overrides it:
TAPE_LOG=debug ./serverA message below the level costs a comparison. Its text does not: the
interpolation in log.debug("state {dump(s)}") runs before debug is
called. When a message is expensive to build, check first:
if (log.enabled(log.Level.Debug)) { log.debug("state {dump(s)}"); }log(log.Level.Off, msg) is a mistake and traps: Off is a level to set,
not one to write at.
parse_level reads a level from text, for a --log-level option; anything
else is LogError.UnknownLevel (“unknown log level”).
Where lines go
| Function | Does |
|---|---|
set_writer(w: io.Writer) | Write lines to w (stderr by default), each with a newline |
set_sink(sink, ctx: *u8) | Hand each line to sink(ctx, level, line) instead |
set_timestamps(on: bool) | Start lines with the timestamp, or not (on by default) |
To log into a file, give log the file’s writer and keep the file open:
var f: fs.File = fs.open("app.log", fs.Access.Append, fs.Creation.OpenOrCreate) or { return 1; };
defer f.close();
log.set_writer(f.writer());A sink is a function of yours with the signature
fn(ctx: *u8, l: log.Level, line: []const u8). It receives the line
without a newline, and ctx is whatever pointer you passed to set_sink
(your own state; log never looks at it). The line is only valid during
the call, so copy what you keep. Don’t log from inside a sink.
fn to_stdout(ctx: *u8, l: log.Level, line: []const u8) {
var problems: *i64 = ctx as *i64;
if (l == log.Level.Error) { problems[0] = problems[0] + 1; }
io.println(line);
}
var problems: i64 = 0;
log.set_sink(to_stdout, &problems as *u8);
log.error("something failed"); // to_stdout gets "2026-...Z ERROR something failed"
log.set_sink(null, null); // back to the writerTurn timestamps off when something else adds them (a service manager, a
test comparing output): the line then starts at the level, INFO ready.
Details
- The level is padded to five characters (
DEBUG,INFO,WARN,ERROR) so messages line up. - A message is written as it is, newlines included.
- Writing never fails: if the writer can’t be written to, the line is lost
and your program carries on, as with
io.eprintln. - Lines up to 512 bytes are built without allocating and written in one piece.
Platforms
All of log works on Windows, Linux and macOS. On Uusi timestamps are in
whole seconds and there is no environment, so the level starts at info.
Last modified: