Skip to content

Commit

Permalink
Add date format option (#93)
Browse files Browse the repository at this point in the history
* Add date format option

* Move unit test to separate file
  • Loading branch information
subahanumanth authored Sep 27, 2024
1 parent d6d0e68 commit d0e14b2
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 10 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ You can specify any of [Sonic-Boom options](https://github.com/pinojs/sonic-boom

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

* `dateFormat?`: the format for appending the current date/time to the file name.
When specified, appends the date/time in the provided format to the log file name.
Supports date formats from `date-fns` (see: [date-fns format documentation](https://date-fns.org/v4.1.0/docs/format)).
For example:
Daily: `'yyyy-MM-dd'``error.2024-09-24.log`
Hourly: `'yyyy-MM-dd-hh'``error.2024-09-24-05.log`

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.

Expand Down
28 changes: 25 additions & 3 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { readdir, stat, unlink, symlink, lstat, readlink } = require('fs/promises')
const { dirname, join } = require('path')
const { format } = require('date-fns')

function parseSize (size) {
let multiplier = 1024 ** 2
Expand Down Expand Up @@ -80,8 +81,9 @@ function getFileName (fileVal) {
return typeof fileVal === 'function' ? fileVal() : fileVal
}

function buildFileName (fileVal, lastNumber = 1, extension) {
return `${getFileName(fileVal)}.${lastNumber}${extension ?? ''}`
function buildFileName (fileVal, date, lastNumber = 1, extension) {
const dateStr = date ? `.${date}` : ''
return `${getFileName(fileVal)}${dateStr}.${lastNumber}${extension ?? ''}`
}

async function getFileSize (filePath) {
Expand Down Expand Up @@ -160,6 +162,24 @@ async function createSymlink (fileVal) {
return false
}

function validateDateFormat (formatStr) {
const invalidChars = /[/\\?%*:|"<>]/g
if (invalidChars.test(formatStr)) {
throw new Error(`${formatStr} contains invalid characters`)
}
return true
}

function parseDate (formatStr, frequencySpec, parseStart = false) {
if (!(formatStr && (frequencySpec?.frequency === 'daily' || frequencySpec?.frequency === 'hourly'))) return null

try {
return format(parseStart ? frequencySpec.start : frequencySpec.next, formatStr)
} catch (error) {
throw new Error(`${formatStr} must be a valid date format`)
}
}

module.exports = {
buildFileName,
checkFileRemoval,
Expand All @@ -172,5 +192,7 @@ module.exports = {
parseSize,
getFileName,
getFileSize,
validateLimitOptions
validateLimitOptions,
parseDate,
validateDateFormat
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
],
"license": "MIT",
"dependencies": {
"date-fns": "^4.1.0",
"sonic-boom": "^4.0.1"
},
"devDependencies": {
"date-fns": "^3.6.0",
"husky": "^9.0.11",
"pino": "^9.2.0",
"snazzy": "^9.0.0",
Expand Down
17 changes: 13 additions & 4 deletions pino-roll.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const {
parseFrequency,
getNext,
getFileSize,
validateLimitOptions
validateLimitOptions,
parseDate,
validateDateFormat
} = require('./lib/utils')

/**
Expand Down Expand Up @@ -46,6 +48,9 @@ const {
* @property {boolean} symlink? - When specified, creates a symlink to the current log file.
*
* @property {LimitOptions} limit? - strategy used to remove oldest files when rotating them.
*
* @property {string} dateFormat? - When specified, appends the current date/time to the file name in the provided format.
* Supports date formats from `date-fns` (see: https://date-fns.org/v4.1.0/docs/format), such as 'yyyy-MM-dd' and 'yyyy-MM-dd-hh'.
*/

/**
Expand All @@ -72,14 +77,17 @@ module.exports = async function ({
extension,
limit,
symlink,
dateFormat,
...opts
} = {}) {
validateLimitOptions(limit)
validateDateFormat(dateFormat)
const frequencySpec = parseFrequency(frequency)

let date = parseDate(dateFormat, frequencySpec, true)
let number = await detectLastNumber(file, frequencySpec?.start)

let fileName = buildFileName(file, number, extension)
let fileName = buildFileName(file, date, number, extension)
const createdFileNames = [fileName]
let currentSize = await getFileSize(fileName)
const maxSize = parseSize(size)
Expand All @@ -103,7 +111,7 @@ module.exports = async function ({
currentSize += writtenSize
if (fileName === destination.file && currentSize >= maxSize) {
currentSize = 0
fileName = buildFileName(file, ++number, extension)
fileName = buildFileName(file, date, ++number, extension)
// delay to let the destination finish its write
destination.once('drain', roll)
}
Expand All @@ -124,7 +132,8 @@ module.exports = async function ({
function scheduleRoll () {
clearTimeout(rollTimeout)
rollTimeout = setTimeout(() => {
fileName = buildFileName(file, ++number, extension)
date = parseDate(dateFormat, frequencySpec)
fileName = buildFileName(file, date, ++number, extension)
roll()
frequencySpec.next = getNext(frequency)
scheduleRoll()
Expand Down
125 changes: 125 additions & 0 deletions test/date-format-option.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use strict'

const { once } = require('events')
const { stat, readFile } = require('fs/promises')
const { join } = require('path')
const { test, beforeEach } = require('tap')
const { format, startOfHour } = require('date-fns')

const { buildStream, cleanAndCreateFolder, sleep } = require('./utils')

const logFolder = join('logs', 'date-format-option', 'roll')

beforeEach(() => cleanAndCreateFolder(logFolder))

test('rotate file with date format based on frequency', async ({ ok, rejects }) => {
const file = join(logFolder, 'log')
const stream = await buildStream({ frequency: 'hourly', file, dateFormat: 'yyyy-MM-dd-hh' })
stream.write('logged message #1\n')
stream.write('logged message #2\n')
stream.end()

const fileName = `${file}.${format(new Date(), 'yyyy-MM-dd-hh')}`
const content = await readFile(`${fileName}.1`, 'utf8')
ok(content.includes('#1'), 'first file contains first log')
ok(content.includes('#2'), 'first file contains second log')
rejects(stat(`${fileName}.2`), 'no other files created')
})

test('rotate file based on custom time and date format', async ({ ok, notOk, rejects }) => {
const file = join(logFolder, 'log')
const stream = await buildStream({ frequency: 100, file, dateFormat: 'yyyy-MM-dd-hh' })
stream.write('logged message #1\n')
stream.write('logged message #2\n')
await sleep(110)
stream.write('logged message #3\n')
stream.write('logged message #4\n')
await sleep(110)
stream.end()
await stat(`${file}.1`)
let content = await readFile(`${file}.1`, 'utf8')
ok(content.includes('#1'), 'first file contains first log')
ok(content.includes('#2'), 'first file contains second log')
notOk(content.includes('#3'), 'first file does not contains third log')
await stat(`${file}.2`)
content = await readFile(`${file}.2`, 'utf8')
ok(content.includes('#3'), 'first file contains third log')
ok(content.includes('#4'), 'first file contains fourth log')
notOk(content.includes('#2'), 'first file does not contains second log')
await stat(`${file}.3`)
rejects(stat(`${file}.4`), 'no other files created')
})

test('rotate file based on size and date format', async ({ ok, rejects }) => {
const file = join(logFolder, 'log')
const fileWithDate = `${file}.${format(startOfHour(new Date()).getTime(), 'yyyy-MM-dd-hh')}`
const size = 20
const stream = await buildStream({ frequency: 'hourly', size: `${size}b`, file, dateFormat: 'yyyy-MM-dd-hh' })
stream.write('logged message #1\n')
stream.write('logged message #2\n')
await once(stream, 'ready')
stream.write('logged message #3\n')
stream.end()
let stats = await stat(`${fileWithDate}.1`)
ok(
size <= stats.size && stats.size <= size * 2,
`first file size: ${size} <= ${stats.size} <= ${size * 2}`
)
stats = await stat(`${fileWithDate}.2`)
ok(stats.size <= size, `second file size: ${stats.size} <= ${size}`)
rejects(stat(`${fileWithDate}.3`), 'no other files created')
})

test('rotate file based on size and date format with custom frequency', async ({ ok, rejects }) => {
const file = join(logFolder, 'log')
const size = 20
const stream = await buildStream({ frequency: 1000, size: `${size}b`, file, dateFormat: 'yyyy-MM-dd-hh' })
stream.write('logged message #1\n')
stream.write('logged message #2\n')
await once(stream, 'ready')
stream.write('logged message #3\n')
await sleep(1010)
stream.write('logged message #4\n')
stream.end()

let stats = await stat(`${file}.1`)
ok(
size <= stats.size && stats.size <= size * 2,
`first file size: ${size} <= ${stats.size} <= ${size * 2}`
)
stats = await stat(`${file}.2`)
ok(stats.size <= size, `second file size: ${stats.size} <= ${size}`)
stats = await stat(`${file}.3`)
const content = await readFile(`${file}.3`, 'utf8')
ok(content.includes('#4'), 'Rotated file should have the log')
rejects(stat(`${file}.4`), 'no other files created')
})

test('rotate file based on size and date format without frequency', async ({ ok, rejects }) => {
const file = join(logFolder, 'log')
const size = 20
const stream = await buildStream({ size: `${size}b`, file, dateFormat: 'yyyy-MM-dd-hh' })
stream.write('logged message #1\n')
stream.write('logged message #2\n')
await once(stream, 'ready')
stream.write('logged message #3\n')
stream.end()
let stats = await stat(`${file}.1`)
ok(
size <= stats.size && stats.size <= size * 2,
`first file size: ${size} <= ${stats.size} <= ${size * 2}`
)
stats = await stat(`${file}.2`)
ok(stats.size <= size, `second file size: ${stats.size} <= ${size}`)
rejects(stat(`${file}.3`), 'no other files created')
})

test('throw on invalid date format', async ({ rejects }) => {
rejects(
buildStream({ file: join(logFolder, 'log'), dateFormat: 'yyyy%MM%dd' }),
{
message: 'yyyy%MM%dd contains invalid characters'
},
'throws on invalid date format'
)
})
30 changes: 28 additions & 2 deletions test/lib/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { addDays, addHours, startOfDay, startOfHour } = require('date-fns')
const { writeFile, rm, stat, readlink, symlink } = require('fs/promises')
const { join } = require('path')
const { test } = require('tap')
const { format } = require('date-fns')

const {
buildFileName,
Expand All @@ -16,7 +17,9 @@ const {
parseFrequency,
parseSize,
getFileName,
validateLimitOptions
validateLimitOptions,
validateDateFormat,
parseDate
} = require('../../lib/utils')
const { cleanAndCreateFolder, sleep } = require('../utils')

Expand Down Expand Up @@ -78,7 +81,30 @@ test('buildFileName()', async ({ equal, throws }) => {
throws(buildFileName, 'throws on empty input')
equal(buildFileName('my-file'), 'my-file.1', 'appends 1 by default')
equal(buildFileName(() => 'my-func'), 'my-func.1', 'appends 1 by default')
equal(buildFileName('my-file', 5, ext), 'my-file.5.json', 'appends number and extension')
equal(buildFileName('my-file', null, 5, ext), 'my-file.5.json', 'appends number and extension')
equal(buildFileName('my-file', '2024-09-26'), 'my-file.2024-09-26.1', 'appends date')
equal(buildFileName('my-file', '2024-09-26-07'), 'my-file.2024-09-26-07.1', 'appends date and hour')
equal(buildFileName('my-file', '2024-09-26', 5), 'my-file.2024-09-26.5', 'appends date and number')
equal(buildFileName('my-file', '2024-09-26', 5, ext), 'my-file.2024-09-26.5.json', 'appends date, number and extension')
})

test('validateDateFormat()', async ({ equal, throws }) => {
equal(validateDateFormat('2024-09-26'), true, 'returns null on valid date format')
equal(validateDateFormat('2024-09-26-10'), true, 'returns null on valid date time format')
throws(() => validateDateFormat('2024:09:26'), 'throws on invalid date format with semicolon')
throws(() => validateDateFormat('2024*09*26'), 'throws on invalid date format with asterisk')
throws(() => validateDateFormat('2024<09>26'), 'throws on invalid date format with <>')
})

test('parseDate()', async ({ equal, throws }) => {
const today = new Date()
const frequencySpec = { frequency: 'hourly', start: startOfHour(today).getTime(), next: startOfHour(addHours(today, 1)).getTime() }
equal(parseDate(null, frequencySpec), null, 'returns null on empty format')
equal(parseDate('yyyy-MM-dd', { frequency: 100 }), null, 'returns null on custom frequency')
equal(parseDate('yyyy-MM-dd-hh', frequencySpec, true), format(frequencySpec.start, 'yyyy-MM-dd-hh'), 'parse start date time')
equal(parseDate('yyyy-MM-dd-hh', frequencySpec), format(frequencySpec.next, 'yyyy-MM-dd-hh'), 'parse next date time')
throws(() => parseDate('yyyy-MM-dd-hhU', frequencySpec), 'throws on invalid date format with character U')
throws(() => parseDate('yyyy-MM-dd-hhJ', frequencySpec), 'throws on invalid date format with character J')
})

test('getFileSize()', async ({ test, beforeEach }) => {
Expand Down

0 comments on commit d0e14b2

Please sign in to comment.