@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
315 lines (270 loc) • 10.3 kB
JavaScript
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;