@ideal-photography/shared
Version:
Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.
574 lines (493 loc) • 18.2 kB
JavaScript
/**
* Error Tracking System
* Cart System Refactor - Phase 5 Deployment
*
* Comprehensive error tracking, logging, and reporting
*/
const fs = require('fs');
const path = require('path');
class ErrorTracker {
constructor(options = {}) {
this.options = {
logLevel: options.logLevel || 'error',
logToFile: options.logToFile !== false,
logToConsole: options.logToConsole !== false,
maxLogFiles: options.maxLogFiles || 10,
maxLogSize: options.maxLogSize || 10 * 1024 * 1024, // 10MB
enableStackTrace: options.enableStackTrace !== false,
enableSourceMap: options.enableSourceMap !== false,
environment: options.environment || process.env.NODE_ENV || 'development',
...options
};
this.logDir = path.join(__dirname, '..', '..', 'logs', 'errors');
this.metricsDir = path.join(__dirname, '..', '..', 'monitoring', 'errors');
this.errorCounts = new Map();
this.errorHistory = [];
this.alertThresholds = {
errorRate: 0.05, // 5% error rate
criticalErrors: 10, // 10 critical errors per hour
memoryLeaks: 100 * 1024 * 1024 // 100MB memory increase
};
this.initializeDirectories();
this.setupGlobalHandlers();
}
/**
* Initialize required directories
*/
initializeDirectories() {
const dirs = [this.logDir, this.metricsDir];
dirs.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
}
/**
* Setup global error handlers
*/
setupGlobalHandlers() {
// Uncaught exceptions
process.on('uncaughtException', (error) => {
this.logError(error, {
type: 'uncaughtException',
severity: 'critical',
fatal: true
});
// Give time for logging before exit
setTimeout(() => {
process.exit(1);
}, 1000);
});
// Unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
this.logError(reason, {
type: 'unhandledRejection',
severity: 'critical',
promise: promise.toString()
});
});
// Warning events
process.on('warning', (warning) => {
this.logError(warning, {
type: 'warning',
severity: 'warning'
});
});
}
/**
* Log an error with context
* @param {Error|string} error - Error object or message
* @param {Object} context - Additional context information
*/
logError(error, context = {}) {
const errorEntry = this.createErrorEntry(error, context);
// Update error counts
this.updateErrorCounts(errorEntry);
// Add to history
this.errorHistory.push(errorEntry);
// Keep history manageable
if (this.errorHistory.length > 1000) {
this.errorHistory = this.errorHistory.slice(-500);
}
// Log to console
if (this.options.logToConsole) {
this.logToConsole(errorEntry);
}
// Log to file
if (this.options.logToFile) {
this.logToFile(errorEntry);
}
// Check for alerts
this.checkAlerts(errorEntry);
// Send to external services if configured
this.sendToExternalServices(errorEntry);
return errorEntry;
}
/**
* Create structured error entry
* @param {Error|string} error - Error object or message
* @param {Object} context - Additional context
* @returns {Object} - Structured error entry
*/
createErrorEntry(error, context = {}) {
const timestamp = new Date().toISOString();
const isErrorObject = error instanceof Error;
const entry = {
id: this.generateErrorId(),
timestamp,
environment: this.options.environment,
level: context.severity || 'error',
message: isErrorObject ? error.message : String(error),
type: context.type || 'application',
// Error details
error: {
name: isErrorObject ? error.name : 'Error',
message: isErrorObject ? error.message : String(error),
stack: isErrorObject && this.options.enableStackTrace ? error.stack : null,
code: isErrorObject ? error.code : null
},
// System context
system: {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
pid: process.pid,
uptime: process.uptime(),
memory: process.memoryUsage(),
cpu: process.cpuUsage()
},
// Request context (if available)
request: context.request ? {
method: context.request.method,
url: context.request.url,
headers: this.sanitizeHeaders(context.request.headers),
userAgent: context.request.get ? context.request.get('User-Agent') : null,
ip: context.request.ip,
userId: context.request.user ? context.request.user.id : null
} : null,
// Additional context
context: {
...context,
request: undefined // Remove to avoid duplication
},
// Fingerprint for grouping similar errors
fingerprint: this.generateFingerprint(error, context)
};
return entry;
}
/**
* Generate unique error ID
* @returns {string} - Unique error ID
*/
generateErrorId() {
return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Generate fingerprint for error grouping
* @param {Error|string} error - Error object or message
* @param {Object} context - Error context
* @returns {string} - Error fingerprint
*/
generateFingerprint(error, context) {
const isErrorObject = error instanceof Error;
const message = isErrorObject ? error.message : String(error);
const stack = isErrorObject ? error.stack : '';
const type = context.type || 'application';
// Create a hash-like fingerprint
const fingerprint = `${type}:${message}:${stack.split('\n')[1] || ''}`;
return Buffer.from(fingerprint).toString('base64').substr(0, 16);
}
/**
* Update error counts for metrics
* @param {Object} errorEntry - Error entry
*/
updateErrorCounts(errorEntry) {
const key = `${errorEntry.level}:${errorEntry.fingerprint}`;
const count = this.errorCounts.get(key) || 0;
this.errorCounts.set(key, count + 1);
}
/**
* Log error to console with formatting
* @param {Object} errorEntry - Error entry
*/
logToConsole(errorEntry) {
const colors = {
critical: '\x1b[41m\x1b[37m', // Red background, white text
error: '\x1b[31m', // Red text
warning: '\x1b[33m', // Yellow text
info: '\x1b[36m', // Cyan text
debug: '\x1b[90m', // Gray text
reset: '\x1b[0m' // Reset
};
const color = colors[errorEntry.level] || colors.error;
const reset = colors.reset;
console.log(`${color}[${errorEntry.level.toUpperCase()}] ${errorEntry.timestamp}${reset}`);
console.log(`${color}Message: ${errorEntry.message}${reset}`);
console.log(`${color}Type: ${errorEntry.type}${reset}`);
console.log(`${color}ID: ${errorEntry.id}${reset}`);
if (errorEntry.error.stack && this.options.logLevel === 'debug') {
console.log(`${color}Stack:${reset}`);
console.log(errorEntry.error.stack);
}
if (errorEntry.request) {
console.log(`${color}Request: ${errorEntry.request.method} ${errorEntry.request.url}${reset}`);
}
console.log('---');
}
/**
* Log error to file
* @param {Object} errorEntry - Error entry
*/
logToFile(errorEntry) {
const logFile = path.join(this.logDir, `errors_${this.getDateString()}.log`);
const logLine = JSON.stringify(errorEntry) + '\n';
try {
// Check file size and rotate if necessary
if (fs.existsSync(logFile)) {
const stats = fs.statSync(logFile);
if (stats.size > this.options.maxLogSize) {
this.rotateLogFile(logFile);
}
}
fs.appendFileSync(logFile, logLine);
} catch (writeError) {
console.error('Failed to write error log:', writeError);
}
}
/**
* Rotate log file when it gets too large
* @param {string} logFile - Path to log file
*/
rotateLogFile(logFile) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const rotatedFile = logFile.replace('.log', `_${timestamp}.log`);
try {
fs.renameSync(logFile, rotatedFile);
// Clean up old log files
this.cleanupOldLogFiles();
} catch (rotateError) {
console.error('Failed to rotate log file:', rotateError);
}
}
/**
* Clean up old log files
*/
cleanupOldLogFiles() {
try {
const files = fs.readdirSync(this.logDir)
.filter(file => file.startsWith('errors_') && file.endsWith('.log'))
.map(file => ({
name: file,
path: path.join(this.logDir, file),
mtime: fs.statSync(path.join(this.logDir, file)).mtime
}))
.sort((a, b) => b.mtime - a.mtime);
// Keep only the most recent files
if (files.length > this.options.maxLogFiles) {
const filesToDelete = files.slice(this.options.maxLogFiles);
filesToDelete.forEach(file => {
fs.unlinkSync(file.path);
});
}
} catch (cleanupError) {
console.error('Failed to cleanup old log files:', cleanupError);
}
}
/**
* Check for alert conditions
* @param {Object} errorEntry - Error entry
*/
checkAlerts(errorEntry) {
// Critical error alert
if (errorEntry.level === 'critical') {
this.sendAlert({
type: 'critical_error',
message: `Critical error occurred: ${errorEntry.message}`,
errorId: errorEntry.id,
severity: 'high'
});
}
// Error rate alert
const recentErrors = this.getRecentErrors(60 * 60 * 1000); // Last hour
const errorRate = recentErrors.length / 100; // Assuming 100 requests per hour baseline
if (errorRate > this.alertThresholds.errorRate) {
this.sendAlert({
type: 'high_error_rate',
message: `High error rate detected: ${(errorRate * 100).toFixed(2)}%`,
errorRate,
severity: 'medium'
});
}
}
/**
* Send alert notification
* @param {Object} alert - Alert information
*/
sendAlert(alert) {
console.warn(`🚨 ALERT: ${alert.message}`);
// Save alert to file
const alertFile = path.join(this.metricsDir, `alerts_${this.getDateString()}.json`);
const alertEntry = {
...alert,
timestamp: new Date().toISOString(),
id: this.generateErrorId()
};
try {
let alerts = [];
if (fs.existsSync(alertFile)) {
alerts = JSON.parse(fs.readFileSync(alertFile, 'utf8'));
}
alerts.push(alertEntry);
fs.writeFileSync(alertFile, JSON.stringify(alerts, null, 2));
} catch (error) {
console.error('Failed to save alert:', error);
}
}
/**
* Send error to external services
* @param {Object} errorEntry - Error entry
*/
sendToExternalServices(errorEntry) {
// Placeholder for external service integration
// e.g., Sentry, LogRocket, Bugsnag, etc.
if (process.env.SENTRY_DSN) {
// Send to Sentry
this.sendToSentry(errorEntry);
}
if (process.env.SLACK_ERROR_WEBHOOK) {
// Send to Slack
this.sendToSlack(errorEntry);
}
}
/**
* Send error to Sentry (placeholder)
* @param {Object} errorEntry - Error entry
*/
sendToSentry(errorEntry) {
// Implementation would depend on Sentry SDK
console.log('Would send to Sentry:', errorEntry.id);
}
/**
* Send error to Slack (placeholder)
* @param {Object} errorEntry - Error entry
*/
sendToSlack(errorEntry) {
// Implementation would use Slack webhook
console.log('Would send to Slack:', errorEntry.id);
}
/**
* Get recent errors within time window
* @param {number} timeWindow - Time window in milliseconds
* @returns {Array} - Recent errors
*/
getRecentErrors(timeWindow) {
const cutoff = Date.now() - timeWindow;
return this.errorHistory.filter(error =>
new Date(error.timestamp).getTime() > cutoff
);
}
/**
* Get error statistics
* @returns {Object} - Error statistics
*/
getStatistics() {
const now = Date.now();
const lastHour = this.getRecentErrors(60 * 60 * 1000);
const lastDay = this.getRecentErrors(24 * 60 * 60 * 1000);
const levelCounts = {};
lastDay.forEach(error => {
levelCounts[error.level] = (levelCounts[error.level] || 0) + 1;
});
return {
total: this.errorHistory.length,
lastHour: lastHour.length,
lastDay: lastDay.length,
levelCounts,
topErrors: this.getTopErrors(10),
errorRate: lastHour.length / 60, // Errors per minute
uniqueErrors: new Set(this.errorHistory.map(e => e.fingerprint)).size
};
}
/**
* Get top errors by frequency
* @param {number} limit - Number of top errors to return
* @returns {Array} - Top errors
*/
getTopErrors(limit = 10) {
const errorCounts = new Map();
this.errorHistory.forEach(error => {
const key = error.fingerprint;
const count = errorCounts.get(key) || 0;
errorCounts.set(key, count + 1);
});
return Array.from(errorCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([fingerprint, count]) => {
const example = this.errorHistory.find(e => e.fingerprint === fingerprint);
return {
fingerprint,
count,
message: example.message,
level: example.level,
lastSeen: example.timestamp
};
});
}
/**
* Sanitize headers for logging
* @param {Object} headers - Request headers
* @returns {Object} - Sanitized headers
*/
sanitizeHeaders(headers = {}) {
const sanitized = { ...headers };
const sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token'];
sensitiveHeaders.forEach(header => {
if (sanitized[header]) {
sanitized[header] = '[REDACTED]';
}
});
return sanitized;
}
/**
* Get date string for file naming
* @returns {string} - Date string (YYYY-MM-DD)
*/
getDateString() {
return new Date().toISOString().split('T')[0];
}
/**
* Export error data
* @param {Object} options - Export options
* @returns {Object} - Exported data
*/
export(options = {}) {
const { timeWindow, level, limit } = options;
let errors = this.errorHistory;
if (timeWindow) {
errors = this.getRecentErrors(timeWindow);
}
if (level) {
errors = errors.filter(error => error.level === level);
}
if (limit) {
errors = errors.slice(-limit);
}
return {
errors,
statistics: this.getStatistics(),
exportedAt: new Date().toISOString(),
options
};
}
}
// Create singleton instance
const errorTracker = new ErrorTracker();
// Express middleware for error tracking
const errorTrackingMiddleware = (err, req, res, next) => {
errorTracker.logError(err, {
type: 'http_request',
severity: 'error',
request: req
});
next(err);
};
// Async error wrapper
const asyncErrorHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(error => {
errorTracker.logError(error, {
type: 'async_handler',
severity: 'error',
request: req
});
next(error);
});
};
};
module.exports = {
ErrorTracker,
errorTracker,
errorTrackingMiddleware,
asyncErrorHandler
};