Skip to content

Commit

Permalink
Allow to provide custom levels and custom colors (#257)
Browse files Browse the repository at this point in the history
* Allow to provide custom levels and custom colors

* Allow log levels with 0 value

* Fixed linting

* Introduced factory instead of signleton override for customColors

* Use one-char x/X for custom level/colors

* Enabled tests and fixed coverage to 100%

Allow to override message, greyMessage  colors

* Rebase and requested fixes

* Reverted c94f89c

* Fixed issue with trailing comma
  • Loading branch information
matteozambon89 authored Jan 20, 2022
1 parent abaf2ac commit 0c3993a
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 22 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ profile-*

# Run Configuration
test/.tmp*

*.tgz
2 changes: 2 additions & 0 deletions bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ args
.option(['e', 'errorProps'], 'Comma separated list of properties on error objects to show (`*` for all properties) (defaults to ``)')
.option(['l', 'levelFirst'], 'Display the log level as the first output field')
.option(['L', 'minimumLevel'], 'Hide messages below the specified log level')
.option(['x', 'customLevels'], 'Override default levels (`-x err:99,info:1`)')
.option(['X', 'customColors'], 'Override default colors using names from https://www.npmjs.com/package/colorette (`-X err:red,info:blue`)')
.option(['k', 'errorLikeObjectKeys'], 'Define which keys contain error objects (`-k err,error`) (defaults to `err,error`)')
.option(['m', 'messageKey'], 'Highlight the message under the specified key', CONSTANTS.MESSAGE_KEY)
.option('levelKey', 'Detect the log level under the specified key', CONSTANTS.LEVEL_KEY)
Expand Down
44 changes: 41 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const defaultOptions = {
crlf: false,
errorLikeObjectKeys: ERROR_LIKE_KEYS,
errorProps: '',
customLevels: null,
customColors: null,
levelFirst: false,
messageKey: MESSAGE_KEY,
messageFormat: false,
Expand All @@ -57,11 +59,47 @@ function prettyFactory (options) {
const timestampKey = opts.timestampKey
const errorLikeObjectKeys = opts.errorLikeObjectKeys
const errorProps = opts.errorProps.split(',')
const customLevels = opts.customLevels
? opts.customLevels
.split(',')
.reduce((agg, value, idx) => {
const [levelName, levelIdx = idx] = value.split(':')

agg[levelIdx] = levelName.toUpperCase()

return agg
}, { default: 'USERLVL' })
: undefined
const customLevelNames = opts.customLevels
? opts.customLevels
.split(',')
.reduce((agg, value, idx) => {
const [levelName, levelIdx = idx] = value.split(':')

agg[levelName] = levelIdx

return agg
}, {})
: undefined
const customColors = opts.customColors
? opts.customColors
.split(',')
.reduce((agg, value) => {
const [level, color] = value.split(':')

const levelNum = customLevelNames !== undefined ? customLevelNames[level] : LEVEL_NAMES[level]
const colorIdx = levelNum !== undefined ? levelNum : level

agg.push([colorIdx, color])

return agg
}, [])
: undefined
const customPrettifiers = opts.customPrettifiers
const ignoreKeys = opts.ignore ? new Set(opts.ignore.split(',')) : undefined
const hideObject = opts.hideObject
const singleLine = opts.singleLine
const colorizer = colors(opts.colorize)
const colorizer = colors(opts.colorize, customColors)

return pretty

Expand All @@ -79,7 +117,7 @@ function prettyFactory (options) {
}

if (minimumLevel) {
const minimum = LEVEL_NAMES[minimumLevel] || Number(minimumLevel)
const minimum = (customLevelNames === undefined ? LEVEL_NAMES[minimumLevel] : customLevelNames[minimumLevel]) || Number(minimumLevel)
const level = log[levelKey === undefined ? LEVEL_KEY : levelKey]
if (level < minimum) return
}
Expand All @@ -90,7 +128,7 @@ function prettyFactory (options) {
log = filterLog(log, ignoreKeys)
}

const prettifiedLevel = prettifyLevel({ log, colorizer, levelKey, prettifier: customPrettifiers.level })
const prettifiedLevel = prettifyLevel({ log, colorizer, levelKey, prettifier: customPrettifiers.level, customLevels, customLevelNames })
const prettifiedMetadata = prettifyMetadata({ log, prettifiers: customPrettifiers })
const prettifiedTime = prettifyTime({ log, translateFormat: opts.translateTime, timestampKey, prettifier: customPrettifiers.time })

Expand Down
63 changes: 50 additions & 13 deletions lib/colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const plain = {
}

const { createColors } = require('colorette')
const { white, bgRed, red, yellow, green, blue, gray, cyan } = createColors({ useColor: true })
const availableColors = createColors({ useColor: true })
const { white, bgRed, red, yellow, green, blue, gray, cyan } = availableColors

const colored = {
default: white,
Expand All @@ -30,34 +31,64 @@ const colored = {
greyMessage: gray
}

function colorizeLevel (level, colorizer) {
function resolveCustomColoredColorizer (customColors) {
return customColors.reduce(
function (agg, [level, color]) {
agg[level] = typeof availableColors[color] === 'function' ? availableColors[color] : white

return agg
},
{ default: white, message: cyan, greyMessage: gray }
)
}

function colorizeLevel (level, colorizer, { customLevels, customLevelNames } = {}) {
const levels = customLevels || LEVELS
const levelNames = customLevelNames || LEVEL_NAMES

let levelNum = 'default'
if (Number.isInteger(+level)) {
return Object.prototype.hasOwnProperty.call(LEVELS, level)
? colorizer[level](LEVELS[level])
: colorizer.default(LEVELS.default)
levelNum = Object.prototype.hasOwnProperty.call(levels, level) ? level : levelNum
} else {
levelNum = Object.prototype.hasOwnProperty.call(levelNames, level.toLowerCase()) ? levelNames[level.toLowerCase()] : levelNum
}
const levelNum = LEVEL_NAMES[level.toLowerCase()] || 'default'
return colorizer[levelNum](LEVELS[levelNum])

const levelStr = levels[levelNum]

return Object.prototype.hasOwnProperty.call(colorizer, levelNum) ? colorizer[levelNum](levelStr) : colorizer.default(levelStr)
}

function plainColorizer (level) {
return colorizeLevel(level, plain)
function plainColorizer (level, opts) {
return colorizeLevel(level, plain, opts)
}
plainColorizer.message = plain.message
plainColorizer.greyMessage = plain.greyMessage

function coloredColorizer (level) {
return colorizeLevel(level, colored)
function coloredColorizer (level, opts) {
return colorizeLevel(level, colored, opts)
}
coloredColorizer.message = colored.message
coloredColorizer.greyMessage = colored.greyMessage

function customColoredColorizerFactory (customColors) {
const customColored = resolveCustomColoredColorizer(customColors)

const customColoredColorizer = function (level, opts) {
return colorizeLevel(level, customColored, opts)
}
customColoredColorizer.message = customColoredColorizer.message || customColored.message
customColoredColorizer.greyMessage = customColoredColorizer.greyMessage || customColored.greyMessage

return customColoredColorizer
}

/**
* Factory function get a function to colorized levels. The returned function
* also includes a `.message(str)` method to colorize strings.
*
* @param {boolean} [useColors=false] When `true` a function that applies standard
* terminal colors is returned.
* @param {array[]} [customColors] Touple where first item of each array is the level index and the second item is the color
*
* @returns {function} `function (level) {}` has a `.message(str)` method to
* apply colorization to a string. The core function accepts either an integer
Expand All @@ -66,6 +97,12 @@ coloredColorizer.greyMessage = colored.greyMessage
* colors as the integer `level` and will also default to `USERLVL` if the given
* string is not a recognized level name.
*/
module.exports = function getColorizer (useColors = false) {
return useColors ? coloredColorizer : plainColorizer
module.exports = function getColorizer (useColors = false, customColors) {
if (useColors && customColors !== undefined) {
return customColoredColorizerFactory(customColors)
} else if (useColors) {
return coloredColorizer
}

return plainColorizer
}
17 changes: 11 additions & 6 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,17 +217,19 @@ function prettifyErrorLog ({
* @param {object} input.log The log object.
* @param {function} [input.colorizer] A colorizer function that accepts a level
* value and returns a colorized string. Default: a no-op colorizer.
* @param {string} [levelKey='level'] The key to find the level under.
* @param {string} [input.levelKey='level'] The key to find the level under.
* @param {function} [input.prettifier] A user-supplied formatter to be called instead of colorizer.
* @param {object} [input.customLevels] The custom levels where key as the level index and value as the level name.
* @param {object} [input.customLevelNames] The custom level names where key is the level name and value is the level index.
*
* @returns {undefined|string} If `log` does not have a `level` property then
* `undefined` will be returned. Otherwise, a string from the specified
* `colorizer` is returned.
*/
function prettifyLevel ({ log, colorizer = defaultColorizer, levelKey = LEVEL_KEY, prettifier }) {
function prettifyLevel ({ log, colorizer = defaultColorizer, levelKey = LEVEL_KEY, prettifier, customLevels, customLevelNames }) {
if (levelKey in log === false) return undefined
const output = log[levelKey]
return prettifier ? prettifier(output) : colorizer(output)
return prettifier ? prettifier(output) : colorizer(output, { customLevels, customLevelNames })
}

/**
Expand All @@ -242,17 +244,20 @@ function prettifyLevel ({ log, colorizer = defaultColorizer, levelKey = LEVEL_KE
* @param {function} [input.colorizer] A colorizer function that has a
* `.message(str)` method attached to it. This function should return a colorized
* string which will be the "prettified" message. Default: a no-op colorizer.
* @param {string} [input.levelLabel='levelLabel'] The label used to output the log level
* @param {string} [input.levelKey='level'] The key to find the level under.
* @param {object} [input.customLevels] The custom levels where key as the level index and value as the level name.
*
* @returns {undefined|string} If the message key is not found, or the message
* key is not a string, then `undefined` will be returned. Otherwise, a string
* that is the prettified message.
*/
function prettifyMessage ({ log, messageFormat, messageKey = MESSAGE_KEY, colorizer = defaultColorizer, levelLabel = LEVEL_LABEL }) {
function prettifyMessage ({ log, messageFormat, messageKey = MESSAGE_KEY, colorizer = defaultColorizer, levelLabel = LEVEL_LABEL, levelKey = LEVEL_KEY, customLevels }) {
if (messageFormat && typeof messageFormat === 'string') {
const message = String(messageFormat).replace(/{([^{}]+)}/g, function (match, p1) {
// return log level as string instead of int
if (p1 === levelLabel && log[LEVEL_KEY]) {
return LEVELS[log[LEVEL_KEY]]
if (p1 === levelLabel && log[levelKey]) {
return customLevels === undefined ? LEVELS[log[levelKey]] : customLevels[log[levelKey]]
}
// Parse nested key access, e.g. `{keyA.subKeyB}`.
return p1.split('.').reduce(function (prev, curr) {
Expand Down
63 changes: 63 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,69 @@ test('cli', (t) => {
})
})

;['--customLevels', '-x'].forEach((optionName) => {
t.test(`customize levels via ${optionName}`, (t) => {
t.plan(1)
const logLine = '{"level":1,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n'
const child = spawn(process.argv[0], [bin, optionName, 'err:99,info:1'], { env })
child.on('error', t.threw)
child.stdout.on('data', (data) => {
t.equal(data.toString(), `[${epoch}] INFO (42 on foo): hello world\n`)
})
child.stdin.write(logLine)
t.teardown(() => child.kill())
})

t.test(`customize levels via ${optionName} without index`, (t) => {
t.plan(1)
const logLine = '{"level":1,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n'
const child = spawn(process.argv[0], [bin, optionName, 'err:99,info'], { env })
child.on('error', t.threw)
child.stdout.on('data', (data) => {
t.equal(data.toString(), `[${epoch}] INFO (42 on foo): hello world\n`)
})
child.stdin.write(logLine)
t.teardown(() => child.kill())
})

t.test(`customize levels via ${optionName} with minimumLevel`, (t) => {
t.plan(1)
const child = spawn(process.argv[0], [bin, '--minimumLevel', 'err', optionName, 'err:99,info:1'], { env })
child.on('error', t.threw)
child.stdout.on('data', (data) => {
t.equal(data.toString(), `[${epoch}] ERR (42 on foo): hello world\n`)
})
child.stdin.write('{"level":1,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n')
child.stdin.write('{"level":99,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n')
t.teardown(() => child.kill())
})
})

;['--customColors', '-X'].forEach((optionName) => {
t.test(`customize levels via ${optionName}`, (t) => {
t.plan(1)
const child = spawn(process.argv[0], [bin, optionName, 'info:blue,message:red'], { env })
child.on('error', t.threw)
child.stdout.on('data', (data) => {
t.equal(data.toString(), `[${epoch}] INFO (42 on foo): hello world\n`)
})
child.stdin.write(logLine)
t.teardown(() => child.kill())
})

t.test(`customize levels via ${optionName} with customLevels`, (t) => {
t.plan(1)
const logLine = '{"level":1,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo"}\n'
const child = spawn(process.argv[0], [bin, '--customLevels', 'err:99,info', optionName, 'info:blue,message:red'], { env })
child.on('error', t.threw)
child.stdout.on('data', (data) => {
t.equal(data.toString(), `[${epoch}] INFO (42 on foo): hello world\n`)
})
child.stdin.write(logLine)
t.teardown(() => child.kill())
})
})

t.test('does ignore escaped keys', (t) => {
t.plan(1)
const child = spawn(process.argv[0], [bin, '-i', 'log\\.domain\\.corp/foo'], { env })
Expand Down
41 changes: 41 additions & 0 deletions test/lib/colors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,48 @@ const testColoringColorizer = getColorizer => async t => {
t.equal(colorized, '\u001B[90mfoo\u001B[39m')
}

const testCustomColoringColorizer = getColorizer => async t => {
const customLevels = {
0: 'INFO',
1: 'ERR',
default: 'USERLVL'
}
const customLevelNames = {
info: 0,
err: 1
}
const customColors = [
[0, 'not-a-color'],
[1, 'red']
]
const opts = {
customLevels,
customLevelNames
}

const colorizer = getColorizer(true, customColors)
let colorized = colorizer(1, opts)
t.equal(colorized, '\u001B[31mERR\u001B[39m')

colorized = colorizer(0, opts)
t.equal(colorized, '\u001B[37mINFO\u001B[39m')

colorized = colorizer(900)
t.equal(colorized, '\u001B[37mUSERLVL\u001B[39m')

colorized = colorizer('err', opts)
t.equal(colorized, '\u001B[31mERR\u001B[39m')

colorized = colorizer('info', opts)
t.equal(colorized, '\u001B[37mINFO\u001B[39m')

colorized = colorizer('use-default')
t.equal(colorized, '\u001B[37mUSERLVL\u001B[39m')
}

test('returns default colorizer - private export', testDefaultColorizer(getColorizerPrivate))
test('returns default colorizer - public export', testDefaultColorizer(getColorizerPublic))
test('returns colorizing colorizer - private export', testColoringColorizer(getColorizerPrivate))
test('returns colorizing colorizer - public export', testColoringColorizer(getColorizerPublic))
test('returns custom colorizing colorizer - private export', testCustomColoringColorizer(getColorizerPrivate))
test('returns custom colorizing colorizer - public export', testCustomColoringColorizer(getColorizerPublic))
5 changes: 5 additions & 0 deletions test/lib/utils.public.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ tap.test('prettifyMessage', t => {
t.equal(str, '[30] INFO appModule - foo')
})

t.test('returns message formatted by `messageFormat` option - levelLabel & customLevels', async t => {
const str = prettifyMessage({ log: { msg: 'foo', context: 'appModule', level: 123 }, messageFormat: '[{level}] {levelLabel} {context} - {msg}', customLevels: { 123: 'CUSTOM' } })
t.equal(str, '[123] CUSTOM appModule - foo')
})

t.test('`messageFormat` supports nested curly brackets', async t => {
const str = prettifyMessage({ log: { level: 30 }, messageFormat: '{{level}}-{level}-{{level}-{level}}' })
t.equal(str, '{30}-30-{30-30}')
Expand Down

0 comments on commit 0c3993a

Please sign in to comment.