UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

475 lines (429 loc) 14.3 kB
import { DeepPartial, flatMap } from "@zwave-js/shared"; import type { Format, TransformFunction } from "logform"; import * as path from "path"; import { configs, MESSAGE } from "triple-beam"; import winston from "winston"; import DailyRotateFile from "winston-daily-rotate-file"; import type Transport from "winston-transport"; import type { ConsoleTransportInstance } from "winston/lib/winston/transports"; import { colorizer } from "./Colorizer"; import { channelPadding, CONTROL_CHAR_WIDTH, directionPrefixPadding, LogConfig, LogContext, LOG_WIDTH, MessageRecord, stringToNodeList, timestampFormatShort, timestampPadding, timestampPaddingShort, ZWaveLogger, ZWaveLogInfo, } from "./shared_safe"; const { combine, timestamp, label } = winston.format; const loglevels = configs.npm.levels; const isTTY = process.stdout.isTTY; const isUnitTest = process.env.NODE_ENV === "test"; export class ZWaveLoggerBase<TContext extends LogContext = LogContext> { constructor(loggers: ZWaveLogContainer, logLabel: string) { this.container = loggers; this.logger = this.container.getLogger(logLabel); } public logger: ZWaveLogger<TContext>; public container: ZWaveLogContainer; } export class ZWaveLogContainer extends winston.Container { private fileTransport: DailyRotateFile | undefined; private consoleTransport: ConsoleTransportInstance | undefined; private loglevelVisibleCache = new Map<string, boolean>(); private logConfig: LogConfig & { level: string } = { enabled: true, level: getTransportLoglevel(), logToFile: !!process.env.LOGTOFILE, nodeFilter: stringToNodeList(process.env.LOG_NODES), transports: undefined as any, filename: require.main ? path.join( path.dirname(require.main.filename), `zwavejs_%DATE%.log`, ) : path.join(__dirname, "../../..", `zwavejs_%DATE%.log`), forceConsole: false, }; constructor(config: DeepPartial<LogConfig> = {}) { super(); this.updateConfiguration(config); } public getLogger(label: string): ZWaveLogger { if (!this.has(label)) { this.add(label, { transports: this.getAllTransports(), format: createLoggerFormat(label), // Accept all logs, no matter what. The individual loggers take care // of filtering the wrong loglevels level: "silly", }); } return this.get(label) as unknown as ZWaveLogger; } public updateConfiguration(config: DeepPartial<LogConfig>): void { const changedLoggingTarget = (config.logToFile != undefined && config.logToFile !== this.logConfig.logToFile) || (config.forceConsole != undefined && config.forceConsole !== this.logConfig.forceConsole); if (typeof config.level === "number") { config.level = loglevelFromNumber(config.level); } const changedLogLevel = config.level != undefined && config.level !== this.logConfig.level; if ( config.filename != undefined && !config.filename.includes("%DATE%") ) { config.filename += "_%DATE%.log"; } const changedFilename = config.filename != undefined && config.filename !== this.logConfig.filename; this.logConfig = Object.assign(this.logConfig, config); // If the loglevel changed, our cached "is visible" info is out of date if (changedLogLevel) { this.loglevelVisibleCache.clear(); } // When the log target (console, file, filename) was changed, recreate the internal transports // because at least the filename does not update dynamically // Also do this when configuring the logger for the first time const recreateInternalTransports = (this.fileTransport == undefined && this.consoleTransport == undefined) || changedLoggingTarget || changedFilename; if (recreateInternalTransports) { this.fileTransport?.destroy(); this.fileTransport = undefined; this.consoleTransport?.destroy(); this.consoleTransport = undefined; } // When the internal transports or the custom transports were changed, we need to update the loggers if (recreateInternalTransports || config.transports != undefined) { this.loggers.forEach((logger) => logger.configure({ transports: this.getAllTransports() }), ); } } public getConfiguration(): LogConfig { return this.logConfig; } /** Tests whether a log using the given loglevel will be logged */ public isLoglevelVisible(loglevel: string): boolean { // If we are not connected to a TTY, not logging to a file and don't have any custom transports, we won't see anything if ( !this.fileTransport && !this.consoleTransport && (!this.logConfig.transports || this.logConfig.transports.length === 0) ) { return false; } if (!this.loglevelVisibleCache.has(loglevel)) { this.loglevelVisibleCache.set( loglevel, loglevel in loglevels && loglevels[loglevel] <= loglevels[this.logConfig.level], ); } return this.loglevelVisibleCache.get(loglevel)!; } public destroy(): void { for (const key in this.loggers) { this.close(key); } this.fileTransport = undefined; this.consoleTransport = undefined; this.logConfig.transports = []; } private getAllTransports(): Transport[] { return [ ...this.getInternalTransports(), ...(this.logConfig.transports ?? []), ]; } private getInternalTransports(): Transport[] { const ret: Transport[] = []; if (this.logConfig.enabled && this.logConfig.logToFile) { if (!this.fileTransport) { this.fileTransport = this.createFileTransport(); } ret.push(this.fileTransport); } else if (!isUnitTest && (isTTY || this.logConfig.forceConsole)) { if (!this.consoleTransport) { this.consoleTransport = this.createConsoleTransport(); } ret.push(this.consoleTransport); } return ret; } private createConsoleTransport(): ConsoleTransportInstance { return new winston.transports.Console({ format: createDefaultTransportFormat( // Only colorize the output if logging to a TTY, otherwise we'll get // ansi color codes in logfiles or redirected shells isTTY || isUnitTest, // Only use short timestamps if logging to a TTY isTTY, ), silent: this.isConsoleTransportSilent(), }); } private isConsoleTransportSilent(): boolean { return process.env.NODE_ENV === "test" || !this.logConfig.enabled; } private isFileTransportSilent(): boolean { return !this.logConfig.enabled; } private createFileTransport(): DailyRotateFile { const ret = new DailyRotateFile({ filename: this.logConfig.filename, auditFile: `${this.logConfig.filename .replace("_%DATE%", "_logrotate") .replace(/\.log$/, "")}.json`, datePattern: "YYYY-MM-DD", createSymlink: true, symlinkName: path .basename(this.logConfig.filename) .replace(`_%DATE%`, "_current"), zippedArchive: true, maxFiles: "7d", format: createDefaultTransportFormat(false, false), silent: this.isFileTransportSilent(), }); ret.on("new", (newFilename: string) => { console.log(`Logging to file: ${newFilename}`); }); return ret; } /** * Checks the log configuration whether logs should be written for a given node id */ public shouldLogNode(nodeId: number): boolean { // If no filters are set, every node gets logged if (!this.logConfig.nodeFilter) return true; return this.logConfig.nodeFilter.includes(nodeId); } } function getTransportLoglevel(): string { return process.env.LOGLEVEL! in loglevels ? process.env.LOGLEVEL! : "debug"; } /** Performs a reverse lookup of the numeric loglevel */ function loglevelFromNumber(numLevel: number | undefined): string | undefined { if (numLevel == undefined) return; for (const [level, value] of Object.entries(loglevels)) { if (value === numLevel) return level; } } /** Creates the common logger format for all loggers under a given channel */ export function createLoggerFormat(channel: string): Format { return combine( // add the channel as a label label({ label: channel }), // default to short timestamps timestamp(), ); } /** Prints a formatted and colorized log message */ export function createLogMessagePrinter(shortTimestamps: boolean): Format { return { transform: ((info: ZWaveLogInfo) => { // The formatter has already split the message into multiple lines const messageLines = messageToLines(info.message); // Also this can only happen if the user forgot to call the formatter first if (info.secondaryTagPadding == undefined) info.secondaryTagPadding = -1; // Format the first message line let firstLine = [ info.primaryTags, messageLines[0], info.secondaryTagPadding < 0 ? undefined : " ".repeat(info.secondaryTagPadding), // If the secondary tag padding is zero, the previous segment gets // filtered out and we have one less space than necessary info.secondaryTagPadding === 0 && info.secondaryTags ? " " + info.secondaryTags : info.secondaryTags, ] .filter((item) => !!item) .join(" "); // The directional arrows and the optional grouping lines must be prepended // without adding spaces firstLine = `${info.timestamp} ${info.label} ${info.direction}${firstLine}`; const lines = [firstLine]; if (info.multiline) { // Format all message lines but the first lines.push( ...messageLines.slice(1).map( (line) => // Skip the columns for the timestamp and the channel name (shortTimestamps ? timestampPaddingShort : timestampPadding) + channelPadding + // Skip the columns for directional arrows directionPrefixPadding + line, ), ); } info[MESSAGE as any] = lines.join("\n"); return info; }) as unknown as TransformFunction, }; } /** Formats the log message and calculates the necessary paddings */ export const logMessageFormatter: Format = { transform: ((info: ZWaveLogInfo) => { const messageLines = messageToLines(info.message); const firstMessageLineLength = messageLines[0].length; info.multiline = messageLines.length > 1 || !messageFitsIntoOneLine(info, info.message.length); // Align postfixes to the right if (info.secondaryTags) { // Calculate how many spaces are needed to right-align the postfix // Subtract 1 because the parts are joined by spaces info.secondaryTagPadding = Math.max( // -1 has the special meaning that we don't print any padding, // because the message takes all the available space -1, LOG_WIDTH - 1 - calculateFirstLineLength(info, firstMessageLineLength), ); } if (info.multiline) { // Break long messages into multiple lines const lines: string[] = []; let isFirstLine = true; for (let message of messageLines) { while (message.length) { const cut = Math.min( message.length, isFirstLine ? LOG_WIDTH - calculateFirstLineLength(info, 0) - 1 : LOG_WIDTH - CONTROL_CHAR_WIDTH, ); isFirstLine = false; lines.push(message.substr(0, cut)); message = message.substr(cut); } } info.message = lines.join("\n"); } return info; }) as unknown as TransformFunction, }; /** The common logger format for built-in transports */ export function createDefaultTransportFormat( colorize: boolean, shortTimestamps: boolean, ): Format { const formats: Format[] = [ // overwrite the default timestamp format if necessary shortTimestamps ? timestamp({ format: timestampFormatShort }) : undefined, logMessageFormatter, colorize ? colorizer() : undefined, createLogMessagePrinter(shortTimestamps), ].filter((f): f is Format => !!f); return combine(...formats); } /** * Calculates the length the first line of a log message would occupy if it is not split * @param info The message and information to log * @param firstMessageLineLength The length of the first line of the actual message text, not including pre- and postfixes. */ function calculateFirstLineLength( info: ZWaveLogInfo, firstMessageLineLength: number, ): number { return ( [ CONTROL_CHAR_WIDTH - 1, firstMessageLineLength, (info.primaryTags || "").length, (info.secondaryTags || "").length, ] // filter out empty parts .filter((len) => len > 0) // simulate adding spaces between parts .reduce((prev, val) => prev + (prev > 0 ? 1 : 0) + val) ); } /** * Tests if a given message fits into a single log line * @param info The message that should be logged * @param messageLength The length that should be assumed for the actual message without pre and postfixes. * Can be set to 0 to exclude the message from the calculation */ export function messageFitsIntoOneLine( info: ZWaveLogInfo, messageLength: number, ): boolean { const totalLength = calculateFirstLineLength(info, messageLength); return totalLength <= LOG_WIDTH; } export function messageToLines(message: string | string[]): string[] { if (typeof message === "string") { return message.split("\n"); } else if (message.length > 0) { return message; } else { return [""]; } } /** Splits a message record into multiple lines and auto-aligns key-value pairs */ export function messageRecordToLines(message: MessageRecord): string[] { const entries = Object.entries(message); if (!entries.length) return []; const maxKeyLength = Math.max(...entries.map(([key]) => key.length)); return flatMap(entries, ([key, value]) => `${key}:${" ".repeat( Math.max(maxKeyLength - key.length + 1, 1), )}${value}` .split("\n") .map((line) => line.trimRight()), ); } /** Wraps an array of strings in square brackets and joins them with spaces */ export function tagify(tags: string[]): string { return tags.map((pfx) => `[${pfx}]`).join(" "); } /** Unsilences the console transport of a logger and returns the original value */ export function unsilence(logger: winston.Logger): boolean { const consoleTransport = logger.transports.find( (t) => (t as any).name === "console", ); if (consoleTransport) { const ret = !!consoleTransport.silent; consoleTransport.silent = false; return ret; } return false; } /** Restores the console transport of a logger to its original silence state */ export function restoreSilence( logger: winston.Logger, original: boolean, ): void { const consoleTransport = logger.transports.find( (t) => (t as any).name === "console", ); if (consoleTransport) { consoleTransport.silent = original; } }