WSyslog.mjs

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import get from 'lodash-es/get.js'
import evem from 'wsemi/src/evem.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import pino from 'pino'


//__dirname
let __filename = fileURLToPath(import.meta.url)
let __dirname = path.dirname(__filename)


/**
 * 輸出log
 *
 * @class
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.fdLog='./_logs'] 輸入儲存log資料夾字串,預設'./_logs'
 * @param {String} [opt.interval='day'] 輸入儲存log時分檔模式字串,可選'day'、'hr',分別代表每日或每時分檔,預設'day'
 * @param {String} [opt.timeZone=null] 輸入時區字串(IANA格式如'Asia/Taipei'、'UTC'),用於決定切檔邊界與檔名,預設null代表使用系統時區
 * @param {Number} [opt.numKeep] 輸入保留log檔案數量整數,超出最舊檔將被自動刪除,預設於day模式為365、hr模式為365*24(約等同保留一年)
 * @returns {Object} 回傳log物件,提供fatal、error、warn、info、debug、trace紀錄函數,cleanLogs手動清理函數,及clear清理計時器函數
 * @example
 * import fs from 'fs'
 * import _ from 'lodash-es'
 * import w from 'wsemi'
 * import WSyslog from './src/WSyslog.mjs'
 *
 * w.fsCleanFolder('./_logs')
 *
 * let log = WSyslog()
 * log.info({ event: 'runner', msg: 'start' })
 * log.warn({ event: 'monitor-memory', msg: 'usage-high', ratio: 85.4 })
 * log.error({ event: 'crash', msg: 'db connection', code: 500 })
 *
 * await w.delay(2000) //等待2秒讓pino能flush數據
 *
 * let vpfs = w.fsTreeFolder('./_logs')
 * // console.log('vpfs', vpfs)
 *
 * let fp = _.get(vpfs, `0.path`, '')
 *
 * let jj = fs.readFileSync(fp, 'utf8')
 * console.log(jj)
 * // {"level":30,"time":1751780174415,"pid":24144,"hostname":"DESKTOP-6R7USAO","event":"runner","msg":"start"}
 * // {"level":40,"time":1751780174415,"pid":24144,"hostname":"DESKTOP-6R7USAO","event":"monitor-memory","msg":"usage-high"}
 * // {"level":50,"time":1751780174415,"pid":24144,"hostname":"DESKTOP-6R7USAO","event":"crash","msg":"db connection","code":500}
 *
 */
function WSyslog(opt = {}) {

    //ev
    let ev = evem()

    //timeZone, 'Asia/Taipei'
    let timeZone = get(opt, 'timeZone', null)

    //fdLog
    let fdLog = get(opt, 'fdLog')
    if (!isestr(fdLog)) {
        fdLog = './_logs'
    }

    //interval
    let interval = get(opt, 'interval')
    if (interval !== 'day' && interval !== 'hr') {
        interval = 'day'
    }

    //numKeep
    let numKeep = get(opt, 'numKeep')
    if (!ispint(numKeep)) {
        if (interval === 'day') {
            numKeep = 365
        }
        else {
            numKeep = 365 * 24
        }
    }
    numKeep = cint(numKeep)

    // 建立 logger,使用自訂 transport
    let transport = pino.transport({
        targets: [
            {

                // fatal: 60
                // error: 50
                // warn: 40
                // info: 30
                // debug: 20
                // trace: 10
                level: 'info', //僅紀錄info(30)以上

                target: path.resolve(__dirname, './formatter.mjs'),

                options: {
                    interval,
                    fdLog,
                    timeZone,
                },

            },
        ]
    })

    //log
    let log = pino(transport)
    // console.log('log', log)

    //cleanLogs, 依numKeep保留最新N個檔, 超出的最舊檔刪除
    let cleanLogs = () => {
        try {
            if (!fsIsFolder(fdLog)) {
                return
            }
            //pattern, day模式為'YYYY-MM-DD.log', hr模式為'YYYY-MM-DDTHH.log'
            let pattern = (interval === 'hr')
                ? /^\d{4}-\d{2}-\d{2}T\d{2}\.log$/
                : /^\d{4}-\d{2}-\d{2}\.log$/
            let files = fs.readdirSync(fdLog).filter((fn) => pattern.test(fn))
            //ISO日期格式字典序等同時間序, 排序後最舊在前
            files.sort()
            let nDel = files.length - numKeep
            if (nDel <= 0) {
                return
            }
            let toDelete = files.slice(0, nDel)
            for (let fn of toDelete) {
                let fp = path.join(fdLog, fn)
                try {
                    fs.unlinkSync(fp)
                    ev.emit('delete', { fp, fn })
                }
                catch (err) {
                    //容忍其他process同時刪除或檔案被佔用之情況
                }
            }
        }
        catch (err) {
            console.error('WSyslog cleanLogs error:', err)
        }
    }

    //啟動時立刻清理一次, 確保短命腳本也能執行清理
    cleanLogs()

    //之後每小時清理一次, 足以跟上day/hr切檔節奏
    let t = setInterval(cleanLogs, 60 * 60 * 1000)
    //unref不阻塞process結束
    t.unref()

    //clear
    let clear = () => {
        clearInterval(t)
    }

    //wrap, 包裝pino各等級method, 於寫log前先觸發'log'事件, 使外部可監聽進行console輸出或轉送其他記錄器
    // fatal: 60
    // error: 50
    // warn: 40
    // info: 30
    // debug: 20
    // trace: 10
    let wrap = (level) => {
        let fn = log[level].bind(log)
        return (...args) => {
            try {
                ev.emit(level, { args })
            }
            catch (err) {
                //容忍listener內部錯誤, 避免影響pino寫log
            }
            fn(...args)
        }
    }
    ev.fatal = wrap('fatal')
    ev.error = wrap('error')
    ev.warn = wrap('warn')
    ev.info = wrap('info')
    ev.debug = wrap('debug')
    ev.trace = wrap('trace')
    ev.cleanLogs = cleanLogs
    ev.clear = clear

    return ev
}


export default WSyslog