import { isBrowser, isNode } from 'browser-or-node' import stringify from 'fast-json-stable-stringify' //////////// // CONFIG // //////////// //(i) disabled by default bc. the detection logic has disappeared after the refactor. //TODO: Find it and check if it works let TIME_PREFIX_ENABLED = false let PERFORMANCE_PREFIX_ENABLED = false if (isNode) { // console.debug('isNode checking', { process }) if (process?.env?.LOGGER_TIME_PREFIX) TIME_PREFIX_ENABLED = process?.env?.LOGGER_TIME_PREFIX === 'true' if (process?.env?.LOGGER_PERF_PREFIX) PERFORMANCE_PREFIX_ENABLED = process?.env?.LOGGER_PERF_PREFIX === 'true' } export let LOGGER_DEFAULT_CONFIG = { prefix: undefined as string | undefined, time: TIME_PREFIX_ENABLED, delta: PERFORMANCE_PREFIX_ENABLED, dimDebugOnServer: true, printOriginOnServer: true, serverInspectOptions: {}, customFormattersOnServer: true, // Depth of stacktrace to show on server // - integer saying how many lines // - false to disable, true for all printStackLinesOnServer: false as boolean | number, dim: true, } export type Config = Partial export type ConsoleFx = (...data: any[]) => void export const StringFormatters = [ // Format to string (Client console - currently not on server) // { // match: obj => obj instanceof Model, // format: m => { // const { displayName } = m // return `${m.constructor.name}#${m.ID}${displayName ? `{${displayName}}` : ''}` // }, // }, ] as unknown as { match: (obj: any) => boolean format: (m: any) => string }[] // let applyCustomFormatters; // applyCustomFormatters = (value, config) => { // for (const formatter of StringFormatters) { // if (formatter.match(value)) return formatter.format(value); // } // if (!_.isString(value)) { // return util.inspect(value, config.inspectOptionsOnServer); // } // return value; // }; /// //////////////// // RUNTIME STUFF // /// //////////////// export class LoggerTime { sinceFirst = 0 sinceLast = 0 time = false delta = false constructor(sinceFirst: number, sinceLast: number, time: boolean, delta: boolean) { Object.assign(this, { sinceFirst, sinceLast, time, delta }) } toString() { const sinceFirstNum = (this.sinceFirst * 0.001) const sinceFirst = sinceFirstNum.toFixed(sinceFirstNum < 5 ? 2 : 1) /* .padStart(3, ' ') */ const sinceLast = this.sinceLast < 1000 // when >= 1s, format as seconds ? `${this.sinceLast.toFixed(0)}`.padStart(5, ' ') : `${`${(this.sinceLast * 0.001).toFixed(this.sinceLast < 10000 ? 1 : 0)}`.padStart(4, ' ')}s` return `[${[this.time ? sinceFirst : null, this.delta ? sinceLast : null] .filter(t => t != null) .join(',')}]` } } export class Style { constructor(style: string, string: string) { Object.assign(this, { style, string }) } } let formattersEnabled = false // for tracking if chrome formatters are enabled (don't change manually) export class Logger { static ERROR = 2 as const static WARN = 4 as const static INFO = 6 as const // alias for LOG static LOG = 6 as const static DEBUG = 8 as const static VERBOSE = 10 as const static firstLog = 0 static lastLog = 0 /** * This is a clever way to log without the extra check on each log line. * Use it like this: * ``` * const { WARN, LOG, DEBUG, VERBOSE } = Logger.setup(Logger.INFO); // eslint-disable-line no-unused-vars * LOG('message', data); * DEBUG(this.collectionName, '...') * if (!VERBOSE.isDisabled) { * const verboseStuff = generateVerboseStuff(); // computationally intensive * VERBOSE('Here it is:', verboseStuff) * } * ``` * * @ param {number} level See {@link LVs} * @ param {object} config see LOGGER_DEFAULT_CONFIG * @ typedef {Function & {isDisabled: boolean, isEnabled: boolean, ...}} LogFunc * @ returns {{LOG: LogFunc, VERBOSE: LogFunc, DEBUG: LogFunc, WARN: LogFunc, ERROR: LogFunc}} */ static setup(level: 2 | 4 | 6 | 8 | 10 = Logger.INFO, config: Config = {}) { config = Object.assign({}, LOGGER_DEFAULT_CONFIG, config) // defaults(config, LOGGER_DEFAULT_CONFIG) // fyi this is like the reverse of Object.assign if (level > 10) console.debug('setup logger', { config }) // manual overide to level:11 to show this trace const debugLogForStub = this.wrapLog(console.debug, { ...config, dim: config.dimDebugOnServer }) const stub = (...args: any[]) => { // @ts-expect-error hack to enable DEBUG logging EVERYWHERE if (typeof window !== 'undefined' && window?.FORCE_DISABLE_LOGLEVEL) debugLogForStub(...args) } // For disabled log-levels, we just return a stub function stub.isDisabled = true // this makes it possible to check, e.g. `DEBUG.isDisabled` stub.isEnabled = false // depending on syntactic preference const groupCollapsed = this.wrapLog(console.groupCollapsed, config) const addExtraFxs = (enabled: boolean, func: (...T: any) => void) =>/* : ((...T) => void & {isDisabled: boolean}) */ { // warning - weird TS hacks approaching: function group( ...args: [...any[], T] ): ReturnType extends Promise ? Promise> : ReturnType { const functionToWrap = args[args.length - 1] as T if (functionToWrap as Func === stub) return functionToWrap() as ReturnType groupCollapsed(...args.slice(0, -1)) // except last const asyncWrapper = async () => { try { const result = functionToWrap() return result instanceof Promise ? await result : result } finally { console.groupEnd() } } return asyncWrapper() as ReturnType // Cast to bypass the Promise type inference } const realFunc = Object.assign( func.bind({}), // clone function - https://stackoverflow.com/a/6772648 { isEnabled: enabled, isDisabled: !enabled, group, force: func, }, ) if (enabled) { return realFunc } else { return Object.assign( stub.bind({}), // clone stub function - https://stackoverflow.com/a/6772648 { isEnabled: enabled, isDisabled: !enabled, group, force: realFunc, }, ) } } const ERRFX = addExtraFxs(level >= Logger.ERROR, this.wrapLog(console.error, config)) //TODO: the error func override should be opt-in - as it modifies stacktrace of error message const ERROR = (...params: any[]) => { ERRFX(...params) // return so we can log & throw in one go return new Error(params.map(param => { return typeof param === 'string' ? param : stringify(param) // stringify is fast & safe with circular references }).join(' ')) } // console.debug("Logger init:", config) return { ERROR, WARN: addExtraFxs(level >= Logger.WARN, this.wrapLog(console.warn, config)), LOG: addExtraFxs(level >= Logger.INFO, this.wrapLog(console.log, config)), DEBUG: addExtraFxs(level >= Logger.DEBUG, this.wrapLog(console.log, { ...config, dim: config.dimDebugOnServer })), // not using console.debug as that requires dev tools verbose to be enabled VERBOSE: addExtraFxs(level >= Logger.VERBOSE, this.wrapLog(console.debug, { ...config, dim: config.dimDebugOnServer })), } } static wrapLog(logFunc: ConsoleFx, config: Config) { if (!isNode) { // ℹ In browser we can only do some of the fancy things, because we cannot call console.log ourselves, // as that would pollute the stacktrace // ℹ Would be nice to get formtted links in devtools, but tags are not allowed: // const stack = new Error().stack; // const origin = Logger.getOriginFromStack(stack); // const originFilename = origin.split('\\').pop().split('/').pop(); // eslint-disable-line // if (origin) fixedArgs.push({ link: originFilename, href: `http://localhost:63342/api/file/${origin}` }); // on the client, we need to return the original log function, as otherwise the origin of the log is set to here // But luckily we can bind fixed parameters on the function: const fixedArgs = [] // if the custom formatters are enabled, and we want performance logging - we add an instance of P as a static prefix if (config.time || config.delta) { const isChrome = isBrowser && navigator.userAgent.includes('Chrome') const p = isChrome ? ['', Logger.timeStr(!!config.time, !!config.delta)] // this is a placeholder that gets replaced by the custom formatter : [] // [{ get perfNiceTry () { return Logger.timeStr(,,true) } }] // this (of course) shows the time when you click the getter fixedArgs.push(...p) // if we start with a non-string, all strings are rendered with quotes :/ } if (config.prefix) { if (formattersEnabled) fixedArgs.push(new Style('dim', config.prefix)) else fixedArgs.push(config.prefix) } // console.log('logger-log', { config, fixedArgs }) return logFunc.bind(console, ...fixedArgs) } else { // ℹ in nodejs we print the stack trace origin ourselves, so we can do a lot more things // TODO: re-import removed code - https://gitlab.com/onezoomin/ztax/ztax/-/commit/f6f9bde617cd9ae93d1bc4bc49a4b5275e4cbff2#b1d1e091ca27ae560becf7ae1b27a4d53d39c061 return logFunc.bind(console) } } // static log(msg, logPerformance = true) { // const p = this.perf(); // return logPerformance ? `${p} ${msg}` : msg; // } static timeStr(time: boolean, delta: boolean, returnAsString = 0): LoggerTime | string | undefined { if (!performance.now) return undefined // TODO: server? p = { now: require('performance-now') }; const now = performance.now() if (!Logger.firstLog) { Logger.firstLog = now Logger.lastLog = now } const p = new LoggerTime(now - Logger.firstLog, now - Logger.lastLog, time, delta) Logger.lastLog = now return returnAsString ? p.toString() : p } // (for nodejs) ~ https://stackoverflow.com/a/47296370/1633985 static getOriginFromStack(stack: any): string | undefined { let skippedFirst = false // the first one is Logger.js for (const line of stack.split('\n')) { const matches = line.match(/^\s+at\s+(.*)/) if (matches) { if (skippedFirst) { // first line - current function // second line - caller (what we are looking for) return ( matches[1] .trim() // Removes spaces // .replace(/^module /, '') // Removes 'module ' at beginning // .replace(/^at /, '') // Removes 'at ' at beginning // .replace(/^ \(/, '') // Removes parentheseses around file // .replace(/\)$/, '') // -||- .replace(__dirname, '') // Removes script folder path (if exists) ) } skippedFirst = true } } } } if (isBrowser) { // isBrowser is not true for web workers // CLIENT or Worker // // @ts-expect-error window hack window.devtoolsFormatters = window.devtoolsFormatters ?? [] // noinspection JSUnusedGlobalSymbols // @ts-expect-error window hack window.devtoolsFormatters.push( { // Custom formatters header: (obj: any) => { if (StringFormatters && StringFormatters.length) { for (const formatter of StringFormatters) { if (formatter.match(obj)) return ['span', {}, formatter.format(obj)] } } return null }, hasBody: () => true, }, { // Performance object // // This is a sneaky way to show performance info withouth any extra calls when logging: // LOG(`...`) // it would work without this if we would have some way to intercept the call: // L.INFO('...') - a getter on L that adds the current time as prefix // LOG('...')() - returns the log function with all argumends bound // But instead we are adding a custom formatter that replaced the placeholder object of class P with the performance info header: (obj: any) => { if (!(obj instanceof LoggerTime)) return null if (!formattersEnabled) formattersEnabled = true const p = Logger.timeStr(obj.time, obj.delta) return [ 'span', { style: 'font-weight: light; color: grey' }, p?.toString(), // ['a', { href: 'vscodium://file/home/manu/dev/tamera/bookr/imports/lib/utils/Logger.js' }, 'WOW'], ] }, hasBody: () => false, }, { // Style object header: (obj: { string: any }) => { if (!(obj instanceof Style)) return null if (!formattersEnabled) formattersEnabled = true return ['span', { style: 'font-weight: light; color: grey; '/* font-style: italic; */ }, obj.string] }, hasBody: () => false, }, ) } else if (isNode) { // SERVER // // node stuff removed as it breaks/bloats webworker build } export function logriniTest(): string { return 'logrini' } type Func = () => T