inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
475 lines (429 loc) • 14.3 kB
text/typescript
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;
}
}