From d724808d216fa0606b45d836ab8b4b152cc66e5b Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Tue, 2 Mar 2021 13:02:34 -0500 Subject: [PATCH] Add `singleLine` config option (#158) * test: multiline option * Add singleLine option with tests (#97) Co-authored-by: Gregor Martynus --- Readme.md | 6 +- bin.js | 1 + index.js | 9 ++- lib/utils.js | 123 +++++++++++++++++++++---------- test/basic.test.js | 34 +++++++++ test/cli.test.js | 20 +++++ test/error-objects.test.js | 60 +++++++++++++++ test/lib/utils.internals.test.js | 14 ++++ 8 files changed, 224 insertions(+), 43 deletions(-) diff --git a/Readme.md b/Readme.md index 089172c9..7fbdd08e 100644 --- a/Readme.md +++ b/Readme.md @@ -85,6 +85,7 @@ node app.js | pino-pretty [jmespath](http://jmespath.org/). - `--ignore` (`-i`): Ignore one or several keys: (`-i time,hostname`) - `--hideObject` (`-H`): Hide objects from output (but not error object) +- `--singleLine` (`-S`): Print each log message on a single line (errors will still be multi-line) - `--config`: Specify a path to a config file containing the pino-pretty options. pino-pretty will attempt to read from a `.pino-prettyrc` in your current directory (`process.cwd`) if not specified @@ -142,8 +143,9 @@ with keys corresponding to the options described in [CLI Arguments](#cliargs): timestampKey: 'time', // --timestampKey translateTime: false, // --translateTime search: 'foo == `bar`', // --search - ignore: 'pid,hostname', // --ignore, - hideObject: false // --hideObject + ignore: 'pid,hostname', // --ignore + hideObject: false, // --hideObject + singleLine: false, // --singleLine customPrettifiers: {} } ``` diff --git a/bin.js b/bin.js index e4d561da..482b6073 100755 --- a/bin.js +++ b/bin.js @@ -46,6 +46,7 @@ args .option(['s', 'search'], 'Specify a search pattern according to jmespath') .option(['i', 'ignore'], 'Ignore one or several keys: (`-i time,hostname`)') .option(['H', 'hideObject'], 'Hide objects from output (but not error object)') + .option(['S', 'singleLine'], 'Print all non-error objects on a single line') .option('config', 'specify a path to a json file containing the pino-pretty options') args diff --git a/index.js b/index.js index ba96afcd..d7050a6f 100644 --- a/index.js +++ b/index.js @@ -36,7 +36,8 @@ const defaultOptions = { useMetadata: false, outputStream: process.stdout, customPrettifiers: {}, - hideObject: false + hideObject: false, + singleLine: false } module.exports = function prettyFactory (options) { @@ -53,6 +54,7 @@ module.exports = function prettyFactory (options) { 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 search = opts.search @@ -131,7 +133,7 @@ module.exports = function prettyFactory (options) { } if (line.length > 0) { - line += EOL + line += (singleLine ? ' ' : EOL) } if (log.type === 'Error' && log.stack) { @@ -151,7 +153,8 @@ module.exports = function prettyFactory (options) { customPrettifiers, errorLikeKeys: errorLikeObjectKeys, eol: EOL, - ident: IDENT + ident: IDENT, + singleLine }) line += prettifiedObject } diff --git a/lib/utils.js b/lib/utils.js index 97c47f18..a365ff9b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -26,7 +26,8 @@ module.exports = { module.exports.internals = { formatTime, - joinLinesWithIndentation + joinLinesWithIndentation, + prettifyError } /** @@ -291,6 +292,9 @@ function prettifyMetadata ({ log }) { * error objects. Default: `ERROR_LIKE_KEYS` constant. * @param {boolean} [input.excludeLoggerKeys] Indicates if known logger specific * keys should be excluded from prettification. Default: `true`. + * @param {boolean} [input.singleLine] Should non-error keys all be formatted + * on a single line? This does NOT apply to errors, which will still be + * multi-line. Default: `false` * * @returns {string} The prettified string. This can be as little as `''` if * there was nothing to prettify. @@ -302,56 +306,64 @@ function prettifyObject ({ skipKeys = [], customPrettifiers = {}, errorLikeKeys = ERROR_LIKE_KEYS, - excludeLoggerKeys = true + excludeLoggerKeys = true, + singleLine = false }) { - const objectKeys = Object.keys(input) const keysToIgnore = [].concat(skipKeys) if (excludeLoggerKeys === true) Array.prototype.push.apply(keysToIgnore, LOGGER_KEYS) let result = '' - const keysToIterate = objectKeys.filter(k => keysToIgnore.includes(k) === false) - for (let i = 0; i < objectKeys.length; i += 1) { - const keyName = keysToIterate[i] - const keyValue = input[keyName] - - if (keyValue === undefined) continue + // Split object keys into two categories: error and non-error + const { plain, errors } = Object.entries(input).reduce(({ plain, errors }, [k, v]) => { + if (keysToIgnore.includes(k) === false) { + // Pre-apply custom prettifiers, because all 3 cases below will need this + const pretty = typeof customPrettifiers[k] === 'function' + ? customPrettifiers[k](v, k, input) + : v + if (errorLikeKeys.includes(k)) { + errors[k] = pretty + } else { + plain[k] = pretty + } + } + return { plain, errors } + }, { plain: {}, errors: {} }) - let lines - if (typeof customPrettifiers[keyName] === 'function') { - lines = customPrettifiers[keyName](keyValue, keyName, input) - } else { - lines = stringifySafe(keyValue, null, 2) + if (singleLine) { + // Stringify the entire object as a single JSON line + if (Object.keys(plain).length > 0) { + result += stringifySafe(plain) } + result += eol + } else { + // Put each object entry on its own line + Object.entries(plain).forEach(([keyName, keyValue]) => { + // custom prettifiers are already applied above, so we can skip it now + const lines = typeof customPrettifiers[keyName] === 'function' + ? keyValue + : stringifySafe(keyValue, null, 2) - if (lines === undefined) continue - const joinedLines = joinLinesWithIndentation({ input: lines, ident, eol }) - - if (errorLikeKeys.includes(keyName) === true) { - const splitLines = `${ident}${keyName}: ${joinedLines}${eol}`.split(eol) - for (let j = 0; j < splitLines.length; j += 1) { - if (j !== 0) result += eol - - const line = splitLines[j] - if (/^\s*"stack"/.test(line)) { - const matches = /^(\s*"stack":)\s*(".*"),?$/.exec(line) - /* istanbul ignore else */ - if (matches && matches.length === 3) { - const indentSize = /^\s*/.exec(line)[0].length + 4 - const indentation = ' '.repeat(indentSize) - const stackMessage = matches[2] - result += matches[1] + eol + indentation + JSON.parse(stackMessage).replace(/\n/g, eol + indentation) - } - } else { - result += line - } - } - } else { + if (lines === undefined) return + + const joinedLines = joinLinesWithIndentation({ input: lines, ident, eol }) result += `${ident}${keyName}: ${joinedLines}${eol}` - } + }) } + // Errors + Object.entries(errors).forEach(([keyName, keyValue]) => { + // custom prettifiers are already applied above, so we can skip it now + const lines = typeof customPrettifiers[keyName] === 'function' + ? keyValue + : stringifySafe(keyValue, null, 2) + + if (lines === undefined) return + + result += prettifyError({ keyName, lines, eol, ident }) + }) + return result } @@ -387,3 +399,38 @@ function prettifyTime ({ log, timestampKey = TIMESTAMP_KEY, translateFormat = un return `[${time}]` } + +/** + * Prettifies an error string into a multi-line format. + * @param {object} input + * @param {string} input.keyName The key assigned to this error in the log object + * @param {string} input.lines The STRINGIFIED error. If the error field has a + * custom prettifier, that should be pre-applied as well + * @param {string} input.ident The indentation sequence to use + * @param {string} input.eol The EOL sequence to use + */ +function prettifyError ({ keyName, lines, eol, ident }) { + let result = '' + const joinedLines = joinLinesWithIndentation({ input: lines, ident, eol }) + const splitLines = `${ident}${keyName}: ${joinedLines}${eol}`.split(eol) + + for (let j = 0; j < splitLines.length; j += 1) { + if (j !== 0) result += eol + + const line = splitLines[j] + if (/^\s*"stack"/.test(line)) { + const matches = /^(\s*"stack":)\s*(".*"),?$/.exec(line) + /* istanbul ignore else */ + if (matches && matches.length === 3) { + const indentSize = /^\s*/.exec(line)[0].length + 4 + const indentation = ' '.repeat(indentSize) + const stackMessage = matches[2] + result += matches[1] + eol + indentation + JSON.parse(stackMessage).replace(/\n/g, eol + indentation) + } + } else { + result += line + } + } + + return result +} diff --git a/test/basic.test.js b/test/basic.test.js index 20beb1f0..3984f9fb 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -697,5 +697,39 @@ test('basic prettifier tests', (t) => { log.info({ key: 'value' }, 'hello world') }) + t.test('Prints extra objects on one line with singleLine=true', (t) => { + t.plan(1) + const pretty = prettyFactory({ + singleLine: true, + colorize: false, + customPrettifiers: { + upper: val => val.toUpperCase(), + undef: () => undefined + } + }) + const log = pino({}, new Writable({ + write (chunk, enc, cb) { + const formatted = pretty(chunk.toString()) + t.is(formatted, `[${epoch}] INFO (${pid} on ${hostname}): message {"extra":{"foo":"bar","number":42},"upper":"FOOBAR"}\n`) + + cb() + } + })) + log.info({ msg: 'message', extra: { foo: 'bar', number: 42 }, upper: 'foobar', undef: 'this will not show up' }) + }) + + t.test('Does not print empty object with singleLine=true', (t) => { + t.plan(1) + const pretty = prettyFactory({ singleLine: true, colorize: false }) + const log = pino({}, new Writable({ + write (chunk, enc, cb) { + const formatted = pretty(chunk.toString()) + t.is(formatted, `[${epoch}] INFO (${pid} on ${hostname}): message \n`) + cb() + } + })) + log.info({ msg: 'message' }) + }) + t.end() }) diff --git a/test/cli.test.js b/test/cli.test.js index c323e7c6..196b24bb 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -113,5 +113,25 @@ test('cli', (t) => { t.tearDown(() => child.kill()) }) + t.test('singleLine=true', (t) => { + t.plan(1) + + const logLineWithExtra = JSON.stringify(Object.assign(JSON.parse(logLine), { + extra: { + foo: 'bar', + number: 42 + } + })) + '\n' + + const env = { TERM: 'dumb' } + const child = spawn(process.argv[0], [bin, '--singleLine'], { env }) + child.on('error', t.threw) + child.stdout.on('data', (data) => { + t.is(data.toString(), `[${epoch}] INFO (42 on foo): hello world {"extra":{"foo":"bar","number":42}}\n`) + }) + child.stdin.write(logLineWithExtra) + t.tearDown(() => child.kill()) + }) + t.end() }) diff --git a/test/error-objects.test.js b/test/error-objects.test.js index 37773a22..e2c5cc91 100644 --- a/test/error-objects.test.js +++ b/test/error-objects.test.js @@ -113,6 +113,66 @@ test('error like objects tests', (t) => { log.info({ err }) }) + t.test('prettifies Error in property with singleLine=true', (t) => { + // singleLine=true doesn't apply to errors + t.plan(8) + const pretty = prettyFactory({ + singleLine: true, + errorLikeObjectKeys: ['err'] + }) + + const err = Error('hello world') + const expected = [ + '{"extra":{"a":1,"b":2}}', + err.message, + ...err.stack.split('\n') + ] + + const log = pino({ serializers: { err: serializers.err } }, new Writable({ + write (chunk, enc, cb) { + const formatted = pretty(chunk.toString()) + const lines = formatted.split('\n') + t.is(lines.length, expected.length + 5) + t.is(lines[0], `[${epoch}] INFO (${pid} on ${hostname}): {"extra":{"a":1,"b":2}}`) + t.match(lines[1], /\s{4}err: {/) + t.match(lines[2], /\s{6}"type": "Error",/) + t.match(lines[3], /\s{6}"message": "hello world",/) + t.match(lines[4], /\s{6}"stack":/) + t.match(lines[5], /\s{6}Error: hello world/) + // Node 12 labels the test `` + t.match(lines[6], /\s{10}(at Test.t.test|at Test.)/) + cb() + } + })) + + log.info({ err, extra: { a: 1, b: 2 } }) + }) + + t.test('prettifies Error in property within errorLikeObjectKeys with custom function', (t) => { + t.plan(1) + const pretty = prettyFactory({ + errorLikeObjectKeys: ['err'], + customPrettifiers: { + err: val => `error is ${val.message}` + } + }) + + const err = Error('hello world') + err.stack = 'Error: hello world\n at anonymous (C:\\project\\node_modules\\example\\index.js)' + const expected = err.stack.split('\n') + expected.unshift(err.message) + + const log = pino({ serializers: { err: serializers.err } }, new Writable({ + write (chunk, enc, cb) { + const formatted = pretty(chunk.toString()) + t.is(formatted, `[${epoch}] INFO (${pid} on ${hostname}):\n err: error is hello world\n`) + cb() + } + })) + + log.info({ err }) + }) + t.test('prettifies Error in property within errorLikeObjectKeys when stack has escaped characters', (t) => { t.plan(8) const pretty = prettyFactory({ diff --git a/test/lib/utils.internals.test.js b/test/lib/utils.internals.test.js index ace810fd..6b81012d 100644 --- a/test/lib/utils.internals.test.js +++ b/test/lib/utils.internals.test.js @@ -1,6 +1,7 @@ 'use strict' const tap = require('tap') +const stringifySafe = require('fast-safe-stringify') const { internals } = require('../../lib/utils') tap.test('#joinLinesWithIndentation', t => { @@ -81,3 +82,16 @@ tap.test('#formatTime', t => { t.end() }) + +tap.test('#prettifyError', t => { + t.test('prettifies error', t => { + const error = Error('Bad error!') + const lines = stringifySafe(error, Object.getOwnPropertyNames(error), 2) + + const prettyError = internals.prettifyError({ keyName: 'errorKey', lines, ident: ' ', eol: '\n' }) + t.match(prettyError, /\s*errorKey: {\n\s*"stack":[\s\S]*"message": "Bad error!"/) + t.end() + }) + + t.end() +})