From fba2c73782b0e503751bd8b680e6ef269f83a6a4 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 8 Mar 2026 16:10:23 +0800 Subject: [PATCH] feat(logging): Wave 3 - add comprehensive error serialization with stack traces - Add error-utils module with serializeError and sanitizeError utilities - Enhance IPC error handling to capture full error context including stack traces - Add logError helper function for consistent error logging across the application - Update console and file log formats to properly serialize error objects - Replace all basic error logging in BIPUsersDAO with structured logError calls - Add ErrorLike and SerializedError type interfaces for type safety This improves debugging capability by preserving full error details in development while sanitizing sensitive information in production logs. Co-Authored-By: Claude Sonnet 4.5 --- src/main/ipc/index.ts | 22 ++- src/main/services/logger/error-utils.ts | 242 ++++++++++++++++++++++++ src/main/services/logger/index.ts | 68 ++++++- src/main/services/user/bip-users-dao.ts | 69 +++++-- src/main/types/errors.ts | 19 ++ 5 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 src/main/services/logger/error-utils.ts diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5f567f5..28b54dd 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -14,7 +14,8 @@ import { registerSettingsHandlers } from './settings-handler' import { registerMaterialTypeHandlers } from './material-type-handler' import { registerUserErpConfigHandlers } from './user-erp-config-handler' import { registerLoggerHandlers } from './logger-handler' -import { createLogger } from '../services/logger' +import { createLogger, logError } from '../services/logger' +import { serializeError, sanitizeError } from '../services/logger/error-utils' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' const log = createLogger('IPC') @@ -39,6 +40,7 @@ export function fail(error: string, code?: string): IpcResult { /** * Higher-order function to wrap IPC handlers with consistent error handling + * Enhanced to capture full error context including stack traces * @param handler - The async handler function to wrap * @param context - The context name for logging * @returns A wrapped handler that returns IpcResult @@ -56,15 +58,27 @@ export function withErrorHandling( const message = getErrorMessage(error) const code = getErrorCode(error) + // Serialize error with full details + const serializedError = serializeError(error) + const errorToLog = + process.env.NODE_ENV === 'production' ? sanitizeError(serializedError) : serializedError + if (isBaseError(error)) { - log.error(`[${context}] ${error.name}: ${message}`, { code, cause: error.cause?.message }) + logError(log, `[${context}] ${error.name}`, error, { + code, + cause: (error as any).cause?.message, + handler: context + }) } else { - log.error(`[${context}] Error: ${message}`, { code }) + logError(log, `[${context}] Error`, error, { + code, + handler: context + }) } // Include stack trace in development if (process.env.NODE_ENV !== 'production' && error instanceof Error) { - log.debug(`[${context}] Stack trace:`, { stack: error.stack }) + log.debug(`[${context}] Stack trace: ${error.stack}`) } return fail(message, code) diff --git a/src/main/services/logger/error-utils.ts b/src/main/services/logger/error-utils.ts new file mode 100644 index 0000000..b577b24 --- /dev/null +++ b/src/main/services/logger/error-utils.ts @@ -0,0 +1,242 @@ +/** + * Error Logging Utilities + * + * Provides comprehensive error serialization and formatting for logging. + * Captures full error context including stack traces, causes, and custom properties. + */ + +import type { ErrorLike, SerializedError } from '../../types/errors' + +/** + * Check if value is an Error or Error-like object + */ +export function isError(value: unknown): value is Error | ErrorLike { + return ( + value instanceof Error || + (typeof value === 'object' && + value !== null && + 'name' in value && + 'message' in value && + typeof (value as any).message === 'string') + ) +} + +/** + * Serialize an error into a plain object for logging + * Captures all enumerable and non-enumerable properties + */ +export function serializeError(error: unknown): SerializedError { + if (error instanceof Error) { + const serialized: SerializedError = { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause ? serializeError(error.cause) : undefined + } + + // Capture custom properties from Error subclasses + const props = Object.getOwnPropertyNames(error) + for (const prop of props) { + if (!['name', 'message', 'stack', 'cause'].includes(prop)) { + const value = (error as any)[prop] + if (value !== undefined) { + serialized[prop] = isError(value) ? serializeError(value) : value + } + } + } + + return serialized + } + + if (isError(error)) { + return { + name: (error as any).name || 'UnknownError', + message: (error as any).message || String(error), + stack: (error as any).stack, + cause: (error as any).cause ? serializeError((error as any).cause) : undefined + } + } + + // Non-error values + return { + name: 'UnknownError', + message: typeof error === 'string' ? error : JSON.stringify(error) || 'Unknown error occurred' + } +} + +/** + * Sanitize error for production logging + * Removes sensitive information while preserving error structure + */ +export function sanitizeError(error: SerializedError): SerializedError { + const sensitiveKeys = [ + 'password', + 'secret', + 'token', + 'apiKey', + 'api_key', + 'credentials', + 'authorization', + 'privateKey', + 'secretKey' + ] + + const sanitized: SerializedError = { ...error } + + // Sanitize message in production + if (process.env.NODE_ENV === 'production') { + // Keep error name and structure, but sanitize message + if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) { + sanitized.message = 'An error occurred due to invalid credentials or configuration' + } + } + + // Recursively sanitize cause + if (sanitized.cause && typeof sanitized.cause === 'object') { + sanitized.cause = sanitizeError(sanitized.cause) + } + + // Sanitize any custom properties that might contain sensitive data + for (const key of Object.keys(sanitized)) { + if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) { + sanitized[key] = '[REDACTED]' + } + } + + return sanitized +} + +/** + * Extract context from error for logging + * Includes file, line, column from stack trace when available + */ +export function extractErrorContext(error: SerializedError): { + fileName?: string + lineNumber?: number + columnName?: number + functionName?: string +} { + if (!error.stack) { + return {} + } + + const stackLines = error.stack.split('\n') + // Skip first line (error name and message), get first stack frame + const stackLine = stackLines[1] || stackLines[0] + + // Parse stack frame: "at Function.module.exports (path/to/file.js:123:45)" + const match = stackLine.match(/at(?:\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)/) + if (match) { + return { + functionName: match[1], + fileName: match[2], + lineNumber: parseInt(match[3], 10), + columnName: parseInt(match[4], 10) + } + } + + // Alternative format: "at path/to/file.js:123:45" + const altMatch = stackLine.match(/at\s+(.+):(\d+):(\d+)/) + if (altMatch) { + return { + fileName: altMatch[1], + lineNumber: parseInt(altMatch[2], 10), + columnName: parseInt(altMatch[3], 10) + } + } + + return {} +} + +/** + * Format error for console/file logging + * Returns a formatted string with all error details + */ +export function formatErrorForLogging( + error: unknown, + context?: { + operation?: string + module?: string + userId?: string + [key: string]: unknown + } +): { + message: string + metadata: Record +} { + const serialized = serializeError(error) + const isProd = process.env.NODE_ENV === 'production' + const errorToLog = isProd ? sanitizeError(serialized) : serialized + const errorContext = extractErrorContext(errorToLog) + + const metadata: Record = { + error: errorToLog, + ...context + } + + // Add error location context if available + if (errorContext.fileName) { + metadata.errorLocation = { + file: errorContext.fileName.split('/').pop() || errorContext.fileName, + line: errorContext.lineNumber, + column: errorContext.columnName, + function: errorContext.functionName + } + } + + // Add environment info in development + if (!isProd) { + metadata.environment = { + NODE_ENV: process.env.NODE_ENV, + platform: process.platform, + nodeVersion: process.version + } + } + + const message = `[${errorToLog.name}] ${errorToLog.message}` + + return { message, metadata } +} + +/** + * Log error with full context + * Wrapper for logger.error that ensures complete error information is captured + */ +export function logError( + logger: { error: (message: string, meta?: Record) => void }, + error: unknown, + options: { + message?: string + operation?: string + module?: string + userId?: string + context?: Record + } = {} +): void { + const { message: customMessage, operation, module: moduleName, userId, context } = options + const { message, metadata } = formatErrorForLogging(error, { + operation, + module: moduleName, + userId, + ...context + }) + + const finalMessage = customMessage || message + logger.error(finalMessage, metadata) +} + +/** + * Re-throw error after logging, preserving original stack + */ +export function throwAfterLogging( + logger: { error: (message: string, meta?: Record) => void }, + error: unknown, + options: { + message?: string + operation?: string + module?: string + } = {} +): never { + logError(logger, error, options) + throw error +} diff --git a/src/main/services/logger/index.ts b/src/main/services/logger/index.ts index b3720d2..ed577f8 100644 --- a/src/main/services/logger/index.ts +++ b/src/main/services/logger/index.ts @@ -1,6 +1,11 @@ /** * Unified logging system using Winston * Console + File transports with daily rotation + * + * Features: + * - Full error serialization with stack traces + * - Development/Production environment differentiation + * - Structured logging with context */ import winston from 'winston' @@ -8,6 +13,7 @@ import DailyRotateFile from 'winston-daily-rotate-file' import path from 'path' import { app } from 'electron' import fs from 'fs' +import { serializeError, sanitizeError } from './error-utils' // Get log directory - use app.getPath('logs') in production, or local logs dir in development function getLogDir(): string { @@ -22,20 +28,54 @@ function getLogDir(): string { return devLogDir } -// Custom format for console output +// Check if running in production +const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production' + +// Custom format for console output - includes full error details const consoleFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, context, ...meta }) => { + winston.format.printf(({ timestamp, level, message, context, error, ...meta }) => { const contextStr = context ? `[${context}]` : '' - const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '' - return `${timestamp} [${level}]${contextStr} ${message}${metaStr}` + + // Format error with full stack trace + let errorStr = '' + if (error) { + const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error) + if (serialized.stack) { + errorStr = `\n${serialized.stack}` + } else { + errorStr = ` ${serialized.message}` + } + } + + const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '' + return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}` }) ) -// Custom format for file output +// Custom format for file output - JSON with full error details const fileFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format((info) => { + // Serialize errors in metadata + if (info.error) { + info.error = isProduction + ? sanitizeError(serializeError(info.error)) + : serializeError(info.error) + } + + // Serialize any error in meta fields + for (const key of Object.keys(info)) { + if (key !== 'error' && info[key] instanceof Error) { + info[key] = isProduction + ? sanitizeError(serializeError(info[key])) + : serializeError(info[key]) + } + } + + return info + })(), winston.format.json() ) @@ -98,6 +138,24 @@ export function createLogger(context: string): winston.Logger { return logger.child({ context }) } +/** + * Log an error with full context and stack trace + * This is the recommended way to log errors in the application + * + * @param log - Logger instance + * @param message - Error message + * @param error - The error object (Error, BaseError, or any) + * @param meta - Additional metadata to include + */ +export function logError( + log: winston.Logger, + message: string, + error: unknown, + meta?: Record +): void { + log.error(message, { error, ...meta }) +} + // Export the main logger for direct use export default logger diff --git a/src/main/services/user/bip-users-dao.ts b/src/main/services/user/bip-users-dao.ts index 6af4cba..9f23f52 100644 --- a/src/main/services/user/bip-users-dao.ts +++ b/src/main/services/user/bip-users-dao.ts @@ -13,7 +13,8 @@ import { SqlServerService } from '../database/sql-server' import { ConfigManager } from '../config/config-manager' import sql from 'mssql' import type { UserInfo } from '../../types/user.types' -import { createLogger } from '../logger' +import { createLogger, logError } from '../logger' +import { serializeError } from '../logger/error-utils' const log = createLogger('BipUsersDao') @@ -163,7 +164,11 @@ export class BIPUsersDAO { return null } } catch (error) { - log.error('Authenticate error:', error) + logError(log, 'Authenticate failed', error, { + operation: 'authenticate', + username, + dbType: this.dbType + }) return null } } @@ -218,7 +223,11 @@ export class BIPUsersDAO { return null } } catch (error) { - log.error('Authenticate by computer name error:', error) + logError(log, 'Silent login failed', error, { + operation: 'authenticateByComputerName', + computerName, + dbType: this.dbType + }) return null } } @@ -250,7 +259,10 @@ export class BIPUsersDAO { createTime: row.CreateTime as Date | undefined })) } catch (error) { - log.error('Get all users error:', error) + logError(log, 'Get all users failed', error, { + operation: 'getAllUsers', + dbType: this.dbType + }) return [] } } @@ -334,7 +346,12 @@ export class BIPUsersDAO { return true } } catch (error) { - log.error('Create user error:', error) + logError(log, 'Create user failed', error, { + operation: 'createUser', + username, + userType, + dbType: this.dbType + }) return false } } @@ -373,7 +390,12 @@ export class BIPUsersDAO { return true } } catch (error) { - log.error('Update user type error:', error) + logError(log, 'Update user type failed', error, { + operation: 'updateUserType', + username, + userType, + dbType: this.dbType + }) return false } } @@ -412,7 +434,11 @@ export class BIPUsersDAO { return true } } catch (error) { - log.error('Update password error:', error) + logError(log, 'Update password failed', error, { + operation: 'updatePassword', + username, + dbType: this.dbType + }) return false } } @@ -447,7 +473,11 @@ export class BIPUsersDAO { return true } } catch (error) { - log.error('Delete user error:', error) + logError(log, 'Delete user failed', error, { + operation: 'deleteUser', + username, + dbType: this.dbType + }) return false } } @@ -484,7 +514,11 @@ export class BIPUsersDAO { return result.rows.length > 0 && (result.rows[0].count as number) > 0 } } catch (error) { - log.error('User exists error:', error) + logError(log, 'Check user exists failed', error, { + operation: 'userExists', + username, + dbType: this.dbType + }) return false } } @@ -541,7 +575,11 @@ export class BIPUsersDAO { return null } } catch (error) { - log.error('Get user ERP credentials error:', error) + logError(log, 'Get user ERP credentials failed', error, { + operation: 'getUserErpCredentials', + username, + dbType: this.dbType + }) return null } } @@ -589,7 +627,11 @@ export class BIPUsersDAO { return true } } catch (error) { - log.error('Update user ERP credentials error:', error) + logError(log, 'Update user ERP credentials failed', error, { + operation: 'updateUserErpCredentials', + username, + dbType: this.dbType + }) return false } } @@ -627,7 +669,10 @@ export class BIPUsersDAO { erpUsername: (row[cols.ERP_USERNAME] as string) || '' })) } catch (error) { - log.error('Get all users ERP config error:', error) + logError(log, 'Get all users ERP config failed', error, { + operation: 'getAllUsersErpConfig', + dbType: this.dbType + }) return [] } } diff --git a/src/main/types/errors.ts b/src/main/types/errors.ts index d6b1c60..1654cd0 100644 --- a/src/main/types/errors.ts +++ b/src/main/types/errors.ts @@ -167,3 +167,22 @@ export function getErrorCode(error: unknown): string { } return 'UNKNOWN_ERROR' } + +/** + * Error-like interface for non-Error objects that have error properties + */ +export interface ErrorLike { + name: string + message: string + stack?: string + cause?: unknown +} + +/** + * Serialized error object for logging + * Can contain additional properties from Error subclasses + */ +export interface SerializedError extends ErrorLike { + cause?: SerializedError | string + [key: string]: unknown +}