diff --git a/src/logging/log.zig b/src/logging/log.zig new file mode 100644 index 0000000..b8a27d5 --- /dev/null +++ b/src/logging/log.zig @@ -0,0 +1,149 @@ +const std = @import("std"); + +/// Severity levels for log messages, ordered from least to most severe. +/// +/// A `Logger`'s configured `level` acts as a filter: messages logged +/// at a lower severity than the logger's level are silently dropped. +pub const Level = enum { + debug, + info, + warn, + @"error", +}; + +/// Configuration passed to `Logger.init`. +/// +/// `level` sets the minimum severity that will be written; messages +/// below this level are discarded. +/// +/// `buffer` is the size, in bytes, of the internal write buffer +/// allocated for `writer`. Larger buffers reduce the number of +/// underlying writes at the cost of more memory and higher latency +/// before data is flushed. +/// +/// `writer` is the underlying file the logger writes to (e.g. stderr +/// or a log file). The logger takes no ownership of it beyond the +/// lifetime of the wrapping `std.Io.File.Writer`. +pub const InitOptions = struct { + level: Level = .info, + buffer: usize = 1024, + file: std.Io.File, + io: std.Io, +}; + +/// A minimal, thread-safe, buffered logger. +/// +/// Writes are buffered and only flushed automatically on `warn`, +/// `@"error"`, or `deinit`. +/// `debug` and `info` messages may sit in the buffer until it fills, +/// a higher-severity message is logged, `flush` or `deinit` is called. +/// +/// Must be initialized with `init` before use and cleaned up with +/// `deinit`. Not copyable once initialized, as `mutex` and `writer` +/// hold state tied to the original instance. +pub const Logger = struct { + level: Level = .info, + buffer: []u8 = undefined, + writer: std.Io.File.Writer = undefined, + mutex: std.Io.Mutex = undefined, + alloc: std.mem.Allocator = undefined, + io: std.Io = undefined, + + const Self = @This(); + + /// Creates and returns an initialized `Logger`. + /// + /// Allocates `options.buffer` bytes from `allocator` for internal + /// write buffering; ownership of this allocation belongs to the + /// returned `Logger` and is freed in `deinit`. + /// + /// `allocator` is stored on the result and reused for cleanup, so + /// it must remain valid for the logger's lifetime. + /// + /// Safe to move or copy the returned value freely before first + /// use, since the write buffer lives on the heap rather than + /// inside the `Logger` struct itself. Once a method has been + /// called on it, the logger must stay at a fixed address (e.g. + /// behind a pointer or in a `var` that isn't reassigned by value) + /// for the remainder of its life. + /// + /// Returns an error if the buffer allocation fails. + pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self + { + const buffer = try allocator.alloc(u8, options.buffer); + + return Self{ + .level = options.level, + .buffer = buffer, + .writer = options.file.writer(options.io, buffer), + .mutex = .init, + .alloc = allocator, + .io = options.io, + }; + } + + /// Flushes any buffered output and releases the logger's buffer. + /// + /// Safe to call once initialization via `init` has succeeded. + /// Flush errors are silently ignored, since there is no caller + /// left to meaningfully report them to at teardown time. + /// + /// Must not be called more than once, as the buffer is freed + /// unconditionally. + pub fn deinit(logger: *Self) void + { + logger.mutex.lock(logger.io) catch {}; + logger.writer.interface.flush() catch {}; + logger.mutex.unlock(logger.io); + + logger.alloc.free(logger.buffer); + } + + // + + /// Formats and writes a log message at the given `level`. + /// + /// Messages below `logger.level` are dropped before anything is + /// written or locked. `warn` and `@"error"` messages are flushed + /// immediately so they reach the destination even if the process + /// crashes shortly after; `debug` and `info` messages are left + /// buffered for efficiency. + /// + /// No buffer size/overflow checks as the writer automatically + /// drains on overflow. + /// + /// Returns an error if formatting or writing to the underlying + /// writer fails. + fn log(logger: *Self, comptime fmt: []const u8, args: anytype, level: Level) !void + { + if (@intFromEnum(level) < @intFromEnum(logger.level)) return; + + try logger.mutex.lock(logger.io); + defer logger.mutex.unlock(logger.io); + + try logger.writer.interface.print(fmt, args); + + if (level == .warn or level == .@"error") { + try logger.writer.interface.flush(); + } + } + + // + + pub fn debug(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch {}; } + pub fn info(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch {}; } + pub fn warn(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch {}; } + pub fn @"error"(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") catch {}; } + + /// Forces any buffered output to be written to the underlying + /// writer immediately. + /// + /// Returns an error if the underlying writer fails to flush. + pub fn flush(logger: *Self) !void + { + try logger.mutex.lock(logger.io); + defer logger.mutex.unlock(logger.io); + + try logger.writer.interface.flush(); + } +}; diff --git a/src/root.zig b/src/root.zig index af16576..b7e99f1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -6,6 +6,7 @@ pub const deps = struct { pub const config = @import("config/config.zig"); pub const memory = @import("memory/util.zig"); pub const signals = @import("signals/models.zig"); +pub const logging = @import("logging/log.zig"); test { _ = config;