Printing everything to the console is fine during development, but in a real application you usually want more control: write to a file, filter by severity, and keep the output even after the terminal session ends. Loft's logging functions give you that without changing your source code.
The four severity levels
Choose the level that matches how serious the event is:
log_info— routine progress; fine to see during development but often silenced in production to keep log files small. Example: "processing file X", "connected to database".
log_warn— something unexpected happened but the program recovered. Example: "config key missing, using default", "retrying after timeout".
log_error— something went wrong and you need to investigate, but the program can continue (perhaps degraded). Example: "failed to save record", "unexpected null value".
log_fatal— a condition so serious that normal operation is impossible. Example: "cannot open database", "required config file not found".
Configuring the log destination
By default, log calls do nothing — no file is written, no console output is produced. To switch logging on, place a log.conf file in the same directory as your .loft file, or pass --log-conf path/to/log.conf on the command line. The directory is exact: a log.conf one level up is not found, and a program with no config logs nothing and says nothing.
To see every setting with its default, print a documented template: loft --generate-log-config # writes it to the terminal loft --generate-log-config log.conf # writes it to that file
A minimal log.conf looks like this:
[log]
file = log.txt # write messages here (relative to the .loft file)
level = info # minimum level to record; choices: info warn error fatal
# (the default, when you leave this out, is 'warn')
[rotation]
max_size_mb = 500 # start a new log file after this many megabytes
daily = true # also start a new file at midnight UTC
max_files = 10 # keep at most this many log files (older ones are deleted)
[rate_limit]
per_site = 5 # suppress messages from the same source line after 5/minute
[levels]
# Override the global level for one file, by its name alone:
"debug_tool.loft" = info
"src/" = error # a path prefix, matched from the project root
Production mode
With production = true in the [log] section of log.conf, or --production on the command line:
panic()becomes a fatal log entry instead of aborting the process.- A failing
assert()becomes an error log entry instead of aborting.
The program keeps running and the problem is captured in the log — useful for long-running services where a single error should not bring everything down. It still exits non-zero at the end, so a supervisor watching the exit status learns that something went wrong.
Using format strings in log messages
Log messages are plain text, but you can embed any expression using the same {...} format syntax as everywhere else in Loft.
The message is built BEFORE the call decides whether to write it — the argument is an ordinary expression, evaluated like any other. So a message that is discarded still costs what it costs, and that is true whether it was filtered out by the level, suppressed by the rate limit, or dropped because there is no log.conf at all. Keep an expensive call out of a message on a hot path, or guard the whole log line with an if.
fn main() {
Without a log.conf these calls do nothing — the tests below pass even though no output is produced.
log_info("starting up");
log_warn("this is a warning");
log_error("something went wrong");
log_fatal("critical failure");
Example log.txt output
When a log.conf is present with level = info, the four calls above produce entries like this in log.txt:
2026-09-01T08:35:38.971Z INFO app.loft:3 starting up
2026-09-01T08:35:38.971Z WARN app.loft:4 this is a warning
2026-09-01T08:35:38.971Z ERROR app.loft:5 something went wrong
2026-09-01T08:35:38.971Z FATAL app.loft:6 critical failure
Each line contains: a UTC timestamp to the millisecond, the severity level, the source file and line number, then the message. This makes it easy to search the file for errors or trace back to the exact line that produced a message. The file path is written the way a [levels] key addresses it: relative to the project root inside a project, the bare file name for a script. A true assert never logs anything — it is only the false case that logs.
assert(true, "this should never fail");
Typical usage pattern
In a real program you would write something like:
fn process(item: Item) {
log_info("processing item {item.id}");
result = do_work(item);
if !result {
log_error("work failed for item {item.id}");
return;
}
log_info("finished item {item.id} successfully");
}
The log messages give you a record of exactly what the program did and where it went wrong, without cluttering the terminal during normal runs.
println("logging test passed");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Logging
// @TITLE: Recording events with the logging system
// Printing everything to the console is fine during development, but in a
// real application you usually want more control: write to a file, filter
// by severity, and keep the output even after the terminal session ends.
// Loft's logging functions give you that without changing your source code.
// ## The four severity levels
//
// Choose the level that matches how serious the event is:
//
// * 'log_info' — routine progress; fine to see during development but often
// silenced in production to keep log files small.
// Example: "processing file X", "connected to database".
//
// * 'log_warn' — something unexpected happened but the program recovered.
// Example: "config key missing, using default", "retrying after timeout".
//
// * 'log_error' — something went wrong and you need to investigate, but the
// program can continue (perhaps degraded).
// Example: "failed to save record", "unexpected null value".
//
// * 'log_fatal' — a condition so serious that normal operation is impossible.
// Example: "cannot open database", "required config file not found".
// ## Configuring the log destination
//
// By default, log calls do nothing — no file is written, no console output
// is produced. To switch logging on, place a 'log.conf' file in the
// same directory as your '.loft' file, or pass '--log-conf path/to/log.conf'
// on the command line. The directory is exact: a 'log.conf' one level up is
// not found, and a program with no config logs nothing and says nothing.
//
// To see every setting with its default, print a documented template:
// loft --generate-log-config # writes it to the terminal
// loft --generate-log-config log.conf # writes it to that file
//
// A minimal 'log.conf' looks like this:
//
// [log]
// file = log.txt # write messages here (relative to the .loft file)
// level = info # minimum level to record; choices: info warn error fatal
// # (the default, when you leave this out, is 'warn')
//
// [rotation]
// max_size_mb = 500 # start a new log file after this many megabytes
// daily = true # also start a new file at midnight UTC
// max_files = 10 # keep at most this many log files (older ones are deleted)
//
// [rate_limit]
// per_site = 5 # suppress messages from the same source line after 5/minute
//
// [levels]
// # Override the global level for one file, by its name alone:
// "debug_tool.loft" = info
// "src/" = error # a path prefix, matched from the project root
// ## Production mode
//
// With 'production = true' in the '[log]' section of log.conf, or '--production'
// on the command line:
// * 'panic()' becomes a fatal log entry instead of aborting the process.
// * A failing 'assert()' becomes an error log entry instead of aborting.
// The program keeps running and the problem is captured in the log — useful
// for long-running services where a single error should not bring everything
// down. It still exits non-zero at the end, so a supervisor watching the exit
// status learns that something went wrong.
// ## Using format strings in log messages
//
// Log messages are plain text, but you can embed any expression using the
// same '{...}' format syntax as everywhere else in Loft.
//
// The message is built BEFORE the call decides whether to write it — the
// argument is an ordinary expression, evaluated like any other. So a message
// that is discarded still costs what it costs, and that is true whether it was
// filtered out by the level, suppressed by the rate limit, or dropped because
// there is no log.conf at all. Keep an expensive call out of a message on a
// hot path, or guard the whole log line with an 'if'.
fn main() {
// Without a log.conf these calls do nothing — the tests below pass
// even though no output is produced.
log_info("starting up");
log_warn("this is a warning");
log_error("something went wrong");
log_fatal("critical failure");
// ## Example log.txt output
//
// When a 'log.conf' is present with 'level = info', the four calls above
// produce entries like this in 'log.txt':
//
// 2026-09-01T08:35:38.971Z INFO app.loft:3 starting up
// 2026-09-01T08:35:38.971Z WARN app.loft:4 this is a warning
// 2026-09-01T08:35:38.971Z ERROR app.loft:5 something went wrong
// 2026-09-01T08:35:38.971Z FATAL app.loft:6 critical failure
//
// Each line contains: a UTC timestamp to the millisecond, the severity level,
// the source file and line number, then the message. This makes it easy to
// search the file for errors or trace back to the exact line that produced a
// message. The file path is written the way a `[levels]` key addresses it:
// relative to the project root inside a project, the bare file name for a script.
// A true assert never logs anything — it is only the false case that logs.
assert(true, "this should never fail");
// ## Typical usage pattern
//
// In a real program you would write something like:
//
// fn process(item: Item) {
// log_info("processing item {item.id}");
// result = do_work(item);
// if !result {
// log_error("work failed for item {item.id}");
// return;
// }
// log_info("finished item {item.id} successfully");
// }
//
// The log messages give you a record of exactly what the program did and
// where it went wrong, without cluttering the terminal during normal runs.
println("logging test passed");
}