/ src / log.lua
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