Skip to content

Commit b0c263f

Browse files
committed
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.
1 parent 7b0285f commit b0c263f

File tree

6 files changed

+487
-109
lines changed

6 files changed

+487
-109
lines changed

api/lib/Gateway.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export type GatewayConfig = {
191191
logEnabled?: boolean
192192
logLevel?: LogLevel
193193
logToFile?: boolean
194+
logFormat?: 'text' | 'json'
194195
values?: GatewayValue[]
195196
jobs?: ScheduledJob[]
196197
plugins?: string[]

api/lib/ZwaveClient.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
} from '@zwave-js/core'
2323
import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node'
2424
import { JSONTransport } from '@zwave-js/log-transport-json'
25+
import winston from 'winston'
26+
import DailyRotateFile from 'winston-daily-rotate-file'
2527
import { isDocker } from './utils'
2628
import {
2729
AssociationAddress,
@@ -133,6 +135,7 @@ import { socketEvents } from './SocketEvents'
133135
import { isUint8Array } from 'util/types'
134136
import { PkgFsBindings } from './PkgFsBindings'
135137
import { join } from 'path'
138+
import * as path from 'path'
136139

137140
export const deviceConfigPriorityDir = join(storeDir, 'config')
138141

@@ -2318,14 +2321,8 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
23182321

23192322
utils.parseSecurityKeys(this.cfg, zwaveOptions)
23202323

2321-
const logTransport = new JSONTransport()
2322-
logTransport.format = createDefaultTransportFormat(true, false)
2323-
2324-
zwaveOptions.logConfig.transports = [logTransport]
2325-
2326-
logTransport.stream.on('data', (data) => {
2327-
this.socket.emit(socketEvents.debug, data.message.toString())
2328-
})
2324+
// Setup driver logging based on format setting
2325+
this.setupDriverLogging(zwaveOptions)
23292326

23302327
try {
23312328
if (shouldUpdateSettings) {
@@ -6946,6 +6943,105 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
69466943
}
69476944
}, 1000)
69486945
}
6946+
6947+
6948+
private setupDriverLogging(zwaveOptions: PartialZWaveOptions) {
6949+
const logFormat = this.getLogFormat()
6950+
6951+
if (logFormat === 'json') {
6952+
this.setupJsonDriverLogging(zwaveOptions)
6953+
} else {
6954+
this.setupTextDriverLogging(zwaveOptions)
6955+
}
6956+
}
6957+
6958+
private getLogFormat(): 'text' | 'json' {
6959+
const settings = jsonStore.get(store.settings)
6960+
return settings?.gateway?.logFormat || 'text'
6961+
}
6962+
6963+
private setupJsonDriverLogging(zwaveOptions: PartialZWaveOptions) {
6964+
const transports = []
6965+
6966+
const parseFormat = this.createParseDriverJsonFormat()
6967+
const jsonFormat = winston.format.combine(
6968+
parseFormat(),
6969+
winston.format.timestamp(),
6970+
winston.format.json(),
6971+
)
6972+
6973+
// Console transport
6974+
transports.push(new winston.transports.Console({
6975+
format: jsonFormat,
6976+
}))
6977+
6978+
// File transport (if enabled)
6979+
if (this.cfg.logToFile) {
6980+
transports.push(new DailyRotateFile({
6981+
filename: ZWAVEJS_LOG_FILE,
6982+
auditFile: utils.joinPath(logsDir, 'zwavejs-logs.audit.json'),
6983+
datePattern: 'YYYY-MM-DD',
6984+
createSymlink: true,
6985+
symlinkName: path.basename(ZWAVEJS_LOG_FILE).replace('_%DATE%', '_current'),
6986+
zippedArchive: true,
6987+
maxFiles: process.env.ZUI_LOG_MAXFILES || '7d',
6988+
maxSize: process.env.ZUI_LOG_MAXSIZE || '50m',
6989+
format: jsonFormat,
6990+
}))
6991+
}
6992+
6993+
// WebSocket transport with JSON format
6994+
const jsonTransport = new JSONTransport()
6995+
jsonTransport.format = jsonFormat
6996+
transports.push(jsonTransport)
6997+
6998+
// Configure driver
6999+
zwaveOptions.logConfig = {
7000+
...zwaveOptions.logConfig,
7001+
enabled: false,
7002+
raw: true,
7003+
showLogo: false,
7004+
transports: transports,
7005+
}
7006+
7007+
// Stream JSON logs to WebSocket for debug view
7008+
jsonTransport.stream.on('data', (data) => {
7009+
this.socket.emit(socketEvents.debug, data.message.toString())
7010+
})
7011+
}
7012+
7013+
private setupTextDriverLogging(zwaveOptions: PartialZWaveOptions) {
7014+
const logTransport = new JSONTransport()
7015+
logTransport.format = createDefaultTransportFormat(true, false)
7016+
7017+
zwaveOptions.logConfig.transports = [logTransport]
7018+
7019+
logTransport.stream.on('data', (data) => {
7020+
this.socket.emit(socketEvents.debug, data.message.toString())
7021+
})
7022+
}
7023+
7024+
private createParseDriverJsonFormat() {
7025+
return winston.format((info) => {
7026+
if (typeof info.message === 'string' && info.message.startsWith('{')) {
7027+
try {
7028+
const parsed = JSON.parse(info.message)
7029+
// Flatten the nested structure: merge tags and message fields
7030+
if (parsed.tags && parsed.message) {
7031+
info.message = {
7032+
...parsed.message,
7033+
tags: parsed.tags
7034+
}
7035+
} else {
7036+
info.message = parsed
7037+
}
7038+
} catch (e) {
7039+
// Keep as string if parsing fails
7040+
}
7041+
}
7042+
return info
7043+
})
7044+
}
69497045
}
69507046

69517047
export default ZwaveClient

api/lib/logger.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,22 @@ export function sanitizedConfig(
7070
/**
7171
* Return a custom logger format
7272
*/
73-
export function customFormat(noColor = false): winston.Logform.Format {
73+
export function customFormat(
74+
noColor = false,
75+
logFormat: 'text' | 'json' = 'text',
76+
): winston.Logform.Format {
7477
noColor = noColor || disableColors
78+
79+
if (logFormat === 'json') {
80+
// JSON format for all outputs
81+
return combine(
82+
timestamp(),
83+
format.errors({ stack: true }),
84+
format.json(),
85+
)
86+
}
87+
88+
// Existing text format
7589
const formats: winston.Logform.Format[] = [
7690
splat(), // used for formats like: logger.log('info', Message %s', strinVal)
7791
timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
@@ -106,7 +120,10 @@ export const logStream = new PassThrough()
106120
/**
107121
* Create the base transports based on settings provided
108122
*/
109-
export function customTransports(config: LoggerConfig): winston.transport[] {
123+
export function customTransports(
124+
config: LoggerConfig,
125+
logFormat: 'text' | 'json' = 'text',
126+
): winston.transport[] {
110127
// setup transports only once (see issue #2937)
111128
if (transportsList) {
112129
return transportsList
@@ -117,15 +134,15 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
117134
if (process.env.ZUI_NO_CONSOLE !== 'true') {
118135
transportsList.push(
119136
new transports.Console({
120-
format: customFormat(),
137+
format: customFormat(false, logFormat),
121138
level: config.level,
122139
stderrLevels: ['error'],
123140
}),
124141
)
125142
}
126143

127144
const streamTransport = new transports.Stream({
128-
format: customFormat(),
145+
format: customFormat(false, logFormat),
129146
level: config.level,
130147
stream: logStream,
131148
})
@@ -137,7 +154,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
137154

138155
if (process.env.DISABLE_LOG_ROTATION === 'true') {
139156
fileTransport = new transports.File({
140-
format: customFormat(true),
157+
format: customFormat(true, logFormat),
141158
filename: config.filePath,
142159
level: config.level,
143160
})
@@ -154,7 +171,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
154171
maxFiles: process.env.ZUI_LOG_MAXFILES || '7d',
155172
maxSize: process.env.ZUI_LOG_MAXSIZE || '50m',
156173
level: config.level,
157-
format: customFormat(true),
174+
format: customFormat(true, logFormat),
158175
}
159176
fileTransport = new DailyRotateFile(options)
160177

@@ -182,6 +199,7 @@ export function setupLogger(
182199
config?: DeepPartial<GatewayConfig>,
183200
): ModuleLogger {
184201
const sanitized = sanitizedConfig(module, config)
202+
const logFormat = config?.logFormat || 'text'
185203
// Winston automatically reuses an existing module logger
186204
const logger = container.add(module) as ModuleLogger
187205
const moduleName = module.toUpperCase() || '-'
@@ -196,7 +214,7 @@ export function setupLogger(
196214
), // to correctly parse errors
197215
silent: !sanitized.enabled,
198216
level: sanitized.level,
199-
transports: customTransports(sanitized),
217+
transports: customTransports(sanitized, logFormat),
200218
})
201219
logger.module = module
202220
logger.setup = (cfg) => setupLogger(container, module, cfg)

0 commit comments

Comments
 (0)