Add getLocalIpAddress() that reliably resolves the primary LAN IPv4 address by collecting all non-loopback, non-APIPA addresses and prioritizing RFC 1918 private ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x) over public IPs. Falls back to any non-internal address or 'N/A'. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
/**
|
|
* 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'
|
|
import DailyRotateFile from 'winston-daily-rotate-file'
|
|
import path from 'path'
|
|
import os from 'os'
|
|
import { app, BrowserWindow } from 'electron'
|
|
import { serializeError, sanitizeError } from './error-utils'
|
|
import { getLogDir, isProduction, cleanupOldScreenshots } from './shared'
|
|
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
|
import { getContext, run } from './request-context'
|
|
import { createSeqTransportSync } from './seq-transport'
|
|
import type { SeqConfig } from '../../types/config.schema'
|
|
|
|
// Custom log levels matching project semantics:
|
|
// verbose (most detailed) → error (most severe)
|
|
// Winston rule: logs with level value <= threshold are emitted.
|
|
const PROJECT_LEVELS = {
|
|
error: 0,
|
|
warn: 1,
|
|
info: 2,
|
|
debug: 3,
|
|
verbose: 4
|
|
} as const
|
|
|
|
// Cache isProduction() at module load — app.isPackaged never changes at runtime
|
|
const IS_PROD = isProduction()
|
|
|
|
/**
|
|
* Check if an IPv4 address is an RFC 1918 private address.
|
|
* Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
*/
|
|
function isPrivateIpv4(ip: string): boolean {
|
|
const parts = ip.split('.').map(Number)
|
|
if (parts.length !== 4) return false
|
|
// 10.0.0.0/8
|
|
if (parts[0] === 10) return true
|
|
// 172.16.0.0/12
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
|
// 192.168.0.0/16
|
|
if (parts[0] === 192 && parts[1] === 168) return true
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Get the primary local IPv4 address (RFC 1918 private address).
|
|
*
|
|
* Strategy:
|
|
* 1. Collect all non-internal, non-APIPA IPv4 addresses from physical adapters
|
|
* 2. Return the first private (LAN) address found
|
|
* 3. Fallback to any remaining non-internal address
|
|
* 4. Returns 'N/A' if none found
|
|
*/
|
|
function getLocalIpAddress(): string {
|
|
const interfaces = os.networkInterfaces()
|
|
const candidates: string[] = []
|
|
|
|
for (const [, addrs] of Object.entries(interfaces)) {
|
|
if (!addrs) continue
|
|
for (const iface of addrs) {
|
|
if (iface.family === 'IPv4' && !iface.internal && !iface.address.startsWith('169.254.')) {
|
|
candidates.push(iface.address)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefer private (LAN) addresses
|
|
const privateIp = candidates.find(isPrivateIpv4)
|
|
if (privateIp) return privateIp
|
|
|
|
// Fallback: any non-internal address
|
|
return candidates[0] || 'N/A'
|
|
}
|
|
|
|
/**
|
|
* Check if an error has already been serialized (plain object with name/message but not an Error instance).
|
|
* Prevents double-serialization when logError() output passes through the format pipeline.
|
|
*/
|
|
function isSerializedError(value: unknown): boolean {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
!(value instanceof Error) &&
|
|
'name' in value &&
|
|
'message' in value
|
|
)
|
|
}
|
|
|
|
// 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(),
|
|
// Auto-inject requestId from async context
|
|
winston.format((info) => {
|
|
const context = getContext()
|
|
if (context) {
|
|
info.requestId = context.requestId
|
|
if (context.userId) {
|
|
info.userId = context.userId
|
|
}
|
|
if (context.operation) {
|
|
info.operation = context.operation
|
|
}
|
|
}
|
|
return info
|
|
})(),
|
|
winston.format.printf(
|
|
({ timestamp, level, message, context, error, requestId, userId, operation, ...meta }) => {
|
|
const contextStr = context ? `[${context}]` : ''
|
|
const requestIdStr = requestId ? ` [${requestId}]` : ''
|
|
const userStr = userId ? ` (user:${userId})` : ''
|
|
const opStr = operation ? ` op:${operation}` : ''
|
|
|
|
// Format error with full stack trace
|
|
let errorStr = ''
|
|
if (error) {
|
|
// Skip re-serialization if already a serialized error object
|
|
const serialized: { stack?: string; message: string } = isSerializedError(error)
|
|
? (error as { stack?: string; message: string })
|
|
: IS_PROD
|
|
? sanitizeError(serializeError(error))
|
|
: serializeError(error)
|
|
if (serialized.stack) {
|
|
errorStr = `\n${serialized.stack}`
|
|
} else {
|
|
errorStr = ` ${serialized.message}`
|
|
}
|
|
}
|
|
|
|
let metaStr = ''
|
|
if (Object.keys(meta).length > 0) {
|
|
try {
|
|
metaStr = ` ${JSON.stringify(meta, null, 2)}`
|
|
} catch {
|
|
// Fallback for circular references: stringify primitives, replace complex objects with placeholder
|
|
metaStr = ` ${JSON.stringify(
|
|
Object.fromEntries(
|
|
Object.entries(meta).map(([k, v]) => [
|
|
k,
|
|
v !== null && typeof v === 'object' ? `[Object]` : v
|
|
])
|
|
),
|
|
null,
|
|
2
|
|
)}`
|
|
}
|
|
}
|
|
return `${timestamp} [${level}]${contextStr}${requestIdStr}${userStr}${opStr} ${message}${errorStr}${metaStr}`
|
|
}
|
|
)
|
|
)
|
|
|
|
// 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' }),
|
|
// Auto-inject requestId from async context for file logs
|
|
winston.format((info) => {
|
|
const context = getContext()
|
|
if (context) {
|
|
info.requestId = context.requestId
|
|
if (context.userId) {
|
|
info.userId = context.userId
|
|
}
|
|
if (context.operation) {
|
|
info.operation = context.operation
|
|
}
|
|
}
|
|
return info
|
|
})(),
|
|
winston.format((info) => {
|
|
// Serialize errors in metadata (skip if already serialized)
|
|
if (info.error) {
|
|
if (!isSerializedError(info.error)) {
|
|
info.error = IS_PROD
|
|
? sanitizeError(serializeError(info.error))
|
|
: serializeError(info.error)
|
|
}
|
|
}
|
|
|
|
// Serialize any error in meta fields (skip if already serialized)
|
|
for (const key of Object.keys(info)) {
|
|
if (key !== 'error' && info[key] instanceof Error) {
|
|
info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key])
|
|
}
|
|
}
|
|
|
|
return info
|
|
})(),
|
|
winston.format.json()
|
|
)
|
|
|
|
// Daily rotate file transport configuration
|
|
const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => {
|
|
return new DailyRotateFile({
|
|
filename: path.join(getLogDir(), 'app-%DATE%.log'),
|
|
datePattern: 'YYYY-MM-DD',
|
|
zippedArchive: true,
|
|
maxSize: '20m',
|
|
maxFiles: maxFiles || '14d',
|
|
level,
|
|
format: fileFormat
|
|
})
|
|
}
|
|
|
|
// Create the logger instance with default level - Console only initially
|
|
// File transports are added after config is loaded via applyLoggingConfig()
|
|
const logger = winston.createLogger({
|
|
levels: PROJECT_LEVELS,
|
|
level: 'info', // Default level, can be updated via setLogLevel()
|
|
defaultMeta: {
|
|
service: 'erpauto',
|
|
appVersion: app.getVersion(),
|
|
computerName: os.hostname(),
|
|
ipAddress: getLocalIpAddress()
|
|
},
|
|
transports: [
|
|
// Console transport - always enabled
|
|
new winston.transports.Console({
|
|
format: consoleFormat
|
|
})
|
|
]
|
|
})
|
|
|
|
/**
|
|
* Update the logger level dynamically and notify renderer processes
|
|
* @param level - The new log level
|
|
*/
|
|
export function setLogLevel(level: string): void {
|
|
logger.level = level
|
|
|
|
// Broadcast level change to all renderer windows so they update their cached level
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (!win.isDestroyed()) {
|
|
win.webContents.send(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, level)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply logging configuration from config file
|
|
* Removes existing DailyRotateFile transports and recreates them with config values
|
|
* Also configures Seq transport if enabled
|
|
*
|
|
* @param config - Logging configuration from config.yaml
|
|
* @param seqConfig - Optional Seq configuration from config.yaml
|
|
*/
|
|
export function applyLoggingConfig(
|
|
config: { level: string; appRetention: number },
|
|
seqConfig?: SeqConfig
|
|
): void {
|
|
// Update log level
|
|
setLogLevel(config.level)
|
|
|
|
// Remove existing DailyRotateFile transports
|
|
const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile)
|
|
for (const transport of existingFileTransports) {
|
|
logger.remove(transport)
|
|
}
|
|
|
|
// Add app log transport with configured retention
|
|
const retentionStr = `${config.appRetention}d`
|
|
logger.add(createFileTransport(undefined, retentionStr))
|
|
|
|
// Add error-specific file transport in production
|
|
if (IS_PROD) {
|
|
logger.add(
|
|
new DailyRotateFile({
|
|
filename: path.join(getLogDir(), 'error-%DATE%.log'),
|
|
datePattern: 'YYYY-MM-DD',
|
|
zippedArchive: true,
|
|
maxSize: '20m',
|
|
maxFiles: retentionStr,
|
|
level: 'error',
|
|
format: fileFormat
|
|
})
|
|
)
|
|
}
|
|
|
|
// Add Seq transport if configured and enabled
|
|
// Note: Uses sync wrapper which initializes asynchronously
|
|
if (seqConfig && seqConfig.enabled && seqConfig.serverUrl) {
|
|
try {
|
|
const seqTransport = createSeqTransportSync(seqConfig)
|
|
if (seqTransport) {
|
|
logger.add(seqTransport)
|
|
logger.info('Seq transport added successfully', {
|
|
serverUrl: seqConfig.serverUrl,
|
|
batchPostingLimit: seqConfig.batchPostingLimit,
|
|
period: seqConfig.period,
|
|
context: 'seq-transport'
|
|
})
|
|
} else {
|
|
// Transport not ready yet, will be initialized on next call
|
|
logger.debug('Seq transport initialization in progress', {
|
|
serverUrl: seqConfig.serverUrl,
|
|
context: 'seq-transport'
|
|
})
|
|
// Initialize and add when ready
|
|
import('./seq-transport').then(({ createSeqTransport }) => {
|
|
createSeqTransport(seqConfig).then((transport) => {
|
|
if (transport) {
|
|
logger.add(transport)
|
|
logger.info('Seq transport added (async init complete)', {
|
|
serverUrl: seqConfig.serverUrl,
|
|
context: 'seq-transport'
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to add Seq transport', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
context: 'seq-transport'
|
|
})
|
|
// Don't throw - allow app to continue without Seq
|
|
}
|
|
}
|
|
|
|
// Clean up old screenshot files beyond the retention window
|
|
cleanupOldScreenshots(config.appRetention)
|
|
}
|
|
|
|
/**
|
|
* Create a child logger with a specific context
|
|
* @param context - The context/module name for the logger
|
|
* @returns A child logger instance
|
|
*/
|
|
export function createLogger(context: string): winston.Logger {
|
|
return logger.child({ context })
|
|
}
|
|
|
|
/**
|
|
* Execute a function with automatic request-scoped logging
|
|
*
|
|
* This wrapper ensures all logging within the function has access to the request context.
|
|
* It's a convenience wrapper around RequestContext.run() that also ensures the logger
|
|
* properly captures the context.
|
|
*
|
|
* @param fn - The async function to execute within the context
|
|
* @param context - Optional business context (userId, operation)
|
|
* @returns Promise resolving to the function's return value
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await withRequestContext(async () => {
|
|
* logger.info('Processing order') // Will include requestId, userId, operation
|
|
* await processOrder()
|
|
* }, { userId: 'user123', operation: 'process-order' })
|
|
* ```
|
|
*/
|
|
export async function withRequestContext<T>(
|
|
fn: () => Promise<T>,
|
|
context?: { userId?: string; operation?: string }
|
|
): Promise<T> {
|
|
return run(fn, context)
|
|
}
|
|
|
|
// Re-export error utilities for convenience
|
|
export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
|
|
|
|
// Export request context management for async-context logging
|
|
export { run, getRequestId, getContext, withContext, type LoggerContext } from './request-context'
|
|
|
|
// Export the main logger for direct use
|
|
export default logger
|
|
|
|
// Export performance monitoring utilities
|
|
export {
|
|
trackDuration,
|
|
PerformanceTracker,
|
|
createPerformanceTracker,
|
|
DEFAULT_SLOW_THRESHOLD_MS
|
|
} from './performance-monitor'
|
|
|
|
// Export log level types for convenience
|
|
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'
|