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:
Misaka
2026-04-04 16:33:03 +08:00
parent fce8dbc37f
commit 1e0bb1de24
10 changed files with 336 additions and 31 deletions

View File

@@ -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
})
})

View File

@@ -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', {

View File

@@ -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 || {}

View File

@@ -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)
}

View 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

View File

@@ -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 */

View File

@@ -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()
})