From ff7b2384398f4c808f2cc6271e1f2726ee973340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20K=C3=B6ster?= <40566351+timlohse1104@users.noreply.github.com> Date: Tue, 25 Jul 2023 13:17:28 +0200 Subject: [PATCH] Add support for messageFormat strings containing conditionals (#442) Co-authored-by: Matteo Collina Co-authored-by: James Sumners <321201+jsumners@users.noreply.github.com> --- Readme.md | 8 ++++ lib/utils.js | 63 +++++++++++++++++++++++------- test/lib/utils.internals.test.js | 67 ++++++++++++++++++++++++++++++++ test/lib/utils.public.test.js | 5 +++ 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/Readme.md b/Readme.md index d39082ba..62b23002 100644 --- a/Readme.md +++ b/Readme.md @@ -343,6 +343,14 @@ const levelPrettifier = logLevel => `LEVEL: ${levelColorize(logLevel)}` } ``` +In addition to this, if / end statement blocks can also be specified. Else statements and nested conditions are not supported. + +```js +{ + messageFormat: '{levelLabel} - {if pid}{pid} - {end}url:{req.url}' +} +``` + This option can also be defined as a `function` with this prototype: ```js diff --git a/lib/utils.js b/lib/utils.js index db3536ba..533578f4 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -42,7 +42,8 @@ module.exports.internals = { deleteLogProperty, splitPropertyKey, createDate, - isValidDate + isValidDate, + interpretConditionals } /** @@ -263,16 +264,21 @@ function prettifyLevel ({ log, colorizer = defaultColorizer, levelKey = LEVEL_KE */ function prettifyMessage ({ log, messageFormat, messageKey = MESSAGE_KEY, colorizer = defaultColorizer, levelLabel = LEVEL_LABEL, levelKey = LEVEL_KEY, customLevels, useOnlyCustomProps }) { if (messageFormat && typeof messageFormat === 'string') { - const message = String(messageFormat).replace(/{([^{}]+)}/g, function (match, p1) { - // return log level as string instead of int - let level - if (p1 === levelLabel && (level = getPropertyValue(log, levelKey)) !== undefined) { - const condition = useOnlyCustomProps ? customLevels === undefined : customLevels[level] === undefined - return condition ? LEVELS[level] : customLevels[level] - } - // Parse nested key access, e.g. `{keyA.subKeyB}`. - return getPropertyValue(log, p1) || '' - }) + const parsedMessageFormat = interpretConditionals(messageFormat, log) + + const message = String(parsedMessageFormat).replace( + /{([^{}]+)}/g, + function (match, p1) { + // return log level as string instead of int + let level + if (p1 === levelLabel && (level = getPropertyValue(log, levelKey)) !== undefined) { + const condition = useOnlyCustomProps ? customLevels === undefined : customLevels[level] === undefined + return condition ? LEVELS[level] : customLevels[level] + } + + // Parse nested key access, e.g. `{keyA.subKeyB}`. + return getPropertyValue(log, p1) || '' + }) return colorizer.message(message) } if (messageFormat && typeof messageFormat === 'function') { @@ -383,7 +389,7 @@ function prettifyObject ({ // 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 + // Pre-apply custom prettifiers, because all 3 cases below will need this const pretty = typeof customPrettifiers[k] === 'function' ? customPrettifiers[k](v, k, input) : v @@ -557,7 +563,7 @@ function splitPropertyKey (key) { * * @param {object} obj The object to be searched. * @param {string|string[]} property A string, or an array of strings, identifying - * the property to be retrieved from the object. + * the property to be retrieved from the object. * Accepts nested properties delimited by a `.`. * Delimiter can be escaped to preserve property names that contain the delimiter. * e.g. `'prop1.prop2'` or `'prop2\.domain\.corp.prop2'`. @@ -766,3 +772,34 @@ function handleCustomlevelNamesOpts (cLevels) { return {} } } + +/** + * Translates all conditional blocks from within the messageFormat. Translates any matching + * {if key}{key}{end} statements and returns everything between if and else blocks if the key provided + * was found in log. + * + * @param {string} messageFormat A format string or function that defines how the + * logged message should be conditionally formatted, e.g. `'{if level}{level}{end} - {if req.id}{req.id}{end}'`. + * @param {object} log The log object to be modified. + * + * @returns {string} The parsed messageFormat. + */ +function interpretConditionals (messageFormat, log) { + messageFormat = messageFormat.replace(/{if (.*?)}(.*?){end}/g, replacer) + + // Remove non-terminated if blocks + messageFormat = messageFormat.replace(/{if (.*?)}/g, '') + // Remove floating end blocks + messageFormat = messageFormat.replace(/{end}/g, '') + + return messageFormat.replace(/\s+/g, ' ').trim() + + function replacer (_, key, value) { + const propertyValue = getPropertyValue(log, key) + if (propertyValue && value.includes(key)) { + return value.replace(new RegExp('{' + key + '}', 'g'), propertyValue) + } else { + return '' + } + } +} diff --git a/test/lib/utils.internals.test.js b/test/lib/utils.internals.test.js index a0c2ea91..43919acb 100644 --- a/test/lib/utils.internals.test.js +++ b/test/lib/utils.internals.test.js @@ -222,3 +222,70 @@ tap.test('#getPropertyValue', t => { t.end() }) + +tap.test('#interpretConditionals', t => { + const logData = { + level: 30, + data1: { + data2: 'bar' + }, + msg: 'foo' + } + + t.test('interpretConditionals translates if / else statement to found property value', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level} - {if data1.data2}{data1.data2}{end}', log), '{level} - bar') + }) + + t.test('interpretConditionals translates if / else statement to found property value and leave unmatched property key untouched', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level} - {if data1.data2}{data1.data2} ({msg}){end}', log), '{level} - bar ({msg})') + }) + + t.test('interpretConditionals removes non-terminated if statements', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level} - {if data1.data2}{data1.data2}', log), '{level} - {data1.data2}') + }) + + t.test('interpretConditionals removes floating end statements', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level} - {data1.data2}{end}', log), '{level} - {data1.data2}') + }) + + t.test('interpretConditionals removes floating end statements within translated if / end statements', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level} - {if msg}({msg}){end}{end}', log), '{level} - (foo)') + }) + + t.test('interpretConditionals removes if / end blocks if existent condition key does not match existent property key', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level}{if msg}{data1.data2}{end}', log), '{level}') + }) + + t.test('interpretConditionals removes if / end blocks if non-existent condition key does not match existent property key', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level}{if foo}{msg}{end}', log), '{level}') + }) + + t.test('interpretConditionals removes if / end blocks if existent condition key does not match non-existent property key', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level}{if msg}{foo}{end}', log), '{level}') + }) + + t.test('interpretConditionals removes if / end blocks if non-existent condition key does not match non-existent property key', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level}{if foo}{bar}{end}', log), '{level}') + }) + + t.test('interpretConditionals removes if / end blocks if nested condition key does not match property key', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{level}{if data1.msg}{data1.data2}{end}', log), '{level}') + }) + + t.test('interpretConditionals removes nested if / end statement blocks', async t => { + const log = fastCopy(logData) + t.equal(internals.interpretConditionals('{if msg}{if data1.data2}{msg}{data1.data2}{end}{end}', log), 'foo{data1.data2}') + }) + + t.end() +}) diff --git a/test/lib/utils.public.test.js b/test/lib/utils.public.test.js index c6b20e2c..068af254 100644 --- a/test/lib/utils.public.test.js +++ b/test/lib/utils.public.test.js @@ -141,6 +141,11 @@ tap.test('prettifyMessage', t => { t.equal(str, 'localhost/test - param: - foo') }) + t.test('`messageFormat` supports conditional blocks', async t => { + const str = prettifyMessage({ log: { level: 30, req: { id: 'foo' } }, messageFormat: '{level} | {if req.id}({req.id}){end}{if msg}{msg}{end}' }) + t.equal(str, '30 | (foo)') + }) + t.test('`messageFormat` supports function definition', async t => { const str = prettifyMessage({ log: { level: 30, request: { url: 'localhost/test' }, msg: 'incoming request' },