notex.nvim/lua/notex/utils/logging.lua

382 lines
9.6 KiB
Lua
Raw Permalink Normal View History

2025-10-05 20:16:33 -04:00
-- Centralized logging and error handling system
local M = {}
-- Log levels
local LOG_LEVELS = {
TRACE = 1,
DEBUG = 2,
INFO = 3,
WARN = 4,
ERROR = 5,
FATAL = 6
}
-- Current log level (can be configured)
local current_log_level = LOG_LEVELS.INFO
-- Log file configuration
local log_config = {
file_enabled = true,
file_path = nil, -- Will be set to stdpath('data')/notex/notex.log
max_file_size = 1024 * 1024, -- 1MB
backup_count = 3,
console_enabled = true
}
-- Error categories for better handling
local ERROR_CATEGORIES = {
DATABASE = "database",
PARSING = "parsing",
QUERY = "query",
UI = "ui",
FILESYSTEM = "filesystem",
VALIDATION = "validation",
CONFIGURATION = "configuration",
NETWORK = "network",
PERFORMANCE = "performance"
}
-- Error context stack for nested operations
local error_context = {}
-- Performance tracking
local performance_metrics = {
query_times = {},
index_times = {},
operation_counts = {},
error_counts = {}
}
-- Initialize logging system
function M.init(config)
config = config or {}
-- Set log level
if config.log_level then
local level = LOG_LEVELS[config.log_level:upper()]
if level then
current_log_level = level
end
end
-- Configure logging
log_config = vim.tbl_deep_extend("force", log_config, config)
-- Set default log file path
if not log_config.file_path then
log_config.file_path = vim.fn.stdpath('data') .. '/notex/notex.log'
end
-- Ensure log directory exists
local log_dir = vim.fn.fnamemodify(log_config.file_path, ':h')
vim.fn.mkdir(log_dir, 'p')
-- Clean up old log files
M.cleanup_log_files()
M.log("INFO", "Logging system initialized", {
log_level = M.get_log_level_name(),
log_file = log_config.file_path
})
return true, "Logging system initialized"
end
-- Core logging function
function M.log(level, message, context)
level = level:upper()
local level_value = LOG_LEVELS[level] or LOG_LEVELS.INFO
-- Skip if below current log level
if level_value < current_log_level then
return
end
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local context_str = context and " | " .. vim.inspect(context) or ""
local log_entry = string.format("[%s] %s: %s%s", timestamp, level, message, context_str)
-- Console output
if log_config.console_enabled then
if level_value >= LOG_LEVELS.ERROR then
vim.notify(message, vim.log.levels.ERROR)
elseif level_value >= LOG_LEVELS.WARN then
vim.notify(message, vim.log.levels.WARN)
elseif level_value >= LOG_LEVELS.INFO then
vim.notify(message, vim.log.levels.INFO)
else
-- Debug/trace go to message history but not notifications
vim.schedule(function()
vim.cmd('echomsg "' .. message:gsub('"', '\\"') .. '"')
end)
end
end
-- File output
if log_config.file_enabled then
M.write_to_file(log_entry)
end
end
-- Write to log file with rotation
function M.write_to_file(log_entry)
-- Check file size and rotate if necessary
local file_info = vim.fn.getfsize(log_config.file_path)
if file_info > log_config.max_file_size then
M.rotate_log_file()
end
-- Append to log file
local file = io.open(log_config.file_path, "a")
if file then
file:write(log_entry .. "\n")
file:close()
end
end
-- Rotate log files
function M.rotate_log_file()
-- Remove oldest backup
local oldest_backup = log_config.file_path .. "." .. log_config.backup_count
if vim.fn.filereadable(oldest_backup) > 0 then
os.remove(oldest_backup)
end
-- Rotate existing backups
for i = log_config.backup_count - 1, 1, -1 do
local current_backup = log_config.file_path .. "." .. i
local next_backup = log_config.file_path .. "." .. (i + 1)
if vim.fn.filereadable(current_backup) > 0 then
os.rename(current_backup, next_backup)
end
end
-- Move current log to backup
if vim.fn.filereadable(log_config.file_path) > 0 then
os.rename(log_config.file_path, log_config.file_path .. ".1")
end
end
-- Clean up old log files
function M.cleanup_log_files()
for i = 1, log_config.backup_count do
local backup_file = log_config.file_path .. "." .. i
if vim.fn.filereadable(backup_file) > 0 then
local file_time = vim.fn.getftime(backup_file)
local age_days = (os.time() - file_time) / 86400
-- Remove backups older than 30 days
if age_days > 30 then
os.remove(backup_file)
end
end
end
end
-- Convenience logging functions
function M.trace(message, context)
M.log("TRACE", message, context)
end
function M.debug(message, context)
M.log("DEBUG", message, context)
end
function M.info(message, context)
M.log("INFO", message, context)
end
function M.warn(message, context)
M.log("WARN", message, context)
end
function M.error(message, context)
M.log("ERROR", message, context)
-- Track error metrics
local category = context and context.category or "unknown"
performance_metrics.error_counts[category] = (performance_metrics.error_counts[category] or 0) + 1
end
function M.fatal(message, context)
M.log("FATAL", message, context)
-- Fatal errors should also be shown as error notifications
vim.notify("FATAL: " .. message, vim.log.levels.ERROR)
end
-- Error handling with context
function M.handle_error(error_msg, category, context)
category = category or "unknown"
context = context or {}
local error_info = {
message = error_msg,
category = category,
context = context,
timestamp = os.time(),
stack_trace = debug.traceback()
}
-- Add current error context
if #error_context > 0 then
error_info.nested_context = vim.deepcopy(error_context)
end
M.error("Error in " .. category, error_info)
return error_info
end
-- Push error context (for nested operations)
function M.push_context(operation, context)
table.insert(error_context, {
operation = operation,
context = context or {},
timestamp = os.time()
})
end
-- Pop error context
function M.pop_context()
return table.remove(error_context)
end
-- Execute with error context
function M.with_context(operation, context, func, ...)
M.push_context(operation, context)
local results = {pcall(func, ...)}
local success = table.remove(results, 1)
M.pop_context()
if not success then
local error_msg = results[1]
M.handle_error(error_msg, context.category or "operation", context)
return nil, error_msg
end
return unpack(results)
end
-- Performance tracking
function M.start_timer(operation)
return {
operation = operation,
start_time = vim.loop.hrtime()
}
end
function M.end_timer(timer)
local end_time = vim.loop.hrtime()
local duration_ms = (end_time - timer.start_time) / 1000000
-- Store performance metrics
if timer.operation:match("query") then
table.insert(performance_metrics.query_times, duration_ms)
-- Keep only last 100 measurements
if #performance_metrics.query_times > 100 then
table.remove(performance_metrics.query_times, 1)
end
elseif timer.operation:match("index") then
table.insert(performance_metrics.index_times, duration_ms)
if #performance_metrics.index_times > 100 then
table.remove(performance_metrics.index_times, 1)
end
end
-- Track operation counts
performance_metrics.operation_counts[timer.operation] = (performance_metrics.operation_counts[timer.operation] or 0) + 1
M.debug("Operation completed", {
operation = timer.operation,
duration_ms = duration_ms
})
return duration_ms
end
-- Timer utility function
function M.timer(operation)
return function()
return M.end_timer(M.start_timer(operation))
end
end
-- Get performance statistics
function M.get_performance_stats()
local stats = {
operations = vim.deepcopy(performance_metrics.operation_counts),
errors = vim.deepcopy(performance_metrics.error_counts)
}
-- Calculate averages
if #performance_metrics.query_times > 0 then
local total = 0
for _, time in ipairs(performance_metrics.query_times) do
total = total + time
end
stats.average_query_time = total / #performance_metrics.query_times
stats.query_count = #performance_metrics.query_times
end
if #performance_metrics.index_times > 0 then
local total = 0
for _, time in ipairs(performance_metrics.index_times) do
total = total + time
end
stats.average_index_time = total / #performance_metrics.index_times
stats.index_count = #performance_metrics.index_times
end
return stats
end
-- Get log level name
function M.get_log_level_name()
for name, level in pairs(LOG_LEVELS) do
if level == current_log_level then
return name
end
end
return "UNKNOWN"
end
-- Set log level
function M.set_log_level(level)
local level_value = LOG_LEVELS[level:upper()]
if level_value then
current_log_level = level_value
M.info("Log level changed to " .. level:upper())
return true
end
return false, "Invalid log level: " .. tostring(level)
end
-- Configuration validation
function M.validate_config(config)
local errors = {}
-- Validate log level
if config.log_level and not LOG_LEVELS[config.log_level:upper()] then
table.insert(errors, "Invalid log level: " .. config.log_level)
end
-- Validate file size
if config.max_file_size and (type(config.max_file_size) ~= "number" or config.max_file_size <= 0) then
table.insert(errors, "max_file_size must be a positive number")
end
-- Validate backup count
if config.backup_count and (type(config.backup_count) ~= "number" or config.backup_count < 1) then
table.insert(errors, "backup_count must be a positive number")
end
return #errors == 0, errors
end
-- Export error categories for use in other modules
M.ERROR_CATEGORIES = ERROR_CATEGORIES
return M