Lumi/src/services/logger.js

323 lines
12 KiB
JavaScript

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
};