380 lines
8.9 KiB
TypeScript
380 lines
8.9 KiB
TypeScript
import DailyRotateFile, {
|
|
DailyRotateFileTransportOptions,
|
|
} from 'winston-daily-rotate-file'
|
|
import { ensureDirSync } from 'fs-extra'
|
|
import winston from 'winston'
|
|
import { logsDir, storeDir } from '../config/app'
|
|
import { GatewayConfig } from './Gateway'
|
|
import { DeepPartial, joinPath } from './utils'
|
|
import * as path from 'path'
|
|
import { readdir, stat, unlink } from 'fs/promises'
|
|
import { Stats } from 'fs'
|
|
import escapeStringRegexp from '@esm2cjs/escape-string-regexp'
|
|
import { PassThrough } from 'stream'
|
|
|
|
const { format, transports, addColors } = winston
|
|
const { combine, timestamp, label, printf, colorize, splat } = format
|
|
|
|
export const defaultLogFile = 'z-ui_%DATE%.log'
|
|
|
|
export const disableColors = process.env.NO_LOG_COLORS === 'true'
|
|
|
|
let transportsList: winston.transport[] = null
|
|
|
|
// ensure store and logs directories exist
|
|
ensureDirSync(storeDir)
|
|
ensureDirSync(logsDir)
|
|
|
|
// custom colors for timestamp and module
|
|
addColors({
|
|
time: 'grey',
|
|
module: 'bold',
|
|
})
|
|
|
|
const colorizer = colorize()
|
|
|
|
export interface ModuleLogger extends winston.Logger {
|
|
module: string
|
|
setup(cfg: DeepPartial<GatewayConfig>): ModuleLogger
|
|
}
|
|
|
|
export type LogLevel = 'silly' | 'verbose' | 'debug' | 'info' | 'warn' | 'error'
|
|
|
|
interface LoggerConfig {
|
|
module: string
|
|
enabled: boolean
|
|
level: LogLevel
|
|
logToFile: boolean
|
|
filePath: string
|
|
}
|
|
|
|
/**
|
|
* Generate logger configuration starting from settings.gateway
|
|
*/
|
|
export function sanitizedConfig(
|
|
module: string,
|
|
config: DeepPartial<GatewayConfig> | undefined,
|
|
): LoggerConfig {
|
|
config = config || ({} as LoggerConfig)
|
|
const filePath = joinPath(logsDir, config.logFileName || defaultLogFile)
|
|
|
|
return {
|
|
module: module || '-',
|
|
enabled: config.logEnabled !== undefined ? config.logEnabled : true,
|
|
level: config.logLevel || 'info',
|
|
logToFile: config.logToFile !== undefined ? config.logToFile : false,
|
|
filePath: filePath,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a custom logger format
|
|
*/
|
|
export function customFormat(
|
|
config: LoggerConfig,
|
|
noColor = false,
|
|
): winston.Logform.Format {
|
|
noColor = noColor || disableColors
|
|
const formats: winston.Logform.Format[] = [
|
|
splat(), // used for formats like: logger.log('info', Message %s', strinVal)
|
|
timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
|
format((info) => {
|
|
info.level = info.level.toUpperCase()
|
|
return info
|
|
})(),
|
|
label({ label: config.module.toUpperCase() }),
|
|
]
|
|
|
|
if (!noColor) {
|
|
formats.push(colorize({ level: true }))
|
|
}
|
|
|
|
// must be added at last
|
|
formats.push(
|
|
printf((info) => {
|
|
if (!noColor) {
|
|
info.timestamp = colorizer.colorize('time', info.timestamp)
|
|
info.label = colorizer.colorize('module', info.label || '-')
|
|
}
|
|
return `${info.timestamp} ${info.level} ${info.label}: ${
|
|
info.message
|
|
}${info.stack ? '\n' + info.stack : ''}`
|
|
}),
|
|
)
|
|
|
|
return combine(...formats)
|
|
}
|
|
|
|
export const logStream = new PassThrough()
|
|
|
|
/**
|
|
* Create the base transports based on settings provided
|
|
*/
|
|
export function customTransports(config: LoggerConfig): winston.transport[] {
|
|
// setup transports only once (see issue #2937)
|
|
if (transportsList) {
|
|
return transportsList
|
|
}
|
|
|
|
transportsList = []
|
|
|
|
if (process.env.ZUI_NO_CONSOLE !== 'true') {
|
|
transportsList.push(
|
|
new transports.Console({
|
|
format: customFormat(config),
|
|
level: config.level,
|
|
stderrLevels: ['error'],
|
|
}),
|
|
)
|
|
}
|
|
|
|
const streamTransport = new transports.Stream({
|
|
format: customFormat(config),
|
|
level: config.level,
|
|
stream: logStream,
|
|
})
|
|
|
|
transportsList.push(streamTransport)
|
|
|
|
if (config.logToFile) {
|
|
let fileTransport: winston.transport
|
|
|
|
if (process.env.DISABLE_LOG_ROTATION === 'true') {
|
|
fileTransport = new transports.File({
|
|
format: customFormat(config, true),
|
|
filename: config.filePath,
|
|
level: config.level,
|
|
})
|
|
} else {
|
|
const options: DailyRotateFileTransportOptions = {
|
|
filename: config.filePath,
|
|
auditFile: joinPath(logsDir, 'zui-logs.audit.json'),
|
|
datePattern: 'YYYY-MM-DD',
|
|
createSymlink: true,
|
|
symlinkName: path
|
|
.basename(config.filePath)
|
|
.replace(`_%DATE%`, '_current'),
|
|
zippedArchive: true,
|
|
maxFiles: process.env.ZUI_LOG_MAXFILES || '7d',
|
|
maxSize: process.env.ZUI_LOG_MAXSIZE || '50m',
|
|
level: config.level,
|
|
format: customFormat(config, true),
|
|
}
|
|
fileTransport = new DailyRotateFile(options)
|
|
|
|
setupCleanJob(options)
|
|
}
|
|
|
|
transportsList.push(fileTransport)
|
|
}
|
|
|
|
// giving that we re-use transports, each module will subscribe to events
|
|
// increeasing the default limit of 100 prevents warnings
|
|
transportsList.forEach((t) => {
|
|
t.setMaxListeners(100)
|
|
})
|
|
|
|
return transportsList
|
|
}
|
|
|
|
/**
|
|
* Setup a logger
|
|
*/
|
|
export function setupLogger(
|
|
container: winston.Container,
|
|
module: string,
|
|
config?: DeepPartial<GatewayConfig>,
|
|
): ModuleLogger {
|
|
const sanitized = sanitizedConfig(module, config)
|
|
// Winston automatically reuses an existing module logger
|
|
const logger = container.add(module) as ModuleLogger
|
|
logger.configure({
|
|
format: combine(format.errors({ stack: true }), format.json()), // to correctly parse errors
|
|
silent: !sanitized.enabled,
|
|
level: sanitized.level,
|
|
transports: customTransports(sanitized),
|
|
})
|
|
logger.module = module
|
|
logger.setup = (cfg) => setupLogger(container, module, cfg)
|
|
return logger
|
|
}
|
|
|
|
const logContainer = new winston.Container()
|
|
|
|
/**
|
|
* Create a new logger for a specific module
|
|
*/
|
|
export function module(module: string): ModuleLogger {
|
|
return setupLogger(logContainer, module)
|
|
}
|
|
|
|
/**
|
|
* Setup all loggers starting from config
|
|
*/
|
|
export function setupAll(config: DeepPartial<GatewayConfig>) {
|
|
stopCleanJob()
|
|
|
|
transportsList.forEach((t) => {
|
|
if (typeof t.close === 'function') {
|
|
t.close()
|
|
}
|
|
})
|
|
|
|
transportsList = null
|
|
|
|
logContainer.loggers.forEach((logger: ModuleLogger) => {
|
|
logger.setup(config)
|
|
})
|
|
}
|
|
|
|
let cleanJob: NodeJS.Timeout
|
|
|
|
export function setupCleanJob(settings: DailyRotateFileTransportOptions) {
|
|
if (cleanJob) {
|
|
return
|
|
}
|
|
|
|
let maxFilesMs: number
|
|
let maxFiles: number
|
|
let maxSizeBytes: number
|
|
|
|
const logger = module('LOGGER')
|
|
|
|
// convert maxFiles to milliseconds
|
|
if (settings.maxFiles !== undefined) {
|
|
const matches = settings.maxFiles.toString().match(/(\d+)([dhm])/)
|
|
|
|
if (settings.maxFiles) {
|
|
const value = parseInt(matches[1])
|
|
const unit = matches[2]
|
|
switch (unit) {
|
|
case 'd':
|
|
maxFilesMs = value * 24 * 60 * 60 * 1000
|
|
break
|
|
case 'h':
|
|
maxFilesMs = value * 60 * 60 * 1000
|
|
break
|
|
case 'm':
|
|
maxFilesMs = value * 60 * 1000
|
|
break
|
|
}
|
|
} else {
|
|
maxFiles = Number(settings.maxFiles)
|
|
}
|
|
}
|
|
|
|
if (settings.maxSize !== undefined) {
|
|
// convert maxSize to bytes
|
|
const matches2 = settings.maxSize.toString().match(/(\d+)([kmg])/)
|
|
if (matches2) {
|
|
const value = parseInt(matches2[1])
|
|
const unit = matches2[2]
|
|
switch (unit) {
|
|
case 'k':
|
|
maxSizeBytes = value * 1024
|
|
break
|
|
case 'm':
|
|
maxSizeBytes = value * 1024 * 1024
|
|
break
|
|
case 'g':
|
|
maxSizeBytes = value * 1024 * 1024 * 1024
|
|
break
|
|
}
|
|
} else {
|
|
maxSizeBytes = Number(settings.maxSize)
|
|
}
|
|
}
|
|
|
|
// clean up old log files based on maxFiles and maxSize
|
|
|
|
const filePathRegExp = new RegExp(
|
|
escapeStringRegexp(path.basename(settings.filename)).replace(
|
|
/%DATE%/g,
|
|
'(.*)',
|
|
),
|
|
)
|
|
|
|
const logsDir = path.dirname(settings.filename)
|
|
|
|
const deleteFile = async (filePath: string) => {
|
|
logger.info(`Deleting log file: ${filePath}`)
|
|
return unlink(filePath).catch((e) => {
|
|
if (e.code !== 'ENOENT') {
|
|
logger.error(`Error deleting log file: ${filePath}`, e)
|
|
}
|
|
})
|
|
}
|
|
|
|
const clean = async () => {
|
|
try {
|
|
logger.info('Cleaning up log files...')
|
|
const files = await readdir(logsDir)
|
|
const logFiles = files.filter(
|
|
(file) =>
|
|
file !== settings.symlinkName && filePathRegExp.test(file),
|
|
)
|
|
|
|
const fileStats = await Promise.allSettled<{
|
|
file: string
|
|
stats: Stats
|
|
}>(
|
|
logFiles.map(async (file) => ({
|
|
file,
|
|
stats: await stat(path.join(logsDir, file)),
|
|
})),
|
|
)
|
|
|
|
const logFilesStats: {
|
|
file: string
|
|
stats: Stats
|
|
}[] = []
|
|
|
|
for (const res of fileStats) {
|
|
if (res.status === 'fulfilled') {
|
|
logFilesStats.push(res.value)
|
|
} else {
|
|
logger.error('Error getting file stats:', res.reason)
|
|
}
|
|
}
|
|
|
|
logFilesStats.sort((a, b) => a.stats.mtimeMs - b.stats.mtimeMs)
|
|
|
|
// sort by mtime
|
|
|
|
let totalSize = 0
|
|
let deletedFiles = 0
|
|
for (const { file, stats } of logFilesStats) {
|
|
const filePath = path.join(logsDir, file)
|
|
totalSize += stats.size
|
|
|
|
// last modified time in milliseconds
|
|
const fileMs = stats.mtimeMs
|
|
|
|
const shouldDelete =
|
|
(maxSizeBytes && totalSize > maxSizeBytes) ||
|
|
(maxFiles && logFiles.length - deletedFiles > maxFiles) ||
|
|
(maxFilesMs && fileMs && Date.now() - fileMs > maxFilesMs)
|
|
|
|
if (shouldDelete) {
|
|
await deleteFile(filePath)
|
|
deletedFiles++
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logger.error('Error cleaning up log files:', e)
|
|
}
|
|
}
|
|
|
|
cleanJob = setInterval(clean, 60 * 60 * 1000)
|
|
clean().catch(() => {})
|
|
}
|
|
|
|
export function stopCleanJob() {
|
|
if (cleanJob) {
|
|
clearInterval(cleanJob)
|
|
cleanJob = undefined
|
|
}
|
|
}
|
|
|
|
export default logContainer.loggers
|