SCUA

Manual

Logging

SCUA has logging built in, with levels you can filter at compile time and structured fields you get almost for free. A log statement looks like a function call but isn't quite one: below the build's level floor, the whole statement is removed from the program, arguments and all.

#The five levels

There are five level keywords, from lowest to highest: trace, debug, info, warn, severe. Each takes a message and prints it to standard output as [level] message.

let player = "Mara"
let hp = 30

info(`player {player} joined`)
debug(`hp is {hp}/100`)
warn(`hp low: {hp}`)
severe(`save upload failed, retrying from cache`)

Run it:

$ scua logging.scua
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache

The message is a backtick template, so {...} holes interpolate normally.

There's deliberately no error level. error(v) already means something else in SCUA: it raises a fault. See the section below on how severe differs from that.

#Structured fields

A hole that's a bare name, like {player}, does double duty. It interpolates into the printed message, and it also becomes a named structured field attached to the log record. A field named player with the value "Mara", a field named hp with the value 30. A logging backend that consumes these records can then query by field, find every line where player was "Mara", without parsing the message text.

You can also attach explicit fields with a trailing table:

debug(`frame tick`, { frame = 120, dt = 0.016 })
[debug] frame tick

The default standard-output sink prints just the message, so you don't see the fields in the terminal. They travel with the record to any host sink that's listening.

#Stdout is a display, not an audit channel

Worth being explicit about, because it's easy to assume otherwise once log lines appear in the terminal alongside everything else. Anything a script can print — or write with sys.write, which has no trailing newline — is text it chose, and a script can produce a line that looks exactly like a log line. Log lines themselves go to stderr and printed text to stdout, so the two don't normally sit in one stream; but merge them (2>&1, or --log-stdout) and a forged [info] ... is indistinguishable from a real one. What a script cannot do is manufacture a structured log record: those go to host sinks through a separate channel that no amount of printing reaches. If you need output a script can't forge — for auditing, for alerting, for anything you'll make a decision on — read it from a sink, not from stdout.

#Filtering by level at compile time

Pass --log=<level> to set the build's floor. Any statement below that level is dropped from the program entirely. Not skipped at runtime, removed: its arguments are never evaluated, so an expensive call inside a stripped log statement costs nothing.

Take this program:

info(`player {player} joined`)
debug(`hp is {hp}/100`)
warn(`hp low: {hp}`)
severe(`save upload failed, retrying from cache`)

With the floor at info, the debug line is gone:

$ scua --log=info logging.scua
[info] player Mara joined
[warn] hp low: 30
[severe] save upload failed, retrying from cache

Raise the floor to warn and both info and debug disappear:

$ scua --log=warn logging.scua
[warn] hp low: 30
[severe] save upload failed, retrying from cache

Because stripping happens at compile time, this is how you keep trace and debug calls in your source for development and pay nothing for them in a shipped build. Treat log arguments as observation only: code that's needed for the program to be correct must not live inside a log call, because a higher floor will delete it.

#Timestamps

--log-time prefixes every line with an ISO-8601 timestamp:

$ scua --log-time logging.scua
2026-06-18T23:31:14.267Z [info] player Mara joined
2026-06-18T23:31:14.267Z [debug] hp is 30/100
2026-06-18T23:31:14.267Z [warn] hp low: 30
2026-06-18T23:31:14.267Z [severe] save upload failed, retrying from cache

It combines with --log; the floor still applies.

#Where log lines go

Log lines go to standard error. print goes to standard output. That split is the point: stdout is the program's output, and a log line is something about the run rather than part of it.

At a terminal you won't notice — both streams land in front of you, in the order the script wrote them. It matters as soon as something else is reading the output. Throw stderr away and the log lines are gone, leaving whatever the script actually printed:

$ scua logging.scua 2>/dev/null

Throw stdout away and there they are:

$ scua logging.scua 1>/dev/null
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache

So a tool whose stdout a caller parses doesn't need anything arranged for it, and you can log freely in one without corrupting what it returns.

If you want one merged stream, scua logging.scua 2>&1 gives you that with the order preserved, and --log-stdout moves the lines back onto stdout permanently. Write the log statements the same way either way. See Write tools for AI agents.

#severe is not a fault

This is the distinction to get right. severe(...) logs a serious problem at error severity and then keeps going. It does not raise, does not unwind, does not stop the program. It's for the recoverable failure you still want on an error dashboard: a save that fell back to cache, a retry that eventually worked.

severe(`disk almost full`)
print("kept going after severe")
$ scua severe.scua
[severe] disk almost full
kept going after severe

error(v) is the other thing. It raises a fault, which unwinds and stops unless something catches it. The code after it doesn't run:

print("before")
error("boom")
print("after")
$ scua fault.scua
before
scua: fault.scua:2: boom

after never prints, and the program exits non-zero. So: reach for severe to record a problem and carry on, and for error to actually abort the current work. An uncaught fault is reported to sinks at a higher severity than severe, so the two stay distinguishable downstream. Faults and how to handle them are covered in Errors and faults.

#The level keywords are contextual

trace, debug, info, warn, and severe only act as log statements in statement position. They're still usable as ordinary names elsewhere, so an existing variable called info won't clash:

let info = "just a string"
print(info)
just a string