301 lines
10 KiB
Lua
301 lines
10 KiB
Lua
--- 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
|