Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): add configurable logger #147

Merged
merged 15 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ts/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"description": "FAB backend",
"scripts": {
"serve": "npm run private:build && cd ../../out-tsc && mkdir -p backend && cp -r backend-build/app backend && mkdir -p backend/config && cp -n ../ts/backend/src/config/config.jsonc \"$_\" && cd backend/app && node main.min.js",
"serve": "npm run private:build && cd ../../out-tsc && mkdir -p backend && cp -r backend-build/app backend && mkdir -p backend/config && cp -n ../ts/backend/src/config/config.jsonc \"$_\" && cd backend/app && node main.min.js --help --log-level=ALL --log-colorful",
"build": "npm run private:build && mkdir -p ../../dist/backend/config && cp -r ../../out-tsc/backend-build/app ../../dist/backend",
"private:build": "tsc && tsc-alias && npx webpack"
}
Expand Down
152 changes: 152 additions & 0 deletions ts/backend/src/app/features/config/command-line.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import ansiStyles from 'ansi-styles'
import { Logger, LogLevel, LogLevelName } from '../logger/logger.mjs'
import { splitStringAt } from '../utils/split-string-at.mjs'

const PRETTY_PRINT_TERMINAL_WIDTH = process.stdout.columns
const PRETTY_PRINT_TITLE_WIDTH = 24

const logger = new Logger('cli')

// adhere to this and the manual writes itself
interface CommandLineArg {
argument: string
alias?: string
description: string
type?: string
default?: string
evaluator: (val?: string) => void
}

// known command line arguments - the order here defines order of evaluation
const commandLineArgs: CommandLineArg[] = [
{
argument: '--help',
alias: '-h',
description: 'Print this message.',
evaluator: () => {
// extract the argument manual from argument definition
commandLineArgs.forEach((cla) => {
let title = cla.argument
if (cla.alias) title += ', ' + cla.alias
prettyPrintArgDef({
[title]: cla.description,
...(cla.type ? { type: cla.type } : {}),
...(cla.default ? { default: cla.default } : {}),
})
})
},
},
{
argument: '--log-level',
description: 'Set log output level.',
type: 'ALL | TRACE | DEBUG | INFO | WARN | ERROR | FATAL | OFF',
default: 'INFO',
evaluator: (val) => {
const level = val ? LogLevel[val as LogLevelName] : undefined
if (typeof level != 'undefined') {
Logger.defaultLogLevel = level
} else {
logger.error('Invalid log level', val)
}
},
},
{
argument: '--log-source',
description: 'Include logging source (method and file path) in log.',
type: 'false | <any other value>',
default: 'false',
evaluator: (val) => {
if (val !== 'false') {
Logger.includeSource = true
}
},
},
{
argument: '--log-colorful',
description: 'Enable colorful terminal output for log.',
type: 'false | <any other value>',
default: 'false',
evaluator: (val) => {
if (val !== 'false') {
Logger.colorTerminal = true
}
},
},
{
argument: '--log-json',
description: 'Output log messages as valid JSON only.',
type: 'false | <any other value>',
default: 'false',
evaluator: (val) => {
if (val !== 'false') {
Logger.jsonMessage = true
}
},
},
]

// read command line arguments and pass them to their respective evaluators
export function parseCommandLine() {
// turn into map first i.o.t. remove duplicates and let the order be defined
// by consumer (that is, this file)
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value] = arg.split('=')
return [key, value] as const
}),
)
commandLineArgs.forEach((cla) => {
// check for argument (full) first
if (args.has(cla.argument)) {
cla.evaluator(args.get(cla.argument))
args.delete(cla.argument)
}
//... and only then check for alias. Otherwise de-duplicating could be circumvented.
else if (cla.alias && args.has(cla.alias)) {
cla.evaluator(args.get(cla.alias))
args.delete(cla.alias)
}
})
// if args are left (undeleted) it means undefined args were given
Array.from(args.keys()).forEach((key) => {
logger.error('Invalid command line argument', key)
})
}

function prettyPrintArgDef(def: Record<string, string>) {
// minus 2 for the space between title and definition
const maxTextWidth = Math.max(PRETTY_PRINT_TERMINAL_WIDTH - PRETTY_PRINT_TITLE_WIDTH - 2, 24)
Object.entries(def).forEach(([key, value], idx) => {
const textLines: string[] = []
value = value.trim()
while (value.length > maxTextWidth) {
// look for breakable char before max text width
let wrapPos = maxTextWidth
for (let i = 0; i < maxTextWidth; i++) {
const pos = maxTextWidth - i - 1
const char = value[pos]
// char is one of the breakable ones
if (' .,:;-'.includes(char)) {
wrapPos = pos
break
}
}
// split string and remove unnecessary white space
const splat = splitStringAt(value, wrapPos)
textLines.push(splat[0].trimEnd())
value = splat[1].trimStart()
}
// append rest of value as-is
textLines.push(value)
// for first pair (argument), color title
const colorStart = idx == 0 ? ansiStyles.color.blue.open : ''
const colorEnd = idx == 0 ? ansiStyles.reset.close : ''
// print property defintion
console.log(`${colorStart}${key.padStart(PRETTY_PRINT_TITLE_WIDTH)}${colorEnd} ${textLines[0]}`)
textLines.slice(1, -1).forEach((line) => {
console.log(`${' '.padStart(PRETTY_PRINT_TITLE_WIDTH)} ${line}`)
})
})
// terminate with blank line
console.log()
}
12 changes: 5 additions & 7 deletions ts/backend/src/app/features/config/config.mts
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
import styles from 'ansi-styles'
import JSON5 from 'json5'
import fs from 'node:fs'
import path from 'node:path'
import { Logger } from '../logger/logger.mjs'
import { defaults } from './defaults.mjs'

export type configuration = typeof defaults

const VERBOSE = false
const logger = new Logger('config')

// go through all keys in defaults and copy over values
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyDefaults(object: any, defaults: any, path = '') {
for (const key in defaults) {
if (VERBOSE) {
console.log(`${styles.gray.open}checking configuration for ${path}${key}${styles.reset.close}`)
}
logger.trace(`Checking configuration for ${path}${key}`)
// copy missing fields from defaults
if (!Object.prototype.hasOwnProperty.call(object, key)) {
// always deep-copy defaults, prevents changing defaults object via changing loaded config
const defs = JSON.parse(JSON.stringify(defaults[key]))
object[key] = defs
// console.warn does not seem to automatically color the output
console.warn(`${styles.yellow.open}Using defaults for ${path}${key}:${styles.reset.close}`, defs)
logger.warn(`Using defaults for ${path}${key}`, defs)
}
// if both are existing objects, go deeper
else if (typeof object[key] === 'object' && typeof defaults[key] === 'object') {
Expand All @@ -38,7 +36,7 @@ export function loadConfig(): configuration {
if (fs.existsSync(confPath)) {
loadedConfig = JSON5.parse<configuration>(fs.readFileSync(confPath, 'utf-8'))
} else {
console.warn(`${styles.yellow.open}No configuration file found under ${confPath}${styles.reset.close}`)
logger.warn(`No configuration file found under ${confPath}`)
}

applyDefaults(loadedConfig, defaults)
Expand Down
182 changes: 182 additions & 0 deletions ts/backend/src/app/features/logger/logger.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { json } from '@common/utility-types.mjs'
import ansiStyles from 'ansi-styles'

// Not using enum because of the JS TSC generates from enums. More info:
// https://www.typescriptlang.org/docs/handbook/enums.html
// https://www.totaltypescript.com/why-i-dont-like-typescript-enums
// numeric values based on https://stackoverflow.com/a/7751276/10135201
const LogLevelRaw = {
OFF: 10_000_000,
FATAL: 50_000,
ERROR: 40_000,
WARN: 30_000,
INFO: 20_000,
DEBUG: 10_000,
TRACE: 5_000,
ALL: 0,
} as const

/**
* Available keys in `LogLevel`.
* @see {@link LogLevel}
*/
export type LogLevelName = keyof typeof LogLevelRaw

/**
* Numeric `LogLevel` values.
* @see {@link LogLevel}
*/
export type LogLevelNumeric = (typeof LogLevelRaw)[LogLevelName]

/**
* Available log levels.
*/
export const LogLevel = {
...LogLevelRaw,

/**
* Returns log level name.
*/
name: (id: LogLevelNumeric): LogLevelName => {
const names = Object.keys(LogLevelRaw) as LogLevelName[]
return names.find((n) => LogLevelRaw[n] === id) ?? 'OFF'
},
}

// The pattern (construct one logger per file instead of a service class) was
// chosen on purpose i.o.t. have logger available at boot already.
export class Logger {
/** Default log level. Used if no instance specific log level is present. */
static defaultLogLevel: LogLevelNumeric = LogLevel.INFO
// these settings are global to ensure uniform log format while alive
/** Whether to include the source of the log call. */
static includeSource = false
/** Whether to output the messages as one JSON array. */
static jsonMessage = false
/** Whether to use ANSI terminal colors in log. */
static colorTerminal = false

logLevel?: LogLevelNumeric
category: string

/**
* Create a new logger instance.
* @param category Logger category. Included in log.
* @param level Optional log level override.
*/
constructor(category = 'main', level?: LogLevelNumeric) {
this.logLevel = level
this.category = category
}

// Must be private i.o.t. always skip the correct number of stack trace lines.
private log(level: LogLevelNumeric, ...messages: json[]) {
// don't do anything if log level isn't active
if (level < (this.logLevel ?? Logger.defaultLogLevel)) return
// color and channel depending on level
let dateColor = ansiStyles.color.gray.open
let priorityColor = ansiStyles.color.gray.open
let categoryColor = ansiStyles.color.blue.open
let resetColor = ansiStyles.reset.close
let channel = console.log
switch (level) {
case LogLevel.DEBUG:
priorityColor = ansiStyles.color.white.open
break
case LogLevel.INFO:
priorityColor = ansiStyles.color.green.open
break
case LogLevel.WARN:
priorityColor = ansiStyles.color.yellow.open
channel = console.error
break
case LogLevel.ERROR:
priorityColor = ansiStyles.color.red.open
channel = console.error
break
case LogLevel.FATAL:
priorityColor = ansiStyles.color.magenta.open
channel = console.error
}
if (!Logger.colorTerminal) {
dateColor = ''
priorityColor = ''
categoryColor = ''
resetColor = ''
}

const date = new Date().toISOString()
const priority = LogLevel.name(level).padEnd(5)
let source = ''
if (Logger.includeSource) {
// stack, beginning with 'Error', Logger.log and Logger.<calledMethod>
const stack = new Error().stack!.split('\n')
// relevant line of stack, either `at class.method (path/file:line:char)` or `at path/file:line:char`
// - Attention: path separator can be / or \ !
// - Can't build target class/method path if not present in stack, because some calls include internal node queue.
// - Printing file:line:char is beneficial only if output is not minified.
let src = stack[3].replace(/\s+at /, '')
if (src.endsWith(')')) {
src = src.replace(/\((.*)\)/, '$1')
} else {
src = '<anonymous> ' + src
}
source = ` (${src})`
}
const message = Logger.jsonMessage
? // with JSON message enabled, output all message bits as array
JSON.stringify(messages)
: // otherwise stringify only those necessary and seperate by space
messages
.map((m) => {
return typeof m === 'object' ? JSON.stringify(m) : m
})
.join(' ')

// log pattern here
const msg = `${dateColor}${date}${resetColor}${source} ${priorityColor}${priority}${resetColor} ${categoryColor}[${this.category}]${resetColor}: ${message}`
channel(msg)
}

/**
* Log a message with log level `TRACE`.
*/
trace(...messages: json[]) {
this.log(LogLevel.TRACE, ...messages)
}

/**
* Log a message with log level `DEBUG`.
*/
debug(...messages: json[]) {
this.log(LogLevel.DEBUG, ...messages)
}

/**
* Log a message with log level `INFO`.
*/
info(...messages: json[]) {
this.log(LogLevel.INFO, ...messages)
}

/**
* Log a message with log level `WARN`.
*/
warn(...messages: json[]) {
this.log(LogLevel.WARN, ...messages)
}

/**
* Log a message with log level `ERROR`.
*/
error(...messages: json[]) {
this.log(LogLevel.ERROR, ...messages)
}

/**
* Log a message with log level `FATAL`.
*/
fatal(...messages: json[]) {
this.log(LogLevel.FATAL, ...messages)
}
}
Loading