added logging support
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
--- 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
|
||||
Reference in New Issue
Block a user