From eb268289ef85f3b2ffed833834b4881c5d30e547 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Wed, 10 Jul 2024 11:50:49 -0400 Subject: [PATCH] feat(server): implement simple log roller Adds a pino destination that performs rudimentary log rotation based on file size / schedule / both. The rotation can also be configured to only keep a certain number of logs. This needs some UI work so I haven't actually hooked it into the logger yet. --- pnpm-lock.yaml | 9 +- server/package.json | 1 + server/src/services/scheduler.ts | 4 +- server/src/util/logging/LoggerFactory.ts | 21 ++ server/src/util/logging/RollingDestination.ts | 218 ++++++++++++++++++ server/src/util/schedulingUtil.test.ts | 4 +- server/src/util/schedulingUtil.ts | 2 +- types/src/schemas/utilSchemas.ts | 2 + 8 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 server/src/util/logging/RollingDestination.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe2f1f02a..96d266691 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: retry: specifier: ^0.13.1 version: 0.13.1 + sonic-boom: + specifier: 3.7.0 + version: 3.7.0 tslib: specifier: ^2.6.2 version: 2.6.2 @@ -10095,7 +10098,7 @@ packages: pump: 3.0.0 readable-stream: 4.4.2 secure-json-parse: 2.7.0 - sonic-boom: 3.7.0 + sonic-boom: 3.8.1 strip-json-comments: 3.1.1 dev: false @@ -10122,7 +10125,7 @@ packages: quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.4.3 - sonic-boom: 3.7.0 + sonic-boom: 3.8.1 thread-stream: 2.7.0 dev: false @@ -10139,7 +10142,7 @@ packages: quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.4.3 - sonic-boom: 3.7.0 + sonic-boom: 3.8.1 thread-stream: 2.7.0 dev: false diff --git a/server/package.json b/server/package.json index 1c89d8043..1315c41b5 100644 --- a/server/package.json +++ b/server/package.json @@ -67,6 +67,7 @@ "random-js": "2.1.0", "reflect-metadata": "^0.2.2", "retry": "^0.13.1", + "sonic-boom": "3.7.0", "tslib": "^2.6.2", "uuid": "^9.0.1", "yargs": "^17.7.2", diff --git a/server/src/services/scheduler.ts b/server/src/services/scheduler.ts index fd7680b0d..35fedb5a6 100644 --- a/server/src/services/scheduler.ts +++ b/server/src/services/scheduler.ts @@ -14,7 +14,7 @@ import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { typedProperty } from '../types/path.js'; import { Maybe } from '../types/util.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; -import { parseEveryScheduleRule } from '../util/schedulingUtil.js'; +import { scheduleRuleToCronString } from '../util/schedulingUtil.js'; import { BackupSettings } from '@tunarr/types/schemas'; import { DeepReadonly } from 'ts-essentials'; @@ -185,7 +185,7 @@ export function scheduleBackupJobs( let cronSchedule: string; switch (config.schedule.type) { case 'every': { - cronSchedule = parseEveryScheduleRule(config.schedule); + cronSchedule = scheduleRuleToCronString(config.schedule); break; } case 'cron': { diff --git a/server/src/util/logging/LoggerFactory.ts b/server/src/util/logging/LoggerFactory.ts index ae0f05872..20b4fce6b 100644 --- a/server/src/util/logging/LoggerFactory.ts +++ b/server/src/util/logging/LoggerFactory.ts @@ -262,6 +262,27 @@ class LoggerFactoryImpl { // We can only add these streams post-initialization because they // require configuration. if (!isUndefined(this.settingsDB)) { + // TODO Expose this in the UI with configuration + // const dest = new RollingLogDestination({ + // fileName: join( + // this.settingsDB.systemSettings().logging.logsDirectory, + // 'tunarr.log', + // ), + // maxSizeBytes: 10000, + // fileLimit: { + // count: 3, + // }, + // destinationOpts: { + // mkdir: true, + // append: true, + // }, + // }); + + // streams.push({ + // stream: dest.initDestination(), + // level: logLevel, + // }); + streams.push({ stream: pino.destination({ dest: join( diff --git a/server/src/util/logging/RollingDestination.ts b/server/src/util/logging/RollingDestination.ts new file mode 100644 index 000000000..f5944ec76 --- /dev/null +++ b/server/src/util/logging/RollingDestination.ts @@ -0,0 +1,218 @@ +import { Tag } from '@tunarr/types'; +import { Schedule } from '@tunarr/types/schemas'; +import { + forEach, + isError, + isNull, + isUndefined, + map, + nth, + uniq, +} from 'lodash-es'; +import fs from 'node:fs'; +import path from 'node:path'; +import SonicBoom, { SonicBoomOpts } from 'sonic-boom'; +import { attemptSync, isDefined } from '..'; +import { ScheduledTask } from '../../tasks/ScheduledTask'; +import { Task, TaskId } from '../../tasks/Task'; +import { Maybe } from '../../types/util'; +import { scheduleRuleToCronString } from '../schedulingUtil'; + +type Opts = { + fileName: string; + fileExt?: string; + maxSizeBytes?: number; + rotateSchedule?: Schedule; + extension?: string; + destinationOpts?: SonicBoomOpts; + fileLimit?: { + count?: number; + }; +}; + +export class RollingLogDestination { + private initialized = false; + private scheduledTask: Maybe; + private destination: SonicBoom; + private currentFileName: string; + private createdFileNames: string[] = []; + private rotatePattern: RegExp; + + constructor(private opts: Opts) { + this.rotatePattern = new RegExp(`(\\d+)${this.opts.extension ?? ''}$`); + this.initState(); + } + + initDestination() { + if (this.initialized || this.destination) { + return this.destination; + } + + if (this.opts.rotateSchedule) { + let schedule: string; + switch (this.opts.rotateSchedule.type) { + case 'cron': + schedule = this.opts.rotateSchedule.cron; + break; + case 'every': + schedule = scheduleRuleToCronString(this.opts.rotateSchedule); + break; + } + + this.scheduledTask = new ScheduledTask( + 'RotateLogs', + schedule, + () => new RollLogFileTask(this), + ); + } + + this.destination = new SonicBoom({ + ...(this.opts.destinationOpts ?? {}), + dest: this.opts.fileName, + }); + + if (this.opts.maxSizeBytes && this.opts.maxSizeBytes > 0) { + let currentSize = getFileSize(this.currentFileName); + this.destination.on('write', (size) => { + currentSize += size; + if ( + isDefined(this.opts.maxSizeBytes) && + this.opts.maxSizeBytes > 0 && + currentSize >= this.opts.maxSizeBytes + ) { + currentSize = 0; + // Make sure the log flushes before we roll + setTimeout(() => { + const rollResult = attemptSync(() => this.roll()); + if (isError(rollResult)) { + console.error('Error while rolling log files', rollResult); + } + }, 0); + } + }); + } + + if (this.scheduledTask) { + this.destination.on('close', () => { + this.scheduledTask?.cancel(); + }); + } + + return this.destination; + } + + roll() { + if (!this.destination) { + return; + } + + this.destination.flushSync(); + + const tmpFile = `${this.opts.fileName}.tmp`; + fs.copyFileSync(this.opts.fileName, tmpFile); + fs.truncateSync(this.opts.fileName); + + const numFiles = this.createdFileNames.length; + const dirname = path.dirname(this.opts.fileName); + const addedFiles: string[] = []; + for (let i = numFiles; i > 0; i--) { + const f = nth(this.createdFileNames, i - 1); + if (isUndefined(f)) { + continue; + } + const rotateMatches = f.match(this.rotatePattern); + // This really shouldn't happen since the file shouldn't + // make it into the array in the first place if it doesn't + // match.. + if (isNull(rotateMatches)) { + continue; + } + + const rotateNum = parseInt(rotateMatches[1]); + + // Again shouldn't happen since we've already matched + // that this part of the file is a number... + if (isNaN(rotateNum)) { + continue; + } + + const nextNum = rotateNum + 1; + const nextFile = f.replace(this.rotatePattern, `${nextNum}`); + + const result = attemptSync(() => + fs.renameSync(path.join(dirname, f), path.join(dirname, nextFile)), + ); + + if (isError(result)) { + console.warn(`Error rotating ${path.join(dirname, f)}`); + } + + addedFiles.push(nextFile); + } + + const nextFile = this.buildFileName(1); + fs.renameSync(tmpFile, nextFile); + + this.createdFileNames = uniq([ + path.basename(nextFile), + ...this.createdFileNames, + ...addedFiles.slice(0, 1), + ]); + + if (this.opts.fileLimit) { + this.checkFileRemoval(); + } + } + + private initState() { + for (const file of fs.readdirSync(path.dirname(this.opts.fileName))) { + if (file.match(this.rotatePattern)) { + this.createdFileNames.push(file); + } + } + } + + private checkFileRemoval() { + const count = this.opts.fileLimit?.count; + + if (count && count >= 1 && this.createdFileNames.length > count) { + // We start removing at the first file to delete and take the rest of the + // array. In general this will be just one file. + const filesToRemove = this.createdFileNames.splice(count); + forEach( + map(filesToRemove, (file) => + path.join(path.dirname(this.opts.fileName), file), + ), + (file) => { + const res = attemptSync(() => fs.unlinkSync(file)); + if (isError(res)) { + console.warn(`Error while deleting log file ${file}`, res); + } + }, + ); + } + return; + } + + private buildFileName(num: number) { + return `${this.opts.fileName}.${num}${this.opts.fileExt ?? ''}`; + } +} + +class RollLogFileTask extends Task { + public ID: string | Tag; + + constructor(private dest: RollingLogDestination) { + super(); + } + + protected runInternal(): Promise { + return Promise.resolve(this.dest.roll()); + } +} + +function getFileSize(path: string) { + const result = attemptSync(() => fs.statSync(path)); + + return isError(result) ? 0 : result.size; +} diff --git a/server/src/util/schedulingUtil.test.ts b/server/src/util/schedulingUtil.test.ts index cfe3f02e1..09bcb0e91 100644 --- a/server/src/util/schedulingUtil.test.ts +++ b/server/src/util/schedulingUtil.test.ts @@ -1,6 +1,6 @@ import { EverySchedule } from '@tunarr/types/schemas'; import dayjs from './dayjs'; -import { parseEveryScheduleRule } from './schedulingUtil'; +import { scheduleRuleToCronString } from './schedulingUtil'; test('should parse every schedules', () => { const schedule: EverySchedule = { type: 'every', @@ -9,5 +9,5 @@ test('should parse every schedules', () => { unit: 'hour', }; - expect(parseEveryScheduleRule(schedule)).toEqual('0 4-23 * * *'); + expect(scheduleRuleToCronString(schedule)).toEqual('0 4-23 * * *'); }); diff --git a/server/src/util/schedulingUtil.ts b/server/src/util/schedulingUtil.ts index 8718e9947..757b92518 100644 --- a/server/src/util/schedulingUtil.ts +++ b/server/src/util/schedulingUtil.ts @@ -53,7 +53,7 @@ const defaultCronFields: CronFields = run(() => { ) as Required; }); -export function parseEveryScheduleRule(schedule: EverySchedule) { +export function scheduleRuleToCronString(schedule: EverySchedule) { const offset = dayjs.duration(schedule.offsetMs); function getRange( diff --git a/types/src/schemas/utilSchemas.ts b/types/src/schemas/utilSchemas.ts index 846cf5d93..620f79eec 100644 --- a/types/src/schemas/utilSchemas.ts +++ b/types/src/schemas/utilSchemas.ts @@ -130,3 +130,5 @@ export const ScheduleSchema = z.discriminatedUnion('type', [ CronScheduleSchema, EveryScheduleSchema, ]); + +export type Schedule = z.infer;