mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
/*
|
|
* Copyright (c) 2026 by Christian Kellner.
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
*/
|
|
|
|
import SqliteConnection from '../storage/SqliteConnection.js';
|
|
import { upsertSettings, getSettings } from '../storage/settingsStorage.js';
|
|
import logger from '../logger.js';
|
|
|
|
/**
|
|
* Hard cap on the total UTF-8 byte length of stored log MESSAGES (5 MiB).
|
|
*
|
|
* Note: this measures the payload bytes (message strings only); SQLite per-row
|
|
* overhead (id, ts, level, byte_size columns + page housekeeping) means the actual
|
|
* sqlite_master page count for debug_logs can be larger than this cap by a constant
|
|
* factor. The cap is intentionally about user-visible payload to keep the math
|
|
* predictable and to align with what ends up in logs.txt.
|
|
*
|
|
* The cap is enforced via a rolling buffer: when the live size exceeds it, the
|
|
* oldest rows are removed until the size falls below the limit again.
|
|
* @type {number}
|
|
*/
|
|
export const MAX_DEBUG_LOG_BYTES = 5 * 1024 * 1024;
|
|
|
|
/** Settings key persisting the active on/off flag. */
|
|
const SETTING_ENABLED = 'debug_logging_enabled';
|
|
|
|
/**
|
|
* Settings key persisting "this feature has been turned on at least once". Used to
|
|
* decide whether the download endpoint returns 409 (never enabled) or whether the
|
|
* "delete previous logs?" confirm dialog should be shown on (re)enable.
|
|
*/
|
|
const SETTING_EVER_ENABLED = 'debug_logging_ever_enabled';
|
|
|
|
/**
|
|
* Cached live byte size of all rows in debug_logs. Initialized lazily from the DB on
|
|
* the first call and kept in sync by append / clear / trim. Storing this in-memory
|
|
* avoids running SUM() on every single insert (logger writes can be very frequent).
|
|
* @type {number|null}
|
|
*/
|
|
let cachedSize = null;
|
|
|
|
/**
|
|
* Cached value of debug_logging_enabled. Reflects DB state; flipped by enable() /
|
|
* disable() so the logger hot-path does not have to hit the settings cache for every
|
|
* log line.
|
|
* @type {boolean|null}
|
|
*/
|
|
let cachedEnabled = null;
|
|
|
|
/**
|
|
* Compute the UTF-8 byte length of a string. Falls back to character count for
|
|
* environments where Buffer is not available (vitest covers Node, so it always is).
|
|
* @param {string} str
|
|
* @returns {number}
|
|
*/
|
|
function byteLengthOf(str) {
|
|
if (typeof str !== 'string') return 0;
|
|
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
|
|
return Buffer.byteLength(str, 'utf-8');
|
|
}
|
|
return str.length;
|
|
}
|
|
|
|
/**
|
|
* Read the current total byte size from the DB and update the local cache.
|
|
* @returns {number}
|
|
*/
|
|
function refreshSizeFromDb() {
|
|
const rows = SqliteConnection.query('SELECT COALESCE(SUM(byte_size), 0) AS total FROM debug_logs');
|
|
cachedSize = Number(rows?.[0]?.total ?? 0);
|
|
return cachedSize;
|
|
}
|
|
|
|
/**
|
|
* Lazily ensure the cached enabled/size values are up to date. Called by every public
|
|
* method that needs to know either value, so external init is not required.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function ensureCachesInitialized() {
|
|
if (cachedEnabled == null) {
|
|
const settings = await getSettings();
|
|
cachedEnabled = settings[SETTING_ENABLED] === true;
|
|
}
|
|
if (cachedSize == null) {
|
|
refreshSizeFromDb();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cached prepared statements used by trimToFit(). Initialized on first use so we do
|
|
* not pay the prepare cost on every overflow event, and skipped entirely when the
|
|
* feature is never activated.
|
|
* @type {{select:any, del:any}|null}
|
|
*/
|
|
let trimStatements = null;
|
|
|
|
/**
|
|
* Drop the oldest rows from debug_logs until the cached size falls below
|
|
* MAX_DEBUG_LOG_BYTES. Implements the rolling buffer behavior chosen for the feature.
|
|
*
|
|
* The deletion is performed in batches of up to 100 oldest rows wrapped in a single
|
|
* transaction. The size cache is updated only after the transaction commits, so a
|
|
* mid-batch failure (rolled back by SQLite) cannot leave cachedSize out of sync with
|
|
* the on-disk reality. A defensive resync via SUM() is performed on transaction
|
|
* failure to recover from any unexpected drift.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
function trimToFit() {
|
|
if (cachedSize == null || cachedSize <= MAX_DEBUG_LOG_BYTES) return;
|
|
|
|
const db = SqliteConnection.getConnection();
|
|
if (trimStatements == null) {
|
|
trimStatements = {
|
|
select: db.prepare('SELECT id, byte_size FROM debug_logs ORDER BY id ASC LIMIT 100'),
|
|
del: db.prepare('DELETE FROM debug_logs WHERE id = @id'),
|
|
};
|
|
}
|
|
|
|
while (cachedSize > MAX_DEBUG_LOG_BYTES) {
|
|
const oldest = trimStatements.select.all();
|
|
if (oldest.length === 0) {
|
|
// Table is empty but the cache still claims we are over the cap. That can only
|
|
// happen if cachedSize drifted (e.g. external DB modification, zero-byte
|
|
// messages that never contributed to SUM(byte_size), or a previous trim that
|
|
// partially succeeded). Resync from the source of truth and bail out.
|
|
refreshSizeFromDb();
|
|
break;
|
|
}
|
|
|
|
// Pick exactly enough oldest rows to bring the cache back under the cap. We do
|
|
// NOT delete the entire 100-row batch unconditionally, that would over-trim in
|
|
// edge cases where just one or two rows are enough.
|
|
const needToFree = cachedSize - MAX_DEBUG_LOG_BYTES;
|
|
let freed = 0;
|
|
const idsToDelete = [];
|
|
for (const row of oldest) {
|
|
idsToDelete.push(row.id);
|
|
freed += Number(row.byte_size) || 0;
|
|
if (freed >= needToFree) break;
|
|
}
|
|
|
|
try {
|
|
const tx = db.transaction((ids) => {
|
|
for (const id of ids) {
|
|
trimStatements.del.run({ id });
|
|
}
|
|
});
|
|
tx(idsToDelete);
|
|
// Only decrement after the transaction has committed; a mid-batch failure
|
|
// would roll the DELETEs back and leave cachedSize untouched.
|
|
cachedSize -= freed;
|
|
if (freed === 0) {
|
|
// We deleted rows but they all had byte_size <= 0, so cachedSize did not
|
|
// move. Without intervention the outer loop would spin again with the same
|
|
// condition. Resync from the DB and bail to prevent that.
|
|
refreshSizeFromDb();
|
|
break;
|
|
}
|
|
} catch {
|
|
// SQLite rolled the batch back; resync cachedSize from the DB to recover from
|
|
// any unexpected drift, then bail out so we do not spin forever on a persistent
|
|
// failure (e.g. database is locked or read-only).
|
|
refreshSizeFromDb();
|
|
break;
|
|
}
|
|
}
|
|
if (cachedSize < 0) cachedSize = 0;
|
|
}
|
|
|
|
/**
|
|
* Whether debug logging is currently enabled. Synchronous and cheap so the logger
|
|
* hot-path can call it on every log line.
|
|
*
|
|
* @returns {boolean} True if logs should be persisted to the debug_logs table.
|
|
*/
|
|
export function isEnabled() {
|
|
return cachedEnabled === true;
|
|
}
|
|
|
|
/**
|
|
* Append a single log entry to debug_logs (if enabled) and trim the rolling buffer if
|
|
* the new row pushes the live size above the cap.
|
|
*
|
|
* Safe to call even when logging is disabled, it becomes a no-op. Any storage error
|
|
* is swallowed so the logger never breaks the calling code; bookkeeping for cachedSize
|
|
* stays consistent because we update it only after a successful insert.
|
|
*
|
|
* @param {{ts:number, level:string, message:string}} entry
|
|
* @returns {void}
|
|
*/
|
|
export function appendLogEntry(entry) {
|
|
if (!isEnabled()) return;
|
|
if (!entry || typeof entry.message !== 'string') return;
|
|
|
|
try {
|
|
const ts = Number.isFinite(entry.ts) ? entry.ts : Date.now();
|
|
const level = String(entry.level || 'info');
|
|
const message = entry.message;
|
|
const byte_size = byteLengthOf(message);
|
|
|
|
SqliteConnection.execute(
|
|
'INSERT INTO debug_logs (ts, level, message, byte_size) VALUES (@ts, @level, @message, @byte_size)',
|
|
{ ts, level, message, byte_size },
|
|
);
|
|
|
|
if (cachedSize == null) {
|
|
refreshSizeFromDb();
|
|
} else {
|
|
cachedSize += byte_size;
|
|
}
|
|
trimToFit();
|
|
} catch {
|
|
// Logging must never break the application. Swallow storage errors silently.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove every row from debug_logs and reset the cached size to zero. Used by both
|
|
* the "clear previous logs" path on (re)enable and by explicit clear actions.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
export function clearAllDebugLogs() {
|
|
SqliteConnection.execute('DELETE FROM debug_logs');
|
|
cachedSize = 0;
|
|
}
|
|
|
|
/**
|
|
* Return the cached live byte size of the debug_logs table contents.
|
|
* @returns {Promise<number>}
|
|
*/
|
|
export async function getCurrentSize() {
|
|
await ensureCachesInitialized();
|
|
return cachedSize ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Return the configured maximum size for the debug_logs table.
|
|
* @returns {number}
|
|
*/
|
|
export function getMaxSize() {
|
|
return MAX_DEBUG_LOG_BYTES;
|
|
}
|
|
|
|
/**
|
|
* Check whether the debug_logs table contains at least one row.
|
|
* @returns {boolean}
|
|
*/
|
|
export function hasAnyLogs() {
|
|
const row = SqliteConnection.query('SELECT 1 AS one FROM debug_logs LIMIT 1');
|
|
return Array.isArray(row) && row.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Has debug logging ever been enabled in this installation? Used by the download
|
|
* endpoint to distinguish "no logs yet" (empty table) from "feature never used"
|
|
* (which returns 409 to surface a friendlier UI error).
|
|
*
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function wasEverEnabled() {
|
|
const settings = await getSettings();
|
|
return settings[SETTING_EVER_ENABLED] === true;
|
|
}
|
|
|
|
/**
|
|
* Turn debug logging on. Persists both the active flag and the "ever enabled" flag,
|
|
* optionally clearing previous logs when the caller passes clearPrevious=true (this
|
|
* is the path taken when the UI confirm dialog "Delete previous logs?" is accepted).
|
|
*
|
|
* @param {object} [options]
|
|
* @param {boolean} [options.clearPrevious=false]
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function enableDebugLogging({ clearPrevious = false } = {}) {
|
|
if (clearPrevious) {
|
|
clearAllDebugLogs();
|
|
}
|
|
upsertSettings({ [SETTING_ENABLED]: true, [SETTING_EVER_ENABLED]: true });
|
|
cachedEnabled = true;
|
|
if (cachedSize == null) {
|
|
refreshSizeFromDb();
|
|
}
|
|
// Attach the logger sink only while recording is on so the logger hot path pays
|
|
// no per-call cost (Date.now + stringifyArgs) when nobody enabled the feature.
|
|
logger.setDebugLogSink(appendLogEntry);
|
|
}
|
|
|
|
/**
|
|
* Turn debug logging off. Previous logs are kept on disk so the user can still
|
|
* download them; they are only cleared when the user re-enables and chooses "delete
|
|
* previous logs".
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function disableDebugLogging() {
|
|
upsertSettings({ [SETTING_ENABLED]: false });
|
|
cachedEnabled = false;
|
|
// Detach the sink so the logger hot path returns immediately on its `if (sink)`
|
|
// check instead of paying the no-op cost on every log line.
|
|
logger.setDebugLogSink(null);
|
|
}
|
|
|
|
/**
|
|
* Return all stored log entries ordered chronologically. Used by the bundle builder
|
|
* when assembling logs.txt.
|
|
*
|
|
* @returns {{id:number, ts:number, level:string, message:string}[]}
|
|
*/
|
|
export function getAllDebugLogs() {
|
|
return SqliteConnection.query('SELECT id, ts, level, message FROM debug_logs ORDER BY id ASC');
|
|
}
|
|
|
|
/**
|
|
* Reload the cached enabled flag from settings storage. Called from the logger at
|
|
* startup so the cache reflects the persisted state after a Fredy restart.
|
|
*
|
|
* @returns {Promise<boolean>} The active enabled flag.
|
|
*/
|
|
export async function reloadEnabledFromSettings() {
|
|
const settings = await getSettings();
|
|
cachedEnabled = settings[SETTING_ENABLED] === true;
|
|
// (Un)wire the sink to match the persisted state. Note: startup work that runs
|
|
// before index.js calls this (CloakBrowser binary check, runMigrations) still
|
|
// logs to stdout only, since the sink is not attached yet at that point.
|
|
if (cachedEnabled) {
|
|
logger.setDebugLogSink(appendLogEntry);
|
|
} else {
|
|
logger.setDebugLogSink(null);
|
|
}
|
|
return cachedEnabled;
|
|
}
|
|
|
|
/**
|
|
* Test-only helper to drop in-memory caches between unit tests. Resets every piece
|
|
* of module-scoped mutable state so a test that swaps the underlying DB does not
|
|
* inherit stale prepared statements from a previous run.
|
|
* @returns {void}
|
|
*/
|
|
export function _resetForTests() {
|
|
cachedSize = null;
|
|
cachedEnabled = null;
|
|
trimStatements = null;
|
|
}
|