const { AsyncLocalStorage } = require("async_hooks"); const util = require("util"); const { db } = require("./db"); const LEVELS = new Set(["debug", "info", "warn", "error"]); const DEFAULT_MAX_AGE_DAYS = 30; const DEFAULT_MAX_ENTRIES = 100000; const MAX_MESSAGE_LENGTH = 1000; const MAX_DETAILS_LENGTH = 64 * 1024; const contextStorage = new AsyncLocalStorage(); let consoleHooked = false; function log(level, ...args) { return writeLog(level, args, contextStorage.getStore() || {}); } function createLogger(source, defaults = {}) { const base = normalizeMetadata({ ...defaults, source }); const emit = (level, message, details, metadata = {}) => { const overrides = compactMetadata(normalizeMetadata(metadata)); return writeLog(level, details === undefined ? [message] : [message, details], { ...base, ...overrides }); }; return Object.freeze({ log: emit, debug: (message, details, metadata) => emit("debug", message, details, metadata), info: (message, details, metadata) => emit("info", message, details, metadata), warn: (message, details, metadata) => emit("warn", message, details, metadata), error: (message, details, metadata) => emit("error", message, details, metadata), child: (childSource, childDefaults = {}) => createLogger( [base.source, normalizeLabel(childSource, "")].filter(Boolean).join(":"), { ...base, ...childDefaults } ), run: (metadata, callback) => withLogContext({ ...base, ...metadata }, callback) }); } function withLogContext(metadata, callback) { const parent = contextStorage.getStore() || {}; return contextStorage.run({ ...parent, ...compactMetadata(normalizeMetadata(metadata)) }, callback); } function writeLog(level, args, metadata = {}) { const safeLevel = LEVELS.has(level) ? level : "info"; const entry = normalizeArgs(args); const normalized = normalizeMetadata(metadata); const createdAt = Date.now(); try { const result = db.prepare( "INSERT INTO logs (level, message, details, source, category, event, request_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ).run( safeLevel, truncate(entry.message, MAX_MESSAGE_LENGTH), truncate(entry.details, MAX_DETAILS_LENGTH), normalized.source || "core", normalized.category || "general", normalized.event || null, normalized.requestId || null, createdAt ); const stored = { id: Number(result.lastInsertRowid), level: safeLevel, message: truncate(entry.message, MAX_MESSAGE_LENGTH), details: truncate(entry.details, MAX_DETAILS_LENGTH), source: normalized.source || "core", category: normalized.category || "general", event: normalized.event || null, request_id: normalized.requestId || null, created_at: createdAt }; publishLogEvent(stored); return stored; } catch { return null; } } function listLogs(options = {}) { const query = buildLogQuery(options); let sql = "SELECT id, level, message, details, source, category, event, request_id, created_at FROM logs"; if (query.clauses.length) sql += ` WHERE ${query.clauses.join(" AND ")}`; sql += " ORDER BY created_at DESC, id DESC"; if (query.limit) { sql += " LIMIT ?"; query.params.push(query.limit); } return db.prepare(sql).all(...query.params); } function summarizeLogs(options = {}) { const query = buildLogQuery({ ...options, limit: null }); let sql = "SELECT level, COUNT(*) AS count FROM logs"; if (query.clauses.length) sql += ` WHERE ${query.clauses.join(" AND ")}`; sql += " GROUP BY level"; const levels = { error: 0, warn: 0, info: 0, debug: 0 }; let total = 0; for (const row of db.prepare(sql).all(...query.params)) { if (levels[row.level] !== undefined) levels[row.level] = Number(row.count) || 0; total += Number(row.count) || 0; } return { total, levels }; } function listLogFacets(options = {}) { const sinceMs = Number.isFinite(options.sinceMs) && options.sinceMs > 0 ? options.sinceMs : null; const where = sinceMs ? " WHERE created_at >= ?" : ""; const params = sinceMs ? [sinceMs] : []; const facetRows = (column) => { const connector = sinceMs ? " AND" : " WHERE"; return db.prepare( `SELECT ${column} AS value, COUNT(*) AS count FROM logs${where}${connector} ${column} IS NOT NULL AND ${column} != '' ` + `GROUP BY ${column} ORDER BY count DESC, ${column} ASC` ).all(...params).map((row) => ({ value: row.value, count: Number(row.count) || 0 })); }; return { sources: facetRows("source"), categories: facetRows("category") }; } function cleanupLogs(options = {}) { const maxAgeDays = clampNumber(options.maxAgeDays, 1, 3650, DEFAULT_MAX_AGE_DAYS); const maxEntries = clampNumber(options.maxEntries, 1000, 1000000, DEFAULT_MAX_ENTRIES); const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; const expired = db.prepare("DELETE FROM logs WHERE created_at < ?").run(cutoff).changes; const overflow = db.prepare( "DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY created_at DESC, id DESC LIMIT ?)" ).run(maxEntries).changes; return { removed: expired + overflow, expired, overflow, maxAgeDays, maxEntries }; } function hookConsole() { if (consoleHooked) return; consoleHooked = true; const original = { debug: console.debug || console.log, log: console.log, info: console.info || console.log, warn: console.warn || console.log, error: console.error || console.log }; const capture = (level, method, args) => { const context = contextStorage.getStore() || {}; writeLog(level, args, { source: context.source || "console", category: context.category || "runtime", event: context.event, requestId: context.requestId }); original[method].apply(console, args); }; console.debug = (...args) => capture("debug", "debug", args); console.log = (...args) => capture("info", "log", args); console.info = (...args) => capture("info", "info", args); console.warn = (...args) => capture("warn", "warn", args); console.error = (...args) => capture("error", "error", args); } function buildLogQuery(options = {}) { const limit = Number.isFinite(options.limit) && options.limit !== null ? Math.max(1, Math.floor(options.limit)) : null; const sinceMs = Number.isFinite(options.sinceMs) && options.sinceMs > 0 ? options.sinceMs : null; const levels = normalizeList(options.levels, LEVELS); const sources = normalizeList(options.sources); const categories = normalizeList(options.categories); const search = String(options.search || "").trim().slice(0, 200); const clauses = []; const params = []; if (sinceMs) { clauses.push("created_at >= ?"); params.push(sinceMs); } appendInFilter(clauses, params, "level", levels); appendInFilter(clauses, params, "source", sources); appendInFilter(clauses, params, "category", categories); if (search) { const needle = `%${escapeLike(search)}%`; clauses.push( "(message LIKE ? ESCAPE '\\' OR details LIKE ? ESCAPE '\\' OR source LIKE ? ESCAPE '\\' " + "OR category LIKE ? ESCAPE '\\' OR event LIKE ? ESCAPE '\\' OR request_id LIKE ? ESCAPE '\\')" ); params.push(needle, needle, needle, needle, needle, needle); } return { clauses, params, limit }; } function appendInFilter(clauses, params, column, values) { if (!values.length) return; clauses.push(`${column} IN (${values.map(() => "?").join(",")})`); params.push(...values); } function normalizeList(values, allowed = null) { const list = Array.isArray(values) ? values : values ? [values] : []; return [...new Set(list.map((value) => normalizeLabel(value, "")).filter((value) => value && (!allowed || allowed.has(value)) ))]; } function normalizeMetadata(metadata = {}) { return { source: normalizeLabel(metadata.source, ""), category: normalizeLabel(metadata.category, ""), event: normalizeLabel(metadata.event, ""), requestId: normalizeLabel(metadata.requestId || metadata.request_id, "") }; } function compactMetadata(metadata) { return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value)); } function normalizeLabel(value, fallback) { const normalized = String(value || "").trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, "-").replace(/^-+|-+$/g, ""); return normalized.slice(0, 80) || fallback; } function normalizeArgs(args) { if (!args || args.length === 0) return { message: "Log entry", details: "" }; let message = ""; const detailParts = []; const first = args[0]; if (first instanceof Error) { message = redactText(first.message || "Error"); detailParts.push(redactText(first.stack || String(first))); } else { message = formatArg(first); } for (const arg of args.slice(1)) { if (arg instanceof Error) { detailParts.push(redactText(arg.stack || arg.message || String(arg))); if (!message) message = redactText(arg.message || "Error"); } else { detailParts.push(formatArg(arg)); } } return { message: message || "Log entry", details: detailParts.filter(Boolean).join("\n") }; } function formatArg(value) { if (typeof value === "string") return redactText(value); if (value instanceof Error) return redactText(value.stack || value.message || String(value)); return redactText(util.inspect(redactValue(value), { depth: 6, maxArrayLength: 100, maxStringLength: 4000, breakLength: 120 })); } function redactValue(value, seen = new WeakSet()) { if (value === null || value === undefined) return value; if (typeof value === "string") return redactText(value); if (typeof value !== "object") return value; if (value instanceof Error) return redactText(value.stack || value.message || String(value)); if (seen.has(value)) return "[Circular]"; seen.add(value); if (Array.isArray(value)) return value.map((item) => redactValue(item, seen)); const output = {}; for (const [key, item] of Object.entries(value)) { output[key] = isSensitiveKey(key) ? "[REDACTED]" : redactValue(item, seen); } return output; } function isSensitiveKey(key) { return /(?:^|[_-])(authorization|cookie|password|passwd|secret|signature|token|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key)(?:$|[_-])/i.test(String(key)); } function redactText(value) { return String(value || "") .replace(/\b(Bearer|Basic|LumiDevice)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") .replace(/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, "$1[REDACTED]") .replace(/([?&](?:token|key|secret|password|authorization)=)[^&#\s]+/gi, "$1[REDACTED]") .replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|signature|authorization)\s*[:=]\s*)[^\s,;}]+/gi, "$1[REDACTED]"); } function truncate(value, maxLength) { const text = String(value || ""); if (text.length <= maxLength) return text; return `${text.slice(0, maxLength)}\n[truncated]`; } function escapeLike(value) { return String(value).replace(/[\\%_]/g, (character) => `\\${character}`); } function clampNumber(value, minimum, maximum, fallback) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(maximum, Math.max(minimum, Math.floor(parsed))); } function publishLogEvent(entry) { try { const { publishWebEvent } = require("./web-events"); publishWebEvent("log:created", { ...entry, details: truncate(entry.details, 4000) }, { role: "admin" }); } catch { // Logging must never fail because live delivery is unavailable. } } module.exports = { DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_ENTRIES, cleanupLogs, createLogger, hookConsole, listLogFacets, listLogs, log, redactValue, summarizeLogs, withLogContext };