Skip to content

Latest commit

 

History

History
370 lines (262 loc) · 12 KB

Log.md

File metadata and controls

370 lines (262 loc) · 12 KB

Log

Structured logging library for Modula-2. Provides level-filtered, sink-dispatched logging with optional key/value fields. Designed for single-threaded programs -- no heap allocation in the log path, fixed buffers throughout, long messages truncated safely.

Why use structured logging?

Traditional WriteString-based debugging is fine for small programs, but falls apart when you need to filter by severity, route output to files or network sinks, or parse logs programmatically. The Log module gives you:

  • Level filtering -- set a minimum severity and messages below it are discarded immediately, with zero formatting cost.
  • Pluggable sinks -- attach up to 8 output destinations (console, file, memory ring buffer, network stream) to a single logger.
  • Structured fields -- attach typed key/value pairs (strings, integers, booleans) to any log call. Fields are sorted and formatted deterministically so logs are both human-readable and machine-parseable.
  • Categories -- tag loggers with a category string (e.g. "http", "db") to identify the subsystem.
  • Recursion guard -- if a sink itself triggers a log call, the inner call is silently suppressed and a drop counter is incremented. This prevents infinite loops without crashing.

Output format

Every log line is deterministic and single-line. The default format includes a millisecond-precision timestamp:

2026-03-30T14:23:45.123 INFO msg="request handled" category="http" method="GET" status=200

The format is controlled by FormatOptions. By default: timestamp on, level on, category on, thread ID off. With all options enabled:

2026-03-30T14:23:45.123 INFO tid=12345 msg="request handled" category="http" method="GET"

Rules: timestamp first (ISO 8601 with millis), level name (uppercase), optional thread ID, message always as msg="...", category next if non-empty, then fields sorted lexicographically by key. String values are quoted and escaped. Integers and booleans are unquoted.

Types

Level

TYPE Level = (TRACE, DEBUG, INFO, WARN, ERROR, FATAL);

Severity levels in ascending order. Compared via ORD -- ORD(INFO) > ORD(DEBUG), so setting minimum level to INFO discards TRACE and DEBUG messages.

FormatOptions

TYPE FormatOptions = RECORD
  showTimestamp: BOOLEAN;  (* %d -- ISO 8601 with millis *)
  showLevel:    BOOLEAN;  (* %p -- level name *)
  showCategory: BOOLEAN;  (* %c -- category *)
  showThread:   BOOLEAN;  (* %t -- OS thread ID *)
END;

Controls which fields appear in formatted log output. Analogous to log4j pattern layout tokens. Defaults: timestamp=TRUE, level=TRUE, category=TRUE, thread=FALSE. Set globally via SetFormatOptions.

Field

TYPE Field = RECORD
  key:     ARRAY [0..MaxKey] OF CHAR;
  kind:    FieldKind;
  intVal:  INTEGER;
  boolVal: BOOLEAN;
  strVal:  ARRAY [0..MaxStrVal] OF CHAR;
END;

A single key/value pair. Fixed-size and stack-allocatable -- no heap allocation. Construct fields using KVStr, KVInt, KVBool, or KVCard rather than filling the record manually.

Record

TYPE Record = RECORD
  level:    Level;
  msg:      ARRAY [0..MaxMsg] OF CHAR;
  category: ARRAY [0..MaxCategory] OF CHAR;
  fields:   ARRAY [0..MaxFields-1] OF Field;
  nFields:  INTEGER;
END;

A fully self-contained log record passed to sinks. Sinks receive this by VAR reference and should treat it as read-only.

Sink

TYPE SinkProc = PROCEDURE(ADDRESS, VAR Record);

TYPE Sink = RECORD
  proc:     SinkProc;
  ctx:      ADDRESS;
  minLevel: Level;
END;

A registered output destination. proc is called with ctx as the first argument and the log record as the second. minLevel provides per-sink level filtering on top of the logger's own minimum.

Logger

TYPE Logger = RECORD
  minLevel:  Level;
  sinks:     ARRAY [0..MaxSinks-1] OF Sink;
  sinkCount: INTEGER;
  category:  ARRAY [0..MaxCategory] OF CHAR;
  inSink:    BOOLEAN;
  dropCount: INTEGER;
END;

Logger state. Declare as a module-level or local variable -- the Logger is a value type, not a heap object. Always call Init before use.

Constants

Constant Value Purpose
MaxMsg 255 Maximum message length in characters.
MaxKey 31 Maximum field key length.
MaxStrVal 127 Maximum field string value length.
MaxFields 16 Maximum fields per log call.
MaxSinks 8 Maximum sinks per logger.
MaxCategory 63 Maximum category string length.
MaxLine 2048 Maximum formatted output line length.

Procedures

Init

PROCEDURE Init(VAR l: Logger);

Initialize a logger with level INFO and no sinks. You must call AddSink at least once before logging, otherwise log calls are silently discarded (which is fine during setup).

InitDefault

PROCEDURE InitDefault;

Initialize the module-level default logger with a console sink (stdout) at level INFO. Safe to call multiple times -- subsequent calls are no-ops. The *D convenience procedures (InfoD, WarnD, etc.) call this automatically on first use.

SetLevel

PROCEDURE SetLevel(VAR l: Logger; level: Level);

Set the minimum severity level. Messages below this level are discarded immediately without formatting. This is the primary performance knob -- set to WARN in production to eliminate the cost of DEBUG/INFO messages entirely.

SetDefaultLevel

PROCEDURE SetDefaultLevel(level: Level);

Set the minimum level on the module-level default logger. Useful for adding --debug or --verbose flags to CLI programs. Calls InitDefault automatically if needed.

IF HasFlag("debug") = 1 THEN
  Log.SetDefaultLevel(Log.DEBUG)
END;

InitFormatOptions

PROCEDURE InitFormatOptions(VAR opts: FormatOptions);

Initialize a FormatOptions record to defaults: timestamp=TRUE, level=TRUE, category=TRUE, thread=FALSE.

SetFormatOptions

PROCEDURE SetFormatOptions(opts: FormatOptions);

Set the global format options used by all Format calls. Affects all loggers and sinks that use the built-in formatter.

VAR opts: Log.FormatOptions;
Log.InitFormatOptions(opts);
opts.showThread := TRUE;
Log.SetFormatOptions(opts);

AddSink

PROCEDURE AddSink(VAR l: Logger; s: Sink): BOOLEAN;

Attach an output sink. Returns FALSE if the logger already has MaxSinks (8) sinks attached. A logger with zero sinks silently discards all messages.

SetCategory

PROCEDURE SetCategory(VAR l: Logger; cat: ARRAY OF CHAR);

Set a category string that appears in all subsequent log records. Useful for tagging loggers by subsystem (e.g. "http", "db", "auth").

WithCategory

PROCEDURE WithCategory(VAR src: Logger; cat: ARRAY OF CHAR; VAR out: Logger);

Create a copy of src with a different category. The sink list is copied (not shared), so changes to one logger's sinks do not affect the other. This is the idiomatic way to create per-subsystem loggers that share the same output configuration.

VAR appLog, dbLog: Logger;
Init(appLog);
MakeConsoleSink(cs);
IF AddSink(appLog, cs) THEN END;
WithCategory(appLog, "db", dbLog);
Info(dbLog, "connection pool ready");
(* Output: INFO msg="connection pool ready" category="db" *)

LogMsg

PROCEDURE LogMsg(VAR l: Logger; level: Level; msg: ARRAY OF CHAR);

Log a plain message. If level < l.minLevel, returns immediately -- the message string is never touched. This is the fast path that makes it safe to leave TRACE/DEBUG calls in production code.

LogKV

PROCEDURE LogKV(VAR l: Logger; level: Level; msg: ARRAY OF CHAR;
                fields: ARRAY OF Field; nFields: INTEGER);

Log a structured message with key/value fields. nFields is clamped to both HIGH(fields)+1 and MaxFields. Fields are sorted by key before formatting, so the output is deterministic regardless of the order you build them.

VAR fs: ARRAY [0..2] OF Field;
KVStr("method", "GET", fs[0]);
KVStr("path", "/api/users", fs[1]);
KVInt("status", 200, fs[2]);
LogKV(lg, INFO, "request handled", fs, 3);
(* Output: INFO msg="request handled" method="GET" path="/api/users" status=200 *)

Trace, Debug, Info, Warn, Error, Fatal

PROCEDURE Trace(VAR l: Logger; msg: ARRAY OF CHAR);
PROCEDURE Debug(VAR l: Logger; msg: ARRAY OF CHAR);
PROCEDURE Info(VAR l: Logger; msg: ARRAY OF CHAR);
PROCEDURE Warn(VAR l: Logger; msg: ARRAY OF CHAR);
PROCEDURE Error(VAR l: Logger; msg: ARRAY OF CHAR);
PROCEDURE Fatal(VAR l: Logger; msg: ARRAY OF CHAR);

Shorthand for LogMsg(l, LEVEL, msg). Use these when you don't need structured fields.

LogKVD

PROCEDURE LogKVD(level: Level; msg: ARRAY OF CHAR;
                 fields: ARRAY OF Field; nFields: INTEGER);

Log a structured message with key/value fields using the module-level default logger. This is the default-logger equivalent of LogKV. Calls InitDefault on first use.

VAR fs: ARRAY [0..1] OF Log.Field;
Log.KVStr("path", "/data/lmdb", fs[0]);
Log.KVCard("size_mb", 1024, fs[1]);
Log.LogKVD(Log.INFO, "store opened", fs, 2);
(* Output: 2026-03-30T14:23:45.123 INFO msg="store opened" path="/data/lmdb" size_mb="1024" *)

TraceD, DebugD, InfoD, WarnD, ErrorD, FatalD

PROCEDURE InfoD(msg: ARRAY OF CHAR);
(* etc. *)

Log using the module-level default logger. Calls InitDefault on first use, so these work without any setup. Useful for quick debugging or simple programs that don't need multiple loggers.

KVStr

PROCEDURE KVStr(key: ARRAY OF CHAR; val: ARRAY OF CHAR; VAR out: Field);

Build a string field. Both key and val are truncated to MaxKey and MaxStrVal respectively if too long. The field is fully self-contained (no pointers to external data).

KVInt

PROCEDURE KVInt(key: ARRAY OF CHAR; val: INTEGER; VAR out: Field);

Build an integer field. Formatted as an unquoted decimal in the output.

KVBool

PROCEDURE KVBool(key: ARRAY OF CHAR; val: BOOLEAN; VAR out: Field);

Build a boolean field. Formatted as true or false (unquoted) in the output.

KVCard

PROCEDURE KVCard(key: ARRAY OF CHAR; val: LONGCARD; VAR dst: Field);

Build a cardinal field from a LONGCARD value. The value is formatted as a decimal string internally (stored as FkStr), so it renders as a quoted string in output. Use this for unsigned values that don't fit in INTEGER (e.g., byte counts, map sizes).

Format

PROCEDURE Format(VAR rec: Record; VAR buf: LineBuf; VAR len: INTEGER);

Format a log record into a character buffer. len is set to the number of characters written (excluding the NUL terminator). If the formatted output would exceed MaxLine, it is truncated. Sink implementations call this to produce their output string.

MakeConsoleSink

PROCEDURE MakeConsoleSink(VAR out: Sink);

Create a sink that writes formatted lines to stdout via InOut.WriteString. The sink's minLevel is set to TRACE so that the logger's own minLevel is the sole filter. This is the sink used by InitDefault.

GetDropCount

PROCEDURE GetDropCount(VAR l: Logger): INTEGER;

Return the number of log calls suppressed by the recursion guard. If this is non-zero, it means a sink triggered a log call during dispatch. Useful for diagnosing misbehaving sinks.

Example

MODULE LogDemo;

FROM Log IMPORT Logger, Level, Field, Sink,
                Init, SetLevel, AddSink, SetCategory,
                MakeConsoleSink, LogKV, KVStr, KVInt, Info;

VAR
  lg: Logger;
  cs: Sink;
  fs: ARRAY [0..1] OF Field;

BEGIN
  Init(lg);
  MakeConsoleSink(cs);
  IF AddSink(lg, cs) THEN END;
  SetLevel(lg, DEBUG);
  SetCategory(lg, "app");

  Info(lg, "started");
  (* Output: 2026-03-30T14:23:45.123 INFO msg="started" category="app" *)

  KVStr("user", "alice", fs[0]);
  KVInt("latency", 42, fs[1]);
  LogKV(lg, DEBUG, "login", fs, 2);
  (* Output: 2026-03-30T14:23:45.124 DEBUG msg="login" category="app" latency=42 user="alice" *)
END LogDemo.