[READ-ONLY] Mirror of https://github.com/FoxxMD/logging. A typed, opinionated, batteries-included, Pino-based logging solution for backend TS/JS projects foxxmd.github.io/logging
child-logger logging logging-library nodejs pinojs typescript-library
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Add more options for file logging

* Add pino-roll options to file options
* Implement file datetime formatting based on frequency option
* Consolidate filePath into path on a new `file` option object

FoxxMD (Mar 7, 2024, 5:12 PM EST) e9b40db2 a153ff34

+277 -81
+65 -12
README.md
··· 92 92 * */ 93 93 console?: LogLevel 94 94 /** 95 - * Specify the minimum log level to output to rotating files. If `false` no log files will be created. 96 - * */ 97 - file?: LogLevel | false 98 - /** 99 - * The full path and filename to use for log files 100 - * 101 - * If using rolling files the filename will be appended with `.N` (a number) BEFORE the extension based on rolling status 102 - * 103 - * May also be specified using env LOG_PATH or a function that returns a string 104 - * 105 - * @default 'CWD/logs/app.log 95 + * Specify the minimum log level to output for files or a log file options object. If `false` no log files will be created. 106 96 * */ 107 - filePath?: string | (() => string) 97 + file?: LogLevel | false | FileLogOptions 108 98 } 109 99 ``` 110 100 Available `LogLevel` levels, from lowest to highest: ··· 128 118 file: 'warn' // file will log `warn` and higher 129 119 }); 130 120 ``` 121 + 122 + ### File Options 123 + 124 + `file` in `LogOptions` may be an object that specifies more behavior log files. 125 + 126 + <details> 127 + 128 + <summary>File Options</summary> 129 + 130 + ```ts 131 + export interface FileOptions { 132 + /** 133 + * The path and filename to use for log files. 134 + * 135 + * If using rolling files the filename will be appended with `.N` (a number) BEFORE the extension based on rolling status. 136 + * 137 + * May also be specified using env LOG_PATH or a function that returns a string. 138 + * 139 + * If path is relative the absolute path will be derived from the current working directory. 140 + * 141 + * @default 'CWD/logs/app.log' 142 + * */ 143 + path?: string | (() => string) 144 + /** 145 + * For rolling log files 146 + * 147 + * When 148 + * * value passed to rolling destination is a string (`filePath` from LogOptions is a string) 149 + * * `frequency` is defined 150 + * 151 + * This determines the format of the datetime inserted into the log file name: 152 + * 153 + * * `unix` - unix epoch timestamp in milliseconds 154 + * * `iso` - Full [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) datetime IE '2024-03-07T20:11:34Z' 155 + * * `auto` 156 + * * When frequency is `daily` only inserts date IE YYYY-MM-DD 157 + * * Otherwise inserts full ISO8601 datetime 158 + * 159 + * @default 'auto' 160 + * */ 161 + timestamp?: 'unix' | 'iso' | 'auto' 162 + /** 163 + * The maximum size of a given rolling log file. 164 + * 165 + * Can be combined with frequency. Use k, m and g to express values in KB, MB or GB. 166 + * 167 + * Numerical values will be considered as MB. 168 + * */ 169 + size?: number | string 170 + /** 171 + * The amount of time a given rolling log file is used. Can be combined with size. 172 + * 173 + * Use `daily` or `hourly` to rotate file every day (or every hour). Existing file within the current day (or hour) will be re-used. 174 + * 175 + * Numerical values will be considered as a number of milliseconds. Using a numerical value will always create a new file upon startup. 176 + * 177 + * @default 'daily' 178 + * */ 179 + frequency?: 'daily' | 'hourly' | number 180 + } 181 + ``` 182 + 183 + </details> 131 184 132 185 ## Usage 133 186
+54 -24
src/destinations.ts
··· 1 1 import pinoRoll from 'pino-roll'; 2 - import {LogLevelStreamEntry, LogLevel, LogOptions, StreamDestination, FileDestination} from "./types.js"; 2 + import { 3 + LogLevelStreamEntry, 4 + LogLevel, 5 + StreamDestination, 6 + FileDestination, 7 + } from "./types.js"; 3 8 import {DestinationStream, pino, destination} from "pino"; 4 9 import prettyDef, {PrettyOptions} from "pino-pretty"; 5 10 import {prettyConsole, prettyFile} from "./pretty.js"; ··· 12 17 if (level === false) { 13 18 return undefined; 14 19 } 15 - const {path: logPath, ...rest} = options; 20 + const { 21 + path: logPath, 22 + size, 23 + frequency, 24 + timestamp = 'auto', 25 + ...rest 26 + } = options; 27 + 28 + if(size === undefined && frequency === undefined) { 29 + throw new Error(`For rolling files must specify at least one of 'frequency' , 'size'`); 30 + } 31 + 32 + const testPath = typeof logPath === 'function' ? logPath() : logPath; 16 33 17 34 try { 18 - const testPath = typeof logPath === 'function' ? logPath() : logPath; 19 35 fileOrDirectoryIsWriteable(testPath); 36 + } catch (e: any) { 37 + throw new ErrorWithCause<Error>('Cannot write logs to rotating file due to an error while trying to access the specified logging directory', {cause: e as Error}); 38 + } 20 39 21 - const pInfo = path.parse(testPath); 22 - let filePath: string | (() => string); 23 - if(typeof logPath === 'string') { 24 - filePath = () => path.resolve(pInfo.dir, `${pInfo.name}-${new Date().toISOString().split('T')[0]}`) 25 - } else { 26 - filePath = logPath; 40 + const pInfo = path.parse(testPath); 41 + let filePath: string | (() => string); 42 + if(typeof logPath === 'string' && frequency !== undefined) { 43 + filePath = () => { 44 + let dtStr: string; 45 + switch(timestamp) { 46 + case 'unix': 47 + dtStr = Date.now().toString(); 48 + break; 49 + case 'iso': 50 + dtStr = new Date().toISOString(); 51 + break; 52 + case 'auto': 53 + dtStr = frequency === 'daily' ? new Date().toISOString().split('T')[0] : new Date().toISOString(); 54 + break; 55 + } 56 + return path.resolve(pInfo.dir, `${pInfo.name}-${dtStr}`) 27 57 } 28 - const rollingDest = await pRoll({ 29 - file: filePath, 30 - size: 10, 31 - frequency: 'daily', 32 - extension: pInfo.ext, 33 - mkdir: true, 34 - sync: false, 35 - }); 58 + } else { 59 + filePath = path.resolve(pInfo.dir, pInfo.name); 60 + } 61 + const rollingDest = await pRoll({ 62 + file: filePath, 63 + size, 64 + frequency, 65 + extension: pInfo.ext, 66 + mkdir: true, 67 + sync: false, 68 + }); 36 69 37 - return { 38 - level: level, 39 - stream: prettyDef.default({...prettyFile, ...rest, destination: rollingDest}) 40 - }; 41 - } catch (e: any) { 42 - throw new ErrorWithCause<Error>('WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory', {cause: e as Error}); 43 - } 70 + return { 71 + level: level, 72 + stream: prettyDef.default({...prettyFile, ...rest, destination: rollingDest}) 73 + }; 44 74 } 45 75 46 76 export const buildDestinationFile = (level: LogLevel | false, options: FileDestination): LogLevelStreamEntry | undefined => {
+30 -5
src/funcs.ts
··· 1 1 import process from "process"; 2 - import {isLogOptions, LogLevel, LogOptions} from "./types.js"; 2 + import {FileLogOptions, FileLogOptionsParsed, isLogOptions, LogLevel, LogOptions, LogOptionsParsed} from "./types.js"; 3 3 import {logPath, projectDir} from "./constants.js"; 4 4 import {isAbsolute, resolve} from 'node:path'; 5 + import {MarkRequired} from "ts-essentials"; 5 6 6 - export const parseLogOptions = (config: LogOptions = {}): Required<LogOptions> => { 7 + export const parseLogOptions = (config: LogOptions = {}): LogOptionsParsed => { 7 8 if (!isLogOptions(config)) { 8 9 throw new Error(`Logging levels were not valid. Must be one of: 'silent', 'fatal', 'error', 'warn', 'info', 'verbose', 'debug', -- 'file' may be false.`) 9 10 } ··· 15 16 level = configLevel || defaultLevel, 16 17 file = configLevel || defaultLevel, 17 18 console = configLevel || 'debug', 18 - filePath 19 19 } = config; 20 20 21 + let fileObj: FileLogOptionsParsed; 22 + if (typeof file === 'object') { 23 + if (file.level === false) { 24 + fileObj = {level: false}; 25 + } else { 26 + const path = typeof file.path === 'function' ? file.path : getLogPath(file.path); 27 + fileObj = { 28 + level: configLevel || defaultLevel, 29 + ...file, 30 + path 31 + } 32 + } 33 + } else if (file === false) { 34 + fileObj = {level: false}; 35 + } else { 36 + fileObj = { 37 + level: file, 38 + path: getLogPath() 39 + }; 40 + } 41 + 42 + if(fileObj.level !== false && fileObj.frequency === undefined && fileObj.size === undefined) { 43 + // set default rolling log behavior 44 + fileObj.frequency = 'daily'; 45 + } 46 + 21 47 return { 22 48 level, 23 - file: file as LogLevel | false, 49 + file: fileObj, 24 50 console, 25 - filePath: typeof filePath === 'function' ? filePath : getLogPath(filePath) 26 51 }; 27 52 } 28 53
+2
src/index.ts
··· 2 2 Logger, 3 3 LogLevelStreamEntry, 4 4 LogOptions, 5 + FileLogOptions, 5 6 isLogOptions, 6 7 LogData, 7 8 LogLevel, ··· 15 16 import {childLogger, loggerApp, loggerDebug, loggerTest, loggerAppRolling} from './loggers.js'; 16 17 17 18 export type { 19 + FileLogOptions, 18 20 Logger, 19 21 LogLevelStreamEntry, 20 22 LogOptions,
+17 -14
src/loggers.ts
··· 2 2 import {Logger, LogLevel, LogLevelStreamEntry, LogOptions} from "./types.js"; 3 3 import {buildDestinationFile, buildDestinationRollingFile, buildDestinationStdout} from "./destinations.js"; 4 4 import {pino} from "pino"; 5 - import {logPath} from "./constants.js"; 6 - import path from "path"; 7 5 8 6 export const buildLogger = (defaultLevel: LogLevel, streams: LogLevelStreamEntry[]): Logger => { 9 7 const plogger = pino({ ··· 62 60 const streams: LogLevelStreamEntry[] = [buildDestinationStdout(options.console)]; 63 61 64 62 let error: Error; 65 - try { 66 - const file = buildDestinationFile(options.file, {path: options.filePath, append: true}); 67 - if (file !== undefined) { 68 - streams.push(file); 63 + if(options.file.level !== false) { 64 + try { 65 + const file = buildDestinationFile(options.file.level, {...options.file, append: true}); 66 + if (file !== undefined) { 67 + streams.push(file); 68 + } 69 + } catch (e) { 70 + error = e; 69 71 } 70 - } catch (e) { 71 - error = e; 72 72 } 73 + 73 74 const logger = buildLogger('debug' as LogLevel, streams); 74 75 if (error !== undefined) { 75 76 logger.warn(error); ··· 82 83 const streams: LogLevelStreamEntry[] = [buildDestinationStdout(options.console)]; 83 84 84 85 let error: Error; 85 - try { 86 - const file = await buildDestinationRollingFile(options.file, {path: logPath}); 87 - if (file !== undefined) { 88 - streams.push(file); 86 + if(options.file.level !== false) { 87 + try { 88 + const file = await buildDestinationRollingFile(options.file.level, options.file); 89 + if (file !== undefined) { 90 + streams.push(file); 91 + } 92 + } catch (e) { 93 + error = e; 89 94 } 90 - } catch (e) { 91 - error = e; 92 95 } 93 96 const logger = buildLogger('debug' as LogLevel, streams); 94 97 if (error !== undefined) {
+101 -20
src/types.ts
··· 1 1 import {DestinationStream, Level, Logger as PinoLogger, StreamEntry} from 'pino'; 2 2 import {ErrorWithCause} from "pony-cause"; 3 3 import {PrettyOptions} from "pino-pretty"; 4 + import {MarkRequired} from "ts-essentials"; 4 5 5 6 export type LogLevel = typeof LOG_LEVELS[number]; 6 7 export const LOG_LEVELS= ['silent', 'fatal', 'error', 'warn', 'info', 'log', 'verbose', 'debug'] as const; ··· 15 16 * */ 16 17 level?: LogLevel 17 18 /** 18 - * Specify the minimum log level to output to rotating files. If `false` no log files will be created. 19 - * */ 20 - file?: LogLevel | false 21 - /** 22 - * The full path and filename to use for log files 23 - * 24 - * If using rolling files the filename will be appended with `.N` (a number) BEFORE the extension based on rolling status 25 - * 26 - * May also be specified using env LOG_PATH or a function that returns a string 27 - * 28 - * @default 'CWD/logs/app.log' 19 + * Specify the minimum log level to output to rotating files or file output options. If `false` no log files will be created. 29 20 * */ 30 - filePath?: string | (() => string) 21 + file?: LogLevel | false | FileLogOptions 31 22 /** 32 23 * Specify the minimum log level streamed to the console (or docker container) 33 24 * */ ··· 40 31 return true; 41 32 } 42 33 const t = typeof val; 43 - if(key === 'filePath') { 44 - return t === 'string' || t === 'function'; 34 + if(key === 'file') { 35 + if(t === 'object') { 36 + return isFileLogOptions(t); 37 + } 38 + return t === 'string' || val === false; 45 39 } 46 - if(t !== 'string' && t !== 'boolean') { 40 + if(t !== 'string') { 47 41 return false; 48 42 } 49 - if (key !== 'file') { 50 - return LOG_LEVELS.includes(val.toLocaleLowerCase()); 51 - } 52 - return val === false || LOG_LEVELS.includes(val.toLocaleLowerCase()); 43 + return LOG_LEVELS.includes(val.toLocaleLowerCase()); 53 44 }); 54 45 } 55 46 ··· 69 60 msg: string | Error | ErrorWithCause 70 61 } 71 62 72 - export type FileDestination = Omit<PrettyOptions, 'destination' | 'sync'> & {path: string | (() => string)}; 63 + export interface PinoRollOptions { 64 + /** 65 + * The maximum size of a given rolling log file. 66 + * 67 + * Can be combined with frequency. Use k, m and g to express values in KB, MB or GB. 68 + * 69 + * Numerical values will be considered as MB. 70 + * 71 + * @default '10MB' 72 + * */ 73 + size?: number | string 74 + /** 75 + * The amount of time a given rolling log file is used. Can be combined with size. 76 + * 77 + * Use `daily` or `hourly` to rotate file every day (or every hour). Existing file within the current day (or hour) will be re-used. 78 + * 79 + * Numerical values will be considered as a number of milliseconds. Using a numerical value will always create a new file upon startup. 80 + * 81 + * @default 'daily' 82 + * */ 83 + frequency?: 'daily' | 'hourly' | number 84 + } 85 + 86 + export interface RollOptions { 87 + /** 88 + * For rolling log files 89 + * 90 + * When 91 + * * value passed to rolling destination is a string (`filePath` from LogOptions is a string) 92 + * * `frequency` is defined 93 + * 94 + * This determines the format of the datetime inserted into the log file name: 95 + * 96 + * * `unix` - unix epoch timestamp in milliseconds 97 + * * `iso` - Full [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) datetime IE '2024-03-07T20:11:34Z' 98 + * * `auto` 99 + * * When frequency is `daily` only inserts date IE YYYY-MM-DD 100 + * * Otherwise inserts full ISO8601 datetime 101 + * 102 + * @default 'auto' 103 + * */ 104 + timestamp?: 'unix' | 'iso' | 'auto' 105 + } 106 + 107 + export interface FileOptions extends PinoRollOptions, RollOptions { 108 + /** 109 + * The path and filename to use for log files. 110 + * 111 + * If using rolling files the filename will be appended with `.N` (a number) BEFORE the extension based on rolling status. 112 + * 113 + * May also be specified using env LOG_PATH or a function that returns a string. 114 + * 115 + * If path is relative the absolute path will be derived from the current working directory. 116 + * 117 + * @default 'CWD/logs/app.log' 118 + * */ 119 + path?: string | (() => string) 120 + } 121 + 122 + export type FileOptionsParsed = MarkRequired<FileOptions, 'path'>; 123 + 124 + export interface FileLogOptions extends FileOptions { 125 + /** 126 + * Specify the minimum log level to output to rotating files. If `false` no log files will be created. 127 + * */ 128 + level?: LogLevel | false 129 + } 130 + 131 + export interface FileLogOptionsStrong extends FileLogOptions { 132 + level: LogLevel 133 + path: string | (() => string) 134 + } 135 + 136 + export type FileLogOptionsParsed = (Omit<FileLogOptions, 'file'> & {level: false}) | FileLogOptionsStrong 137 + 138 + const isFileLogOptions = (obj: any): obj is FileLogOptions => { 139 + if (obj === null || typeof obj !== 'object') { 140 + return false; 141 + } 142 + const levelOk = obj.level === undefined || ('level' in obj && obj.level === false || LOG_LEVELS.includes(obj.level.toLocaleLowerCase())); 143 + const pathOk = obj.path === undefined || ('path' in obj && typeof obj.path === 'string' || typeof obj.path === 'function'); 144 + const frequencyOk = obj.frequency === undefined || ('frequency' in obj && typeof obj.frequency === 'string' || typeof obj.frequency === 'number'); 145 + const sizeOk = obj.size === undefined || ('size' in obj && typeof obj.size === 'string' || typeof obj.size === 'number'); 146 + const tsOk = obj.timestamp === undefined || ('timestamp' in obj && typeof obj.timestamp === 'string'); 147 + 148 + return levelOk && pathOk && frequencyOk && sizeOk && tsOk; 149 + } 150 + 151 + export type FileDestination = Omit<PrettyOptions, 'destination' | 'sync'> & FileOptionsParsed; 73 152 export type StreamDestination = Omit<PrettyOptions, 'destination'> & {destination: number | DestinationStream | NodeJS.WritableStream}; 153 + 154 + export type LogOptionsParsed = Omit<Required<LogOptions>, 'file'> & { file: FileLogOptionsParsed }
+8 -6
tests/index.test.ts
··· 37 37 const testFileRollingLogger = async (config?: object, logPath: string = '.') => { 38 38 const opts = parseLogOptions(config); 39 39 const streamEntry = await buildDestinationRollingFile( 40 - opts.file, 40 + opts.file.level, 41 41 { 42 + frequency: 'daily', 42 43 path: logPath, 43 44 ...opts 44 45 } ··· 51 52 const testFileLogger = async (config?: object, logPath: string = './app.log') => { 52 53 const opts = parseLogOptions(config); 53 54 const streamEntry = buildDestinationFile( 54 - opts.file, 55 + opts.file.level, 55 56 { 56 57 path: logPath, 57 58 ...opts ··· 67 68 const testStream = new PassThrough(); 68 69 const rawStream = new PassThrough(); 69 70 const streamEntry = await buildDestinationRollingFile( 70 - opts.file, 71 + opts.file.level, 71 72 { 73 + frequency: 'daily', 72 74 path: logPath, 73 75 ...opts 74 76 } ··· 97 99 const testStream = new PassThrough(); 98 100 const rawStream = new PassThrough(); 99 101 const streamEntry = buildDestinationFile( 100 - opts.file, 102 + opts.file.level, 101 103 { 102 104 path: logPath, 103 105 ...opts ··· 156 158 it(`defaults to 'info' level except console`, function () { 157 159 const defaultConfig = parseLogOptions(); 158 160 expect(defaultConfig.level).eq('info') 159 - expect(defaultConfig.file).eq('info') 161 + expect(defaultConfig.file.level).eq('info') 160 162 }); 161 163 162 164 it(`defaults to 'debug' level for console`, function () { ··· 171 173 level: 'debug' 172 174 }); 173 175 expect(config.console).eq('warn') 174 - expect(config.file).eq('error') 176 + expect(config.file.level).eq('error') 175 177 }); 176 178 }) 177 179