Skip to content

Commit c8af97e

Browse files
committed
feat: implement unified JSON/text logging format
- Add logFormat setting to Gateway config (text/json) - Update UI logger to respect global logFormat setting - Add log format selector in Settings UI - Update Debug view to handle both text and JSON log formats - Implement driver logging configuration for JSON mode - Use DailyRotateFile for JSON file transport to maintain symlink functionality - Redirect driver internal file transport to /dev/null in JSON mode - Add WebSocket streaming for both text and JSON formats - Add basic tests for logging format functionality This provides a unified logging system where users can switch between human-readable text logs and structured JSON logs across all output destinations: console, files, and WebSocket/UI debug view.
1 parent 3c9348d commit c8af97e

File tree

6 files changed

+212
-24
lines changed

6 files changed

+212
-24
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
@@ -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,
@@ -2318,14 +2320,8 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
23182320

23192321
utils.parseSecurityKeys(this.cfg, zwaveOptions)
23202322

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-
})
2323+
// Setup driver logging based on format setting
2324+
this.setupDriverLogging(zwaveOptions)
23292325

23302326
try {
23312327
if (shouldUpdateSettings) {
@@ -6946,6 +6942,97 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
69466942
}
69476943
}, 1000)
69486944
}
6945+
6946+
/**
6947+
* Setup driver logging based on the configured format (text or JSON)
6948+
* Uses the same logFormat setting as the gateway for consistency
6949+
*/
6950+
private setupDriverLogging(zwaveOptions: PartialZWaveOptions) {
6951+
// Get the log format from gateway settings - this applies to both app and driver logging
6952+
const settings = jsonStore.get(store.settings)
6953+
const logFormat = settings?.gateway?.logFormat || 'text'
6954+
6955+
if (logFormat === 'json') {
6956+
// JSON logging for driver - create custom transports for all outputs
6957+
const transports = []
6958+
6959+
// Custom console transport for JSON output
6960+
const jsonConsoleTransport = new winston.transports.Console({
6961+
format: winston.format.combine(
6962+
winston.format.timestamp(),
6963+
winston.format.json()
6964+
)
6965+
})
6966+
transports.push(jsonConsoleTransport)
6967+
6968+
// Custom file transport for JSON output (if file logging is enabled)
6969+
// Use DailyRotateFile to maintain symlink functionality and proper date handling
6970+
if (this.cfg.logToFile) {
6971+
const fileTransport = new DailyRotateFile({
6972+
filename: ZWAVEJS_LOG_FILE,
6973+
auditFile: ZWAVEJS_LOG_FILE.replace('_%DATE%', '_logrotate').replace('.log', '.json'),
6974+
datePattern: 'YYYY-MM-DD',
6975+
createSymlink: true,
6976+
symlinkName: 'zwavejs_current.log',
6977+
zippedArchive: true,
6978+
maxFiles: `${this.cfg.maxFiles || 7}d`,
6979+
maxSize: '50m',
6980+
format: winston.format.combine(
6981+
winston.format.timestamp(),
6982+
winston.format.json()
6983+
)
6984+
})
6985+
transports.push(fileTransport)
6986+
}
6987+
6988+
// Custom WebSocket transport for JSON output
6989+
const jsonTransport = new JSONTransport()
6990+
jsonTransport.format = winston.format.combine(
6991+
winston.format.timestamp(),
6992+
winston.format.json()
6993+
)
6994+
transports.push(jsonTransport)
6995+
6996+
// Configure driver to use ONLY our custom transports
6997+
// Set logToFile: true to prevent the driver's internal console transport from being created
6998+
// Set forceConsole: false to ensure no internal console transport
6999+
// Redirect driver's internal file transport to /dev/null to prevent text logs
7000+
zwaveOptions.logConfig = {
7001+
...zwaveOptions.logConfig,
7002+
logToFile: true, // This prevents internal console transport creation
7003+
forceConsole: false, // This ensures no internal console transport
7004+
filename: '/dev/null', // Redirect driver's internal file transport to /dev/null
7005+
transports: transports // Use ONLY our custom transports
7006+
}
7007+
7008+
// Stream JSON logs to WebSocket for debug view
7009+
jsonTransport.stream.on('data', (data) => {
7010+
this.socket.emit(socketEvents.debug, data.message.toString())
7011+
})
7012+
7013+
} else {
7014+
// Text logging for driver (preserve original behavior)
7015+
// Ensure console output by setting forceConsole: true when logToFile: true
7016+
// This matches the original behavior where console logs were visible
7017+
zwaveOptions.logConfig.forceConsole = true
7018+
7019+
// Only add JSONTransport for WebSocket streaming
7020+
const logTransport = new JSONTransport()
7021+
logTransport.format = createDefaultTransportFormat(true, false)
7022+
7023+
// Add JSONTransport to existing transports instead of replacing them
7024+
if (zwaveOptions.logConfig?.transports) {
7025+
zwaveOptions.logConfig.transports.push(logTransport)
7026+
} else {
7027+
zwaveOptions.logConfig.transports = [logTransport]
7028+
}
7029+
7030+
// Stream text logs to WebSocket for debug view
7031+
logTransport.stream.on('data', (data) => {
7032+
this.socket.emit(socketEvents.debug, data.message.toString())
7033+
})
7034+
}
7035+
}
69497036
}
69507037

69517038
export default ZwaveClient

api/lib/logger.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,19 @@ export function sanitizedConfig(
7070
/**
7171
* Return a custom logger format
7272
*/
73-
export function customFormat(noColor = false): winston.Logform.Format {
73+
export function customFormat(noColor = false, logFormat: 'text' | 'json' = 'text'): winston.Logform.Format {
7474
noColor = noColor || disableColors
75+
76+
if (logFormat === 'json') {
77+
// JSON format for all outputs
78+
return combine(
79+
timestamp(),
80+
format.errors({ stack: true }),
81+
format.json()
82+
)
83+
}
84+
85+
// Existing text format
7586
const formats: winston.Logform.Format[] = [
7687
splat(), // used for formats like: logger.log('info', Message %s', strinVal)
7788
timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
@@ -106,7 +117,7 @@ export const logStream = new PassThrough()
106117
/**
107118
* Create the base transports based on settings provided
108119
*/
109-
export function customTransports(config: LoggerConfig): winston.transport[] {
120+
export function customTransports(config: LoggerConfig, logFormat: 'text' | 'json' = 'text'): winston.transport[] {
110121
// setup transports only once (see issue #2937)
111122
if (transportsList) {
112123
return transportsList
@@ -117,15 +128,15 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
117128
if (process.env.ZUI_NO_CONSOLE !== 'true') {
118129
transportsList.push(
119130
new transports.Console({
120-
format: customFormat(),
131+
format: customFormat(false, logFormat),
121132
level: config.level,
122133
stderrLevels: ['error'],
123134
}),
124135
)
125136
}
126137

127138
const streamTransport = new transports.Stream({
128-
format: customFormat(),
139+
format: customFormat(false, logFormat),
129140
level: config.level,
130141
stream: logStream,
131142
})
@@ -137,7 +148,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
137148

138149
if (process.env.DISABLE_LOG_ROTATION === 'true') {
139150
fileTransport = new transports.File({
140-
format: customFormat(true),
151+
format: customFormat(true, logFormat),
141152
filename: config.filePath,
142153
level: config.level,
143154
})
@@ -154,7 +165,7 @@ export function customTransports(config: LoggerConfig): winston.transport[] {
154165
maxFiles: process.env.ZUI_LOG_MAXFILES || '7d',
155166
maxSize: process.env.ZUI_LOG_MAXSIZE || '50m',
156167
level: config.level,
157-
format: customFormat(true),
168+
format: customFormat(true, logFormat),
158169
}
159170
fileTransport = new DailyRotateFile(options)
160171

@@ -182,6 +193,7 @@ export function setupLogger(
182193
config?: DeepPartial<GatewayConfig>,
183194
): ModuleLogger {
184195
const sanitized = sanitizedConfig(module, config)
196+
const logFormat = config?.logFormat || 'text'
185197
// Winston automatically reuses an existing module logger
186198
const logger = container.add(module) as ModuleLogger
187199
const moduleName = module.toUpperCase() || '-'
@@ -196,7 +208,7 @@ export function setupLogger(
196208
), // to correctly parse errors
197209
silent: !sanitized.enabled,
198210
level: sanitized.level,
199-
transports: customTransports(sanitized),
211+
transports: customTransports(sanitized, logFormat),
200212
})
201213
logger.module = module
202214
logger.setup = (cfg) => setupLogger(container, module, cfg)

src/views/Debug.vue

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,59 @@ export default {
175175
// no need to make this reative
176176
this.prevScrollTop = scrollTop
177177
},
178+
isJsonFormat(data) {
179+
// Check if the data looks like JSON
180+
try {
181+
const parsed = JSON.parse(data)
182+
return typeof parsed === 'object' && parsed !== null
183+
} catch (e) {
184+
return false
185+
}
186+
},
187+
handleJsonLog(data) {
188+
try {
189+
const logEntry = JSON.parse(data)
190+
const formattedLog = this.formatJsonLog(logEntry)
191+
this.debug.push(formattedLog)
192+
} catch (e) {
193+
// If JSON parsing fails, treat as text
194+
this.handleTextLog(data)
195+
}
196+
},
197+
handleTextLog(data) {
198+
// Original text log handling
199+
data = ansiUp.ansi_to_html(data)
200+
data = data.replace(/\n/g, '</br>')
201+
if (!data.endsWith('</br>')) {
202+
data += '</br>'
203+
}
204+
205+
// remove background colors styles
206+
data = data.replace(/background-color:rgb\([0-9, ]+\)/g, '')
207+
this.debug.push(data)
208+
},
209+
formatJsonLog(logEntry) {
210+
// Format JSON logs for display in debug view
211+
const timestamp = logEntry.timestamp || new Date().toISOString()
212+
const level = logEntry.level || 'info'
213+
const label = logEntry.label || 'UNKNOWN'
214+
const message = logEntry.message || ''
215+
const context = logEntry.context || {}
216+
217+
// Create a formatted display string
218+
let formatted = `<span style="color: #888;">${timestamp}</span> `
219+
formatted += `<span style="color: #2196F3;">[${level.toUpperCase()}]</span> `
220+
formatted += `<span style="color: #4CAF50;">${label}:</span> `
221+
formatted += `<span style="color: #333;">${message}</span>`
222+
223+
// Add context information if available
224+
if (Object.keys(context).length > 0) {
225+
formatted += ` <span style="color: #666;">(${JSON.stringify(context)})</span>`
226+
}
227+
228+
formatted += '</br>'
229+
return formatted
230+
},
178231
},
179232
mounted() {
180233
// init socket events
@@ -187,17 +240,13 @@ export default {
187240
188241
this.socket.on(socketEvents.debug, (data) => {
189242
if (this.debugActive) {
190-
data = ansiUp.ansi_to_html(data)
191-
data = data.replace(/\n/g, '</br>')
192-
if (!data.endsWith('</br>')) {
193-
data += '</br>'
243+
// Check if the data is JSON format
244+
if (this.isJsonFormat(data)) {
245+
this.handleJsonLog(data)
246+
} else {
247+
this.handleTextLog(data)
194248
}
195249
196-
// remove background colors styles
197-
data = data.replace(/background-color:rgb\([0-9, ]+\)/g, '')
198-
// \b[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z\b
199-
this.debug.push(data)
200-
201250
if (this.debug.length > MAX_DEBUG_LINES) {
202251
this.debug.shift()
203252
}

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)