Skip to content

Commit 2267d93

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 - Disable driver internal transports with enabled: false for cleaner config - Add WebSocket streaming for both text and JSON formats - Optimize JSON detection with fast string operations - 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 2267d93

File tree

6 files changed

+202
-25
lines changed

6 files changed

+202
-25
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: 92 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,94 @@ 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(
6974+
'_%DATE%',
6975+
'_logrotate',
6976+
).replace('.log', '.json'),
6977+
datePattern: 'YYYY-MM-DD',
6978+
createSymlink: true,
6979+
symlinkName: 'zwavejs_current.log',
6980+
zippedArchive: true,
6981+
maxFiles: `${this.cfg.maxFiles || 7}d`,
6982+
maxSize: '50m',
6983+
format: winston.format.combine(
6984+
winston.format.timestamp(),
6985+
winston.format.json(),
6986+
),
6987+
})
6988+
transports.push(fileTransport)
6989+
}
6990+
6991+
// Custom WebSocket transport for JSON output
6992+
const jsonTransport = new JSONTransport()
6993+
jsonTransport.format = winston.format.combine(
6994+
winston.format.timestamp(),
6995+
winston.format.json(),
6996+
)
6997+
transports.push(jsonTransport)
6998+
6999+
// Configure driver to use ONLY our custom transports
7000+
// Disable internal transports and use only our custom ones
7001+
zwaveOptions.logConfig = {
7002+
...zwaveOptions.logConfig,
7003+
enabled: false, // This disables ALL internal transports
7004+
transports: transports, // Use ONLY our custom 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+
} else {
7012+
// Text logging for driver (preserve original behavior)
7013+
// Ensure console output by setting forceConsole: true when logToFile: true
7014+
// This matches the original behavior where console logs were visible
7015+
zwaveOptions.logConfig.forceConsole = true
7016+
7017+
// Add JSONTransport for WebSocket streaming alongside existing transports
7018+
const logTransport = new JSONTransport()
7019+
logTransport.format = createDefaultTransportFormat(true, false)
7020+
7021+
// Add JSONTransport to existing transports instead of replacing them
7022+
if (!zwaveOptions.logConfig.transports) {
7023+
zwaveOptions.logConfig.transports = []
7024+
}
7025+
zwaveOptions.logConfig.transports.push(logTransport)
7026+
7027+
// Stream logs to WebSocket for debug view
7028+
logTransport.stream.on('data', (data) => {
7029+
this.socket.emit(socketEvents.debug, data.message.toString())
7030+
})
7031+
}
7032+
}
69497033
}
69507034

69517035
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/Debug.vue

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,48 @@ export default {
175175
// no need to make this reative
176176
this.prevScrollTop = scrollTop
177177
},
178+
formatLog(data) {
179+
// Quick check if data looks like JSON (faster than JSON.parse)
180+
const trimmed = data.trim()
181+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
182+
try {
183+
// Parse JSON only once
184+
const logEntry = JSON.parse(data)
185+
const timestamp =
186+
logEntry.timestamp || new Date().toISOString()
187+
const level = logEntry.level || 'info'
188+
const label = logEntry.label || 'UNKNOWN'
189+
const message = logEntry.message || ''
190+
const context = logEntry.context || {}
191+
192+
// Create a formatted display string
193+
let formatted = `<span style="color: #888;">${timestamp}</span> `
194+
formatted += `<span style="color: #2196F3;">[${level.toUpperCase()}]</span> `
195+
formatted += `<span style="color: #4CAF50;">${label}:</span> `
196+
formatted += `<span style="color: #333;">${message}</span>`
197+
198+
// Add context information if available
199+
if (Object.keys(context).length > 0) {
200+
formatted += ` <span style="color: #666;">(${JSON.stringify(context)})</span>`
201+
}
202+
203+
formatted += '</br>'
204+
return formatted
205+
} catch (e) {
206+
// If JSON parsing fails, fall through to text handling
207+
}
208+
}
209+
210+
// Fallback to treating as plain text
211+
data = ansiUp.ansi_to_html(data)
212+
data = data.replace(/\n/g, '</br>')
213+
if (!data.endsWith('</br>')) {
214+
data += '</br>'
215+
}
216+
// remove background colors styles
217+
data = data.replace(/background-color:rgb\([0-9, ]+\)/g, '')
218+
return data
219+
},
178220
},
179221
mounted() {
180222
// init socket events
@@ -187,16 +229,9 @@ export default {
187229
188230
this.socket.on(socketEvents.debug, (data) => {
189231
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>'
194-
}
195-
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)
232+
// Format and add the log entry
233+
const formattedLog = this.formatLog(data)
234+
this.debug.push(formattedLog)
200235
201236
if (this.debug.length > MAX_DEBUG_LINES) {
202237
this.debug.shift()

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)