log.lua
1 --[[ 2 ToasterGen Spin 3 4 Copyright (C) 2025 Clifton Toaster Reid <cliftontreid@duck.com> 5 6 This library is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Lesser General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This library is distributed in the hope that it will be useful, 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Lesser General Public License for more details. 15 16 You should have received a copy of the GNU Lesser General Public License 17 along with this library. If not, see <https://www.gnu.org/licenses/>. 18 ]] 19 20 ---@class Logger 21 ---@field LEVELS table<string, { priority: number, color: number }> Log levels with priority and color. 22 ---@field cachedRequest LokiRequest The current batch of logs waiting to be sent. 23 ---@field setLokiURL fun(url: string) Sets the URL for the Loki logging endpoint. 24 ---@field getLokiURL fun(): string|nil Gets the currently configured Loki URL. 25 ---@field sendLogs fun() Sends the cached log entries to the configured Loki endpoint. 26 ---@field getTimestamp fun(): string Returns the current timestamp formatted as [YYYY-MM-DD HH:MM:SS]. 27 ---@field debug fun(fmt: string, ...) Logs a message with DEBUG level. 28 ---@field info fun(fmt: string, ...) Logs a message with INFO level. 29 ---@field warning fun(fmt: string, ...) Logs a message with WARNING level. 30 ---@field error fun(fmt: string, ...) Logs a message with ERROR level. 31 ---@field success fun(fmt: string, ...) Logs a message with SUCCESS level. 32 ---@field getLogLevel fun(): string Gets the current minimum log level. 33 ---@field setLogLevel fun(level: string) Sets the minimum log level. 34 ---@field log fun(level: string, ...) Logs a message with the specified level. 35 36 ---@class LokiRequest 37 ---@field streams LokiStream[] 38 ---@field streamMap table<string, number> 39 40 ---@class LokiStream 41 ---@field stream table<string, string> 42 ---@field values { [0]: string, [1]: string, [2]: LokiExtra | nil }[] 43 44 ---@class LokiExtra 45 ---@field trace_id string | nil 46 ---@field user_id string | nil 47 48 ---@type string | nil 49 local LOKI_URL = nil -- Loki URL for sending logs 50 ---@type string 51 local CURRENT_LOG_LEVEL = "DEBUG" -- Default level, shows all logs 52 53 local expect = require("cc.expect").expect 54 55 local Logger 56 57 --- Creates a new, empty Loki request object. 58 ---@return LokiRequest An empty request object with initialized streams and streamMap. 59 local function newRequest() 60 return { 61 streams = {}, 62 streamMap = {} 63 } 64 end 65 66 --- Creates a new Loki log entry with timestamp and message. 67 ---@param msg string The log message content. 68 ---@param extra LokiExtra | nil Optional extra data (like trace_id, user_id). 69 ---@return { [0]: number, [1]: string, [2]: LokiExtra | nil } A formatted Loki log entry. 70 local function newLokiEntry(msg, extra) 71 local entry = { 72 [0] = os.epoch() * 1e6, -- convert ms to ns 73 [1] = msg, 74 } 75 if extra then 76 entry[2] = extra 77 end 78 return entry 79 end 80 81 --- Adds a log entry to the cached Loki request, grouping by labels. 82 ---@param level string The log level (e.g., "INFO", "ERROR"). 83 ---@param msg string The log message. 84 ---@param extra LokiExtra | nil Optional extra data for the log entry. 85 local function addToCache(level, msg, extra) 86 -- Construct a table of labels for this log entry. 87 -- 'job' is the computer label (or "unknown" if not set), 88 -- 'host' is the computer ID, and 'level' is the log level. 89 local labels = { job = os.getComputerLabel() or "unknown", host = os.getComputerID(), level = level } 90 91 -- Build a stable, unique key for this label set by concatenating sorted key-value pairs. 92 local parts = {} 93 for k, v in pairs(labels) do 94 parts[#parts + 1] = k .. "=" .. v 95 end 96 table.sort(parts) -- Ensure consistent order for the key. 97 local key = table.concat(parts, ",") 98 99 -- Check if a stream for this label set already exists in the cache. 100 local idx = Logger.cachedRequest.streamMap[key] 101 if not idx then 102 -- If not, create a new stream entry and update the stream map. 103 idx = #Logger.cachedRequest.streams + 1 104 Logger.cachedRequest.streams[idx] = { stream = labels, values = {} } 105 Logger.cachedRequest.streamMap[key] = idx 106 end 107 108 -- Add the new log entry (with optional extra fields) to the appropriate stream. 109 table.insert(Logger.cachedRequest.streams[idx].values, newLokiEntry(msg, extra)) 110 end 111 112 --- Attempts to POST data with exponential backoff on failure. 113 ---@param url string The URL to POST to. 114 ---@param body string The request body. 115 ---@param headers table HTTP headers. 116 ---@return table | nil The HTTP response object on success, or nil after retries fail. 117 local function postWithRetry(url, body, headers) 118 local maxRetries, backoff = 3, 1 119 for i = 1, maxRetries do 120 local ok, resp = pcall(http.post, url, body, headers) 121 if ok and resp then return resp end 122 sleep(backoff) 123 backoff = backoff * 2 124 end 125 return nil 126 end 127 128 Logger = { 129 LEVELS = { 130 DEBUG = { priority = 1, color = colors.gray }, 131 INFO = { priority = 2, color = colors.lightGray }, 132 WARNING = { priority = 3, color = colors.yellow }, 133 ERROR = { priority = 4, color = colors.red }, 134 SUCCESS = { priority = 2, color = colors.lime }, 135 }, 136 cachedRequest = newRequest(), 137 } 138 139 --- Sets the URL for the Loki logging endpoint. 140 --- Validates the URL before setting it. Throws an error if the URL is invalid. 141 --- @param url string The URL for the Loki push API (e.g., "http://loki:3100/loki/api/v1/push"). 142 function Logger.setLokiURL(url) 143 expect(1, url, "string") 144 local valid, reason = http.checkURL(url) 145 if not valid then 146 Logger.error("The following URL (" .. url .. ") is invalid: " .. reason) 147 error("Invalid URL: " .. reason) 148 return 149 end 150 LOKI_URL = url 151 end 152 153 --- Gets the currently configured Loki URL. 154 --- @return string|nil The configured Loki URL, or nil if not set. 155 function Logger.getLokiURL() 156 return LOKI_URL 157 end 158 159 function Logger.sendLogs() 160 if LOKI_URL and #Logger.cachedRequest.streams > 0 then 161 local request = Logger.cachedRequest 162 local response = postWithRetry(LOKI_URL, textutils.serializeJSON(request), { 163 ["Content-Type"] = "application/json", 164 }) 165 if response then 166 -- temporarily disable Loki push for status logs 167 local prevURL = LOKI_URL 168 LOKI_URL = nil 169 if response.getResponseCode() == 204 then 170 Logger.info("Logs sent to Loki successfully.") 171 else 172 Logger.error("Failed to send logs to Loki. Response code: " .. response.getResponseCode()) 173 end 174 LOKI_URL = prevURL 175 else 176 -- temporarily disable Loki push for status logs 177 local prevURL = LOKI_URL 178 LOKI_URL = nil 179 Logger.error("Failed to send logs to Loki.") 180 LOKI_URL = prevURL 181 end 182 -- clear the cache so sent logs aren’t re-sent 183 Logger.cachedRequest = newRequest() 184 end 185 186 -- Reset the cached request to avoid sending duplicate logs 187 Logger.cachedRequest = newRequest() 188 end 189 190 --- Returns the current timestamp in the format [YYYY-MM-DD HH:MM:SS] 191 ---@return string|osdate time 192 function Logger.getTimestamp() 193 return os.date("[%Y-%m-%d %H:%M:%S]") 194 end 195 196 --- Log a message to the console as DEBUG 197 --- @param fmt string, format string for message. 198 --- @param ... any, arguments for message formatting. 199 function Logger.debug(fmt, ...) 200 expect(1, fmt, "string") 201 local msg = string.format(fmt, ...) 202 if msg then 203 Logger.log("DEBUG", msg) 204 end 205 end 206 207 --- Log a message to the console as INFO 208 --- @param fmt string, format string for message. 209 --- @param ... any, arguments for message formatting. 210 function Logger.info(fmt, ...) 211 expect(1, fmt, "string") 212 local msg = string.format(fmt, ...) 213 if msg then 214 Logger.log("INFO", msg) 215 end 216 end 217 218 --- Log a message to the console as WARNING 219 --- @param fmt string, format string for message. 220 --- @param ... any, arguments for message formatting. 221 function Logger.warning(fmt, ...) 222 expect(1, fmt, "string") 223 local msg = string.format(fmt, ...) 224 if msg then 225 Logger.log("WARNING", msg) 226 end 227 end 228 229 --- Log a message to the console as ERROR 230 --- @param fmt string, format string for message. 231 --- @param ... any, arguments for message formatting. 232 function Logger.error(fmt, ...) 233 expect(1, fmt, "string") 234 local msg = string.format(fmt, ...) 235 if msg then 236 Logger.log("ERROR", msg) 237 end 238 end 239 240 --- Log a message to the console as SUCCESS 241 --- @param fmt string, format string for message. 242 --- @param ... any, arguments for message formatting. 243 function Logger.success(fmt, ...) 244 expect(1, fmt, "string") 245 local msg = string.format(fmt, ...) 246 if msg then 247 Logger.log("SUCCESS", msg) 248 end 249 end 250 251 --- Get the current log level 252 --- @return string The current log level 253 function Logger.getLogLevel() 254 return CURRENT_LOG_LEVEL 255 end 256 257 --- Set the log level, logs below this level will be ignored 258 --- @param level string The log level (DEBUG, INFO, WARNING, ERROR, SUCCESS) 259 function Logger.setLogLevel(level) 260 expect(1, level, "string") 261 if not Logger.LEVELS[level] then 262 error("Invalid log level: " .. level) 263 return 264 end 265 CURRENT_LOG_LEVEL = level 266 end 267 268 local logFile = fs.open("/log.txt", "a") 269 270 --- Log a message to the console with a specific level 271 --- @param level string The log level (DEBUG, INFO, WARNING, ERROR, SUCCESS) 272 --- @param ... any, arguments for message formatting. 273 function Logger.log(level, ...) 274 expect(1, level, "string") 275 local msg = string.format(...) 276 if msg then 277 local logLevel = Logger.LEVELS[level] 278 if logLevel then 279 -- Check if this log level should be displayed 280 local currentLevelInfo = Logger.LEVELS[CURRENT_LOG_LEVEL] 281 if currentLevelInfo and logLevel.priority >= currentLevelInfo.priority then 282 term.setTextColor(logLevel.color) 283 print(string.format("%s %s: %s", Logger.getTimestamp(), level, msg)) 284 logFile.write(string.format("%s: %s\n", Logger.getTimestamp(), level, msg)) 285 if LOKI_URL then 286 addToCache(level, msg) 287 end 288 logFile.flush() 289 term.setTextColor(colors.white) 290 end 291 else 292 error("Invalid log level: " .. level) 293 end 294 end 295 end 296 297 return Logger