mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding ability to record logs for debug purposes
This commit is contained in:
@@ -24,6 +24,7 @@ import userSettingsPlugin from './routes/userSettingsRoute.js';
|
||||
import trackingPlugin from './routes/trackingRoute.js';
|
||||
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
|
||||
import backupPlugin from './routes/backupRouter.js';
|
||||
import debugPlugin, { registerDebugPublicProbe } from './routes/debugRouter.js';
|
||||
import userPlugin from './routes/userRoute.js';
|
||||
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
|
||||
import providerPlugin from './routes/providerRouter.js';
|
||||
@@ -77,6 +78,16 @@ fastify.register(async (app) => {
|
||||
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
|
||||
app.register(trackingPlugin, { prefix: '/api/tracking' });
|
||||
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
|
||||
// The lightweight /api/debug/active probe used by the app-wide red banner. Lives
|
||||
// here (under authHook, NOT adminHook) so non-admin users also see the warning
|
||||
// banner when an admin has enabled the feature, without exposing the rest of the
|
||||
// settings payload.
|
||||
app.register(
|
||||
async (sub) => {
|
||||
registerDebugPublicProbe(sub);
|
||||
},
|
||||
{ prefix: '/api/debug' },
|
||||
);
|
||||
});
|
||||
|
||||
// Admin-only routes
|
||||
@@ -84,6 +95,7 @@ fastify.register(async (app) => {
|
||||
app.addHook('preHandler', authHook);
|
||||
app.addHook('preHandler', adminHook);
|
||||
app.register(backupPlugin, { prefix: '/api/admin/backup' });
|
||||
app.register(debugPlugin, { prefix: '/api/admin/debug' });
|
||||
app.register(userPlugin, { prefix: '/api/admin/users' });
|
||||
});
|
||||
|
||||
|
||||
93
lib/api/routes/debugRouter.js
Normal file
93
lib/api/routes/debugRouter.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import {
|
||||
isEnabled,
|
||||
enableDebugLogging,
|
||||
disableDebugLogging,
|
||||
getCurrentSize,
|
||||
getMaxSize,
|
||||
hasAnyLogs,
|
||||
wasEverEnabled,
|
||||
clearAllDebugLogs,
|
||||
} from '../../services/debug/debugLogStorage.js';
|
||||
import { buildDebugBundleFileName, buildDebugBundleZip } from '../../services/debug/debugBundleService.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
/**
|
||||
* Build the JSON status payload returned by /status and after each enable/disable.
|
||||
* @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>}
|
||||
*/
|
||||
async function buildStatus() {
|
||||
return {
|
||||
enabled: isEnabled(),
|
||||
size: await getCurrentSize(),
|
||||
max: getMaxSize(),
|
||||
hasLogs: hasAnyLogs(),
|
||||
everEnabled: await wasEverEnabled(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the lightweight /active probe used by the app-wide red banner. Exposed
|
||||
* to every authenticated user (not just admins) so non-admin users see the warning
|
||||
* banner too. Returns only a single boolean so it cannot be repurposed to leak any
|
||||
* other state.
|
||||
*
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
*/
|
||||
export async function registerDebugPublicProbe(fastify) {
|
||||
fastify.get('/active', async () => ({ enabled: isEnabled() }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only debug logging endpoints.
|
||||
*
|
||||
* Routes (all relative to the registered prefix /api/admin/debug):
|
||||
* GET /status → current feature status (used by the UI polling).
|
||||
* POST /enable → turn debug logging on. Body: { clearPrevious?:boolean }.
|
||||
* POST /disable → turn debug logging off (existing logs are kept on disk).
|
||||
* GET /download → ZIP with logs.txt + sys.txt. 409 when the feature has
|
||||
* never been enabled OR there are no logs to export.
|
||||
* DELETE /logs → drop every stored debug log row (does NOT change the
|
||||
* enabled flag — useful to free space while keeping
|
||||
* recording on).
|
||||
*
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
*/
|
||||
export default async function debugPlugin(fastify) {
|
||||
fastify.get('/status', async () => buildStatus());
|
||||
|
||||
fastify.post('/enable', async (request) => {
|
||||
const clearPrevious = request.body?.clearPrevious === true;
|
||||
await enableDebugLogging({ clearPrevious });
|
||||
return buildStatus();
|
||||
});
|
||||
|
||||
fastify.post('/disable', async () => {
|
||||
await disableDebugLogging();
|
||||
return buildStatus();
|
||||
});
|
||||
|
||||
fastify.delete('/logs', async () => {
|
||||
clearAllDebugLogs();
|
||||
return buildStatus();
|
||||
});
|
||||
|
||||
fastify.get('/download', async (request, reply) => {
|
||||
const ever = await wasEverEnabled();
|
||||
if (!ever || !hasAnyLogs()) {
|
||||
return reply.code(409).send({
|
||||
error: 'Debug logging has never produced any data on this Fredy installation.',
|
||||
});
|
||||
}
|
||||
const settings = await getSettings();
|
||||
const zipBuffer = await buildDebugBundleZip({ settings });
|
||||
const fileName = await buildDebugBundleFileName();
|
||||
reply.header('Content-Type', 'application/zip');
|
||||
reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
return reply.send(zipBuffer);
|
||||
});
|
||||
}
|
||||
263
lib/services/debug/debugBundleService.js
Normal file
263
lib/services/debug/debugBundleService.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { getAllDebugLogs } from './debugLogStorage.js';
|
||||
import { getPackageVersion } from '../../utils.js';
|
||||
|
||||
const LOGS_FILE_NAME = 'logs.txt';
|
||||
const SYSTEM_INFO_FILE_NAME = 'sys.txt';
|
||||
const DEBUG_FILE_PREFIX = 'FredyDebug-';
|
||||
|
||||
/**
|
||||
* Lazily resolve AdmZip via dynamic import so tests can swap it via globalThis.
|
||||
* Mirrors the pattern used by backupRestoreService.js for consistency.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
let _AdmZipSingleton = null;
|
||||
async function getAdmZip() {
|
||||
if (_AdmZipSingleton) return _AdmZipSingleton;
|
||||
if (globalThis && globalThis.__TEST_ADM_ZIP__) {
|
||||
_AdmZipSingleton = globalThis.__TEST_ADM_ZIP__;
|
||||
return _AdmZipSingleton;
|
||||
}
|
||||
const mod = await import('adm-zip');
|
||||
_AdmZipSingleton = (mod && mod.default) || mod;
|
||||
return _AdmZipSingleton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as YYYY-MM-DD using local time. Used for the download filename.
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDateOnly(date) {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the debug bundle filename, e.g. "2026-06-08-FredyDebug-22.5.0.zip".
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function buildDebugBundleFileName() {
|
||||
const version = await getPackageVersion();
|
||||
return `${formatDateOnly(new Date())}-${DEBUG_FILE_PREFIX}${version}.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a stored debug_logs row into a single text line. The format mirrors the
|
||||
* console layout from logger.js so support staff sees familiar output:
|
||||
* [YYYY-MM-DD HH:MM:SS] LEVEL: message
|
||||
*
|
||||
* @param {{ts:number, level:string, message:string}} row
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatLogLine(row) {
|
||||
const d = new Date(row.ts);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
const level = String(row.level || 'info').toUpperCase();
|
||||
return `[${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}] ${level}: ${row.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render every stored debug log row into a single newline-delimited text blob.
|
||||
* Returns an empty string when there are no rows.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderLogsTxt() {
|
||||
const rows = getAllDebugLogs();
|
||||
if (!rows || rows.length === 0) return '';
|
||||
return rows.map(formatLogLine).join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort Docker detection. Used as a context hint in sys.txt so issue triage
|
||||
* knows whether the user runs the official container image.
|
||||
*
|
||||
* @returns {{inDocker:boolean, evidence:string[]}}
|
||||
*/
|
||||
function detectDocker() {
|
||||
const evidence = [];
|
||||
let inDocker = false;
|
||||
|
||||
if (process.env.FREDY_IN_DOCKER === 'true' || process.env.FREDY_IN_DOCKER === '1') {
|
||||
inDocker = true;
|
||||
evidence.push('FREDY_IN_DOCKER env var is set');
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync('/.dockerenv')) {
|
||||
inDocker = true;
|
||||
evidence.push('/.dockerenv exists');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync('/proc/1/cgroup')) {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');
|
||||
if (/docker|containerd|kubepods/i.test(cgroup)) {
|
||||
inDocker = true;
|
||||
evidence.push('/proc/1/cgroup mentions a container runtime');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { inDocker, evidence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip credentials from URL-like strings so they can safely appear in sys.txt.
|
||||
* Returns the input unchanged for non-URL values.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeUrlLike(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) return value;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
if (u.username || u.password) {
|
||||
u.username = '***';
|
||||
u.password = '***';
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return String(bytes);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!Number.isFinite(seconds)) return String(seconds);
|
||||
const s = Math.floor(seconds);
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const secs = s % 60;
|
||||
return `${days}d ${hours}h ${minutes}m ${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plaintext system / runtime report for inclusion in the debug zip. Settings
|
||||
* are sanitized, proxy URL credentials and session secrets are stripped before
|
||||
* serialization.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.settings]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function buildSystemInfo({ settings = null } = {}) {
|
||||
const fredyVersion = await getPackageVersion();
|
||||
const docker = detectDocker();
|
||||
const cpus = os.cpus() || [];
|
||||
const procMem = process.memoryUsage();
|
||||
|
||||
const lines = [];
|
||||
lines.push('# Fredy Debug Report');
|
||||
lines.push(`Generated at: ${new Date().toISOString()}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Application');
|
||||
lines.push(`Fredy version: ${fredyVersion}`);
|
||||
lines.push(`Node.js version: ${process.version}`);
|
||||
lines.push(`Process uptime: ${formatDuration(process.uptime())}`);
|
||||
lines.push(`PID: ${process.pid}`);
|
||||
lines.push(`Env (NODE_ENV): ${process.env.NODE_ENV || 'development'}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Operating System');
|
||||
lines.push(`Platform: ${process.platform}`);
|
||||
lines.push(`Architecture: ${process.arch}`);
|
||||
lines.push(`OS type: ${os.type()}`);
|
||||
lines.push(`OS release: ${os.release()}`);
|
||||
lines.push(`OS version: ${typeof os.version === 'function' ? os.version() : 'n/a'}`);
|
||||
lines.push(`Hostname: ${os.hostname()}`);
|
||||
lines.push(`System uptime: ${formatDuration(os.uptime())}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Container');
|
||||
lines.push(`Running in Docker: ${docker.inDocker ? 'yes' : 'no'}`);
|
||||
if (docker.evidence.length > 0) {
|
||||
lines.push(`Evidence: ${docker.evidence.join('; ')}`);
|
||||
}
|
||||
if (process.env.FREDY_IMAGE_TAG) {
|
||||
lines.push(`Image tag: ${process.env.FREDY_IMAGE_TAG}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Hardware');
|
||||
lines.push(`CPU count: ${cpus.length}`);
|
||||
lines.push(`CPU model: ${cpus[0]?.model || 'unknown'}`);
|
||||
lines.push(`Total memory: ${formatBytes(os.totalmem())}`);
|
||||
lines.push(`Free memory: ${formatBytes(os.freemem())}`);
|
||||
lines.push(`Process RSS: ${formatBytes(procMem.rss)}`);
|
||||
lines.push(`Process heapUsed: ${formatBytes(procMem.heapUsed)}`);
|
||||
lines.push(`Process heapTotal: ${formatBytes(procMem.heapTotal)}`);
|
||||
lines.push('');
|
||||
|
||||
if (settings && typeof settings === 'object') {
|
||||
lines.push('## Settings (sanitized)');
|
||||
const safe = { ...settings };
|
||||
if (safe.proxyUrl) safe.proxyUrl = sanitizeUrlLike(safe.proxyUrl);
|
||||
delete safe.session_secret;
|
||||
delete safe.sessionSecret;
|
||||
for (const [key, value] of Object.entries(safe)) {
|
||||
let printed;
|
||||
if (value == null) {
|
||||
printed = 'null';
|
||||
} else if (typeof value === 'object') {
|
||||
try {
|
||||
printed = JSON.stringify(value);
|
||||
} catch {
|
||||
printed = String(value);
|
||||
}
|
||||
} else {
|
||||
printed = String(value);
|
||||
}
|
||||
lines.push(`${key}: ${printed}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final debug bundle zip buffer (logs.txt + sys.txt). The caller is
|
||||
* responsible for checking wasEverEnabled() before invoking this, we still produce
|
||||
* a valid zip even when there are zero log rows (logs.txt will contain a placeholder)
|
||||
* because the route layer handles the user-friendly 409 case.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.settings] Runtime settings to embed in sys.txt.
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function buildDebugBundleZip({ settings = null } = {}) {
|
||||
const logsContent = renderLogsTxt() || 'No debug log entries are currently stored.\n';
|
||||
const sysContent = await buildSystemInfo({ settings });
|
||||
|
||||
const AdmZip = await getAdmZip();
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(LOGS_FILE_NAME, Buffer.from(logsContent, 'utf-8'));
|
||||
zip.addFile(SYSTEM_INFO_FILE_NAME, Buffer.from(sysContent, 'utf-8'));
|
||||
return zip.toBuffer();
|
||||
}
|
||||
346
lib/services/debug/debugLogStorage.js
Normal file
346
lib/services/debug/debugLogStorage.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
@@ -14,6 +14,20 @@ const COLORS = {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const useColor = process.stdout.isTTY || process.stderr.isTTY;
|
||||
|
||||
/**
|
||||
* Optional sink that forwards formatted log entries to the opt-in "Debug Logging"
|
||||
* DB storage. Wired and unwired by debugLogStorage as the feature is toggled, so
|
||||
* when nobody enabled the feature this stays null and the logger hot path skips
|
||||
* the Date.now + stringifyArgs work entirely.
|
||||
*
|
||||
* We deliberately do NOT import debugLogStorage here, because that would create a
|
||||
* cycle (debugLogStorage → SqliteConnection → utils → logger → debugLogStorage).
|
||||
* Inversion of control via setDebugLogSink() keeps the dependency one-way.
|
||||
*
|
||||
* @type {((entry:{ts:number, level:string, message:string}) => void)|null}
|
||||
*/
|
||||
let debugLogSink = null;
|
||||
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
@@ -31,10 +45,50 @@ function lvl(level) {
|
||||
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a colour-free plain text representation of variadic console args. Errors
|
||||
* are unwrapped to their stack/message, objects are JSON-serialized. Used when
|
||||
* forwarding to the DB sink so the stored text is portable across terminals.
|
||||
*
|
||||
* @param {any[]} args
|
||||
* @returns {string}
|
||||
*/
|
||||
function stringifyArgs(args) {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (a == null) return String(a);
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
if (typeof a === 'object') {
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
}
|
||||
return String(a);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
function log(level, ...args) {
|
||||
// Forward to the DB sink first (regardless of console suppression rules) so the
|
||||
// recorded debug bundle truly contains every level, including debug entries that
|
||||
// would otherwise be silenced in production.
|
||||
if (debugLogSink) {
|
||||
try {
|
||||
debugLogSink({
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message: `${stringifyArgs(args)}`,
|
||||
});
|
||||
} catch {
|
||||
// never break the caller because of logging
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 'debug' && env !== 'development') {
|
||||
return; // Skip debug logs in non-development environments
|
||||
return; // Skip debug logs in non-development environments (console only)
|
||||
}
|
||||
|
||||
const prefix = `[${ts()}] ${lvl(level)}:`;
|
||||
@@ -56,9 +110,28 @@ function log(level, ...args) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a sink function that receives every log entry the logger sees, regardless
|
||||
* of console suppression rules. debugLogStorage attaches its sink only while the
|
||||
* feature is enabled and detaches it on disable, so the logger's hot path can use
|
||||
* the null check as a cheap on/off gate and skip stringification when off.
|
||||
*
|
||||
* Pass null to remove the sink (used both by the storage module on disable and by
|
||||
* tests to reset state between cases).
|
||||
*
|
||||
* @param {((entry:{ts:number, level:string, message:string}) => void)|null} sink
|
||||
* @returns {void}
|
||||
*/
|
||||
function setDebugLogSink(sink) {
|
||||
debugLogSink = typeof sink === 'function' ? sink : null;
|
||||
}
|
||||
|
||||
export { setDebugLogSink };
|
||||
|
||||
export default {
|
||||
debug: (...a) => log('debug', ...a),
|
||||
info: (...a) => log('info', ...a),
|
||||
warn: (...a) => log('warn', ...a),
|
||||
error: (...a) => log('error', ...a),
|
||||
setDebugLogSink,
|
||||
};
|
||||
|
||||
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal file
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration: create the debug_logs table used by the opt-in "Debug Logging" feature.
|
||||
*
|
||||
* Each row is a single log line (timestamp + level + message) captured by the in-app
|
||||
* logger while debug logging is enabled. We store the UTF-8 byte size of the message
|
||||
* alongside the row so the debugLogStorage can maintain a rolling 5 MB cap without
|
||||
* having to run length() / SUM() on every insert.
|
||||
*
|
||||
* The "debug_logging_enabled" and "debug_logging_ever_enabled" flags are persisted in
|
||||
* the existing settings table (no schema change needed there) and are managed by
|
||||
* debugLogStorage.js at runtime.
|
||||
*/
|
||||
export function up(db) {
|
||||
// id is INTEGER PRIMARY KEY AUTOINCREMENT, which is an alias for SQLite's rowid and
|
||||
// is implicitly indexed. No additional index needed; selecting / deleting by id and
|
||||
// ordering by id ASC (rolling buffer) both use the existing rowid index.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS debug_logs
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user