UNPKG

@pbrgld/loggify

Version:

A lightweight, dependency-free logging library for Bun.js and Node.js – featuring emoji support, styled output, contextual logging, and high-performance stdout logging.

977 lines (976 loc) 51.4 kB
/** * ^Console class * This is a common library that handels all console operations. It's purpose is to provide a fast and powerful package * of methods and features that allows to go from zero to hero in no time using standards accross all projects. */ import os from 'os'; import { basename } from "path"; import util from 'util'; import { ansiCodes } from './ansi.js'; import { emojis } from './emojis.js'; /** * Todo section */ //TODO: Prepare everything to make everything v1.0.0 ready //TODO: Document -> GrafanaLoki: overwrite mapping - when using logType suqareRed, add an option to create a mapping that shows the emoji as logType but writes another value to GrafanaLoki e.g. server error, could be an object {squareRed:'server error',...} //TODO: Document as info -> GrafanaLoki: Remove LogLevel when none <- check if possible //TODO: Document as info -> GrafanaLoki: Merge warn and warning to warning - one tag only //TODO: Constructor: implement default ANSI default color&style //TODO: [WIP]: Banner //TODO: [WIP]: Banner, add space for description - 1 chart between description and frame //TODO: [WIP]: Banner: extract ansi-style&code started and start with it in next line, if an [ansi:reset] had not been thrown /** * Console class */ export default class Loggify { logLevel = 'full'; logTimestamp = true; logTimestampType = 'time'; logTypeBadge = 'emoji'; logCallerInformation = true; logCallerCallStackLevel = 3; logMemory = true; logBuffer = new Map(); grafanaLoki = {}; ansi = ansiCodes; emoji = emojis; /** * Initializing the log class to be used over console.log, as it should hopefully be faster and less resource * demanding, while supporting quite some nice features, such as ANSI coloring and styling without requiring third * party packages. * @param {ConstructorOptions} options Constructor Options */ constructor(options) { // Init/set class if (options?.loglevel) this.logLevel = options.loglevel; if (typeof options?.logTypeBadge === 'string') this.logTypeBadge = options.logTypeBadge; if (typeof options?.logTimestamp?.enabled === 'boolean') this.logTimestamp = options.logTimestamp.enabled; if (typeof options?.logTimestamp?.mode === 'string') this.logTimestampType = options.logTimestamp.mode; if (typeof options?.logCallerInformation === 'boolean') this.logCallerInformation = options.logCallerInformation; if (!isNaN(parseInt(`${options?.defaultCallerCallStackLevel}`))) this.logCallerCallStackLevel = parseInt(`${options?.defaultCallerCallStackLevel}`); if (typeof options?.logMemoryUsage === 'boolean') this.logMemory = options.logMemoryUsage; // Initialize GrafanaLoki if (options?.grafanaLoki) { this.grafanaLokiInit(options.grafanaLoki); } // Output (info) if (options?.initSilent !== true) { this.logInit(); } } /** Logs the current initialization and setup of Loggify */ logInit() { this.console(`╭ Loggify (@pbrgld/loggify) loaded [ansi:reset]`, `init`, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`├─── Log level: [ansi:magenta]${this.logLevel}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`├─── Log timestamp: [ansi:magenta]${this.logTimestamp}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); if (this.logTimestamp) this.console(`├─── Timestamp format: [ansi:magenta]${this.logTimestampType}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`├─── Log type badge: [ansi:magenta]${this.logTypeBadge}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`├─── Log caller information: [ansi:magenta]${this.logCallerInformation}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`├─── Default caller information level: [ansi:magenta]${this.logCallerCallStackLevel}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); this.console(`╰─── Log memory usage: [ansi:magenta]${this.logMemory}[ansi:reset]`, undefined, { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 2 } }); } /** Method to set/change the log level */ setLogLevel(logLevel) { // Save current log level const currentLogLevel = this.logLevel; // Log Level has been changed if (currentLogLevel != logLevel) { this.logLevel = logLevel; this.console(`Loggify @LogLevel: "[ansi:brightBlue]${currentLogLevel}[ansi:reset]" => "[ansi:brightGreen]${this.logLevel}[ansi:reset]"`, 'info', { logLevel: 'off', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 1 } }); return true; } // No change required - new value equals current value else { this.console(`Loggify @LogLevel: already set to "${logLevel}"`, 'warn', { logLevel: 'minimal', callerInformation: { overwriteCallerStackLevel: this.logCallerCallStackLevel + 1 } }); return false; } } /** Initialize Grafana Loki */ grafanaLokiInit(options) { // Manipulate/optimized init object // Correct hostname if (options.isSecure === true) { options.hostname = `https://${options.hostname?.replaceAll('http://', '').replaceAll('https://', '')}`; } else if (options.isSecure === false) { options.hostname = `http://${options.hostname?.replaceAll('http://', '').replaceAll('https://', '')}`; } else if (!options.isSecure) { options.hostname = `http://${options.hostname?.replaceAll('http://', '').replaceAll('https://', '')}`; } //* Connection -> Connecting if (options.hostname && options.port) options.connection = { status: 'connecting', message: `Connecting to "${options.hostname}" via port "${options.port}" ...` }; //° Connection -> Invalid = undefined else options.connection = { status: undefined, message: `Incomplete connection setup! Require hostname and port!` }; // Store object in calls property this.grafanaLoki = options; // Test connection this.grafanaLokiTestConnection(); } /** * Build Authorization Header Value for GrafanaLoki REST API requests * This is done based on the auth initialozation of the GrafanaLoki configuration. Will either use none/empty value, * basic auth - using user name and password or bearer token * @returns {String} Returns either empty string for none, basic or bearer token value string for HTTP request header */ grafanaLokiAuthorizationHeader() { // Init let authorization = ''; // Build Basic Authorization Header Value if (this.grafanaLoki.auth?.type === 'basic') { authorization = `Basic ${Buffer.from(`${this.grafanaLoki.auth.user}:${this.grafanaLoki.auth.pass}`).toString('base64')}`; } // Build Bearer Authorization Header Value else if (this.grafanaLoki.auth?.type === 'bearer') { authorization = `Bearer ${this.grafanaLoki.auth.bearerToken}`; } return authorization; } /** * Test connection to Grafana Loki will test the server and port settings provided as well as user and password or * bearer token. In case of success the endpoint will provide some information such as version and revision into * the server info object and the function returns TRUE in case of success and FALSE in case of an error * @returns {Boolean} */ async grafanaLokiTestConnection() { //! GrafanaLoki not instantiated if (Object.keys(this.grafanaLoki).length === 0 || !this.grafanaLoki.connection?.status) { this.console('GrafanaLoki: [ansi:red]Not initialized![ansi:reset] Cannot test connection!', 'error', { logLevel: 'off' }); return false; } //! GrafanaLoki missing hostname if (this.grafanaLoki.hostname === 'http://' || this.grafanaLoki.hostname === 'https://') { this.grafanaLoki.connection = { status: 'error', message: 'Incomplete setup! Missing hostname!' }; return false; } // Init const pathTest = '/loki/api/v1/status/buildinfo'; let serverInfo = { connectionTested: false, version: '', revision: '', branch: '', buildUser: '', buildDate: '', goVersion: '' }; // Request to GrafanaLoki Server try { const response = await fetch(`${this.grafanaLoki.hostname}:${this.grafanaLoki.port}${pathTest}`, { method: 'get', headers: { 'Content-Type': 'application/json', 'Authorization': this.grafanaLokiAuthorizationHeader() } }); // Response => 200 OK if (response.status === 200) { const body = await response.json(); if (body.version) { serverInfo.version = body.version; serverInfo.connectionTested = true; } if (body.revision) serverInfo.revision = body.revision; if (body.branch) serverInfo.branch = body.branch; if (body.buildUser) serverInfo.buildUser = body.buildUser; if (body.buildDate) serverInfo.buildDate = body.buildDate; if (body.goVersion) serverInfo.goVersion = body.goVersion; this.grafanaLoki.serverInfo = serverInfo; this.grafanaLoki.connection = { status: 'connected', message: `Connected to "${this.grafanaLoki.hostname}:${this.grafanaLoki.port}${pathTest}"!` }; this.console(`GrafanaLoki: [ansi:green]Connection successfully tested![ansi:reset] v${serverInfo.version}(${serverInfo.revision}) [ansi:gray][${serverInfo.buildDate}][ansi:reset]`, 'okay', { logLevel: 'minimal' }); return true; } //° Failed to connect ==> error else { // Update connection info this.grafanaLoki.connection = { status: 'error', message: `Bad response connecting to "${this.grafanaLoki.hostname}:${this.grafanaLoki.port}${pathTest}" => ${response.status} - ${response.statusText}` }; this.console(`GrafanaLoki: [ansi:yellow]Testing connection failed![ansi:reset] => ${response.status} - ${response.statusText}`, 'error', { logLevel: 'off' }); return false; } } catch (error) { // Update connection info this.grafanaLoki.connection = { status: 'error', message: `Failed connecting to "${this.grafanaLoki.hostname}:${this.grafanaLoki.port}${pathTest}" => ${error.toString()}` }; this.console(`GrafanaLoki: [ansi:red]Error while testing connection![ansi:reset] => ${error.toString()}`, 'error', { logLevel: 'off' }); return false; } } /** * Pushes stream with a set of labels and a set of log records to the connected GrafanaLoki Server. Will not do anythins when there is no tested connection is class property * @param {GrafanaLokiEntry} entries Array of records you want to log. TS will be automatically set if not provided, must be in nanoseconds. Message can be either a string or an object which will be stringified and can be parsed by GrafanaLoki later * @param {GrafanaLokiLabels} labels A set of labels defining the stream combining the global labels set during GrafanaLoki init and this method. NOTE: Globals will overwrite locals * @param {any} options Options - not yet implemented * @returns {Boolean} In case of SUCCESS will return TRUE otherwise will return FALSE */ async grafanaLokiPush(entries, labels, options) { //° Connection still connecting if (this.grafanaLoki.connection?.status === "connecting") { const timeoutMs = 15000; const intervallMs = 100; const start = Date.now(); while (this.grafanaLoki.connection.status === "connecting") { if (Date.now() - start >= timeoutMs) { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:yellow]Connection timeout reached[ansi:reset] connecting to "${this.grafanaLoki.hostname}"`, 'warn'); this.grafanaLoki.connection = { status: 'error', message: `Connection timeout reached connecting to "${this.grafanaLoki.hostname}"` }; return false; } await new Promise(resolve => setTimeout(resolve, intervallMs)); } // Ab hier ist x !== "connecting" this.grafanaLoki.connection.status = 'connected'; } //! No connection setup if (Object.keys(this.grafanaLoki).length === 0 || !this.grafanaLoki.connection?.status) { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:red]Cannot push![ansi:reset] No connection setup!`, 'error'); return false; } //! Connection is in error state if (this.grafanaLoki.connection?.status === "error") { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:red]Cannot push![ansi:reset] Connection error => ${this.grafanaLoki.connection.message}!`, 'error'); return false; } //° Empty array -> no entries if (entries.length === 0) { if (options?.silent !== true) this.console('GrafanaLoki: [ansi:yellow]Nothing to push![ansi:reset] => No records provided in entries!'); return false; } //* Init const path = `/loki/api/v1/push`; const payload = { streams: [] }; const appLabels = this.grafanaLoki.labels; let streamRecord = {}; let entriesModified = []; // Merge lebels from function level and app level //° Note: keys defined on function level will overwrite keys on app level const stream = { ...appLabels, ...labels }; // Manipulate level values in stream if ('level' in stream) { // Overwrite keys based on logTypeMapping for (const [key, value] of Object.entries(this.grafanaLoki.logTypeMapping || {})) { if (stream.level === key) { stream.level = value; } } // Merge warn and warning to warning if (stream.level === 'warn') stream.level = 'warning'; // Remove level when none and undefined if (stream.level === 'none' || stream.level === undefined) delete stream.level; } // Find host reference to this and replace with hostname if (stream?.host === 'this') { const ip = Object.values(os.networkInterfaces()).flat().find(i => i?.family === 'IPv4' && !i.internal)?.address; stream.host = `${os.hostname()}[${ip}]`; } streamRecord.stream = stream; // Iterate through entries for (let i = 0; i < entries.length; i++) { // Fix missing timestamp if (!entries[i].ts) { entries[i].ts = `${Date.now() * 1_000_000}`; } if (typeof entries[i]?.line === 'object') entries[i].line = JSON.stringify(entries[i]?.line); // To Array entriesModified[i] = [`${entries[i]?.ts}`, `${entries[i]?.line}`]; } streamRecord.values = entriesModified; // Push to payload payload.streams.push(streamRecord); try { const response = await fetch(`${this.grafanaLoki.hostname}:${this.grafanaLoki.port}${path}`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json', 'Authorization': this.grafanaLokiAuthorizationHeader() } }); //* ==> 204 = Successful //§ HTTP 204 - no Content appears to be the standard response on success from GrafanaLoki if (response.status === 204) { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:green]Successfully pushed![ansi:reset] ==> ${this.grafanaLoki.hostname}:${this.grafanaLoki.port}`, 'okay', { logLevel: 'full' }); return true; } //° ==> 4xx Bad response else { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:yellow]Bad response while pushing![ansi:reset] ==> ${response.status} ${response.statusText}`, 'warn', { logLevel: 'off' }); return false; } } catch (error) { if (options?.silent !== true) this.console(`GrafanaLoki: [ansi:red]Push failed![ansi:reset] ==> ${error.toString()}`, 'error', { logLevel: 'off' }); return false; } } /** * Console output * Based on the log level and the type lines will be added to the console * @param {String} message Message you want to display - can contain ANSI colors and styling * @param {String} type Type of styling to be applied to output (e.g. info, error, success, debug, etc ...) * @param {Object} object Along with the message provide an object to be displayed within the output * @param {Object} options Set of options to be applied to the output (e.g. timestamp: true) */ console(message, type, options, object) { // Log level control if (this.logLevel == 'off' && options?.logLevel != 'off') return false; if (this.logLevel == 'minimal' && options?.logLevel != 'off' && options?.logLevel != 'minimal') return false; // Init let typeTag = ''; let timestamp = ''; let contextId = options?.context?.id || undefined; const contextColor = options?.context?.color || this.logBuffer.get(contextId)?.color || 'orange'; let callerInformation = undefined; let memory = ''; // Auto-Handle Object as a message and check message for a string if (message === null) message = '[ansi:blue][ansi:italic]null[ansi:reset]'; else if (typeof message === 'object') { object = message; message = 'Found object in message => object:'; } else if (typeof message === 'number') message = `[ansi:yellow]${message}[ansi:reset]`; else if (typeof message === 'undefined') { message = '[ansi:gray][ansi:italic]undefined[ansi:reset]'; } else if (typeof message === 'boolean') { if (message === true) message = '[ansi:magenta][ansi:italic]true[ansi:reset]'; else message = '[ansi:magenta][ansi:italic]false[ansi:reset]'; } else if (typeof message !== 'string') { message = `Invalid data type [ansi:red]"${typeof message}"[ansi:reset] for message! Must be either string, number or object`; type = 'error'; if (options) options.logLevel = 'off'; else options = { logLevel: 'off' }; } /** Check for context and initialize if needed */ if (contextId) { // Init const contextMode = options?.context?.mode || 'full'; if (!this.logBuffer.has(contextId)) { // Initialize context in log buffer this.logBuffer.set(contextId, { title: options?.context?.title, mode: contextMode, color: contextColor, start: performance.now(), end: 0, logs: [] }); // Define context start element Full Header and push to context in log buffer if (contextMode == 'full') { const contextFrame = this.replaceAnsi(`[ansi:${contextColor}]╔═══════════════< Context start: ${options?.context?.title || `contextId: ${options?.context?.id?.toString()}`}[ansi:reset]`); this.logBuffer.get(contextId).logs.push(`${contextFrame}\n`); } // Define context start element as small start End Title only and push to context in log buffer else if (contextMode == 'startEnd') { const headerStart = this.replaceAnsi(`[ansi:${contextColor}]<───────────────| Context start: ${options?.context?.title || `contextId: ${options?.context?.id?.toString()}`}[ansi:reset]`); const headerEnd = this.replaceAnsi(`[ansi:${contextColor}] |───────────────>[ansi:reset]`); this.logBuffer.get(contextId).logs.push(`${headerStart}${headerEnd}\n`); } } // Update end time - will always be overwritten with the last log else this.logBuffer.get(contextId).end = performance.now(); } /** Log type badge is off */ if (this.logTypeBadge === 'off') { typeTag = ''; } /** Log type badge is set to use Emoji */ else if (this.logTypeBadge === 'emoji') { // Determine correct emoji from Emoji object to render typeTag = Object.entries(this.emoji).find(([k]) => k === type)?.[1]; // When no matcing emoji could be determined if (!typeTag) typeTag = ' '; // No emoji aparently must be 2 blank spaces // Add an additional space in the and typeTag = `${typeTag} `; } /** Log type badge is set to tiny or mini */ else if (this.logTypeBadge === 'tiny' || this.logTypeBadge === 'mini') { // Init let spacer = ' '; // set value for tiny if (this.logTypeBadge === 'mini') spacer = ' '; // change for mini // Build Tags if (type == 'okay') typeTag = `[ansi:green][ansi:inverse]${spacer}`; else if (type == 'success') typeTag = `[ansi:green][ansi:inverse]${spacer}`; else if (type == 'info') typeTag = `[ansi:cyan][ansi:inverse]${spacer}`; else if (type == 'debug') typeTag = `[ansi:blue][ansi:inverse]${spacer}`; else if (type == 'warn' || type == 'warning') typeTag = `[ansi:yellow][ansi:inverse]${spacer}`; else if (type == 'error') typeTag = `[ansi:red][ansi:inverse]${spacer}`; else if (type == 'create' || type == 'add') typeTag = `[ansi:green][ansi:inverse]${spacer}`; else if (type == 'remove') typeTag = `[ansi:brightRed][ansi:inverse]${spacer}`; else if (type == 'metrics') typeTag = `[ansi:brightMagenta][ansi:inverse]${spacer}`; else if (type == 'init') typeTag = `[ansi:orange][ansi:inverse]${spacer}`; else if (type == 'finished') typeTag = `[ansi:gray][ansi:inverse]${spacer}`; else if (type?.startsWith('custom=')) typeTag = `${type.split('=')[1]}${spacer}`; else if (type) typeTag = `${spacer}`; else typeTag = spacer; // Replace ANSI colors and Styles typeTag = this.replaceAnsi(typeTag); // Add ANSI reset typeTag = typeTag + this.ansi.reset + ' '; } /** Log type badge is set to full */ else if (this.logTypeBadge === 'full') { // Build Tags if (type == 'okay') typeTag = '[ansi:green][ansi:inverse]OKAY'; else if (type == 'success') typeTag = '[ansi:green][ansi:inverse]SUCCESS'; else if (type == 'info') typeTag = '[ansi:cyan][ansi:inverse]INFO'; else if (type == 'debug') typeTag = '[ansi:blue][ansi:inverse]DEBUG'; else if (type == 'warn' || type == 'warning') typeTag = '[ansi:yellow][ansi:inverse]WARNING'; else if (type == 'error') typeTag = '[ansi:red][ansi:inverse]ERROR'; else if (type == 'create') typeTag = '[ansi:green][ansi:inverse]CREATE'; else if (type == 'add') typeTag = '[ansi:green][ansi:inverse]Add'; else if (type == 'remove') typeTag = '[ansi:brightRed][ansi:inverse]REMOVE'; else if (type == 'metrics') typeTag = '[ansi:brightMagenta][ansi:inverse]METRICS'; else if (type == 'init') typeTag = '[ansi:orange][ansi:inverse]INIT'; else if (type == 'finished') typeTag = '[ansi:gray][ansi:inverse]FINISHED'; else if (type?.startsWith('custom=')) typeTag = `${type.split('=')[1]}${type.split('=')[2]}`; else if (type) typeTag = ''; else typeTag = ''; // Replace ANSI colors and Styles typeTag = this.replaceAnsi(typeTag); // Stretch Type Tag to a fixed limit typeTag = this.padEndAnsiSafe(typeTag, 11, ' ', true); // Add ANSI reset typeTag = typeTag + this.ansi.reset + ' '; } /** Option: Timestamp */ if (this.logTimestamp || options?.timestamp) { if (this.logTimestampType === 'dateTime') timestamp = `${this.getLocalDateString()} ${this.getLocalTimeString()}`; else timestamp = this.getLocalTimeString(); // Add an empty string char at end of timestamp timestamp = this.replaceAnsi(`[ansi:gray]~${timestamp}~[ansi:reset] `); } /** Caller information */ if (this.logCallerInformation) { const location = this.getCallerLocation(options?.callerInformation?.overwriteCallerStackLevel || this.logCallerCallStackLevel); // Set the level how deep you want look-up the caller. usualy you would be looking for level 2 when initialized in each file and 3 when using a global or one-instance approach //* Caller information found if (location) { let functionInfo = ''; if (location?.function && !options?.callerInformation?.hideFunctionInfo) functionInfo = `[ansi:brightMagenta](Func:${location.function})[ansi:reset]`; callerInformation = this.replaceAnsi(`[ansi:blue]${basename(`${location?.file}`)}[ansi:reset]:[ansi:yellow]${location.line}[ansi:reset]${functionInfo}`) + ' '; } //! No caller information found else callerInformation = this.replaceAnsi('[ansi:red]Caller unknown![ansi:reset] '); //° Caller information disabled } else callerInformation = ''; /** Memory usage */ if (this.logMemory) { // Get memory usage const { heapUsed, heapTotal, rss } = process.memoryUsage(); memory = this.replaceAnsi(`[ansi:gray][[ansi:reset][ansi:cyan]${(heapUsed / 1024 / 1024).toFixed(2)}MB[ansi:reset][ansi:gray] of [ansi:reset][ansi:brightBlue]${(heapTotal / 1024 / 1024).toFixed(2)}MB[ansi:reset][ansi:gray]|[ansi:reset][ansi:white]${(rss / 1024 / 1024).toFixed(2)}MB[ansi:reset][ansi:gray]][ansi:reset] `); } /** Render message to console */ if (message) { // Replace [ansi:xxx] pattern with matching ANSI color/option message = this.replaceAnsi(message); // Replace [emoji:xxx] pattern with matching emoji message = this.replaceEmojis(message); // Metrics Special: When metrics start and end are defined prefix duration if (options?.metrics?.start && options?.metrics?.end) { message = this.replaceAnsi(`[ansi:gray][[ansi:reset][ansi:magenta]Duration: [ansi:reset]${this.formatDuration(options.metrics.end - options.metrics.start)}[ansi:gray]][ansi:reset]`) + ' ' + message; } // Metrics Special: When metrics duration is provided if (options?.metrics?.duration) { message = this.replaceAnsi(`[ansi:gray][[ansi:reset][ansi:magenta]Duration: [ansi:reset]${this.formatDuration(options.metrics.duration)}[ansi:gray]][ansi:reset]`) + ' ' + message; } // Push to logBuffer: when contextId is set if (contextId) { let contextFrame = ''; let contextMode = this.logBuffer.get(contextId)?.mode; // Context Frame Full if (contextMode == 'full') { // Context frame contextFrame = this.replaceAnsi(`[ansi:${contextColor}]║[ansi:reset] `); } // Push to log buffer this.logBuffer.get(contextId).logs.push(`${contextFrame}${typeTag}${timestamp}${memory}${callerInformation}${message}\n`); } // Send to standard output else process.stdout.write(`${typeTag}${timestamp}${memory}${callerInformation}${message}\n`); } /** Render object to console */ if (object && typeof object === 'object') { // JSON pretty format and use colors - custom colors const objectFormatted = util.inspect(this.flattenObject(object), { colors: false, // Disable ANSI colors - to use own depth: null, // Show all levels compact: false // pretty print }) .replace(/(.*?):/g, `${this.ansi.cyan}$1${this.ansi.reset}:`) // colorize keys .replace(/: '(.*?)'/g, `: ${this.ansi.brightGreen}"$1"${this.ansi.reset}`) // colorize strings .replace(/: "(.*?)"/g, `: ${this.ansi.brightGreen}"$1"${this.ansi.reset}`) // colorize strings .replace(/: (\d+)/g, `: ${this.ansi.yellow}$1${this.ansi.reset}`) // colorize numbers .replace(/(?<!["'])\btrue\b(?!["'])/g, `${this.ansi.magenta}true${this.ansi.reset}`) // colorize boolean true .replace(/(?<!["'])\bfalse\b(?!["'])/g, `${this.ansi.magenta}false${this.ansi.reset}`) // colorize boolean false .replace(/(?<!["'])\bundefined\b(?!["'])/g, `${this.ansi.gray}undefined${this.ansi.reset}`) // colorize undefined .replace(/\s*'([^']+)'\s*(?=,|\])/g, (_, str) => ` '${this.ansi.orange}${str}${this.ansi.reset}'`); // coloirze strings within an array // Object size information const objectInfo = this.getObjectSize(object); const objectInfoMessage = this.replaceAnsi(`[ansi:gray]Object has "${objectInfo.chars}" characters with a total size of ${objectInfo.size} [ansi:reset]`); // Push to logBuffer: when contextId is set if (contextId) { // init let contextFrame = ''; // Frame mode Full if (!options?.context?.mode || options.context.mode === 'full') { // Context frame contextFrame = this.replaceAnsi(`[ansi:${contextColor}]║[ansi:reset] `); } const linePrefix = `${contextFrame} `; const objectIndented = objectFormatted .split('\n') .map(line => `${linePrefix}${line}`) .join('\n'); this.logBuffer.get(contextId).logs.push(`${objectIndented}\n${linePrefix}${objectInfoMessage}\n`); } // Send to standard output else { // Indent object const linePrefix = ` `; const objectIndented = objectFormatted .split('\n') .map(line => `${linePrefix}${line}`) .join('\n'); process.stdout.write(`${objectIndented}\n${linePrefix}${objectInfoMessage}\n`); } } /** Push to GrafanaLoki */ if (!options?.grafanaLoki?.doNotPush || options.grafanaLoki.doNotPush !== true) { if (this.grafanaLoki?.connection?.status === 'connected' || this.grafanaLoki?.connection?.status === 'connecting') { // Init let line = {}; let labels = { level: type }; // Add to line object if valid if (message) line.message = this.stripAnsi(message).replaceAll('╭', '').replaceAll('├', '').replaceAll('╰', '').replaceAll('─', '').trim(); if (object && typeof object === 'object') line.object = object; if (options?.context?.id) labels.contextId = options?.context?.id; // Push to GrafanaLoki this.grafanaLokiPush([{ ts: `${Date.now() * 1_000_000}`, line }], labels, { silent: true }); } } } banner(content, options) { // Init const currentWidth = process.stdout.columns; let frameLineTitle = ''; let frameLineDescription = undefined; let descriptionLines = []; let descriptionContent; if (!options) options = {}; if (!options.frame) options.frame = {}; if (!options.frame.color) options.frame.color = 'brightWhite'; // Frame elements const frameTop = this.replaceAnsi(`[ansi:${options?.frame?.color}]╒${'═'.repeat(currentWidth - 2)}╕[ansi:reset]`); const frameLineSpacer = this.replaceAnsi(`[ansi:${options?.frame?.color}]│[ansi:reset]${' '.repeat(currentWidth - 2)}[ansi:${options?.frame?.color}]│[ansi:reset]`); const frameBottom = this.replaceAnsi(`[ansi:${options?.frame?.color}]╘${'═'.repeat(currentWidth - 2)}╛[ansi:reset]`); // Upgrade content string to object if (typeof content === 'string') content = { title: content }; // Build title if (content.title) { const titleContent = this.replaceAnsi(this.replaceEmojis(content.title)); frameLineTitle = this.replaceAnsi(`[ansi:${options?.frame?.color}]│[ansi:reset]${this.padEndAnsiSafe(titleContent, currentWidth - 2, ' ', true)}[ansi:${options?.frame?.color}]│[ansi:reset]`); } else frameLineTitle = frameLineSpacer; // Build description if (content.description) { descriptionLines = this.splitTextByNewLineLength(this.replaceAnsi(this.replaceEmojis(content.description.replaceAll('\t', `[ansi:hidden]${' '.repeat(4)}[ansi:reset]`))), currentWidth - 4, true); // Iterate through description lines for (const line of descriptionLines) { // Following line if (frameLineDescription) { frameLineDescription = `${frameLineDescription}\n${this.replaceAnsi(`[ansi:${options?.frame?.color}]│[ansi:reset] ${this.padEndAnsiSafe(line, currentWidth - 4)} [ansi:${options?.frame?.color}]│[ansi:reset]`)}`; } // First line else { frameLineDescription = `${this.replaceAnsi(`[ansi:${options?.frame?.color}]│[ansi:reset] ${this.padEndAnsiSafe(line, currentWidth - 4)} [ansi:${options?.frame?.color}]│[ansi:reset]`)}`; } } } // Render banner process.stdout.write(`${frameTop}\n${frameLineTitle}\n${frameLineDescription ? `${frameLineSpacer + frameLineDescription + frameLineSpacer}\n` : ''}${frameBottom}\n`); } /** * A function that uses the trick to throw an error to access the call stack and extract the levels of them * @param {number} depth Put in the number of the level you want to scan * @returns {Object} Information from where the function has been called, such as path to file and line */ getCallerLocation(depth = 2) { const err = new Error(); const stack = err.stack?.split('\n'); if (!stack || stack.length <= depth) return null; if (!stack[depth]) return null; const line = stack[depth].trim(); const regexWithFunc = /^at (.+?) \((.+):(\d+):(\d+)\)$/; const regexNoFunc = /^at (.+):(\d+):(\d+)$/; let match = line.match(regexWithFunc); if (match) { const [, func, file, lineNum, colNum] = match; return { function: func, file, line: Number(lineNum), column: Number(colNum) }; } match = line.match(regexNoFunc); if (match) { const [, file, lineNum, colNum] = match; return { function: null, file, line: Number(lineNum), column: Number(colNum) }; } return null; } /** * Flatten object is required to make to logging of object work with certain outputs. * @param {Object} obj Object to be flattened * @returns {Object} flattened object */ flattenObject(obj) { const flat = {}; let proto = obj; while (proto && proto !== Object.prototype) { for (const key of Object.getOwnPropertyNames(proto)) { if (!(key in flat)) { try { const value = obj[key]; flat[key] = typeof value === 'function' ? '[Function]' : value; } catch { flat[key] = '[unreadable]'; } } } proto = Object.getPrototypeOf(proto); } return flat; } /** * Generates a unique context ID to be used grouping functions and parallel executions together * @returns {String} Unique context ID */ generateContextId() { const contextId = () => { const rand = globalThis.crypto?.getRandomValues?.(new Uint32Array(1)); return (rand?.[0] ?? Date.now()).toString(36); }; return contextId(); } /** * Flush specific context from log buffer will dump a specific log record set to the standard output and remove it * from log buffer. When a context ID is not found or does not exist anymore the function will log an error and * return FALSE, welse it will print the logs and return TRUE. * @param {String} contextId Unique ID to identify the context area that holds all relevant logs * @param {FlushOptions} options Options like discarding the logs and not render to console * @returns {Boolean} Returns FALSE when context ID cannot found in log buffer, otherwise will return TRUE */ flush(contextId, options) { //! ContextId not found in LogBuffer if (!this.logBuffer.has(contextId)) { this.console(`Context ID "[ansi:yellow]${contextId}[ansi:reset] does not exist in log buffer!`, 'error', { logLevel: 'off' }); return false; } // Init - when ContextID exists const contextColor = this.logBuffer.get(contextId)?.color || 'orange'; // Do not render to console when discard is explicitly set if (options?.discardContextLog != true) { // Iterate through log records for (const record of this.logBuffer.get(contextId).logs) { process.stdout.write(record); } // Frame mode Full if (!this.logBuffer.get(contextId).mode || this.logBuffer.get(contextId).mode === 'full') { // Render context closing frame const contextFrame = this.replaceAnsi(`[ansi:${contextColor}]╚═══════════════> Context end: ${this.logBuffer.get(contextId).title || `contextID: ${contextId}`}[ansi:reset] | [ansi:magenta]Duration:[ansi:reset] ${this.formatDuration(this.logBuffer.get(contextId).end - this.logBuffer.get(contextId).start)}\n`); process.stdout.write(contextFrame); } // Start End mode else if (this.logBuffer.get(contextId).mode === 'startEnd') { // Render context closing frame const footerStart = this.replaceAnsi(`[ansi:${contextColor}]┌───────────────│ Context end:${this.logBuffer.get(contextId).title || `contextID: ${contextId}`}[ansi:reset]\n`); const footerEnd = this.replaceAnsi(`[ansi:${contextColor}]└───────────────> [ansi:magenta]Duration:[ansi:reset] ${this.formatDuration(this.logBuffer.get(contextId).end - this.logBuffer.get(contextId).start)}\n`); process.stdout.write(`${footerStart}${footerEnd}`); } } // Delete context from log buffer after written output this.logBuffer.delete(contextId); // End function return true; } /** * Replace ANSI style commands to ANSI CODE in String * @param {String} str String to be searched for ANSI commands and replaced into ANSI code * @returns {String} String with ANSI characters */ replaceAnsi(str) { str = str.replace(/\[ansi:(\w+)\]/g, (_, key) => this.ansi[key] || `[ansi:${key}]`); return str; } /** * Strip all ANSI code from String and return a clean string * @param {String} str Provide a string that may or may not have ANSI code in it * @returns {String} Receive a string stripped by ANSI code */ stripAnsi(str) { const ansiRegex = // Matches most common ANSI escape sequences /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; return str.replace(ansiRegex, ''); } /** * Replace emoji code to actual emojis in string * @param {String} str String to be searched for emoji commands and replace them with actual emojis. * @returns {String} String with actual emoji icons */ replaceEmojis(str) { str = str.replace(/\[emoji:(\w+)\]/g, (_, key) => this.emoji[key] || `[emoji:${key}]`); return str; } /** * ANSI Safe version of padEnd() Function * @param {String} str Message or label you want to handle * @param {Number} targetLength The target length of chars you want to stretch the label to * @param {String} padChar The character you want to use filling up the space * @param {Boolean} center If TRUE, the label will be displayed in center and the ANSI style of the last field will be applied to the front of str filling chars * @returns */ padEndAnsiSafe(str = '', targetLength, padChar = ' ', center = false) { const ansiRegex = /\x1b\[[0-9;]*m/g; const RESET = '\x1b[0m'; // Visible length without ANSI const visibleLength = str.replace(ansiRegex, '').length; const totalPadding = Math.max(0, targetLength - visibleLength); if (totalPadding <= 0) return str; // Track active ANSI code (that not has been reset by \x1b[0m ) const ansiCodes = []; let match; ansiRegex.lastIndex = 0; while ((match = ansiRegex.exec(str))) { const code = match[0]; if (code === RESET) { ansiCodes.length = 0; // Reset delets previous style } else { ansiCodes.push(code); // Track all active codes } } const stylePrefix = ansiCodes.join(''); const styleReset = stylePrefix ? RESET : ''; if (!center) { return str + padChar.repeat(totalPadding); } const padLeft = Math.floor(totalPadding / 2); const padRight = totalPadding - padLeft; const leftPad = stylePrefix + padChar.repeat(padLeft) + styleReset; const rightPad = padChar.repeat(padRight); return leftPad + str + rightPad; } /** * Fast way to determine the current date based on the current plattform's time zone. Not using UTC! * @returns {String} Local date in current time zone */ getLocalDateString() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); // 0-basiert const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Fast way to determine the current time based on the current plattform's time zone. Not using UTC! * @returns {String} Local time in current time zone */ getLocalTimeString() { const now = new Date(); const time = now.toLocaleTimeString(undefined, { hour12: false }); const ms = String(now.getMilliseconds()).padStart(3, '0'); return `${time}.${ms}`; } /** * Converts miliseconds (ms) into human readable time * @param {Number} ms Provide the miliseconds you want to convert into human readabe time * @returns {String} Returns human readable string */ formatDuration(ms) { const msInSec = 1000; const msInMin = 60 * msInSec; const msInHour = 60 * msInMin; const hours = Math.floor(ms / msInHour); const minutes = Math.floor((ms % msInHour) / msInMin); const seconds = Math.floor((ms % msInMin) / msInSec); const milliseconds = Math.floor(ms % msInSec); const parts = []; if (hours) parts.push(`${hours}h`); if (minutes) parts.push(`${minutes}m`); if (seconds || (!hours && !minutes)) { parts.push(`${seconds}.${String(milliseconds).padStart(3, '0')}s`); } return parts.join(' '); } /** * Converts object into string and than calculates the size * @param {Object} obj Object you want to calculate the size of * @returns {String} Returns a size formatted string of the object */ getObjectSize(obj) { let size = ''; let bytes = 0; let chars = 0; const objectStringified = JSON.stringify(obj); const bytesUtf8 = Buffer.byteLength(objectStringified, 'utf8'); // Determine size if (bytesUtf8 < 1024) size = `${bytesUtf8} Bytes`; if (bytesUtf8 < 1024 * 1024) size = `${(bytesUtf8 / 1024).toFixed(2)} KB`; else size = `${(bytesUtf8 / 1024 / 1024).toFixed(2)} MB`; // Assign bytes and chars bytes = bytesUtf8; chars = objectStringified.length; return { size, bytes, chars }; } //° WIP - to be optimized splitTextByNewLineLength(text, maxLength, wordWrap = false) { const ansiRegex = /\x1B\[[0-9;]*m/g; // Determine visible length function visibleLength(str) { return str.replace(ansiRegex, '').length; } // Cuts visible characters in consideration to ANSI function sliceVisible(string, start, end) { let result = ''; let visible = 0; const matches = [...string.matchAll(ansiRegex)]; let i = 0; let skip = 0; while (i < string.length && visible < end) { const nextAnsi = matches.find(m => m.index === i); if (nextAnsi) { result += nextAnsi[0]; i += nextAnsi[0].length; continue; } if (visible >= start) { result += string[i]; } i++; visible++; } return result; } const lines = []; const rawLines = text.split('\n'); for (const rawLine of rawLines) { let line = rawLine.trim(); if (!wordWrap) { let visible = visibleLength(line); let start = 0; while (visible > maxLength) { const part = sliceVisible(line, start, start + maxLength); lines.push(part); start += maxLength; visible -= maxLength; } const remaining = sliceVisible(line, start, start + maxLength); if (visibleLength(remaining) > 0) lines.push(remaining); } else { // Word wrap const words = line.match(/\S+\s*/g) || []; let buffer = ""; for (const word of words) { const testLine = buffer + word; if (visibleLength(testLine) <= maxLength) { buffer = testLine;