Logging
SuperJ's logging framework (sj.util.logging) is a garbage-free, fluent, memory-mapped logger designed for single-threaded servers and systems programs. Log records are written directly into a memory-mapped file — no syscall per record, no buffered I/O, and records survive a process crash (the kernel flushes dirty pages even after the process dies).
Quick Start
import sj.util.logging.FileLoggerFactory;
import sj.util.logging.LogFormat;
import sj.util.logging.RollPolicy;
import sj.util.logging.Level;
import sj.util.logging.Logger;
import sj.util.logging.LoggerFactory;
public class MyApp {
public static void main(String[] args) {
LoggerFactory factory = new FileLoggerFactory(
"logs/app.log", // file path
1024 * 1024, // 1 MiB capacity per segment
LogFormat.PLAIN, // human-readable (tail/grep-friendly)
RollPolicy.ROLL // roll to app.log.0, app.log.1, ...
);
factory.setLevel(Level.DEBUG);
Logger log = factory.createLogger("main");
log.info().log("Server started on port ").log(8080).flush();
log.debug().log("Config: ").log("debug mode enabled").flush();
log.warn().log("Cache miss for key ").log("user:42").flush();
log.error().log("Failed to connect: ").log("connection refused").flush();
factory.close(); // flush dirty pages and close the mmap
}
}
Compile and run:
superj run -- main
The log file logs/app.log.0 will contain:
Server started on port 8080
Config: debug mode enabled
Cache miss for key user:42
Failed to connect: connection refused
Log Levels
Levels are ordered from most to least verbose:
| Level | Ordinal | Description |
|---|---|---|
ALL | 0 | Log everything |
DEBUG | 1 | Debugging detail |
INFO | 2 | Informational messages (default) |
WARN | 3 | Warnings |
ERROR | 4 | Errors |
FATAL | 5 | Fatal errors |
OFF | 6 | Logging disabled |
Set the level on the factory before creating loggers:
factory.setLevel(Level.WARN); // only WARN and above will be emitted
Logger log = factory.createLogger("app");
Or change a logger's level directly:
log.setLevel(Level.DEBUG);
The Fluent API
Every log(...) call returns the logger, so calls chain. The level selector (info(), debug(), etc.) starts a chain — if the level is not enabled, all subsequent log(...) calls in that chain are cheap no-ops (a single boolean check, no string allocation):
log.info()
.log("Request ")
.log(requestId)
.log(" from ")
.log(remoteAddr)
.flush();
flush() writes the accumulated buffer to the output and clears it. Every chain must end with flush() — without it, the message is not written.
What you can log
| Method | Description |
|---|---|
log(string) | UTF-8 text |
logAscii(string) | ASCII text (skips UTF-8 encoding) |
log(int) / log(long) / log(short) | Decimal representation |
log(boolean) | "true" / "false" |
log(char) | Single character (UTF-8) |
log(byte[]) / log(byte[], offset, len) | Raw bytes |
logHEX(byte[], offset, len) | Hexadecimal string |
log(Loggable) | Custom rendering (see below) |
log(Throwable) | Exception message |
Custom object logging
Implement Loggable to render an object directly into the logger's byte buffer — no intermediate string allocation:
import sj.util.logging.Loggable;
import sj.util.ByteArrayBuilder;
public class Point implements Loggable {
int x, y;
public void log(ByteArrayBuilder buf) {
buf.append("Point(");
buf.append(this.x);
buf.append(",");
buf.append(this.y);
buf.append(")");
}
}
// usage:
log.info().log(new Point(3, 4)).flush();
// output: Point(3,4)
File Format
Choose between two on-disk formats at factory construction time:
PLAIN — human-readable
Each message is written as its raw bytes followed by a newline. The file is directly readable with tail, grep, less, or any text editor:
tail -f logs/app.log.0
grep ERROR logs/app.log.0
BINARY — length-prefixed records
Each message is written as a 4-byte little-endian length prefix followed by the raw bytes. The framing is self-describing, so a reader can replay records without scanning for delimiters, and messages may contain newlines:
LoggerFactory factory = new FileLoggerFactory(
"logs/events.bin",
4 * 1024 * 1024, // 4 MiB
LogFormat.BINARY,
RollPolicy.RING
);
Roll Policies
When the memory-mapped region fills up, the roll policy decides what happens:
ROLL — numbered segments (full history)
The current file is synced and closed, and the next numbered file is opened: app.log.0, app.log.1, app.log.2, etc. All history is preserved. On restart, the factory finds the highest existing segment number and continues from there.
new FileLoggerFactory("logs/app.log", 1024 * 1024, LogFormat.PLAIN, RollPolicy.ROLL);
// files: app.log.0, app.log.1, app.log.2, ...
RING — circular buffer (most recent only)
A single fixed-size file. When full, the write head wraps back to the beginning and overwrites the oldest records. This is a "black box" pattern — you always have the most recent N bytes of log history, with bounded disk usage:
new FileLoggerFactory("logs/app.log", 1024 * 1024, LogFormat.PLAIN, RollPolicy.RING);
// single file: app.log (overwrites oldest data when full)
Where to Find Log Files
Log files are created at the path you pass to FileLoggerFactory. The path is relative to the working directory of the process:
| Configuration | Files created |
|---|---|
PLAIN + ROLL | path.0, path.1, path.2, ... |
PLAIN + RING | path (single file, circular) |
BINARY + ROLL | path.0, path.1, path.2, ... |
BINARY + RING | path (single file, circular) |
Each file has a 64-byte header (LOGS magic + version + write head). The actual log data starts at byte 64. For PLAIN format, the data portion is human-readable text; for BINARY, it is length-prefixed records.
On restart, the factory resumes from the stored write head — new records append after the last written record, with no gap.
Custom Loggers
To log to a destination other than a file (stdout, a network socket, an in-memory buffer), extend AbstractLogger and implement writeOut:
import sj.util.logging.AbstractLogger;
import sj.util.ByteArrayBuilder;
public class ConsoleLogger extends AbstractLogger {
protected void writeOut(ByteArrayBuilder buffer) {
System.out.println(buffer.getBuffer(), 0, buffer.length());
}
}
The AbstractLogger base class handles level filtering, the fluent chain, and buffering. Your subclass only supplies the final output step.
println(byte[], off, len) writes the buffer's bytes straight to the stream. Prefer it to println(buffer.toString()): toString() copies the bytes into a fresh String on every line, so a logger written that way allocates per record even though the builder is reused. Measured on the line above — route=/v1/orders status=200, 27 bytes — 100 records cost 6,400 bytes through toString() and 0 through the span form. It is the only output overload that builds nothing: all the others render their argument to a String first. The bytes go out verbatim, so the buffer must already hold the encoding you want.
Guarding Expensive Messages
The level selectors return this with the active flag set. When the level is not enabled, log(...) calls are no-ops — but the argument is still evaluated. For expensive arguments (string concatenation, object construction), guard with a level check first:
if (log.logDebug()) {
log.debug().log("State: ").log(dumpAllState()).flush();
}
Idiomatic Usage in SDK Components
The SDK's own components (e.g. the network adapter) follow this pattern:
if (this.logger != null && this.logger.logInfo()) {
this.logger.log("Listening at port ").log(port).flush();
}
Null-check the logger (it may not be configured), check the level, then chain the message and flush.
Relationship to the Memory-Event Log
The logging framework (sj.util.logging) is separate from the memory-event log (--mem-track / superj memlog). The memory-event log tracks arena allocation growth for debugging memory issues; the logging framework is for application-level log messages. They share no code and serve different purposes.