--- logger.lua — A feature-rich logging library for Lua -- Supports log levels, multiple handlers, formatting, and filtering. -- -- Usage: -- local Logger = require("logger") -- local log = Logger.new("MyApp") -- log:info("Server started on port %d", 8080) -- log:warn("Disk usage at %d%%", 92) -- log:error("Connection refused: %s", err) local Logger = {} Logger.__index = Logger -- ── Level definitions ────────────────────────────────────────────────────── Logger.LEVELS = { TRACE = 1, DEBUG = 2, INFO = 3, WARN = 4, ERROR = 5, FATAL = 6, OFF = 7, -- disable all output } -- Reverse map: number → name local LEVEL_NAMES = {} for name, val in pairs(Logger.LEVELS) do LEVEL_NAMES[val] = name end -- ANSI colour codes (used by the ConsoleHandler when colours are enabled) local COLOURS = { [1] = "\27[36m", -- TRACE cyan [2] = "\27[34m", -- DEBUG blue [3] = "\27[32m", -- INFO green [4] = "\27[33m", -- WARN yellow [5] = "\27[31m", -- ERROR red [6] = "\27[35m", -- FATAL magenta RESET = "\27[0m", } -- ── Utility helpers ──────────────────────────────────────────────────────── --- Returns an ISO-8601-style timestamp: "2025-06-08 14:32:01" local function timestamp() return os.date("%Y-%m-%d %H:%M:%S") end --- Safe string formatting — falls back gracefully when fmt is not a string local function safe_format(fmt, ...) if select("#", ...) == 0 then return tostring(fmt) end local ok, result = pcall(string.format, tostring(fmt), ...) return ok and result or (tostring(fmt) .. " " .. table.concat({...}, " ")) end --- Pads or truncates a string to a fixed width local function pad(s, width) s = tostring(s) if #s >= width then return s:sub(1, width) end return s .. string.rep(" ", width - #s) end -- ── Built-in Handlers ────────────────────────────────────────────────────── --- ConsoleHandler — writes to stdout (or stderr for ERROR+) -- Options: -- colours (bool, default true) — enable ANSI colours -- stderr (bool, default false) — always write to stderr local function ConsoleHandler(opts) opts = opts or {} local use_colour = (opts.colours ~= false) local force_stderr = opts.stderr or false return function(record) local line = record.formatted if use_colour then local c = COLOURS[record.level] or "" line = c .. line .. COLOURS.RESET end local dest = (force_stderr or record.level >= Logger.LEVELS.ERROR) and io.stderr or io.stdout dest:write(line .. "\n") dest:flush() end end --- FileHandler — appends log records to a file -- Options: -- path (string, required) — path to the log file -- max_size (number, optional) — rotate when file exceeds this many bytes -- max_files(number, optional) — keep this many rotated files (default 5) local function FileHandler(opts) assert(opts and opts.path, "FileHandler requires opts.path") local path = opts.path local max_size = opts.max_size -- nil = never rotate local max_files = opts.max_files or 5 local file local function open_file() file = assert(io.open(path, "a"), "Cannot open log file: " .. path) end local function rotate() if file then file:close(); file = nil end -- shift old files: .4 → .5, .3 → .4, …, .1 → .2, base → .1 for i = max_files - 1, 1, -1 do os.rename(path .. "." .. i, path .. "." .. (i + 1)) end os.rename(path, path .. ".1") open_file() end open_file() return function(record) if not file then open_file() end file:write(record.formatted .. "\n") file:flush() if max_size then local pos = file:seek("cur") if pos and pos >= max_size then rotate() end end end end --- MemoryHandler — stores records in a Lua table (useful for testing) -- Returns handler, get_records() function local function MemoryHandler(opts) opts = opts or {} local limit = opts.limit or 10000 local records = {} local handler = function(record) if #records >= limit then table.remove(records, 1) end table.insert(records, record) end local function get_records() return records end local function clear() records = {} end return handler, get_records, clear end -- Expose built-ins Logger.handlers = { ConsoleHandler = ConsoleHandler, FileHandler = FileHandler, MemoryHandler = MemoryHandler, } -- ── Formatter ───────────────────────────────────────────────────────────── -- Tokens in the format string: -- {time} — timestamp -- {level} — padded level name (5 chars) -- {name} — logger name -- {message} — the log message local DEFAULT_FORMAT = "[{time}] [{level}] [{name}] {message}" local function escape_replacement(s) -- In Lua's gsub, '%' in the replacement string is special; escape it. return (s:gsub("%%", "%%%%")) end local function apply_format(fmt, record) return (fmt :gsub("{time}", escape_replacement(record.time)) :gsub("{level}", escape_replacement(pad(record.level_name, 5))) :gsub("{name}", escape_replacement(record.name)) :gsub("{message}", escape_replacement(record.message))) end -- ── Logger constructor ───────────────────────────────────────────────────── --- Create a new logger instance. -- @param name string — logger name shown in output -- @param opts table — optional configuration: -- level (number) — minimum level, default INFO -- format (string) — format string, default DEFAULT_FORMAT -- handlers (table) — list of handler functions; defaults to ConsoleHandler function Logger.new(name, opts) opts = opts or {} local self = setmetatable({}, Logger) self.name = name or "root" self.level = opts.level or Logger.LEVELS.INFO self.format = opts.format or DEFAULT_FORMAT self._handlers = opts.handlers or { ConsoleHandler() } self._filters = {} -- list of predicate functions return self end -- ── Configuration API ────────────────────────────────────────────────────── --- Set the minimum log level (Logger.LEVELS.*) function Logger:set_level(level) assert(type(level) == "number", "level must be a number from Logger.LEVELS") self.level = level return self end --- Add a handler function function Logger:add_handler(handler) table.insert(self._handlers, handler) return self end --- Remove all handlers and replace with the given ones function Logger:set_handlers(handlers) self._handlers = handlers return self end --- Add a filter predicate — a function(record) → bool -- If ANY filter returns false the record is dropped. function Logger:add_filter(fn) table.insert(self._filters, fn) return self end --- Set the output format string function Logger:set_format(fmt) self.format = fmt return self end -- ── Core emit logic ──────────────────────────────────────────────────────── function Logger:_emit(level, fmt, ...) if level < self.level then return end local record = { time = timestamp(), level = level, level_name = LEVEL_NAMES[level] or ("LVL" .. level), name = self.name, message = safe_format(fmt, ...), } record.formatted = apply_format(self.format, record) -- run filters for _, filter in ipairs(self._filters) do if not filter(record) then return end end -- dispatch to handlers for _, handler in ipairs(self._handlers) do local ok, err = pcall(handler, record) if not ok then io.stderr:write("[logger] handler error: " .. tostring(err) .. "\n") end end end -- ── Convenience level methods ────────────────────────────────────────────── function Logger:trace(fmt, ...) self:_emit(Logger.LEVELS.TRACE, fmt, ...) end function Logger:debug(fmt, ...) self:_emit(Logger.LEVELS.DEBUG, fmt, ...) end function Logger:info (fmt, ...) self:_emit(Logger.LEVELS.INFO, fmt, ...) end function Logger:warn (fmt, ...) self:_emit(Logger.LEVELS.WARN, fmt, ...) end function Logger:error(fmt, ...) self:_emit(Logger.LEVELS.ERROR, fmt, ...) end function Logger:fatal(fmt, ...) self:_emit(Logger.LEVELS.FATAL, fmt, ...) end --- Log at a given numeric level function Logger:log(level, fmt, ...) self:_emit(level, fmt, ...) end --- Returns true if the given level would be emitted function Logger:is_enabled(level) return level >= self.level end -- ── Child loggers ────────────────────────────────────────────────────────── --- Create a child logger that inherits handlers/format but has its own name. function Logger:child(name, opts) opts = opts or {} return Logger.new(self.name .. "." .. name, { level = opts.level or self.level, format = opts.format or self.format, handlers = opts.handlers or self._handlers, }) end -- ── Module-level root logger ─────────────────────────────────────────────── Logger._root = Logger.new("root") --- Get / lazily create a named logger backed by the root's handlers. local _registry = {} function Logger.get(name) if not _registry[name] then _registry[name] = Logger._root:child(name) end return _registry[name] end return Logger