log

tape
import log from "log";

log.info("listening on port {port}");
log.warn("disk {used}% full");
log.debug("only shown with TAPE_LOG=debug");
plaintext
2026-10-04T09:15:02.071Z INFO  listening on port 8080
2026-10-04T09:15:02.071Z WARN  disk 93% full

log 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

tape
pub enum Level : u8 { Debug = 0; Info = 1; Warn = 2; Error = 3; Off = 4; }
FunctionDoes
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() -> LevelThe current level
enabled(l: Level) -> boolWhether 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:

plaintext
TAPE_LOG=debug ./server

A 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:

tape
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

FunctionDoes
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:

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

tape
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 writer

Turn 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: