lily/logging
Logging that reads the same on both runtimes. Erlang already has a solid
logging package (the same one mist
and wisp log through), JavaScript has the console, and Lily runs on
both, so this module papers over the gap and hands you one API that
behaves identically whether you’re on the BEAM or in a browser or Node.
On Erlang it’s a thin wrapper over the logging package, so your lines
blend in with the framework’s own output. On JavaScript they go to
console.error / console.warn / console.info / console.debug by
level, wearing the same colour palette the Erlang package uses so the two
look the same side by side.
Call configure once at startup, optionally pick a minimum
level with set_level (anything below it is dropped), then
log with the level shortcuts:
import lily/logging
pub fn main() {
logging.configure()
logging.set_level(logging.Info)
logging.info("server ready")
logging.warning("cache miss")
logging.error("payment gateway timed out")
logging.debug("dropped while the level is Info")
}
auto_info and its siblings log any value with
string.inspect, skipping the work when the level is suppressed.
For an HTTP server you probably want one compact line per request.
Build a RequestLog with request_log and
emit it with request. In a real backend that’s a small
middleware that times the handler and mints a correlation id, sat behind
your static-file serving so only real routes get a line:
fn log_request(req: wisp.Request, handler) -> wisp.Response {
let start = timestamp.system_time()
// Reuse an inbound correlation id when a proxy set one, otherwise make
// one, so a request's log lines (and any downstream service's) tie
// together.
let request_id = case request.get_header(req, "x-request-id") {
Ok(id) -> id
Error(Nil) -> wisp.random_string(16)
}
let response = handler()
logging.RequestLog(
method: string.uppercase(http.method_to_string(req.method)),
path: req.path,
status: response.status,
duration_milliseconds: elapsed_milliseconds(start),
request_id: option.Some(request_id),
)
|> logging.request
// Echo the id back so clients and downstream services can correlate.
response.set_header(response, "x-request-id", request_id)
}
// wire it in behind static serving, so assets stay silent
use <- wisp.serve_static(req, under: "/", from: "priv/static")
use <- log_request(req)
The level is read from the status (5xx Error, 4xx Warning, else
Info) so a failing route stands out by colour, and a
RequestLog has nowhere to put a request or response body,
keeping secrets out of your logs.
Types
Log severity. Matches the eight levels used by Erlang’s logger and the
logging hex package.
pub type Level {
Alert
Critical
Debug
Emergency
Error
Info
Notice
Warning
}
Constructors
-
Alert -
Critical -
Debug -
Emergency -
Error -
Info -
Notice -
Warning
A single HTTP request to log. The four core fields are always present;
request_id is an optional correlation id that, when set, is echoed into
the line as #<id>. Build one with request_log() and
emit it with request().
Request and response bodies are deliberately absent: logging them risks leaking passwords, tokens, and other GDPR-protected data, so the transport should never put them here.
pub type RequestLog {
RequestLog(
method: String,
path: String,
status: Int,
duration_milliseconds: Int,
request_id: option.Option(String),
)
}
Constructors
-
RequestLog( method: String, path: String, status: Int, duration_milliseconds: Int, request_id: option.Option(String), )
Values
pub fn auto_debug(value: a) -> Nil
Inspect value with string.inspect and log the result at Debug level.
pub fn auto_error(value: a) -> Nil
Inspect value with string.inspect and log the result at Error level.
pub fn auto_info(value: a) -> Nil
Inspect value with string.inspect and log the result at Info level.
This is probably used the most.
server.on_message(server, fn(message, _model, _client_id) {
logging.auto_info(message) // e.g. logs "INFO AddTodo(\"milk\")"
})
pub fn auto_log(level: Level, value: a) -> Nil
Inspect value with string.inspect and log the result at the given level.
The inspection is skipped when the level is suppressed, so passing a large
model at Debug is cheap in production.
pub fn auto_warning(value: a) -> Nil
Inspect value with string.inspect and log the result at Warning level.
pub fn configure() -> Nil
Configure the default logger. On Erlang, this installs the logging
package’s pretty formatter and sets the level to Info. On JavaScript,
this is a no-op, the console is always ready.
pub fn is_enabled(level: Level) -> Bool
Returns True if a message at level would be emitted by the current
logger configuration. Useful for guarding expensive payload construction
outside the auto_* helpers.
case logging.is_enabled(logging.Debug) {
True -> logging.debug(expensive_dump(state))
False -> Nil
}
pub fn request(entry: RequestLog) -> Nil
Emit a compact request log line through the same sink as the other helpers,
so it inherits the level tag and colour. The level is derived from the
status (5xx is Error, 4xx is Warning, everything else Info), so a
failing route stands out by colour. Suppressed levels short-circuit before
the line is built.
logging.request_log(
method: "GET",
path: "/controls",
status: 200,
duration_milliseconds: 12,
)
|> logging.request
// Logs `INFO GET /controls 200 in 12ms`
pub fn request_log(
method method: String,
path path: String,
status status: Int,
duration_milliseconds duration_milliseconds: Int,
) -> RequestLog
Build a RequestLog from the four fields every transport
can supply, leaving request_id as option.None. Add a correlation id
with a record update where you have one.
logging.request_log(method: "GET", path: "/", status: 200, duration_milliseconds: 3)
|> fn(entry) { logging.RequestLog(..entry, request_id: option.Some(id)) }
pub fn set_level(level: Level) -> Nil
Set the minimum level of log messages to emit. Messages below this level are suppressed.
On Erlang, delegates to logger:set_primary_config. On JavaScript,
maintains a module-level threshold, useful on Node/Bun/Deno servers where
DevTools is not available.