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 debug(message: String) -> Nil

Shortcut for log(Debug, message).

pub fn error(message: String) -> Nil

Shortcut for log(Error, message).

pub fn info(message: String) -> Nil

Shortcut for log(Info, message).

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 log(level: Level, message: String) -> Nil

Log a message at the given level.

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.

pub fn warning(message: String) -> Nil

Shortcut for log(Warning, message).

Search Document