Skip to content

Commit 9adefe0

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 19ec7e0 commit 9adefe0

File tree

5 files changed

+160
-15
lines changed

5 files changed

+160
-15
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: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
} from '@zwave-js/core'
2222
import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node'
2323
import { JSONTransport } from '@zwave-js/log-transport-json'
24+
import winston from 'winston'
25+
import DailyRotateFile from 'winston-daily-rotate-file'
2426
import { isDocker } from './utils'
2527
import {
2628
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
import { regionSupportsAutoPowerlevel } from './shared'
137140

138141
export const deviceConfigPriorityDir = join(storeDir, 'config')
@@ -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) {
@@ -6962,6 +6959,96 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
69626959
}
69636960
}, 1000)
69646961
}
6962+
6963+
6964+
private setupDriverLogging(zwaveOptions: PartialZWaveOptions) {
6965+
const logFormat = this.getLogFormat()
6966+
6967+
if (logFormat === 'json') {
6968+
this.setupJsonDriverLogging(zwaveOptions)
6969+
} else {
6970+
this.setupTextDriverLogging(zwaveOptions)
6971+
}
6972+
}
6973+
6974+
private getLogFormat(): 'text' | 'json' {
6975+
const settings = jsonStore.get(store.settings)
6976+
return settings?.gateway?.logFormat || 'text'
6977+
}
6978+
6979+
private setupJsonDriverLogging(zwaveOptions: PartialZWaveOptions) {
6980+
const transports = []
6981+
6982+
const parseFormat = this.createParseDriverJsonFormat()
6983+
const jsonFormat = winston.format.combine(
6984+
parseFormat(),
6985+
winston.format.json(),
6986+
)
6987+
6988+
// Console transport
6989+
transports.push(new winston.transports.Console({
6990+
format: jsonFormat,
6991+
}))
6992+
6993+
// File transport (if enabled)
6994+
if (this.cfg.logToFile) {
6995+
transports.push(new DailyRotateFile({
6996+
filename: ZWAVEJS_LOG_FILE,
6997+
auditFile: utils.joinPath(logsDir, 'zwavejs-logs.audit.json'),
6998+
datePattern: 'YYYY-MM-DD',
6999+
createSymlink: true,
7000+
symlinkName: path.basename(ZWAVEJS_LOG_FILE).replace('_%DATE%', '_current'),
7001+
zippedArchive: true,
7002+
maxFiles: process.env.ZUI_LOG_MAXFILES || '7d',
7003+
maxSize: process.env.ZUI_LOG_MAXSIZE || '50m',
7004+
format: jsonFormat,
7005+
}))
7006+
}
7007+
7008+
// WebSocket transport with JSON format
7009+
const jsonTransport = new JSONTransport()
7010+
jsonTransport.format = jsonFormat
7011+
transports.push(jsonTransport)
7012+
7013+
// Configure driver
7014+
zwaveOptions.logConfig = {
7015+
...zwaveOptions.logConfig,
7016+
enabled: false,
7017+
raw: true,
7018+
showLogo: false,
7019+
transports: transports,
7020+
}
7021+
7022+
// Stream JSON logs to WebSocket for debug view
7023+
jsonTransport.stream.on('data', (data) => {
7024+
this.socket.emit(socketEvents.debug, data.message.toString())
7025+
})
7026+
}
7027+
7028+
private setupTextDriverLogging(zwaveOptions: PartialZWaveOptions) {
7029+
const logTransport = new JSONTransport()
7030+
logTransport.format = createDefaultTransportFormat(true, false)
7031+
7032+
zwaveOptions.logConfig.transports = [logTransport]
7033+
7034+
logTransport.stream.on('data', (data) => {
7035+
this.socket.emit(socketEvents.debug, data.message.toString())
7036+
})
7037+
}
7038+
7039+
private createParseDriverJsonFormat() {
7040+
return winston.format((info) => {
7041+
if (typeof info.message === 'string' && info.message.startsWith('{')) {
7042+
try {
7043+
const parsed = JSON.parse(info.message)
7044+
info.message = parsed
7045+
} catch (e) {
7046+
// Keep as string if parsing fails
7047+
}
7048+
}
7049+
return info
7050+
})
7051+
}
69657052
}
69667053

69677054
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)

src/views/Settings.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@
132132
label="Log Level"
133133
></v-select>
134134
</v-col>
135+
<v-col
136+
cols="12"
137+
sm="6"
138+
md="4"
139+
v-if="newGateway.logEnabled"
140+
>
141+
<v-select
142+
:items="logFormats"
143+
v-model="newGateway.logFormat"
144+
label="Log Format"
145+
hint="Choose between human-readable text or structured JSON logging"
146+
persistent-hint
147+
></v-select>
148+
</v-col>
135149
<v-col
136150
cols="12"
137151
sm="6"
@@ -2269,6 +2283,10 @@ export default {
22692283
{ title: 'Debug', value: 'debug' },
22702284
{ title: 'Silly', value: 'silly' },
22712285
],
2286+
logFormats: [
2287+
{ title: 'Text (Human-readable)', value: 'text' },
2288+
{ title: 'JSON (Structured)', value: 'json' },
2289+
],
22722290
headers: [
22732291
{ title: 'Device', key: 'device' },
22742292
{ title: 'Value', key: 'value', sortable: false },

test/lib/logger.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as utils from '../../api/lib/utils'
33
import { logsDir } from '../../api/config/app'
44
import {
55
customTransports,
6+
customFormat,
67
defaultLogFile,
78
ModuleLogger,
89
sanitizedConfig,
@@ -177,4 +178,24 @@ describe('logger.js', () => {
177178
expect(logger2.level).to.equal('warn')
178179
})
179180
})
181+
182+
describe('customFormat()', () => {
183+
it('should return format object when logFormat is json', () => {
184+
const format = customFormat(false, 'json')
185+
expect(format).to.be.an('object')
186+
expect(format).to.have.property('transform')
187+
})
188+
189+
it('should return format object when logFormat is text', () => {
190+
const format = customFormat(true, 'text')
191+
expect(format).to.be.an('object')
192+
expect(format).to.have.property('transform')
193+
})
194+
195+
it('should return format object when logFormat is undefined', () => {
196+
const format = customFormat(true, undefined)
197+
expect(format).to.be.an('object')
198+
expect(format).to.have.property('transform')
199+
})
200+
})
180201
})

0 commit comments

Comments
 (0)