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') + }) + }) })