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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T = unknown>(error: string, code?: string): IpcResult<T> {
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
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<T>(message, code)
|
||||
|
||||
242
src/main/services/logger/error-utils.ts
Normal file
242
src/main/services/logger/error-utils.ts
Normal file
@@ -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<string, unknown>
|
||||
} {
|
||||
const serialized = serializeError(error)
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const errorToLog = isProd ? sanitizeError(serialized) : serialized
|
||||
const errorContext = extractErrorContext(errorToLog)
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
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<string, unknown>) => void },
|
||||
error: unknown,
|
||||
options: {
|
||||
message?: string
|
||||
operation?: string
|
||||
module?: string
|
||||
userId?: string
|
||||
context?: Record<string, unknown>
|
||||
} = {}
|
||||
): 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<string, unknown>) => void },
|
||||
error: unknown,
|
||||
options: {
|
||||
message?: string
|
||||
operation?: string
|
||||
module?: string
|
||||
} = {}
|
||||
): never {
|
||||
logError(logger, error, options)
|
||||
throw error
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): void {
|
||||
log.error(message, { error, ...meta })
|
||||
}
|
||||
|
||||
// Export the main logger for direct use
|
||||
export default logger
|
||||
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user