Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add limit count option #71

Merged
merged 4 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ You can specify any of [Sonic-Boom options](https://github.com/pinojs/sonic-boom
Numerical values will be considered as a number of milliseconds.
Using a numerical value will always create a new file upon startup.

* `extension?` appends the provided string after the file number.
* `extension?`: appends the provided string after the file number.

* `limit?`: strategy used to remove oldest files when rotating them:

* `limit.count?`: number of log files, **in addition to the currently used file**.

Please not that `limit` only considers **created log files**. It will not consider any pre-existing files.
Therefore, starting your logger with a limit will never tries deleting older log files, created during previous executions.

## License

Expand Down
34 changes: 31 additions & 3 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

const { readdir, stat } = require('fs/promises')
const { readdir, stat, unlink } = require('fs/promises')
const { dirname, join } = require('path')

function parseSize (size) {
Expand Down Expand Up @@ -40,6 +40,17 @@ function parseFrequency (frequency) {
return null
}

function validateLimitOptions (limit) {
if (limit) {
if (typeof limit !== 'object') {
throw new Error('limit must be an object')
}
if (typeof limit.count !== 'number' || limit.count <= 0) {
throw new Error('limit.count must be a number greater than 0')
}
}
}

function getNextDay (start) {
return new Date(start + 24 * 60 * 60 * 1000).setHours(0, 0, 0, 0)
}
Expand Down Expand Up @@ -86,7 +97,7 @@ async function detectLastNumber (fileVal, time = null) {
async function readFileTrailingNumbers (folder, time) {
const numbers = [1]
for (const file of await readdir(folder)) {
if (time && !await isMatchingTime(join(folder, file), time)) {
if (time && !(await isMatchingTime(join(folder, file), time))) {
continue
}
const number = extractTrailingNumber(file)
Expand All @@ -107,4 +118,21 @@ async function isMatchingTime (filePath, time) {
return birthtimeMs >= time
}

module.exports = { buildFileName, detectLastNumber, parseFrequency, getNext, parseSize, getFileName }
async function checkFileRemoval (files, { count }) {
if (files.length > count) {
const filesToRemove = files.splice(0, files.length - 1 - count)
await Promise.allSettled(filesToRemove.map(file => unlink(file)))
}
return files
}

module.exports = {
buildFileName,
checkFileRemoval,
detectLastNumber,
parseFrequency,
getNext,
parseSize,
getFileName,
validateLimitOptions
}
39 changes: 35 additions & 4 deletions pino-roll.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
'use strict'

const SonicBoom = require('sonic-boom')
const { buildFileName, detectLastNumber, parseSize, parseFrequency, getNext } = require('./lib/utils')
const {
buildFileName,
checkFileRemoval,
detectLastNumber,
parseSize,
parseFrequency,
getNext,
validateLimitOptions
} = require('./lib/utils')

/**
* A function that returns a string path to the base file name
Expand Down Expand Up @@ -32,6 +40,14 @@ const { buildFileName, detectLastNumber, parseSize, parseFrequency, getNext } =
* Using a numerical value will always create a new file upon startup.
*
* @property {string} extension? - When specified, appends a file extension after the file number.
*
* @property {LimitOptions} limit? - strategy used to remove oldest files when rotating them.
*/

/**
* @typedef {object} LimitOptions
*
* @property {number} count? -number of log files, **in addition to the currently used file**.
*/

/**
Expand All @@ -45,15 +61,25 @@ const { buildFileName, detectLastNumber, parseSize, parseFrequency, getNext } =
* @param {PinoRollOptions} options - to configure file destionation, and rolling rules.
* @returns {SonicBoom} the Sonic boom steam, usabled as Pino transport.
*/
module.exports = async function ({ file, size, frequency, extension, ...opts } = {}) {
module.exports = async function ({
file,
size,
frequency,
extension,
limit,
...opts
} = {}) {
validateLimitOptions(limit)
const frequencySpec = parseFrequency(frequency)

let number = await detectLastNumber(file, frequencySpec?.start)

const fileName = buildFileName(file, number, extension)
const createdFileNames = [fileName]
let currentSize = 0
const maxSize = parseSize(size)

const destination = new SonicBoom({ ...opts, dest: buildFileName(file, number, extension) })
const destination = new SonicBoom({ ...opts, dest: fileName })

let rollTimeout
if (frequencySpec) {
Expand All @@ -75,7 +101,12 @@ module.exports = async function ({ file, size, frequency, extension, ...opts } =
}

function roll () {
destination.reopen(buildFileName(file, ++number, extension))
const fileName = buildFileName(file, ++number, extension)
destination.reopen(fileName)
if (limit) {
createdFileNames.push(fileName)
checkFileRemoval(createdFileNames, limit)
}
}

function scheduleRoll () {
Expand Down
19 changes: 18 additions & 1 deletion test/lib/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ const { writeFile, rm, stat } = require('fs/promises')
const { join } = require('path')
const { test } = require('tap')

const { buildFileName, detectLastNumber, getNext, parseFrequency, parseSize, getFileName } = require('../../lib/utils')
const {
buildFileName,
detectLastNumber,
getNext,
parseFrequency,
parseSize,
getFileName,
validateLimitOptions
} = require('../../lib/utils')
const { cleanAndCreateFolder, sleep } = require('../utils')

test('parseSize()', async ({ equal, throws }) => {
Expand Down Expand Up @@ -112,3 +120,12 @@ test('detectLastNumber()', async ({ test, beforeEach }) => {
equal(await detectLastNumber(join(folder, 'file')), 1, 'returns 1')
})
})

test('validateLimitOptions()', async ({ doesNotThrow, throws }) => {
doesNotThrow(() => validateLimitOptions(), 'allows no limit')
doesNotThrow(() => validateLimitOptions({ count: 2 }), 'allows valid count')
throws(() => validateLimitOptions(true), { message: 'limit must be an object' }, 'throws when limit is not an object')
throws(() => validateLimitOptions({ count: [] }), { message: 'limit.count must be a number greater than 0' }, 'throws when limit.count is not an number')
throws(() => validateLimitOptions({ count: -2 }), { message: 'limit.count must be a number greater than 0' }, 'throws when limit.count is negative')
throws(() => validateLimitOptions({ count: 0 }), { message: 'limit.count must be a number greater than 0' }, 'throws when limit.count is 0')
})
109 changes: 104 additions & 5 deletions test/pino-roll.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,79 @@ test('resume writing in last file', async ({ equal, rejects }) => {
stream.write(newContent)
await sleep(10)

equal(await readFile(`${file}.6`, 'utf8'), `${previousContent}${newContent}`, 'old and new content were written')
equal(
await readFile(`${file}.6`, 'utf8'),
`${previousContent}${newContent}`,
'old and new content were written'
)
rejects(stat(`${file}.1`), 'no other files created')
})

test('remove files based on count', async ({ ok, rejects }) => {
const file = join(logFolder, 'log')
const stream = await buildStream({
size: '20b',
file,
limit: { count: 1 }
})
for (let i = 1; i <= 5; i++) {
stream.write(`logged message #${i}\n`)
await sleep(20)
}
stream.end()
await stat(`${file}.2`)
let content = await readFile(`${file}.2`, 'utf8')
ok(content.includes('#3'), 'second file contains thrid log')
ok(content.includes('#4'), 'second file contains fourth log')
await stat(`${file}.3`)
content = await readFile(`${file}.3`, 'utf8')
ok(content.includes('#5'), 'third file contains fifth log')
await rejects(stat(`${file}.1`), 'first file was deleted')
await rejects(stat(`${file}.4`), 'no other files created')
})

test('do not remove pre-existing file when removing files based on count', async ({
ok,
equal,
rejects
}) => {
const file = join(logFolder, 'log')
await writeFile(`${file}.1`, 'oldest content')
await writeFile(`${file}.2`, 'old content')
const stream = await buildStream({
size: '20b',
file,
limit: { count: 2 }
})
for (let i = 1; i <= 7; i++) {
stream.write(`logged message #${i}\n`)
await sleep(20)
}
stream.end()
await stat(`${file}.1`)
let content = await readFile(`${file}.1`, 'utf8')
equal(content, 'oldest content', 'oldest file was not touched')
await stat(`${file}.3`)
content = await readFile(`${file}.3`, 'utf8')
ok(content.includes('#3'), 'second file contains third log')
ok(content.includes('#4'), 'second file contains fourth log')
await stat(`${file}.4`)
content = await readFile(`${file}.4`, 'utf8')
ok(content.includes('#5'), 'third file contains fifth log')
ok(content.includes('#6'), 'third file contains sixth log')
await stat(`${file}.5`)
content = await readFile(`${file}.5`, 'utf8')
ok(content.includes('#7'), 'fourth file contains seventh log')
await rejects(stat(`${file}.2`), 'resumed file was deleted')
await rejects(stat(`${file}.6`), 'no other files created')
})

test('throw on missing file parameter', async ({ rejects }) => {
rejects(buildStream(), { message: 'No file name provided' }, 'throws on missing file parameters')
rejects(
buildStream(),
{ message: 'No file name provided' },
'throws on missing file parameters'
)
})

test('throw on unexisting folder without mkdir', async ({ rejects }) => {
Expand All @@ -101,15 +168,47 @@ test('throw on unparseable size', async ({ rejects }) => {
rejects(
buildStream({ file: join(logFolder, 'log'), size }),
{ message: `${size} is not a valid size in KB, MB or GB` },
'throws on unexisting folder'
'throws on unparseable size'
)
})

test('throw on unparseable frequency', async ({ rejects }) => {
const frequency = 'unparseable'
rejects(
buildStream({ file: join(logFolder, 'log'), frequency }),
{ message: `${frequency} is neither a supported frequency or a number of milliseconds` },
'throws on unexisting folder'
{
message: `${frequency} is neither a supported frequency or a number of milliseconds`
},
'throws on unparseable frequency'
)
})

test('throw on unparseable limit object', async ({ rejects }) => {
rejects(
buildStream({ file: join(logFolder, 'log'), limit: 10 }),
{
message: 'limit must be an object'
},
'throws on limit option not being an object'
)
})

test('throw when limit.count is not a number', async ({ rejects }) => {
rejects(
buildStream({ file: join(logFolder, 'log'), limit: { count: true } }),
{
message: 'limit.count must be a number greater than 0'
},
'throws on limit.count not being a number'
)
})

test('throw when limit.count is 0', async ({ rejects }) => {
rejects(
buildStream({ file: join(logFolder, 'log'), limit: { count: 0 } }),
{
message: 'limit.count must be a number greater than 0'
},
'throws on limit.count being 0'
)
})