UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

315 lines (270 loc) 10.3 kB
import { getDB, getUserProfile } from './config.js'; import { v4 as uuidv4 } from 'uuid'; /** * Self-pruning error logger for hippo-lib operations. * Stores error documents inside each book's CouchDB database. * Automatically captures DB errors via driver interceptor + manual API for business logic errors. */ class ErrorLog { static _enabled = true; static _isLogging = false; static _config = { maxEntries: 500, maxAgeDays: 30, pruneChance: 0.1, captureStack: true, maxStackLength: 500 }; /** * Configure error logging behavior * @param {Object} options * @param {number} [options.maxEntries] - Max error docs before pruning (default: 500) * @param {number} [options.maxAgeDays] - Delete errors older than this (default: 30) * @param {number} [options.pruneChance] - Probability of pruning per write, 0-1 (default: 0.1) * @param {boolean} [options.enabled] - Enable/disable error logging (default: true) * @param {boolean} [options.captureStack] - Include stack traces (default: true) * @param {number} [options.maxStackLength] - Truncate stacks to this length (default: 500) */ static configure(options = {}) { Object.assign(this._config, options); if ('enabled' in options) { this._enabled = options.enabled; } } /** * Log an error to the book's database * @param {'error'|'warn'|'fatal'} severity - Error severity level * @param {string} operation - Operation that failed (e.g. 'JournalEntry.post') * @param {Error|string} error - The error object or message * @param {Object} [context] - Additional context * @param {string} [context.entityType] - Entity type (e.g. 'JournalEntry') * @param {string} [context.entityId] - Entity document ID */ static async log(severity, operation, error, context = {}) { if (!this._enabled || this._isLogging) return; let db; try { db = getDB(); } catch { return; // No book selected — silently skip } this._isLogging = true; try { const now = new Date().toISOString(); const doc = { _id: `elog:${now}:${uuidv4().slice(0, 4)}`, docType: 'error_log', timestamp: now, severity, operation, entityType: context.entityType || null, entityId: context.entityId || null, error: { message: error?.message || String(error), code: error?.code || null, status: error?.status || null, stack: this._config.captureStack ? (error?.stack || '').slice(0, this._config.maxStackLength) : null }, context: { userId: getUserProfile()?.userId || null, bookId: db.database || null, ...context } }; await db.insert(doc); this._maybePrune(); } catch (logError) { console.error('ErrorLog: Failed to write error:', logError.message); } finally { this._isLogging = false; } } /** * List error logs with optional filters * @param {Object} [options] * @param {number} [options.limit] - Max results (default: 50) * @param {string} [options.since] - ISO date — only errors after this * @param {string} [options.until] - ISO date — only errors before this * @param {string} [options.severity] - Filter by severity * @returns {Promise<Array>} Error log documents */ static async list(options = {}) { const db = getDB(); const { limit = 50, since, until, severity } = options; const selector = { docType: 'error_log' }; if (since || until) { selector.timestamp = {}; if (since) selector.timestamp.$gte = since; if (until) selector.timestamp.$lte = until; } if (severity) { selector.severity = severity; } const result = await db.find({ selector, sort: [{ timestamp: 'desc' }], limit }); return result.docs; } /** * List error logs filtered by operation * @param {string} operation - Operation name (e.g. 'JournalEntry.post') * @param {Object} [options] * @param {number} [options.limit] - Max results (default: 50) * @returns {Promise<Array>} Error log documents */ static async listByOperation(operation, options = {}) { const db = getDB(); const { limit = 50 } = options; const result = await db.find({ selector: { docType: 'error_log', operation }, sort: [{ timestamp: 'desc' }], limit }); return result.docs; } /** * List error logs filtered by entity ID * @param {string} entityId - Entity document ID * @param {Object} [options] * @param {number} [options.limit] - Max results (default: 50) * @returns {Promise<Array>} Error log documents */ static async listByEntity(entityId, options = {}) { const db = getDB(); const { limit = 50 } = options; const result = await db.find({ selector: { docType: 'error_log', entityId }, sort: [{ timestamp: 'desc' }], limit }); return result.docs; } /** * Get error log statistics using design doc views * @returns {Promise<Object>} { total, bySeverity, byOperation, oldest, newest } */ static async stats() { const db = getDB(); const [countResult, operationResult, severityResult] = await Promise.all([ db.view('error_log', 'count', { reduce: true }), db.view('error_log', 'by-operation', { group: true }), db.view('error_log', 'by-severity', { group: true }) ]); const total = countResult.rows?.[0]?.value || 0; const byOperation = {}; for (const row of (operationResult.rows || [])) { byOperation[row.key] = row.value; } const bySeverity = {}; for (const row of (severityResult.rows || [])) { bySeverity[row.key] = row.value; } // Get oldest and newest via timestamp view let oldest = null; let newest = null; if (total > 0) { const [oldestResult, newestResult] = await Promise.all([ db.view('error_log', 'by-timestamp', { limit: 1 }), db.view('error_log', 'by-timestamp', { limit: 1, descending: true }) ]); oldest = oldestResult.rows?.[0]?.key || null; newest = newestResult.rows?.[0]?.key || null; } return { total, bySeverity, byOperation, oldest, newest }; } /** * Prune error logs beyond the cap or older than maxAgeDays * @returns {Promise<number>} Number of documents deleted */ static async prune() { let db; try { db = getDB(); } catch { return 0; } try { // Get all error log docs using ID prefix range const result = await db.list({ startkey: 'elog:', endkey: 'elog:\ufff0', include_docs: true }); const docs = (result.rows || []).map(row => row.doc); if (docs.length === 0) return 0; const toDelete = []; // Age-based pruning const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - this._config.maxAgeDays); const cutoffISO = cutoffDate.toISOString(); for (const doc of docs) { if (doc.timestamp < cutoffISO) { toDelete.push({ _id: doc._id, _rev: doc._rev, _deleted: true }); } } // Count-based pruning (keep only maxEntries, delete oldest) const remaining = docs.filter(d => !toDelete.find(td => td._id === d._id)); if (remaining.length > this._config.maxEntries) { // docs are sorted by _id (elog:{timestamp}), so oldest come first const excess = remaining.slice(0, remaining.length - this._config.maxEntries); for (const doc of excess) { toDelete.push({ _id: doc._id, _rev: doc._rev, _deleted: true }); } } if (toDelete.length > 0) { await db.bulk({ docs: toDelete }); } return toDelete.length; } catch (pruneError) { console.error('ErrorLog: Prune failed:', pruneError.message); return 0; } } /** * Delete all error log documents * @returns {Promise<number>} Number of documents deleted */ static async clear() { const db = getDB(); const result = await db.list({ startkey: 'elog:', endkey: 'elog:\ufff0', include_docs: true }); const docs = (result.rows || []).map(row => ({ _id: row.doc._id, _rev: row.doc._rev, _deleted: true })); if (docs.length > 0) { await db.bulk({ docs }); } return docs.length; } /** * Probabilistically trigger pruning (fire-and-forget) * @private */ static _maybePrune() { if (Math.random() < this._config.pruneChance) { this.prune().catch(() => {}); } } /** * Attach error logging callback to a CouchDB driver instance * @param {CouchDBDriver} driver - Driver instance to monitor */ static _attachToDriver(driver) { driver.onError = (error, requestContext) => { this.log('error', `CouchDB.${requestContext.method || 'unknown'}`, error, { entityType: 'CouchDB', url: requestContext.url || null }).catch(() => {}); }; } } export default ErrorLog;