feat(logging): add Seq transport, global meta fields, and improve error handling
- Add Seq centralized logging transport with async ESM import - Add appVersion and computerName to logger defaultMeta (all app logs) - Add appVersion to audit log entries for version-level traceability - Improve unhandledRejection to capture full stack traces for Error instances - Add Seq config schema and template configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
import logger from '../services/logger/index'
|
||||
import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
|
||||
import { serializeError } from '../services/logger/error-utils'
|
||||
|
||||
export function setupProcessGuards(): void {
|
||||
process.on('uncaughtException', (err) => {
|
||||
@@ -16,13 +17,17 @@ export function setupProcessGuards(): void {
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled Rejection', { reason: String(reason) })
|
||||
const errorMeta =
|
||||
reason instanceof Error
|
||||
? { error: serializeError(reason) }
|
||||
: { reason: String(reason) }
|
||||
logger.error('Unhandled Rejection', errorMeta)
|
||||
logAudit('SYSTEM_ERROR', 'system', {
|
||||
username: 'system',
|
||||
computerName: process.env.COMPUTERNAME || 'unknown',
|
||||
resource: 'main-process',
|
||||
status: 'failure',
|
||||
metadata: { reason: String(reason) }
|
||||
metadata: errorMeta
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -102,6 +102,15 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
auditRetention: 30,
|
||||
appRetention: 14
|
||||
},
|
||||
seq: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
apiKey: '',
|
||||
batchPostingLimit: 50,
|
||||
period: 2000,
|
||||
queueLimit: 10000,
|
||||
maxRetries: 3
|
||||
},
|
||||
rustfs: {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
@@ -207,7 +216,7 @@ export class ConfigManager {
|
||||
this.config = validated
|
||||
|
||||
// Apply logging configuration
|
||||
applyLoggingConfig(validated.logging)
|
||||
applyLoggingConfig(validated.logging, validated.seq)
|
||||
applyAuditConfig(validated.logging.auditRetention)
|
||||
|
||||
log.info('Configuration loaded and validated successfully', {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import winston from 'winston'
|
||||
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { getLogDir } from './shared'
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,8 @@ export interface AuditEntry {
|
||||
username: string
|
||||
/** Computer name from which the action was performed */
|
||||
computerName: string
|
||||
/** Application version when the action was performed */
|
||||
appVersion: string
|
||||
/** The resource that was affected (e.g., table name, file path) */
|
||||
resource: string
|
||||
/** Status of the action: 'success' | 'failure' | 'partial' */
|
||||
@@ -104,6 +107,7 @@ export function logAudit(
|
||||
userId,
|
||||
username: details.username,
|
||||
computerName: details.computerName,
|
||||
appVersion: app.getVersion(),
|
||||
resource: details.resource,
|
||||
status: details.status,
|
||||
metadata: details.metadata || {}
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
import winston from 'winston'
|
||||
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
import path from 'path'
|
||||
import { BrowserWindow } from 'electron'
|
||||
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'
|
||||
|
||||
// Cache isProduction() at module load — app.isPackaged never changes at runtime
|
||||
const IS_PROD = isProduction()
|
||||
@@ -154,7 +157,11 @@ const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile
|
||||
// File transports are added after config is loaded via applyLoggingConfig()
|
||||
const logger = winston.createLogger({
|
||||
level: 'info', // Default level, can be updated via setLogLevel()
|
||||
defaultMeta: { service: 'erpauto' },
|
||||
defaultMeta: {
|
||||
service: 'erpauto',
|
||||
appVersion: app.getVersion(),
|
||||
computerName: os.hostname()
|
||||
},
|
||||
transports: [
|
||||
// Console transport - always enabled
|
||||
new winston.transports.Console({
|
||||
@@ -181,10 +188,15 @@ export function setLogLevel(level: string): void {
|
||||
/**
|
||||
* 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 }): void {
|
||||
export function applyLoggingConfig(
|
||||
config: { level: string; appRetention: number },
|
||||
seqConfig?: SeqConfig
|
||||
): void {
|
||||
// Update log level
|
||||
setLogLevel(config.level)
|
||||
|
||||
@@ -213,6 +225,47 @@ export function applyLoggingConfig(config: { level: string; appRetention: number
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
131
src/main/services/logger/seq-transport.ts
Normal file
131
src/main/services/logger/seq-transport.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Seq logging transport using @datalust/winston-seq
|
||||
*
|
||||
* Provides asynchronous batched logging to Seq server
|
||||
* with configurable parameters for production use.
|
||||
*
|
||||
* Features:
|
||||
* - Non-blocking async batch sending (via dynamic import)
|
||||
* - Configurable batch size, period, queue limits
|
||||
* - Automatic retry on failure
|
||||
* - Graceful shutdown with flush
|
||||
* - Error suppression (Seq failures don't crash app)
|
||||
*
|
||||
* Note: Uses dynamic import because @datalust/winston-seq is an ESM module
|
||||
*/
|
||||
|
||||
import type { SeqConfig } from '../../types/config.schema'
|
||||
import logger from './index'
|
||||
|
||||
/**
|
||||
* Seq transport constructor type (from dynamic import)
|
||||
*/
|
||||
type SeqTransportClass = new (options: {
|
||||
serverUrl: string
|
||||
apiKey?: string
|
||||
batchSizeLimit?: number
|
||||
maxBatchingTime?: number
|
||||
maxRetries?: number
|
||||
onError?: (error: Error) => void
|
||||
}) => any
|
||||
|
||||
/**
|
||||
* Create and configure Seq transport dynamically
|
||||
*
|
||||
* This function handles the ESM module loading and returns
|
||||
* a configured transport instance compatible with Winston.
|
||||
*
|
||||
* @param config - Seq configuration from config.schema
|
||||
* @returns Promise resolving to transport instance or null if disabled/failed
|
||||
*/
|
||||
export async function createSeqTransport(config: SeqConfig): Promise<any> {
|
||||
// Don't create transport if disabled
|
||||
if (!config.enabled || !config.serverUrl) {
|
||||
logger.info('Seq logging is disabled or server URL not provided', {
|
||||
enabled: config.enabled,
|
||||
hasServerUrl: !!config.serverUrl,
|
||||
context: 'seq-transport'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamically import the ESM module
|
||||
const { SeqTransport } = await import('@datalust/winston-seq')
|
||||
const SeqTransportClass = SeqTransport as SeqTransportClass
|
||||
|
||||
// Create transport instance with mapped config
|
||||
const transport = new SeqTransportClass({
|
||||
serverUrl: config.serverUrl,
|
||||
apiKey: config.apiKey || undefined,
|
||||
batchSizeLimit: config.batchPostingLimit,
|
||||
maxBatchingTime: config.period,
|
||||
maxRetries: config.maxRetries,
|
||||
onError: (error: Error) => {
|
||||
// Log errors but don't throw - Seq failures should not crash app
|
||||
logger.error('Seq transport error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
context: 'seq-transport'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Seq transport created successfully', {
|
||||
serverUrl: config.serverUrl,
|
||||
batchPostingLimit: config.batchPostingLimit,
|
||||
period: config.period,
|
||||
maxRetries: config.maxRetries,
|
||||
context: 'seq-transport'
|
||||
})
|
||||
|
||||
return transport
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Seq transport', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context: 'seq-transport'
|
||||
})
|
||||
// Return null to allow app to continue without Seq
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync factory wrapper for backward compatibility
|
||||
*
|
||||
* Note: This creates the transport asynchronously internally.
|
||||
* The transport is cached and reused.
|
||||
*/
|
||||
let cachedTransport: any = null
|
||||
let isInitializing = false
|
||||
|
||||
export function createSeqTransportSync(config: SeqConfig): any {
|
||||
if (!config.enabled || !config.serverUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cachedTransport) {
|
||||
return cachedTransport
|
||||
}
|
||||
|
||||
if (!isInitializing) {
|
||||
isInitializing = true
|
||||
createSeqTransport(config)
|
||||
.then((transport) => {
|
||||
cachedTransport = transport
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Seq transport initialization failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
isInitializing = false
|
||||
})
|
||||
}
|
||||
|
||||
// Return null on first call, transport will be available on next call
|
||||
return null
|
||||
}
|
||||
|
||||
export default createSeqTransportSync
|
||||
@@ -35,6 +35,8 @@ export interface AuditEntry {
|
||||
username: string
|
||||
/** Computer name where action was performed */
|
||||
computerName: string
|
||||
/** Application version when action was performed */
|
||||
appVersion: string
|
||||
/** Resource affected by the action */
|
||||
resource?: string
|
||||
/** Status of the action */
|
||||
|
||||
@@ -132,6 +132,21 @@ export const loggingConfigSchema = z.object({
|
||||
appRetention: z.number().int().min(1).max(365).default(14)
|
||||
})
|
||||
|
||||
/**
|
||||
* Seq 日志聚合服务配置 Schema
|
||||
*/
|
||||
export const seqConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
serverUrl: z.string().url('Seq server URL must be a valid URL').optional().default(''),
|
||||
apiKey: z.string().optional().default(''),
|
||||
batchPostingLimit: z.number().int().min(1).max(1000).default(50),
|
||||
period: z.number().int().min(1000).max(30000).default(2000), // milliseconds
|
||||
queueLimit: z.number().int().min(1).max(10000).default(10000),
|
||||
maxRetries: z.number().int().min(0).max(10).default(3)
|
||||
})
|
||||
|
||||
export type SeqConfig = z.infer<typeof seqConfigSchema>
|
||||
|
||||
/**
|
||||
* RustFS 对象存储配置 Schema
|
||||
*/
|
||||
@@ -172,6 +187,7 @@ export const fullConfigSchema = z.object({
|
||||
cleaner: cleanerConfigSchema,
|
||||
orderResolution: orderResolutionSchema,
|
||||
logging: loggingConfigSchema,
|
||||
seq: seqConfigSchema.optional(),
|
||||
rustfs: rustfsConfigSchema.optional(),
|
||||
update: updateConfigSchema.optional()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user