hanki

log

stdlib/extra/log.hk: leveled diagnostics on standard error.

log.warn!("…") writes one formatted line to stderr; the effect row is [io]. A user effect Log with a provide block was considered and left out: a provider whose row is [io] contributes that [io] to the program's capability set anyway (HANKI.md §6, Capability accounting).

A failed stderr write faults the run: hanki run app.hk 2>&1 | head ends the program when the reader quits. Redirect with the shell: hanki run app.hk 2>app.log.

Records below the minimum level are dropped. The level defaults to Debug; HANKI_LOG (debug/info/warn/error, case-insensitive) seeds it for a run, and set_min_level! changes it mid-run, winning over the environment whichever order the two happen in. It is process-wide: a set from any actor is what every actor's next record is measured against. The runtime owns the threshold and sys.log_emit! drops the records below it, which leaves writing a record plain [io] while reading or setting the level charges [runtime_state], the row atom for observing process-global state the program does not own (HANKI.md §6).

The threshold saves the write and never the message. #{…} interpolation happens at the call site, and log.debug!("#{expensive()}") therefore pays for expensive() at every level; put a costly one behind if log.enabled!(…). Comparing against min_level!() asks the same question and costs more, though Level is Ord for a caller keeping a threshold of its own.

For a threshold fixed at build time, comptime configuration costs the run nothing (core/config):

# log.config.hk open log Warn

MIN: log.Level = config.load("log.config.hk")

after which log.set_min_level!(MIN) once at startup, or a caller-side if level >= MIN, is a threshold baked into the binary.

There is no timestamp. That is a gap and no principle: the runtime would read its own clock, as it consults its own threshold, and no caller's row would widen.

Level

type Level
  Debug
  Info
  Warn
  Error
end

Severity of one record, ascending DebugError. Display renders the uppercase tag that format writes.

impl Display<Level>

to_string

def to_string(self) -> string

The level's uppercase tag, which is also what #{…} interpolation of a Level yields.

Debug.to_string() => "DEBUG"
Error.to_string() => "ERROR"

impl Eq<Level>

eq?

def eq?(self, other: Self) -> bool

Two levels are equal when they are the same severity, which is what min_level!() == Warn asks.

Debug.eq?(Debug) => true
(Warn == Error)  => false

impl Ord<Level>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by severity, which makes a threshold an ordinary comparison.

Debug.cmp(Error) => Less
(Warn < Error)   => true
(Warn < Info)    => false

_rank

def _rank(level: Level) -> u8

The rank is not private to this module: core/sys's three log intrinsics traffic in it, Level sitting in extra where core cannot name it, and the runtime parses HANKI_LOG into the same numbering and renders a written record's tag from it (hanki-rtcore/src/log_level.rs, which pins the mapping on that side). Both ends must agree; change neither alone.

ofrank

def _of_rank(rank: u8) -> Level

_rank inverted. The seam clamps into 0..=3 before storing, which leaves the fallback unreachable from sys.log_set_min_level!; it is here because a match on u8 must be total, and Debug, meaning write everything, is the right answer for a rank this module cannot name.

format

def format(level: Level, message: string) -> string

The line a record renders as for level and message, terminating newline included. Pure, which lets a caller sending records somewhere other than stderr render with this and write the result itself. emit! does not call it: the runtime renders a written record to these bytes on its own side of the seam, which spares a dropped record the formatting. The cross-tier tests hold the two renderings to one output, the way _rank and the runtime's HANKI_LOG parse are held to one numbering.

One record is always one line, which leaves message's own newlines cannot be passed through: a \n in a message would otherwise let untrusted text forge a second record indistinguishable from a real one (a user= field containing a newline and [ERROR] … is the standard shape of that attack). \n and \r are therefore escaped to their two-character spellings, and a multi-line message arrives as one escaped record and never as several forged ones.

format(Warn, "disk almost full")  => "[WARN] disk almost full\n"
format(Error, "boom")             => "[ERROR] boom\n"
format(Info, "a\nb")              => "[INFO] a\\nb\n"
format(Info, "a\r\nb")            => "[INFO] a\\r\\nb\n"

oneline

def _one_line(message: string) -> string

emit!

def emit!(level: Level, message: string) -> () [io]

Write message to standard error at level. The four severity-named actions below are the usual entry points; reach for this one when the level is computed and not literal.

The threshold is applied on the runtime side of the seam: every record is handed to sys.log_emit!, which drops the ones below it before rendering, which leaves this [io] alone, logging never observing the knob, and a dropped record costing the call and no formatting. A caller that also applies its own minimum and writes if level >= mine then log.emit!(…) filters twice, which is harmless: both are lower bounds, and the effective threshold is whichever is higher, and no record passes one check to be surprised by the other. @no-doctest: writes to standard error; no return value to assert

min_level!

def min_level!() -> Level [runtime_state]

The run's minimum level: records below it are dropped. Debug unless HANKI_LOG or set_min_level! says otherwise, and the first call is what settles the seed from HANKI_LOG. An unusable value there costs one line of complaint on stderr and leaves the default in place: a misspelled diagnostics knob must not be able to kill a run.

[runtime_state] is what the row observes: process-global state the program does not own. The atom is viral, a caller declaring it, and it is not a capability and cannot be denied. In particular the row is not [env]: the program performs no environment read, the runtime consults its own diagnostic knob. @no-doctest: answers from process-wide state a run's environment can seed

enabled!

def enabled!(level: Level) -> bool [runtime_state]

Whether a record at level would be written: the cheap guard for a call site whose message costs something to build. #{...} interpolation happens at the call site, and the threshold therefore saves the write and never the message; this is what lets a caller skip building it.

Prefer it to comparing against min_level!(), which is the same question asked the expensive way: that path builds a Level from the stored rank and then dispatches Eq/Ord on the pair, where this reads the rank off the level you pass and compares two u8s. Same [runtime_state] row, and the same answer as the runtime's own drop decision, since both compare ranks.

It is not allocation-free: a nullary variant is still a heap value, so enabled!(Debug) builds that Debug at the call site. What is gone is the second Level and the trait dispatch over the two. @no-doctest: answers from process-wide state a run's environment can seed

setminlevel!

def set_min_level!(level: Level) -> () [runtime_state]

Set the run's minimum level, from any actor, effective immediately for every actor. Overrides HANKI_LOG whichever order the two happen in, which lets a program that means to decide this itself say so at startup. Flipping process-global logging state is what [runtime_state] marks, and the row of anything that reaches this says so. @no-doctest: changes process-wide state; no return value to assert

debug!

def debug!(message: string) -> () [io]

Write message at Debug: detail useful while diagnosing, noise otherwise. @no-doctest: writes to standard error; no return value to assert

info!

def info!(message: string) -> () [io]

Write message at Info: the ordinary progress a run is expected to report. @no-doctest: writes to standard error; no return value to assert

warn!

def warn!(message: string) -> () [io]

Write message at Warn: something the run recovered from and continued past. @no-doctest: writes to standard error; no return value to assert

error!

def error!(message: string) -> () [io]

Write message at Error: something the run could not do. @no-doctest: writes to standard error; no return value to assert

emit_with!

def emit_with!(level: Level, build: () -> string) -> () [io, runtime_state]

emit! with the message built only if it will be kept: Hanki is strict, so the string-taking forms interpolate at the call site whatever the threshold, and on the AOT tier a dropped six-value message still costs microseconds. Here build runs after the threshold check, which leaves a dropped record costing the guard alone. The row charges [runtime_state] beside [io] because this form checks the level; the plain forms only log, and are [io]. @no-doctest: writes to standard error; no return value to assert

debug_with!

def debug_with!(build: () -> string) -> () [io, runtime_state]

debug! with the message built only if kept: the form for a hot path whose interpolation outweighs its guard. @no-doctest: writes to standard error; no return value to assert

info_with!

def info_with!(build: () -> string) -> () [io, runtime_state]

info! with the message built only if kept. @no-doctest: writes to standard error; no return value to assert

warn_with!

def warn_with!(build: () -> string) -> () [io, runtime_state]

warn! with the message built only if kept. @no-doctest: writes to standard error; no return value to assert

error_with!

def error_with!(build: () -> string) -> () [io, runtime_state]

error! with the message built only if kept. @no-doctest: writes to standard error; no return value to assert