From 4e1b7cbd7d451af736c0cd0fb49fd69a32efcebb Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 27 Feb 2026 21:44:01 +0000 Subject: [PATCH] feat: Implement label filtering --- README.md | 37 ++++++++ src/funcs.ts | 53 +++++++++++ src/index.ts | 24 ++++- src/loggers.ts | 27 ++++-- src/types.ts | 12 +++ tests/index.test.ts | 220 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 363 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 644f5b2..a54a17b 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,43 @@ logger.debug({labels: ['MyLabel']}, 'My log message'); **NOTE:** If a label *function* throws an error then the label will be the error's message. Make sure your labels don't throw! +#### Filtering By Label + +Logs can be filtered by labels similar to how [debug-js](https://github.com/debug-js/debug) works. + +By default *no* filtering is done. Your child loggers follow the Pino Child Logger behavior of inherting parent log level. + +Using either `LOG_FILTER_ENABLE` or `LOG_FILTER_DISABLE` you can enable or disable a child logger and it's children. + +```ts + +/* + * process.env.LOG_FILTER_ENABLE = 'Third:Fourth,Second:*:Foo,Bar' + */ + +import {loggerApp, childLogger} from '@foxxmd/logging'; + +logger = loggerApp(); +logger.debug('Test'); +// [2024-03-07 11:27:41.944 -0500] DEBUG: Test + +const nestedChild1 = childLogger(logger, 'First'); +nestedChild1.debug('I am nested one level'); +// [2024-03-07 11:27:41.945 -0500] DEBUG: [First] I am nested one level + +const nestedChild2 = childLogger(nestedChild1, ['Second', 'Third'], { level: 'silent' }); +nestedChild2.info('I do not log because of set level and not enabled by filter'); + +const nestedChild3 = childLogger(nestedChild2, ['Fourth']); +nestedChild3.info('I do log because of filter Third:Fourth'); + +const nestedChild4 = childLogger(nestedChild3, ['Foo']); +nestedChild4.info('I do log because of filter Second:*:Foo'); + +const nestedChild5 = childLogger(nestedChild4, ['Bar']); +nestedChild5.info('I do log because of filter Bar'); +``` + ### Serializing Objects and Errors Passing an object or array as the first argument to the logger will cause the object to be JSONified and pretty printed below the log message diff --git a/src/funcs.ts b/src/funcs.ts index a013722..e3c1fcc 100644 --- a/src/funcs.ts +++ b/src/funcs.ts @@ -1,8 +1,10 @@ import process from "process"; import { + ChildLabel, FileLogOptions, FileLogOptionsParsed, FileLogPathOptions, + FilterLabelsFunc, LOG_LEVEL_NAMES, LogLevel, LogOptions, @@ -111,3 +113,54 @@ export const getLogPath = (path?: string, options: FileLogPathOptions = {}) => { return resolve(baseDir, pathVal); } + +export const labelsStrBuilder = (labels: ChildLabel[]) => { + return labels.map(x => typeof x === 'function' ? 'dynamicfunc' : x.toLocaleLowerCase().trim()).reverse().join(':'); +} +export const labelFilterToRegex = (filter: string) => { + const reverseCleaned = filter.split(':').map(x => x.trim().toLocaleLowerCase().replace('*','.+')).reverse().join(':'); + return new RegExp(`^${reverseCleaned}`); +} + +export const labelFiltersFromStr = (str?: string): RegExp[] | undefined => { + if(str === undefined || str.trim() === '') { + return undefined; + } + return str.split(',').map(x => labelFilterToRegex(x.trim())); +} + +export const labelFiltersFromEnvSingleton = (envName: string): FilterLabelsFunc => { + let envFilterRes: false | RegExp[]; + return (labels: ChildLabel[]) => { + if(envFilterRes === undefined) { + envFilterRes = labelFiltersFromStr(process.env[envName]); + if(envFilterRes === undefined) { + envFilterRes = false; + } + } + if(envFilterRes === false) { + return false; + } + + const labelStr = labelsStrBuilder(labels); + return envFilterRes.some(x => x.test(labelStr)); + } +} + + +export const labelsEnableFromEnvSingleton = labelFiltersFromEnvSingleton('LOG_FILTER_ENABLE'); +export const labelsDisableFromEnvSingleton = labelFiltersFromEnvSingleton('LOG_FILTER_DISABLE'); + +export const labelsFilterFromEnv = (envName: string): FilterLabelsFunc => { + return (labels: ChildLabel[]) => { + const envFilterRes = labelFiltersFromStr(process.env[envName]); + if(envFilterRes === undefined) { + return false; + } + const labelStr = labelsStrBuilder(labels); + return envFilterRes.some(x => x.test(labelStr)); + } +} + +export const labelsEnableFromEnv = labelsFilterFromEnv('LOG_FILTER_ENABLE'); +export const labelsDisableFromEnv = labelsFilterFromEnv('LOG_FILTER_DISABLE'); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 2aa1a31..d083bcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,21 @@ import { LogLevel, LOG_LEVEL_NAMES, LoggerAppExtras, - PrettyOptionsExtra + PrettyOptionsExtra, + ChildLoggerOptions } from './types.js' import { isLogOptions, parseLogOptions, + labelsStrBuilder, + labelFiltersFromStr, + labelFiltersFromEnvSingleton, + labelsEnableFromEnvSingleton, + labelsDisableFromEnvSingleton, + labelsFilterFromEnv, + labelsEnableFromEnv, + labelsDisableFromEnv } from './funcs.js' import {childLogger, loggerApp, loggerDebug, loggerTest, loggerTrace, loggerAppRolling} from './loggers.js'; @@ -27,7 +36,8 @@ export type { LogDataPretty, LogLevel, LoggerAppExtras, - PrettyOptionsExtra + PrettyOptionsExtra, + ChildLoggerOptions } export { @@ -39,5 +49,13 @@ export { LOG_LEVEL_NAMES, childLogger, parseLogOptions, - isLogOptions + isLogOptions, + labelsStrBuilder, + labelFiltersFromStr, + labelFiltersFromEnvSingleton, + labelsEnableFromEnvSingleton, + labelsDisableFromEnvSingleton, + labelsFilterFromEnv, + labelsEnableFromEnv, + labelsDisableFromEnv } diff --git a/src/loggers.ts b/src/loggers.ts index 177e721..0683507 100644 --- a/src/loggers.ts +++ b/src/loggers.ts @@ -1,4 +1,4 @@ -import {parseLogOptions} from "./funcs.js"; +import {labelsDisableFromEnv, labelsDisableFromEnvSingleton, labelsEnableFromEnv, labelsEnableFromEnvSingleton, parseLogOptions} from "./funcs.js"; import { PinoLoggerOptions, CUSTOM_LEVELS, @@ -6,7 +6,8 @@ import { LoggerAppExtras, LogLevel, LogLevelStreamEntry, - LogOptions + LogOptions, + ChildLoggerOptions } from "./types.js"; import {buildDestinationFile, buildDestinationRollingFile, buildDestinationStdout} from "./destinations.js"; import {pino, levels, stdSerializers} from "pino"; @@ -45,7 +46,7 @@ export const buildLogger = (defaultLevel: LogLevel, streams: LogLevelStreamEntry }, mixinMergeStrategy(mergeObject: Record, mixinObject: Record) { if(mergeObject.labels === undefined || mixinObject.labels === undefined || mixinObject.labels.length === 0) { - return Object.assign(mergeObject, mixinObject) + return Object.assign(mergeObject, mixinObject); } const runtimeLabels = Array.isArray(mergeObject.labels) ? mergeObject.labels : [mergeObject.labels]; const finalObj = Object.assign(mergeObject, mixinObject); @@ -91,10 +92,18 @@ export const buildLogger = (defaultLevel: LogLevel, streams: LogLevelStreamEntry * @param parent Logger Parent logger to inherit from * @param labelsVal (any | any[]) Labels to always apply to logs from this logger * @param context object Additional properties to always apply to logs from this logger - * @param options object + * @param options ChildLoggerOptions * */ -export const childLogger = (parent: Logger, labelsVal: any | any[] = [], context: object = {}, options = {}): Logger => { - const newChild = parent.child(context, options) as Logger; +export const childLogger = (parent: Logger, labelsVal: any | any[] = [], context: object = {}, options: ChildLoggerOptions = {}): Logger => { + const { + labelEnable = labelsEnableFromEnvSingleton, + labelEnableLevel = parent.level, + labelDisable = labelsDisableFromEnvSingleton, + labelDisableLevel = 'silent', + ...rest + } = options; + + const newChild = parent.child(context, rest) as Logger; const labels = Array.isArray(labelsVal) ? labelsVal : [labelsVal]; newChild.labels = [...[...(parent.labels ?? [])], ...labels]; newChild.addLabel = function (value) { @@ -103,6 +112,12 @@ export const childLogger = (parent: Logger, labelsVal: any | any[] = [], context } this.labels.push(value); } + + if(newChild.level !== labelEnableLevel && labelEnable !== undefined && labelEnable(newChild.labels)) { + newChild.level = labelEnableLevel; + } else if (newChild.level !== labelDisableLevel && labelDisable !== undefined && labelDisable(newChild.labels)) { + newChild.level = labelDisableLevel; + } return newChild } /** diff --git a/src/types.ts b/src/types.ts index 65cbaca..38055c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,18 @@ const CUSTOM_LEVEL_NAMES = Object.keys(CUSTOM_LEVELS); export const LOG_LEVEL_NAMES= ['silent', 'fatal', 'error', 'warn', 'info', 'log', 'verbose', 'debug', 'trace'] as const; +export type ChildLabel = (string | CallableFunction); + +export type FilterLabelsFunc = (labels: ChildLabel[]) => boolean; + +export interface ChildLoggerOptions { + labelEnable?: (labels: ChildLabel[]) => boolean, + labelEnableLevel?: LogLevel, + labelDisable?: (labels: ChildLabel[]) => boolean, + labelDisableLevel?: LogLevel + [key: string]: any +} + /** * Configure log levels and file options for an AppLogger. * diff --git a/tests/index.test.ts b/tests/index.test.ts index 6a3dfd4..7bba284 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -24,6 +24,7 @@ import {buildLogger} from "../src/loggers.js"; import {readFileSync} from "fs"; import path from "path"; import dateFormatDef from "dateformat"; +import { labelsDisableFromEnv, labelsEnableFromEnv, labelFilterToRegex, labelsStrBuilder } from "../src/funcs.js"; const dateFormat = dateFormatDef as unknown as typeof dateFormatDef.default; @@ -32,7 +33,7 @@ const testConsoleLogger = (config?: object, colorize = false): [Logger, Transfor const opts = parseLogOptions(config, {logBaseDir: process.cwd()}); const testStream = new PassThrough(); const rawStream = new PassThrough(); - const logger = buildLogger('trace', [ + const logger = buildLogger('debug', [ buildDestinationStream( opts.console, { @@ -702,6 +703,90 @@ describe('Child Logger', function() { expect(formatted).includes(' [Runtime] '); expect(formatted).includes(' [Runtime2] '); }); + + describe('Filtering', function () { + + before(function () { + delete process.env.LOG_FILTER_ENABLE; + delete process.env.LOG_FILTER_DISABLE; + }); + + it('Does not output if silent', async function () { + const [logger, testStream, rawStream] = testConsoleLogger(); + const child = childLogger(logger, 'Test', {}, {level: 'silent'}); + const race = Promise.race([ + pEvent(testStream, 'data'), + sleep(10) + ]) as Promise; + child.debug('Test'); + const res = await race; + expect(res).to.be.undefined; + }); + + it('Does not output if parent is silent', async function () { + const [logger, testStream, rawStream] = testConsoleLogger(); + const child = childLogger(logger, 'Test', {}, {level: 'silent'}); + const child2 = childLogger(child, 'Test1', {}); + const race = Promise.race([ + pEvent(testStream, 'data'), + sleep(10) + ]) as Promise; + child2.debug('Test'); + const res = await race; + expect(res).to.be.undefined; + }); + + it('Does output if enabled by env filter', async function () { + process.env.LOG_FILTER_ENABLE = 'Test:*'; + + const [logger, testStream, rawStream] = testConsoleLogger(); + const child = childLogger(logger, ['Test','Bar'], {}, { + level: 'silent', + labelEnable: labelsEnableFromEnv + }); + const race = Promise.race([ + pEvent(testStream, 'data'), + sleep(10) + ]) as Promise; + child.debug('Test'); + const res = await race; + expect(res).to.not.be.undefined; + }); + + it('Does output if enabled by env filter and parent was silent', async function () { + process.env.LOG_FILTER_ENABLE = 'Bar'; + + const [logger, testStream, rawStream] = testConsoleLogger(); + const parent = childLogger(logger, ['Foo'], {}, {level: 'silent'}) + const child = childLogger(parent, ['Test','Bar'], {}, { + labelEnable: labelsEnableFromEnv, + labelEnableLevel: 'debug' + }); + const race = Promise.race([ + pEvent(testStream, 'data'), + sleep(10) + ]) as Promise; + child.debug('Test'); + const res = await race; + expect(res).to.not.be.undefined; + }); + + it('Does not output if disabled by env filter', async function () { + process.env.LOG_FILTER_ENABLE = 'Bar:Foo'; + + const [logger, testStream, rawStream] = testConsoleLogger(); + const child = childLogger(logger, ['Test','Bar','Foo'], {}, { + labelDisable: labelsDisableFromEnv + }); + const race = Promise.race([ + pEvent(testStream, 'data'), + sleep(10) + ]) as Promise; + child.debug('Test'); + const res = await race; + expect(res).to.not.be.undefined; + }); + }); }); }); @@ -781,3 +866,136 @@ describe('Pretty Options', function() { expect(formatted).includes(dt); }); }); + +describe('Label Filtering', function() { + + describe('Filter Building and Parsing Funcs', function() { + + it('takes a user filter, cleans it, and reverses it', function() { + + const userFilter = 'My Foo: My Bar'; + + const expected = new RegExp(/^my bar:my foo/); + + const parsed = labelFilterToRegex(userFilter); + expect(parsed.toString()).eq(expected.toString()); + }); + + it('takes a user filter with wildcard and converts to .+', function() { + + const userFilter = 'My Foo:*:My Bar'; + + const expected = new RegExp(/^my bar:.+:my foo/); + + const parsed = labelFilterToRegex(userFilter); + expect(parsed.toString()).eq(expected.toString()); + }); + + it('takes labels and converts to string for filter', function() { + + const labels = ['My Foo','My Bar', 'My GAZ']; + + const expected = 'my gaz:my bar:my foo'; + + const parsed = labelsStrBuilder(labels); + expect(parsed).eq(expected); + }); + + }); + + describe('Filter regex testing', function() { + + it('Passes when single label matches single filter', function() { + + const labels = ['My Label']; + const filter = 'My Label'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.true; + + }); + + it('Passes based on last user filter matching right-most labels', function() { + + const labels = ['My Label', 'My Second']; + const filter = 'My Second'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.true; + + }); + + it('Does not pass if last user filter does not match right-most label', function() { + + const labels = ['My Label', 'My Second']; + const filter = 'My Label'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.false; + + }); + + it('Passes when explicit, multiple user filters match right-most labels', function() { + + const labels = ['Foo','My Label', 'My Second']; + const filter = 'My Label:My Second'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.true; + + }); + + it('Passes when wildcard satisfies interim labels', function() { + + const labels = ['Foo','My Label','Other','My Second']; + const filter = 'Foo:*:My Second'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.true; + + }); + + it('Does not pass if wildcard satisfies interim labels but not all explicit right-most labels', function() { + + const labels = ['Foo','My Label','Other','My Second']; + const filter = 'Foo:*:My Second:Final'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.false; + + }); + + it('Passes if wildcard is for all subsequent labels', function() { + + const labels = ['Foo','My Label','Other','My Second']; + const filter = 'Foo:My Label:*'; + + + const reg = labelFilterToRegex(filter); + const labelStr = labelsStrBuilder(labels); + + expect(reg.test(labelStr)).is.true; + + }); + + }); + +}); \ No newline at end of file -- 2.51.2