@knowark/loggarkjs 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,3 +50,16 @@ const logger = new Logger({
50
50
  flat: false
51
51
  })
52
52
  ```
53
+
54
+ ## Journald integration
55
+
56
+ Set the boolean `globalThis.JOURNALD` flag to `true` and `Logger` will open `/run/systemd/journal/socket` for you. Every JSON record is translated into Journald fields (`PRIORITY`, `MESSAGE`, `LEVEL`, optional `SYSLOG_IDENTIFIER`, and uppercased context payloads), so you can pipe `logger.info()` calls directly into `journalctl` for centralized viewing.
57
+
58
+ ```js
59
+ globalThis.JOURNALD = true
60
+ const logger = new Logger({ namespace: 'api' })
61
+
62
+ logger.info('User login', { userId: 'u-123', extra: undefined })
63
+ ```
64
+
65
+ Undefined values are dropped, `null` turns into the string `'null'`, and functions/symbols are rendered so Journald receives a predictable payload. The socket connection is cached per process, so later `Logger` instances reuse the same transport without extra setup.
package/lib/logger.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { journaldSocket } from './utilities/journald-socket.js'
2
+
1
3
  export class Logger {
2
4
  static context () {
3
5
  return null
@@ -14,6 +16,7 @@ export class Logger {
14
16
  Boolean).join('_').toUpperCase().replaceAll(' ', '_')
15
17
  this.global = global
16
18
  this.#context = context
19
+ this.journald = getJournaldTransport(this.global)
17
20
  }
18
21
 
19
22
  get context () {
@@ -27,23 +30,23 @@ export class Logger {
27
30
  }
28
31
 
29
32
  log (...args) {
30
- if (this.logindex >= 0) log(this.global, 'log', this.context, args, this.namespace, this.format, this.flat)
33
+ if (this.logindex >= 0) log(this.global, 'log', this.context, args, this.namespace, this.format, this.flat, this.journald)
31
34
  }
32
35
 
33
36
  error (...args) {
34
- if (this.logindex >= 0) log(this.global, 'error', this.context, args, this.namespace, this.format, this.flat)
37
+ if (this.logindex >= 0) log(this.global, 'error', this.context, args, this.namespace, this.format, this.flat, this.journald)
35
38
  }
36
39
 
37
40
  warn (...args) {
38
- if (this.logindex >= 1) log(this.global, 'warn', this.context, args, this.namespace, this.format, this.flat)
41
+ if (this.logindex >= 1) log(this.global, 'warn', this.context, args, this.namespace, this.format, this.flat, this.journald)
39
42
  }
40
43
 
41
44
  info (...args) {
42
- if (this.logindex >= 2) log(this.global, 'info', this.context, args, this.namespace, this.format, this.flat)
45
+ if (this.logindex >= 2) log(this.global, 'info', this.context, args, this.namespace, this.format, this.flat, this.journald)
43
46
  }
44
47
 
45
48
  debug (...args) {
46
- if (this.logindex >= 3) log(this.global, 'debug', this.context, args, this.namespace, this.format, this.flat)
49
+ if (this.logindex >= 3) log(this.global, 'debug', this.context, args, this.namespace, this.format, this.flat, this.journald)
47
50
  }
48
51
  }
49
52
 
@@ -94,16 +97,73 @@ function stringifyJson (payload) {
94
97
  })
95
98
  }
96
99
 
97
- function log (global, level, context, args, namespace, format, flat) {
98
- const timestamp = new global.Date().toISOString()
100
+ export const JOURNAL_PRIORITY = {
101
+ log: 6,
102
+ error: 3,
103
+ warn: 4,
104
+ info: 6,
105
+ debug: 7
106
+ }
99
107
 
100
- if (format === 'plain') {
101
- const message = [`${timestamp} [${level.toUpperCase()}]`]
102
- if (context) message.push(stringifyJson(context))
103
- message.push(...args)
104
- return global.console[level](...message)
108
+ function toJournaldFields (record) {
109
+ const entry = {
110
+ PRIORITY: JOURNAL_PRIORITY[record.level] ?? 6,
111
+ MESSAGE: serializeJournaldValue(record.message) ?? '',
112
+ TIMESTAMP: record.timestamp,
113
+ LEVEL: record.level
114
+ }
115
+
116
+ if (record.namespace) {
117
+ entry.SYSLOG_IDENTIFIER = record.namespace
118
+ }
119
+
120
+ for (const [key, value] of Object.entries(record)) {
121
+ if (['timestamp', 'level', 'namespace', 'message'].includes(key)) continue
122
+ const normalized = serializeJournaldValue(value)
123
+ if (normalized !== undefined) {
124
+ entry[key.toUpperCase()] = normalized
125
+ }
126
+ }
127
+
128
+ return entry
129
+ }
130
+
131
+ function serializeJournaldValue (value) {
132
+ if (value === undefined) return undefined
133
+ if (value === null) return 'null'
134
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
135
+ if (typeof value === 'symbol') return value.toString()
136
+ if (typeof value === 'object') return stringifyJson(value)
137
+ return String(value)
138
+ }
139
+
140
+ let defaultJournaldTransport = null
141
+
142
+ function getJournaldTransport (global) {
143
+ if (!global?.JOURNALD) return null
144
+ if (defaultJournaldTransport) return defaultJournaldTransport
145
+ defaultJournaldTransport = journaldSocket.createTransport()
146
+ return defaultJournaldTransport
147
+ }
148
+
149
+ function sendToJournald (journald, fields) {
150
+ if (typeof journald === 'function') {
151
+ journald(fields)
152
+ return
153
+ }
154
+
155
+ if (journald && typeof journald.send === 'function') {
156
+ journald.send(fields)
157
+ return
105
158
  }
106
159
 
160
+ if (journald && typeof journald.log === 'function') {
161
+ journald.log(fields)
162
+ }
163
+ }
164
+
165
+ function log (global, level, context, args, namespace, format, flat, journald) {
166
+ const timestamp = new global.Date().toISOString()
107
167
  const record = {
108
168
  ...(flat && context ? context : {}),
109
169
  timestamp,
@@ -113,5 +173,20 @@ function log (global, level, context, args, namespace, format, flat) {
113
173
  ...parseArgs(args)
114
174
  }
115
175
 
116
- return global.console[level](stringifyJson(record))
176
+ let consoleResult
177
+
178
+ if (format === 'plain') {
179
+ const message = [`${timestamp} [${level.toUpperCase()}]`]
180
+ if (context) message.push(stringifyJson(context))
181
+ message.push(...args)
182
+ consoleResult = global.console[level](...message)
183
+ } else {
184
+ consoleResult = global.console[level](stringifyJson(record))
185
+ }
186
+
187
+ if (journald) {
188
+ sendToJournald(journald, toJournaldFields(record))
189
+ }
190
+
191
+ return consoleResult
117
192
  }
@@ -1,6 +1,20 @@
1
1
  import assert from 'node:assert/strict'
2
- import { it } from 'node:test'
3
- import { Logger } from './logger.js'
2
+ import test, { it } from 'node:test'
3
+
4
+ const journaldTransportState = { lastFields: null }
5
+
6
+ const journaldSocketModule = await import('./utilities/journald-socket.js')
7
+ const { journaldSocket } = journaldSocketModule
8
+ test.mock.method(journaldSocket, 'createTransport', () => {
9
+ return {
10
+ send (fields) {
11
+ journaldTransportState.lastFields = fields
12
+ }
13
+ }
14
+ })
15
+
16
+ const loggerModule = await import('./logger.js')
17
+ const { Logger, JOURNAL_PRIORITY } = loggerModule
4
18
 
5
19
  function setup () {
6
20
  const mockGlobal = {
@@ -392,3 +406,187 @@ it('supports plain formatting without context labels', () => {
392
406
  '2025-02-15T00:00:00.000Z [INFO]'
393
407
  ])
394
408
  })
409
+
410
+ it('reports JSON records to Journald when the global flag is enabled', () => {
411
+ const { mockGlobal } = setup()
412
+ mockGlobal.LOGLEVEL = 'info'
413
+ journaldTransportState.lastFields = null
414
+ mockGlobal.JOURNALD = true
415
+ const logger = new Logger({ global: mockGlobal })
416
+
417
+ logger.info('Journald event', { action: 'create' })
418
+
419
+ const fields = journaldTransportState.lastFields
420
+ assert.ok(fields)
421
+ assert.deepStrictEqual(fields, {
422
+ PRIORITY: 6,
423
+ MESSAGE: 'Journald event',
424
+ TIMESTAMP: '2025-02-15T00:00:00.000Z',
425
+ LEVEL: 'info',
426
+ ACTION: 'create'
427
+ })
428
+
429
+ delete mockGlobal.JOURNALD
430
+ })
431
+
432
+ it('serializes nested context for Journald when flattening is disabled', () => {
433
+ const { mockGlobal } = setup()
434
+ mockGlobal.LOGLEVEL = 'info'
435
+ const context = { correlationId: 'ABCD1234', nested: { role: 'admin' } }
436
+ journaldTransportState.lastFields = null
437
+ mockGlobal.JOURNALD = true
438
+ const logger = new Logger({ global: mockGlobal, context, flat: false })
439
+
440
+ logger.info('Nested context')
441
+
442
+ const fields = journaldTransportState.lastFields
443
+ assert.ok(fields)
444
+ assert.strictEqual(fields.CONTEXT, JSON.stringify(context))
445
+ assert.strictEqual(fields.MESSAGE, 'Nested context')
446
+ assert.strictEqual(fields.PRIORITY, 6)
447
+
448
+ delete mockGlobal.JOURNALD
449
+ })
450
+
451
+ it('defaults Journald MESSAGE to an empty string when no message is provided', () => {
452
+ const { mockGlobal } = setup()
453
+ mockGlobal.LOGLEVEL = 'info'
454
+ mockGlobal.JOURNALD = true
455
+ const logger = new Logger({ global: mockGlobal })
456
+ let recorded
457
+ logger.journald = (fields) => { recorded = fields }
458
+
459
+ const payload = { action: 'create', detail: undefined }
460
+ logger.info(payload)
461
+
462
+ assert.ok(recorded)
463
+ assert.strictEqual(recorded.MESSAGE, '')
464
+ assert.strictEqual(recorded.ACTION, 'create')
465
+ assert.ok(!('DETAIL' in recorded))
466
+
467
+ delete mockGlobal.JOURNALD
468
+ })
469
+
470
+ it('falls back to the default Journald priority when the level is missing', () => {
471
+ const { mockGlobal } = setup()
472
+ mockGlobal.LOGLEVEL = 'info'
473
+ mockGlobal.JOURNALD = true
474
+ const logger = new Logger({ global: mockGlobal })
475
+ let recorded
476
+ logger.journald = (fields) => { recorded = fields }
477
+
478
+ const original = JOURNAL_PRIORITY.info
479
+ delete JOURNAL_PRIORITY.info
480
+
481
+ try {
482
+ logger.info('Fallback priority')
483
+ assert.ok(recorded)
484
+ assert.strictEqual(recorded.PRIORITY, 6)
485
+ } finally {
486
+ JOURNAL_PRIORITY.info = original
487
+ delete mockGlobal.JOURNALD
488
+ }
489
+ })
490
+
491
+ it('serializes null, function, and symbol values for Journald payloads', () => {
492
+ const { mockGlobal } = setup()
493
+ mockGlobal.LOGLEVEL = 'info'
494
+ mockGlobal.JOURNALD = true
495
+ const logger = new Logger({ global: mockGlobal })
496
+ let recorded
497
+ logger.journald = (fields) => { recorded = fields }
498
+
499
+ const symbol = Symbol('token')
500
+ function namedFn () {}
501
+ const anonymousFn = Object.defineProperty(function () {}, 'name', { value: '' })
502
+ const payload = {
503
+ value: null,
504
+ handler: namedFn,
505
+ key: symbol,
506
+ unused: undefined,
507
+ data: { detail: 'rich' },
508
+ shadowed: anonymousFn
509
+ }
510
+ logger.info('Special types', payload)
511
+
512
+ assert.ok(recorded)
513
+ assert.strictEqual(recorded.VALUE, 'null')
514
+ assert.strictEqual(recorded.HANDLER, '[Function namedFn]')
515
+ assert.strictEqual(recorded.KEY, 'Symbol(token)')
516
+ assert.strictEqual(recorded.DATA, JSON.stringify(payload.data))
517
+ assert.strictEqual(recorded.SHADOWED, '[Function anonymous]')
518
+ assert.ok(!('UNUSED' in recorded))
519
+
520
+ delete mockGlobal.JOURNALD
521
+ })
522
+
523
+ it('includes the namespace as SYSLOG_IDENTIFIER in Journald payloads', () => {
524
+ const { mockGlobal } = setup()
525
+ mockGlobal.CORE_LOGLEVEL = 'info'
526
+ const logger = new Logger({ global: mockGlobal, namespace: 'core' })
527
+
528
+ let recorded
529
+ logger.journald = (fields) => {
530
+ recorded = fields
531
+ }
532
+
533
+ logger.info('Namespaced event', { module: 'inventory' })
534
+
535
+ assert.ok(recorded)
536
+ assert.strictEqual(recorded.SYSLOG_IDENTIFIER, 'core')
537
+ assert.strictEqual(recorded.MESSAGE, 'Namespaced event')
538
+ assert.strictEqual(recorded.MODULE, 'inventory')
539
+ })
540
+
541
+ it('calls journald transports that are functions directly', () => {
542
+ const { mockGlobal } = setup()
543
+ mockGlobal.LOGLEVEL = 'info'
544
+ const logger = new Logger({ global: mockGlobal })
545
+
546
+ const payloads = []
547
+ logger.journald = (fields) => {
548
+ payloads.push(fields)
549
+ }
550
+
551
+ logger.info('Function transport')
552
+
553
+ assert.strictEqual(payloads.length, 1)
554
+ assert.strictEqual(payloads[0].LEVEL, 'info')
555
+ assert.strictEqual(payloads[0].MESSAGE, 'Function transport')
556
+ })
557
+
558
+ it('prefers send() when the Journald transport exposes it', () => {
559
+ const { mockGlobal } = setup()
560
+ mockGlobal.LOGLEVEL = 'info'
561
+ const logger = new Logger({ global: mockGlobal })
562
+
563
+ const payloads = []
564
+ logger.journald = {
565
+ send (fields) {
566
+ payloads.push(fields)
567
+ }
568
+ }
569
+
570
+ logger.info('Send transport')
571
+
572
+ assert.strictEqual(payloads.length, 1)
573
+ assert.strictEqual(payloads[0].MESSAGE, 'Send transport')
574
+ })
575
+
576
+ it('falls back to log() when send() is unavailable', () => {
577
+ const { mockGlobal } = setup()
578
+ mockGlobal.LOGLEVEL = 'info'
579
+ const logger = new Logger({ global: mockGlobal })
580
+
581
+ const payloads = []
582
+ logger.journald = {
583
+ log (fields) {
584
+ payloads.push(fields)
585
+ }
586
+ }
587
+
588
+ logger.info('Log transport')
589
+
590
+ assert.strictEqual(payloads.length, 1)
591
+ assert.strictEqual(payloads[0].MESSAGE, 'Log transport')
592
+ })
@@ -0,0 +1,51 @@
1
+ import { createSocket } from 'node:dgram'
2
+
3
+ const JOURNAL_SOCKET_PATH = '/run/systemd/journal/socket'
4
+
5
+ function buildTransport (createSocketImplementation) {
6
+ try {
7
+ const socket = createSocketImplementation({ type: 'unix_dgram' })
8
+ let activeSocket = socket
9
+
10
+ const cleanup = () => {
11
+ if (!activeSocket) return
12
+ activeSocket.close()
13
+ activeSocket = null
14
+ }
15
+
16
+ socket.on('error', cleanup)
17
+ socket.on('close', cleanup)
18
+ socket.connect(JOURNAL_SOCKET_PATH, (error) => {
19
+ if (error) cleanup()
20
+ })
21
+ socket.unref?.()
22
+
23
+ return {
24
+ send (fields) {
25
+ if (!activeSocket) return
26
+ const payload = Buffer.from(
27
+ Object.entries(fields).map(
28
+ ([key, value]) => `${key}=${value}`).join('\n') + '\n')
29
+ activeSocket.send(payload, (error) => {
30
+ if (error) cleanup()
31
+ })
32
+ }
33
+ }
34
+ } catch (error) {
35
+ throw error
36
+ }
37
+ }
38
+
39
+ export function createJournaldTransport ({ createSocket: createSocketImplementation } = {}) {
40
+ try {
41
+ return buildTransport(createSocketImplementation ?? createSocket)
42
+ } catch (error) {
43
+ return null
44
+ }
45
+ }
46
+
47
+ export const journaldSocket = {
48
+ createTransport (options) {
49
+ return createJournaldTransport(options)
50
+ }
51
+ }
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict'
2
+ import { it } from 'node:test'
3
+ import { createJournaldTransport, journaldSocket } from './journald-socket.js'
4
+
5
+ function createFakeSocket ({ connectError = null, sendError = null } = {}) {
6
+ const events = {}
7
+ return {
8
+ events,
9
+ connectedPath: null,
10
+ sentPayload: null,
11
+ sendCalls: 0,
12
+ closed: false,
13
+ on (event, callback) {
14
+ events[event] = callback
15
+ },
16
+ connect (path, callback) {
17
+ this.connectedPath = path
18
+ callback?.(connectError)
19
+ },
20
+ send (payload, callback) {
21
+ this.sentPayload = payload
22
+ this.sendCalls += 1
23
+ callback?.(sendError)
24
+ },
25
+ close () {
26
+ this.closed = true
27
+ },
28
+ unref () {}
29
+ }
30
+ }
31
+
32
+ it('falls back to the built-in socket factory when no override is supplied', () => {
33
+ const transport = createJournaldTransport()
34
+
35
+ assert.ok(transport === null || typeof transport.send === 'function')
36
+ })
37
+
38
+ it('establishes a journald socket and encodes payloads', () => {
39
+ const socket = createFakeSocket()
40
+ const transport = createJournaldTransport({ createSocket: () => socket })
41
+
42
+ assert.ok(transport)
43
+ assert.strictEqual(socket.connectedPath, '/run/systemd/journal/socket')
44
+
45
+ transport.send({ foo: 'bar', baz: 'qux' })
46
+ assert.strictEqual(socket.sendCalls, 1)
47
+ assert.strictEqual(socket.sentPayload.toString(), 'foo=bar\nbaz=qux\n')
48
+ })
49
+
50
+ it('silently ignores send once cleanup runs after an error event', () => {
51
+ const socket = createFakeSocket()
52
+ const transport = createJournaldTransport({ createSocket: () => socket })
53
+
54
+ socket.events.error?.(new Error('boom'))
55
+
56
+ transport.send({ foo: 'bar' })
57
+ assert.strictEqual(socket.sendCalls, 0)
58
+ assert.strictEqual(socket.closed, true)
59
+ })
60
+
61
+ it('returns null when the socket factory throws', () => {
62
+ const transport = createJournaldTransport({ createSocket: () => { throw new Error('failed') } })
63
+ assert.strictEqual(transport, null)
64
+ })
65
+
66
+ it('exposes the same transport via journaldSocket.createTransport', () => {
67
+ const socket = createFakeSocket()
68
+ const createSocket = () => socket
69
+ const transport = journaldSocket.createTransport({ createSocket })
70
+
71
+ assert.ok(transport)
72
+ assert.strictEqual(socket.connectedPath, '/run/systemd/journal/socket')
73
+ })
74
+
75
+ it('runs cleanup when connect reports an error', () => {
76
+ const socket = createFakeSocket({ connectError: new Error('connect fail') })
77
+ const transport = createJournaldTransport({ createSocket: () => socket })
78
+
79
+ assert.ok(transport)
80
+ assert.strictEqual(socket.closed, true)
81
+
82
+ transport.send({ foo: 'bar' })
83
+ assert.strictEqual(socket.sendCalls, 0)
84
+ })
85
+
86
+ it('runs cleanup when send reports an error', () => {
87
+ const socket = createFakeSocket({ sendError: new Error('send fail') })
88
+ const transport = createJournaldTransport({ createSocket: () => socket })
89
+
90
+ transport.send({ foo: 'bar' })
91
+ assert.strictEqual(socket.closed, true)
92
+ assert.strictEqual(socket.sendCalls, 1)
93
+ })
94
+
95
+ it('safely ignores repeated cleanup calls', () => {
96
+ const socket = createFakeSocket()
97
+ const transport = createJournaldTransport({ createSocket: () => socket })
98
+
99
+ socket.events.error?.(new Error('boom'))
100
+ socket.events.error?.(new Error('boom again'))
101
+
102
+ transport.send({ foo: 'bar' })
103
+ assert.strictEqual(socket.closed, true)
104
+ assert.strictEqual(socket.sendCalls, 0)
105
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowark/loggarkjs",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Utilitarian Logging Library",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",