From 9adefe0479783fac35afda5da20ff43be5a309a3 Mon Sep 17 00:00:00 2001 From: Logan Garrett Date: Thu, 9 Oct 2025 09:25:08 -0400 Subject: [PATCH] feat: add JSON logging format support - Add support for JSON logging format alongside existing text format - JSON mode outputs structured JSON logs to console, files, and debug view - Text mode continues to output human-readable text with ANSI colors - Debug view displays raw format as received (JSON strings or formatted text) - Logging format configurable via gateway settings (logFormat: 'json' | 'text') - Both formats use consistent JSONTransport architecture - Maintains backward compatibility with existing text logging behavior This enables structured logging for better log parsing, monitoring, and integration with log aggregation systems while preserving the existing text format for human readability. --- api/lib/Gateway.ts | 1 + api/lib/ZwaveClient.ts | 103 ++++++++++++++++++++++++++++++++++++---- api/lib/logger.ts | 32 ++++++++++--- src/views/Settings.vue | 18 +++++++ test/lib/logger.test.ts | 21 ++++++++ 5 files changed, 160 insertions(+), 15 deletions(-) diff --git a/api/lib/Gateway.ts b/api/lib/Gateway.ts index ab0a1b6e2d..adb3a1cb05 100644 --- a/api/lib/Gateway.ts +++ b/api/lib/Gateway.ts @@ -191,6 +191,7 @@ export type GatewayConfig = { logEnabled?: boolean logLevel?: LogLevel logToFile?: boolean + logFormat?: 'text' | 'json' values?: GatewayValue[] jobs?: ScheduledJob[] plugins?: string[] diff --git a/api/lib/ZwaveClient.ts b/api/lib/ZwaveClient.ts index 82634d8290..0594c26483 100644 --- a/api/lib/ZwaveClient.ts +++ b/api/lib/ZwaveClient.ts @@ -21,6 +21,8 @@ import { } from '@zwave-js/core' import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node' import { JSONTransport } from '@zwave-js/log-transport-json' +import winston from 'winston' +import DailyRotateFile from 'winston-daily-rotate-file' import { isDocker } from './utils' import { AssociationAddress, @@ -133,6 +135,7 @@ import { socketEvents } from './SocketEvents' import { isUint8Array } from 'util/types' import { PkgFsBindings } from './PkgFsBindings' import { join } from 'path' +import * as path from 'path' import { regionSupportsAutoPowerlevel } from './shared' export const deviceConfigPriorityDir = join(storeDir, 'config') @@ -2318,14 +2321,8 @@ class ZwaveClient extends TypedEventEmitter { utils.parseSecurityKeys(this.cfg, zwaveOptions) - const logTransport = new JSONTransport() - logTransport.format = createDefaultTransportFormat(true, false) - - zwaveOptions.logConfig.transports = [logTransport] - - logTransport.stream.on('data', (data) => { - this.socket.emit(socketEvents.debug, data.message.toString()) - }) + // Setup driver logging based on format setting + this.setupDriverLogging(zwaveOptions) try { if (shouldUpdateSettings) { @@ -6962,6 +6959,96 @@ class ZwaveClient extends TypedEventEmitter { } }, 1000) } + + + private setupDriverLogging(zwaveOptions: PartialZWaveOptions) { + const logFormat = this.getLogFormat() + + if (logFormat === 'json') { + this.setupJsonDriverLogging(zwaveOptions) + } else { + this.setupTextDriverLogging(zwaveOptions) + } + } + + private getLogFormat(): 'text' | 'json' { + const settings = jsonStore.get(store.settings) + return settings?.gateway?.logFormat || 'text' + } + + private setupJsonDriverLogging(zwaveOptions: PartialZWaveOptions) { + const transports = [] + + const parseFormat = this.createParseDriverJsonFormat() + const jsonFormat = winston.format.combine( + parseFormat(), + winston.format.json(), + ) + + // Console transport + transports.push(new winston.transports.Console({ + format: jsonFormat, + })) + + // File transport (if enabled) + if (this.cfg.logToFile) { + transports.push(new DailyRotateFile({ + filename: ZWAVEJS_LOG_FILE, + auditFile: utils.joinPath(logsDir, 'zwavejs-logs.audit.json'), + datePattern: 'YYYY-MM-DD', + createSymlink: true, + symlinkName: path.basename(ZWAVEJS_LOG_FILE).replace('_%DATE%', '_current'), + zippedArchive: true, + maxFiles: process.env.ZUI_LOG_MAXFILES || '7d', + maxSize: process.env.ZUI_LOG_MAXSIZE || '50m', + format: jsonFormat, + })) + } + + // WebSocket transport with JSON format + const jsonTransport = new JSONTransport() + jsonTransport.format = jsonFormat + transports.push(jsonTransport) + + // Configure driver + zwaveOptions.logConfig = { + ...zwaveOptions.logConfig, + enabled: false, + raw: true, + showLogo: false, + transports: transports, + } + + // Stream JSON logs to WebSocket for debug view + jsonTransport.stream.on('data', (data) => { + this.socket.emit(socketEvents.debug, data.message.toString()) + }) + } + + private setupTextDriverLogging(zwaveOptions: PartialZWaveOptions) { + const logTransport = new JSONTransport() + logTransport.format = createDefaultTransportFormat(true, false) + + zwaveOptions.logConfig.transports = [logTransport] + + logTransport.stream.on('data', (data) => { + this.socket.emit(socketEvents.debug, data.message.toString()) + }) + } + + private createParseDriverJsonFormat() { + return winston.format((info) => { + if (typeof info.message === 'string' && info.message.startsWith('{')) { + try { + const parsed = JSON.parse(info.message) + info.message = parsed + } catch (e) { + // Keep as string if parsing fails + } + } + return info + }) + } } export default ZwaveClient diff --git a/api/lib/logger.ts b/api/lib/logger.ts index 70bb3805fa..d4ab2b997e 100644 --- a/api/lib/logger.ts +++ b/api/lib/logger.ts @@ -70,8 +70,22 @@ export function sanitizedConfig( /** * Return a custom logger format */ -export function customFormat(noColor = false): winston.Logform.Format { +export function customFormat( + noColor = false, + logFormat: 'text' | 'json' = 'text', +): winston.Logform.Format { noColor = noColor || disableColors + + if (logFormat === 'json') { + // JSON format for all outputs + return combine( + timestamp(), + format.errors({ stack: true }), + format.json(), + ) + } + + // Existing text format const formats: winston.Logform.Format[] = [ splat(), // used for formats like: logger.log('info', Message %s', strinVal) timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), @@ -106,7 +120,10 @@ export const logStream = new PassThrough() /** * Create the base transports based on settings provided */ -export function customTransports(config: LoggerConfig): winston.transport[] { +export function customTransports( + config: LoggerConfig, + logFormat: 'text' | 'json' = 'text', +): winston.transport[] { // setup transports only once (see issue #2937) if (transportsList) { return transportsList @@ -117,7 +134,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] { if (process.env.ZUI_NO_CONSOLE !== 'true') { transportsList.push( new transports.Console({ - format: customFormat(), + format: customFormat(false, logFormat), level: config.level, stderrLevels: ['error'], }), @@ -125,7 +142,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] { } const streamTransport = new transports.Stream({ - format: customFormat(), + format: customFormat(false, logFormat), level: config.level, stream: logStream, }) @@ -137,7 +154,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] { if (process.env.DISABLE_LOG_ROTATION === 'true') { fileTransport = new transports.File({ - format: customFormat(true), + format: customFormat(true, logFormat), filename: config.filePath, level: config.level, }) @@ -154,7 +171,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] { maxFiles: process.env.ZUI_LOG_MAXFILES || '7d', maxSize: process.env.ZUI_LOG_MAXSIZE || '50m', level: config.level, - format: customFormat(true), + format: customFormat(true, logFormat), } fileTransport = new DailyRotateFile(options) @@ -182,6 +199,7 @@ export function setupLogger( config?: DeepPartial, ): ModuleLogger { const sanitized = sanitizedConfig(module, config) + const logFormat = config?.logFormat || 'text' // Winston automatically reuses an existing module logger const logger = container.add(module) as ModuleLogger const moduleName = module.toUpperCase() || '-' @@ -196,7 +214,7 @@ export function setupLogger( ), // to correctly parse errors silent: !sanitized.enabled, level: sanitized.level, - transports: customTransports(sanitized), + transports: customTransports(sanitized, logFormat), }) logger.module = module logger.setup = (cfg) => setupLogger(container, module, cfg) diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 68405aba6e..a6458c2cf9 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -132,6 +132,20 @@ label="Log Level" > + + + { expect(logger2.level).to.equal('warn') }) }) + + describe('customFormat()', () => { + it('should return format object when logFormat is json', () => { + const format = customFormat(false, 'json') + expect(format).to.be.an('object') + expect(format).to.have.property('transform') + }) + + it('should return format object when logFormat is text', () => { + const format = customFormat(true, 'text') + expect(format).to.be.an('object') + expect(format).to.have.property('transform') + }) + + it('should return format object when logFormat is undefined', () => { + const format = customFormat(true, undefined) + expect(format).to.be.an('object') + expect(format).to.have.property('transform') + }) + }) })