323 lines
12 KiB
JavaScript
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
|
|
};
|