consoleiq
Version:
Enhanced console logging with remote capabilities
761 lines (681 loc) • 23.7 kB
JavaScript
/**
* ConsoleIQ - Enhanced console logging with remote capabilities
* @module ConsoleIQ
*/
const axios = require('axios');
const mask = require('mask-json');
const { applyColor } = require('./utils/colorizer');
const storage = require('./utils/storage');
/**
* ConsoleIQ class for enhancing and extending console functionality
*/
class ConsoleIQ {
/**
* Create a new ConsoleIQ instance
* @param {Object} config - Configuration options
* @param {string} [config.endpoint] - URL endpoint for remote logging
* @param {string} [config.apiKey] - API key for authentication with remote endpoint
* @param {boolean} [config.colorize=true] - Whether to colorize console output
* @param {boolean} [config.silent=false] - Whether to suppress console output
* @param {string} [config.name] - Name for logger instance
* @param {Array} [config.allowedLevels] - Array of allowed levels for remote logging
* @param {boolean} [config.captureGlobalErrors=true] - Whether to capture global errors
* @param {boolean} [config.captureUnhandledRejections=true] - Whether to capture unhandled promise rejections
* @param {boolean} [config.captureConsoleErrors=true] - Whether to capture console errors
* @param {boolean} [config.autoTraceErrors=true] - Whether to automatically add stack traces to errors
* @param {boolean} [config.enhanceErrors=true] - Whether to enhance error objects with additional context
* @param {number} [config.batchSize=10] - The number of logs to batch before sending.
* @param {number} [config.batchInterval=5000] - The maximum time in milliseconds to wait before sending a batch.
* @param {boolean} [config.offlineCaching=true] - Whether to cache logs when offline.
* @param {Array<string>} [config.sensitiveKeys=[]] - Keys to mask in logs.
*/
constructor(config = {}) {
this.config = {
endpoint: config.endpoint || "https://api.consoleiq.io/logs",
apiKey: config.apiKey || null,
colorize: config.colorize !== false,
silent: config.silent || false,
name: config.name || 'ConsoleIQ',
allowedLevels: config.allowedLevels || ['error', 'text'],
captureGlobalErrors: config.captureGlobalErrors !== false,
captureUnhandledRejections: config.captureUnhandledRejections !== false,
captureConsoleErrors: config.captureConsoleErrors !== false,
autoTraceErrors: config.autoTraceErrors !== false,
enhanceErrors: config.enhanceErrors !== false,
maxErrorDepth: config.maxErrorDepth || 5,
environment: typeof window !== 'undefined' ? 'browser' : 'node',
batchSize: config.batchSize || 10,
batchInterval: config.batchInterval || 5000,
offlineCaching: config.offlineCaching !== false,
sensitiveKeys: config.sensitiveKeys || [],
};
// Store original console methods
this.originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
dir: console.dir,
table: console.table,
time: console.time,
timeEnd: console.timeEnd,
trace: console.trace,
assert: console.assert,
text: console.log
};
// Store original error handlers
this.originalErrorHandlers = {
onerror: null,
onunhandledrejection: null
};
// Track if we've initialized
this._initialized = false;
// For batching remote logs
this._logQueue = [];
this._batchTimer = null;
this._retryTimer = null;
}
/**
* Initialize by overriding console methods and setting up error handlers
* @returns {ConsoleIQ} - The current instance for chaining
*/
init() {
this._consoleWrappers = {};
const standardMethods = ['log', 'info', 'warn', 'error', 'debug'];
standardMethods.forEach(method => {
this._consoleWrappers[method] = (...args) => this._handleLog(method, args);
console[method] = this._consoleWrappers[method];
});
// Add support for other console methods without server logging
const otherMethods = ['dir', 'table', 'time', 'timeEnd', 'trace', 'assert'];
otherMethods.forEach(method => {
console[method] = (...args) => {
if (!this.config.silent) {
this.originalConsole[method](...args);
}
};
});
// Add custom text method (send to server if configured)
console.text = (...args) => {
if (this.config.endpoint && this.config.allowedLevels.includes('text')) {
this._sendToServer('text', args);
}
if (!this.config.silent) {
this._applyColorAndLog('text', ...args);
}
};
// Initialize error handlers
this._setupErrorHandlers();
this._initialized = true;
// Load and send cached logs
if (this.config.offlineCaching) {
this._sendCachedLogs();
}
return this;
}
/**
* Set up global error handlers
* @private
*/
_setupErrorHandlers() {
// Browser and Node.js error handling
if (this.config.captureGlobalErrors) {
if (typeof window !== 'undefined' && window.addEventListener) {
// Browser error handling
this.originalErrorHandlers.onerror = window.onerror;
window.onerror = (message, source, lineno, colno, error) => {
const enhancedError = this._enhanceError({
message,
source,
lineno,
colno,
stack: error?.stack || null,
name: error?.name || 'Error'
}, 'window.onerror');
this._handleGlobalError('window.onerror', enhancedError);
if (typeof this.originalErrorHandlers.onerror === 'function') {
return this.originalErrorHandlers.onerror(message, source, lineno, colno, error);
}
return false;
};
} else if (typeof process !== 'undefined') {
// Node.js error handling
this.originalErrorHandlers.onerror = process.listeners('uncaughtException').pop() || null;
process.removeAllListeners('uncaughtException');
process.on('uncaughtException', (error) => {
const enhancedError = this._enhanceError(error, 'uncaughtException');
this._handleGlobalError('uncaughtException', enhancedError);
if (typeof this.originalErrorHandlers.onerror === 'function') {
this.originalErrorHandlers.onerror(error);
}
});
}
}
// Unhandled promise rejections
if (this.config.captureUnhandledRejections) {
if (typeof window !== 'undefined' && window.addEventListener) {
// Browser
this.originalErrorHandlers.onunhandledrejection = window.onunhandledrejection;
window.addEventListener('unhandledrejection', (event) => {
const enhancedError = this._enhanceRejection(event.reason);
this._handleRejection(enhancedError);
if (typeof this.originalErrorHandlers.onunhandledrejection === 'function') {
this.originalErrorHandlers.onunhandledrejection(event);
}
});
} else if (typeof process !== 'undefined') {
// Node.js
this.originalErrorHandlers.onunhandledrejection = process.listeners('unhandledRejection').pop() || null;
process.removeAllListeners('unhandledRejection');
process.on('unhandledRejection', (reason, promise) => {
const enhancedError = this._enhanceRejection(reason);
this._handleRejection(enhancedError);
if (typeof this.originalErrorHandlers.onunhandledrejection === 'function') {
this.originalErrorHandlers.onunhandledrejection(reason, promise);
}
});
}
}
// Console errors (if not already captured via console.error override)
if (this.config.captureConsoleErrors) {
this.originalConsoleError = console.error;
console.error = (...args) => {
const enhancedArgs = args.map(arg => {
if (arg instanceof Error) {
return this._enhanceError(arg, 'console.error');
}
return arg;
});
this._handleConsoleError(enhancedArgs);
this.originalConsoleError(...args);
};
}
}
/**
* Enhance an error object with additional context
* @private
* @param {Error|Object} error - The error to enhance
* @param {string} source - Where the error originated from
* @returns {Object} - Enhanced error object
*/
_enhanceError(error, source) {
if (!this.config.enhanceErrors) return error;
const stack = error.stack || new Error().stack;
const enhancedError = {
...error,
source,
timestamp: new Date().toISOString(),
loggerName: this.config.name,
environment: this.config.environment,
stack: this.config.autoTraceErrors ? this._cleanStack(stack) : stack
};
// Add browser context if available
if (typeof window !== 'undefined') {
enhancedError.browser = {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform
};
}
// Add Node.js context if available
if (typeof process !== 'undefined') {
enhancedError.node = {
version: process.version,
pid: process.pid,
cwd: process.cwd(),
argv: process.argv
};
}
return enhancedError;
}
/**
* Enhance a promise rejection reason
* @private
* @param {any} reason - Rejection reason
* @returns {Object} - Enhanced error object
*/
_enhanceRejection(reason) {
if (reason instanceof Error) {
return this._enhanceError(reason, 'unhandledRejection');
}
return {
message: String(reason),
name: 'UnhandledRejection',
source: 'unhandledRejection',
timestamp: new Date().toISOString(),
loggerName: this.config.name,
environment: this.config.environment,
stack: this.config.autoTraceErrors ? this._cleanStack(new Error().stack) : undefined
};
}
/**
* Clean up stack trace by removing noise
* @private
* @param {string} stack - Original stack trace
* @returns {string} - Cleaned stack trace
*/
_cleanStack(stack) {
if (!stack) return '';
const lines = stack.split('\n');
// Remove ConsoleIQ internal traces from the stack
const filtered = lines.filter(line => !line.includes('ConsoleIQ.'));
return filtered.join('\n');
}
/**
* Handle global errors
* @private
* @param {string} type - Error type
* @param {Object} errorInfo - Error information
*/
_handleGlobalError(type, errorInfo) {
const errorMessage = this._formatError(type, errorInfo);
if (this.config.endpoint && this.config.allowedLevels.includes('error')) {
this._sendToServer('error', [errorMessage, errorInfo]);
}
if (!this.config.silent) {
this._applyColorAndLog('error', errorMessage);
if (this.config.autoTraceErrors && errorInfo.stack) {
this._applyColorAndLog('trace', errorInfo.stack);
}
}
}
/**
* Format error message with context
* @private
* @param {string} type - Error type
* @param {Object} errorInfo - Error information
* @returns {string} - Formatted error message
*/
_formatError(type, errorInfo) {
let message = `[${this.config.name}] [${type}] ${errorInfo.message || errorInfo}`;
if (errorInfo.source) {
message += ` (source: ${errorInfo.source})`;
}
if (errorInfo.lineno && errorInfo.colno) {
message += ` at ${errorInfo.lineno}:${errorInfo.colno}`;
}
return message;
}
/**
* Handle unhandled promise rejections
* @private
* @param {Error|any} reason - Rejection reason
*/
_handleRejection(reason) {
const errorMessage = this._formatError('unhandledRejection', reason);
if (this.config.endpoint && this.config.allowedLevels.includes('error')) {
this._sendToServer('error', [errorMessage, reason]);
}
if (!this.config.silent) {
this._applyColorAndLog('error', errorMessage);
if (this.config.autoTraceErrors && reason.stack) {
this._applyColorAndLog('trace', reason.stack);
}
}
}
/**
* Handle console errors
* @private
* @param {Array} args - Error arguments
*/
_handleConsoleError(args) {
if (this.config.endpoint && this.config.allowedLevels.includes('error')) {
const enhancedArgs = args.map(arg => {
if (arg instanceof Error) {
return this._enhanceError(arg, 'console.error');
}
return arg;
});
this._sendToServer('error', enhancedArgs);
}
}
/**
* Handle log output (send to server if configured & allowed)
* @private
* @param {string} level - Log level
* @param {Array} args - Arguments to log
*/
_handleLog(level, args) {
// Enhance errors in the arguments
const enhancedArgs = args.map(arg => {
if (arg instanceof Error) {
return this._enhanceError(arg, `console.${level}`);
}
return arg;
});
const maskedArgs = this._maskData(enhancedArgs);
// Send to server if endpoint & allowed
if (this.config.endpoint && this.config.allowedLevels.includes(level)) {
this._sendToServer(level, maskedArgs);
}
// Output to console only
if (!this.config.silent) {
this._applyColorAndLog(level, ...maskedArgs);
// Automatically add stack trace for errors if enabled
if (level === 'error' && this.config.autoTraceErrors) {
const errorArg = enhancedArgs.find(arg => arg instanceof Error);
if (errorArg?.stack) {
this._applyColorAndLog('trace', errorArg.stack);
} else if (this.config.environment === 'browser') {
this._applyColorAndLog('trace', new Error().stack);
}
}
}
}
/**
* Apply colors to console output
* @private
* @param {string} level - Log level
* @param {...any} args - Arguments to log
*/
_applyColorAndLog(level, ...args) {
if (!this.config.colorize) {
this.originalConsole[level === 'text' ? 'log' : level](...args);
return;
}
const colorized = applyColor(level, args);
this.originalConsole[level === 'text' ? 'log' : level](...colorized);
}
_maskData(args) {
if (this.config.sensitiveKeys.length === 0) {
return args;
}
return mask(args, this.config.sensitiveKeys);
}
/**
* Send logs to server preserving original console format
* @private
* @param {string} level - Log level
* @param {Array} args - Arguments to log
* @returns {Promise<void>}
*/
_sendToServer(level, args) {
if (!this.config.endpoint) return;
// Create a payload that preserves the original console data structure
const logData = {
level,
// Console-formatted output
message: this._formatConsoleLike(level, args),
// Additional metadata
timestamp: new Date().toISOString(),
name: this.config.name,
environment: this.config.environment,
metadata: this._getEnvironmentMetadata(),
// Stack trace for errors
...(level === 'error' ? { stack: this._getErrorStack(args) } : {})
};
this._logQueue.push(logData);
if (this._logQueue.length >= this.config.batchSize) {
this._flushQueue();
} else if (!this._batchTimer) {
this._batchTimer = setTimeout(() => this._flushQueue(), this.config.batchInterval);
}
}
async _flushQueue() {
if (this._logQueue.length === 0) {
this._batchTimer = null;
return;
}
const logsToSend = [...this._logQueue];
this._logQueue = [];
clearTimeout(this._batchTimer);
this._batchTimer = null;
try {
const headers = {
'Content-Type': 'application/json'
};
if (this.config.apiKey) {
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
}
await axios.post(this.config.endpoint, logsToSend, {
headers,
timeout: 5000
});
} catch (error) {
if (!this.config.silent) {
this.originalConsole.error(`ConsoleIQ: Failed to send logs:`, error.message);
}
if (this.config.offlineCaching) {
storage.save(logsToSend);
this._scheduleRetry();
} else {
// If offline caching is disabled, re-queue for next flush
this._logQueue.unshift(...logsToSend);
}
}
}
_scheduleRetry() {
if (this._retryTimer) return;
const retryInterval = this.config.batchInterval * 2; // Simple retry interval
this._retryTimer = setTimeout(() => {
this._retryTimer = null;
this._sendCachedLogs();
}, retryInterval);
}
async _sendCachedLogs() {
const cachedLogs = storage.load();
if (cachedLogs.length === 0) return;
this.originalConsole.info(`ConsoleIQ: Attempting to send ${cachedLogs.length} cached logs.`);
storage.clear();
try {
const headers = {
'Content-Type': 'application/json'
};
if (this.config.apiKey) {
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
}
await axios.post(this.config.endpoint, cachedLogs, {
headers,
timeout: 10000
});
} catch (error) {
if (!this.config.silent) {
this.originalConsole.error(`ConsoleIQ: Failed to send cached logs:`, error.message);
}
storage.save(cachedLogs); // Save them back if sending fails
}
}
/**
* Prepare data for serialization while preserving structure
* @private
* @param {any} data - Data to prepare
* @param {Set} [seen] - Track circular references
* @param {number} [depth=0] - Current depth
* @returns {any} - Prepared data
*/
_prepareForSerialization(data, seen = new WeakSet(), depth = 0) {
if (depth > this.config.maxErrorDepth) return '[Max Depth Reached]';
// Handle primitives
if (data === null || typeof data !== 'object') {
return data;
}
// Handle circular references
if (seen.has(data)) {
return '[Circular Reference]';
}
seen.add(data);
// Handle errors specially
if (data instanceof Error) {
const errorObj = {
__type: 'Error',
name: data.name,
message: data.message,
stack: data.stack
};
// Include any custom error properties
Object.getOwnPropertyNames(data).forEach(key => {
if (!['name', 'message', 'stack'].includes(key)) {
try {
errorObj[key] = this._prepareForSerialization(data[key], seen, depth + 1);
} catch (e) {
errorObj[key] = '[Unable to serialize]';
}
}
});
return errorObj;
}
// Handle arrays
if (Array.isArray(data)) {
return data.map(item => {
try {
return this._prepareForSerialization(item, seen, depth + 1);
} catch (e) {
return '[Array item unable to serialize]';
}
});
}
// Handle plain objects
const result = {};
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
try {
result[key] = this._prepareForSerialization(data[key], seen, depth + 1);
} catch (e) {
result[key] = '[Unable to serialize property]';
}
}
}
return result;
}
/**
* Format data like the console would display it
* @private
* @param {string} level - Log level
* @param {Array} args - Arguments to format
* @returns {string} - Formatted string
*/
_formatConsoleLike(level, args) {
try {
return args.map(arg => {
if (typeof arg === 'object' && arg !== null) {
try {
// Handle Errors specially
if (arg instanceof Error) {
return JSON.stringify({
message: arg.message,
name: arg.name,
stack: arg.stack,
...Object.getOwnPropertyDescriptors(arg)
}, null, 2);
}
// Handle regular objects
return JSON.stringify(arg, null, 2);
} catch (e) {
// Fallback if JSON.stringify fails (circular references etc.)
return String(arg);
}
}
return String(arg);
}).join(' ');
} catch (e) {
// Fallback to simple string joining if everything fails
return args.map(arg => String(arg)).join(' ');
}
}
/**
* Get error stack from arguments if available
* @private
* @param {Array} args - Arguments to check
* @returns {string|null} - Stack trace or null
*/
_getErrorStack(args) {
const errorArg = args.find(arg => arg instanceof Error);
if (errorArg?.stack) {
return errorArg.stack;
}
return null;
}
/**
* Get environment metadata
* @private
* @returns {Object} - Environment metadata
*/
_getEnvironmentMetadata() {
if (typeof window !== 'undefined') {
return {
type: 'browser',
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
screen: {
width: window.screen.width,
height: window.screen.height
}
};
} else if (typeof process !== 'undefined') {
return {
type: 'node',
version: process.version,
pid: process.pid,
cwd: process.cwd(),
argv: process.argv,
memoryUsage: process.memoryUsage()
};
}
return { type: 'unknown' };
}
/**
* Reset console to original behavior and remove error handlers
* @returns {ConsoleIQ} - The current instance for chaining
*/
restore() {
if (!this._initialized) return this;
this._flushQueue();
clearTimeout(this._batchTimer);
clearTimeout(this._retryTimer);
// Only restore methods that we actually overrode
Object.keys(this.originalConsole).forEach(method => {
if (console[method] === this._consoleWrappers?.[method]) {
console[method] = this.originalConsole[method];
}
});
// Remove custom text method only if it's ours
if (console.text === this._textWrapper) {
delete console.text;
}
// Restore error handlers
if (typeof window !== 'undefined' && window.addEventListener) {
// Browser
if (window.onerror === this._handleWindowError) {
window.onerror = this.originalErrorHandlers.onerror;
}
if (window.onunhandledrejection === this._handleWindowRejection) {
window.onunhandledrejection = this.originalErrorHandlers.onunhandledrejection;
}
} else if (typeof process !== 'undefined') {
// Node.js
if (typeof this.originalErrorHandlers.onerror === 'function') {
process.removeAllListeners('uncaughtException');
process.on('uncaughtException', this.originalErrorHandlers.onerror);
}
if (typeof this.originalErrorHandlers.onunhandledrejection === 'function') {
process.removeAllListeners('unhandledRejection');
process.on('unhandledRejection', this.originalErrorHandlers.onunhandledrejection);
}
}
// Restore console.error if we modified it
if (this.config.captureConsoleErrors && this.originalConsoleError) {
console.error = this.originalConsoleError;
}
this._initialized = false;
return this;
}
/**
* Get the current configuration
* @returns {Object} - Current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Update the configuration
* @param {Object} newConfig - New configuration values
* @returns {ConsoleIQ} - The current instance for chaining
*/
setConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
return this;
}
}
module.exports = ConsoleIQ;