@knowark/loggarkjs 0.3.11 → 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
@@ -1,2 +1,65 @@
1
1
  # loggark
2
2
  Utilitarian Logging Library
3
+
4
+ ## Logger output format
5
+
6
+ `Logger` now emits a single JSON object per log line by default (JSON Lines). This format is easier to consume with `journalctl` pipelines (`jq`, `grep`, field selection).
7
+
8
+ - Default: `format: 'json'`
9
+ - Backward compatibility: `format: 'plain'`
10
+ - Context flattening in JSON mode: `flat: true` (default)
11
+
12
+ Example:
13
+
14
+ ```js
15
+ import { Logger } from '@knowark/loggarkjs'
16
+
17
+ const logger = new Logger({ namespace: 'api' })
18
+ logger.info('User login', { userId: 'u-123' })
19
+ ```
20
+
21
+ Outputs:
22
+
23
+ ```json
24
+ {"timestamp":"2025-02-15T00:00:00.000Z","level":"info","namespace":"api","message":"User login","userId":"u-123"}
25
+ ```
26
+
27
+ Additional object parameters are merged into the base log object. If keys collide, later merged values overwrite earlier keys.
28
+
29
+ If you prefer context keys at top level (for simpler `jq` filters), enable flattening:
30
+
31
+ ```js
32
+ const logger = new Logger({
33
+ namespace: 'api',
34
+ context: { correlationId: 'ABCD1234', interactor: 'Informer' },
35
+ flat: true
36
+ })
37
+
38
+ logger.info('User login')
39
+ ```
40
+
41
+ ```json
42
+ {"timestamp":"2025-02-15T00:00:00.000Z","level":"info","namespace":"api","correlationId":"ABCD1234","interactor":"Informer","message":"User login"}
43
+ ```
44
+
45
+ To keep nested context under `context`, disable flattening:
46
+
47
+ ```js
48
+ const logger = new Logger({
49
+ context: { correlationId: 'ABCD1234' },
50
+ flat: false
51
+ })
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/index.d.ts CHANGED
@@ -4,7 +4,9 @@ export declare class Logger {
4
4
  constructor (dependencies?: {
5
5
  namespace?: string,
6
6
  context?: object,
7
- global?: object
7
+ global?: object,
8
+ format?: 'json' | 'plain',
9
+ flat?: boolean
8
10
  })
9
11
 
10
12
  context?: object | null
@@ -34,4 +36,4 @@ export declare class Translator {
34
36
 
35
37
  export declare function t(key: string, options?: object): string
36
38
 
37
- export declare function lt(key: string, options?: object): () => string
39
+ export declare function lt(key: string, options?: object): () => string
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
@@ -5,12 +7,16 @@ export class Logger {
5
7
 
6
8
  #context = {}
7
9
 
8
- constructor ({ namespace = '', context = null, global = globalThis } = {}) {
10
+ constructor ({ namespace = '', context = null, global = globalThis, format = 'json', flat = true } = {}) {
9
11
  this.levels = ['error', 'warn', 'info', 'debug']
12
+ this.namespace = namespace
13
+ this.format = format
14
+ this.flat = flat
10
15
  this.logvar = [namespace, 'LOGLEVEL'].filter(
11
16
  Boolean).join('_').toUpperCase().replaceAll(' ', '_')
12
17
  this.global = global
13
18
  this.#context = context
19
+ this.journald = getJournaldTransport(this.global)
14
20
  }
15
21
 
16
22
  get context () {
@@ -24,28 +30,163 @@ export class Logger {
24
30
  }
25
31
 
26
32
  log (...args) {
27
- if (this.logindex >= 0) log(this.global, 'log', this.context, args)
33
+ if (this.logindex >= 0) log(this.global, 'log', this.context, args, this.namespace, this.format, this.flat, this.journald)
28
34
  }
29
35
 
30
36
  error (...args) {
31
- if (this.logindex >= 0) log(this.global, 'error', this.context, args)
37
+ if (this.logindex >= 0) log(this.global, 'error', this.context, args, this.namespace, this.format, this.flat, this.journald)
32
38
  }
33
39
 
34
40
  warn (...args) {
35
- if (this.logindex >= 1) log(this.global, 'warn', this.context, args)
41
+ if (this.logindex >= 1) log(this.global, 'warn', this.context, args, this.namespace, this.format, this.flat, this.journald)
36
42
  }
37
43
 
38
44
  info (...args) {
39
- if (this.logindex >= 2) log(this.global, 'info', this.context, args)
45
+ if (this.logindex >= 2) log(this.global, 'info', this.context, args, this.namespace, this.format, this.flat, this.journald)
40
46
  }
41
47
 
42
48
  debug (...args) {
43
- if (this.logindex >= 3) log(this.global, 'debug', this.context, args)
49
+ if (this.logindex >= 3) log(this.global, 'debug', this.context, args, this.namespace, this.format, this.flat, this.journald)
50
+ }
51
+ }
52
+
53
+ function parseArgs (args) {
54
+ const payload = {}
55
+
56
+ args.forEach((value, index) => {
57
+ if (index === 0 && typeof value === 'string') {
58
+ payload.message = value
59
+ return
60
+ }
61
+
62
+ Object.assign(payload, toLogFields(value, index))
63
+ })
64
+
65
+ return payload
66
+ }
67
+
68
+ function toLogFields (value, index) {
69
+ if (value instanceof Error) return toSerializableError(value)
70
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value
71
+ return { [`arg${index}`]: value }
72
+ }
73
+
74
+ function toSerializableError (error) {
75
+ return {
76
+ name: error.name,
77
+ message: error.message,
78
+ stack: error.stack,
79
+ ...error
44
80
  }
45
81
  }
46
82
 
47
- function log (global, level, context, args) {
48
- return global.console[level](
49
- `${new global.Date().toISOString()} [${level.toUpperCase()}]`,
50
- ...[context ? JSON.stringify(context) : false, ...args].filter(Boolean))
51
- }
83
+ function stringifyJson (payload) {
84
+ const seen = new WeakSet()
85
+ return JSON.stringify(payload, (_key, value) => {
86
+ if (typeof value === 'bigint') return value.toString()
87
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
88
+ if (typeof value === 'symbol') return value.toString()
89
+ if (value instanceof Error) return toSerializableError(value)
90
+
91
+ if (value && typeof value === 'object') {
92
+ if (seen.has(value)) return '[Circular]'
93
+ seen.add(value)
94
+ }
95
+
96
+ return value
97
+ })
98
+ }
99
+
100
+ export const JOURNAL_PRIORITY = {
101
+ log: 6,
102
+ error: 3,
103
+ warn: 4,
104
+ info: 6,
105
+ debug: 7
106
+ }
107
+
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
158
+ }
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()
167
+ const record = {
168
+ ...(flat && context ? context : {}),
169
+ timestamp,
170
+ level,
171
+ ...(namespace ? { namespace } : {}),
172
+ ...(!flat && context ? { context } : {}),
173
+ ...parseArgs(args)
174
+ }
175
+
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
192
+ }
@@ -1,5 +1,20 @@
1
- import { it, expect } from '@jest/globals'
2
- import { Logger } from './logger.js'
1
+ import assert from 'node:assert/strict'
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
3
18
 
4
19
  function setup () {
5
20
  const mockGlobal = {
@@ -11,22 +26,29 @@ function setup () {
11
26
  debug (...args) { mockGlobal.debugArgs = args }
12
27
  },
13
28
  Date: class {
14
- toISOString() {
29
+ toISOString () {
15
30
  return new Date('2025-02-15').toISOString()
16
31
  }
17
32
  }
18
33
  }
19
- return {mockGlobal}
34
+
35
+ return { mockGlobal }
36
+ }
37
+
38
+ function parseRecord (args) {
39
+ assert.ok(args)
40
+ assert.strictEqual(args.length, 1)
41
+ return JSON.parse(args[0])
20
42
  }
21
43
 
22
44
  it('can be instantiated', () => {
23
45
  const logger = new Logger()
24
- expect(logger).toBeTruthy()
46
+ assert.ok(logger)
25
47
  })
26
48
 
27
- it('can be instantiated', () => {
49
+ it('returns null context when no static or instance context is available', () => {
28
50
  const logger = new Logger()
29
- expect(logger).toBeTruthy()
51
+ assert.strictEqual(logger.context, null)
30
52
  })
31
53
 
32
54
  it('is disabled by default', () => {
@@ -34,98 +56,537 @@ it('is disabled by default', () => {
34
56
  const logger = new Logger({ global: mockGlobal })
35
57
 
36
58
  logger.log('Logging something...')
37
- expect(mockGlobal.logArgs).toBeFalsy()
59
+ assert.ok(!mockGlobal.logArgs)
38
60
 
39
61
  logger.error('Logging something...')
40
- expect(mockGlobal.errorArgs).toBeFalsy()
62
+ assert.ok(!mockGlobal.errorArgs)
41
63
 
42
64
  logger.warn('Logging something...')
43
- expect(mockGlobal.warnArgs).toBeFalsy()
65
+ assert.ok(!mockGlobal.warnArgs)
44
66
 
45
67
  logger.info('Logging something...')
46
- expect(mockGlobal.infoArgs).toBeFalsy()
68
+ assert.ok(!mockGlobal.infoArgs)
47
69
 
48
70
  logger.debug('Logging something...')
49
- expect(mockGlobal.debugArgs).toBeFalsy()
71
+ assert.ok(!mockGlobal.debugArgs)
50
72
  })
51
73
 
52
- it('adds a prefix to the logging methods according to loglevel', () => {
74
+ it('emits JSON logs by default according to loglevel', () => {
53
75
  const { mockGlobal } = setup()
54
76
  mockGlobal.LOGLEVEL = 'debug'
55
77
  const logger = new Logger({ global: mockGlobal })
56
78
 
57
79
  logger.log('Logging something...')
58
- expect(mockGlobal.logArgs).toEqual(
59
- ['2025-02-15T00:00:00.000Z [LOG]', 'Logging something...'])
80
+ assert.deepStrictEqual(parseRecord(mockGlobal.logArgs), {
81
+ timestamp: '2025-02-15T00:00:00.000Z',
82
+ level: 'log',
83
+ message: 'Logging something...'
84
+ })
60
85
 
61
86
  logger.error('Logging something...')
62
- expect(mockGlobal.errorArgs).toEqual(
63
- ['2025-02-15T00:00:00.000Z [ERROR]', 'Logging something...'])
87
+ assert.deepStrictEqual(parseRecord(mockGlobal.errorArgs), {
88
+ timestamp: '2025-02-15T00:00:00.000Z',
89
+ level: 'error',
90
+ message: 'Logging something...'
91
+ })
64
92
 
65
93
  logger.warn('Logging something...')
66
- expect(mockGlobal.warnArgs).toEqual(
67
- ['2025-02-15T00:00:00.000Z [WARN]', 'Logging something...'])
94
+ assert.deepStrictEqual(parseRecord(mockGlobal.warnArgs), {
95
+ timestamp: '2025-02-15T00:00:00.000Z',
96
+ level: 'warn',
97
+ message: 'Logging something...'
98
+ })
68
99
 
69
100
  logger.info('Logging something...')
70
- expect(mockGlobal.infoArgs).toEqual(
71
- ['2025-02-15T00:00:00.000Z [INFO]', 'Logging something...'])
101
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
102
+ timestamp: '2025-02-15T00:00:00.000Z',
103
+ level: 'info',
104
+ message: 'Logging something...'
105
+ })
72
106
 
73
107
  logger.debug('Logging something...')
74
- expect(mockGlobal.debugArgs).toEqual(
75
- ['2025-02-15T00:00:00.000Z [DEBUG]', 'Logging something...'])
108
+ assert.deepStrictEqual(parseRecord(mockGlobal.debugArgs), {
109
+ timestamp: '2025-02-15T00:00:00.000Z',
110
+ level: 'debug',
111
+ message: 'Logging something...'
112
+ })
76
113
  })
77
114
 
78
- it('adds the provided logging context labels to its final output', () => {
115
+ it('merges object parameters into the base JSON log object', () => {
116
+ const { mockGlobal } = setup()
117
+ mockGlobal.LOGLEVEL = 'info'
118
+ const logger = new Logger({ global: mockGlobal })
119
+
120
+ logger.info('Logging something...', { action: 'create', entity: 'product' })
121
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
122
+ timestamp: '2025-02-15T00:00:00.000Z',
123
+ level: 'info',
124
+ message: 'Logging something...',
125
+ action: 'create',
126
+ entity: 'product'
127
+ })
128
+ })
129
+
130
+ it('can emit JSON logs without message arguments', () => {
131
+ const { mockGlobal } = setup()
132
+ mockGlobal.LOGLEVEL = 'info'
133
+ const logger = new Logger({ global: mockGlobal })
134
+
135
+ logger.info()
136
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
137
+ timestamp: '2025-02-15T00:00:00.000Z',
138
+ level: 'info'
139
+ })
140
+ })
141
+
142
+ it('can emit JSON logs without context when flat is disabled', () => {
143
+ const { mockGlobal } = setup()
144
+ mockGlobal.LOGLEVEL = 'info'
145
+ const logger = new Logger({ global: mockGlobal, flat: false })
146
+
147
+ logger.info('Logging something...')
148
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
149
+ timestamp: '2025-02-15T00:00:00.000Z',
150
+ level: 'info',
151
+ message: 'Logging something...'
152
+ })
153
+ })
154
+
155
+ it('flattens provided logging context labels by default', () => {
79
156
  const { mockGlobal } = setup()
80
157
  mockGlobal.LOGLEVEL = 'debug'
81
158
  const context = { correlationId: 'ABCD1234', interactor: 'Informer' }
82
159
  const logger = new Logger({ global: mockGlobal, context })
83
160
 
84
- logger.log('Logging something...')
85
- expect(mockGlobal.logArgs).toEqual([
86
- '2025-02-15T00:00:00.000Z [LOG]',
87
- JSON.stringify({ correlationId: 'ABCD1234', interactor: 'Informer' }),
88
- 'Logging something...'])
161
+ logger.info('Logging something...')
162
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
163
+ timestamp: '2025-02-15T00:00:00.000Z',
164
+ level: 'info',
165
+ correlationId: 'ABCD1234',
166
+ interactor: 'Informer',
167
+ message: 'Logging something...'
168
+ })
169
+ })
89
170
 
90
- logger.error('Logging something...')
91
- expect(mockGlobal.errorArgs).toEqual([
92
- '2025-02-15T00:00:00.000Z [ERROR]',
93
- JSON.stringify({ correlationId: 'ABCD1234', interactor: 'Informer' }),
94
- 'Logging something...'])
171
+ it('can flatten context labels into top-level JSON fields with flat option', () => {
172
+ const { mockGlobal } = setup()
173
+ mockGlobal.LOGLEVEL = 'info'
174
+ const context = { correlationId: 'ABCD1234', interactor: 'Informer' }
175
+ const logger = new Logger({ global: mockGlobal, context, flat: true })
95
176
 
96
- logger.warn('Logging something...')
97
- expect(mockGlobal.warnArgs).toEqual([
98
- '2025-02-15T00:00:00.000Z [WARN]',
99
- JSON.stringify({ correlationId: 'ABCD1234', interactor: 'Informer' }),
100
- 'Logging something...'])
177
+ logger.info('Logging something...')
178
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
179
+ timestamp: '2025-02-15T00:00:00.000Z',
180
+ level: 'info',
181
+ correlationId: 'ABCD1234',
182
+ interactor: 'Informer',
183
+ message: 'Logging something...'
184
+ })
185
+ })
186
+
187
+ it('can keep nested context when flat option is disabled', () => {
188
+ const { mockGlobal } = setup()
189
+ mockGlobal.LOGLEVEL = 'info'
190
+ const context = { correlationId: 'ABCD1234', interactor: 'Informer' }
191
+ const logger = new Logger({ global: mockGlobal, context, flat: false })
101
192
 
102
193
  logger.info('Logging something...')
103
- expect(mockGlobal.infoArgs).toEqual([
104
- '2025-02-15T00:00:00.000Z [INFO]',
105
- JSON.stringify({ correlationId: 'ABCD1234', interactor: 'Informer' }),
106
- 'Logging something...'])
194
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
195
+ timestamp: '2025-02-15T00:00:00.000Z',
196
+ level: 'info',
197
+ context,
198
+ message: 'Logging something...'
199
+ })
200
+ })
107
201
 
108
- logger.debug('Logging something...')
109
- expect(mockGlobal.debugArgs).toEqual([
110
- '2025-02-15T00:00:00.000Z [DEBUG]',
111
- JSON.stringify({ correlationId: 'ABCD1234', interactor: 'Informer' }),
112
- 'Logging something...'])
202
+ it('keeps log metadata and payload fields over flat context collisions', () => {
203
+ const { mockGlobal } = setup()
204
+ mockGlobal.CORE_LOGLEVEL = 'info'
205
+ const context = {
206
+ timestamp: 'old',
207
+ level: 'fatal',
208
+ namespace: 'context-namespace',
209
+ message: 'context message',
210
+ args: ['context args'],
211
+ correlationId: 'ABCD1234'
212
+ }
213
+ const logger = new Logger({
214
+ global: mockGlobal,
215
+ namespace: 'core',
216
+ context,
217
+ flat: true
218
+ })
219
+
220
+ logger.info('real message', 'value')
221
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
222
+ timestamp: '2025-02-15T00:00:00.000Z',
223
+ level: 'info',
224
+ namespace: 'core',
225
+ message: 'real message',
226
+ args: ['context args'],
227
+ correlationId: 'ABCD1234',
228
+ arg1: 'value'
229
+ })
113
230
  })
114
231
 
115
232
  it('supports including static context labels', () => {
233
+ const originalContext = Logger.context
116
234
  Logger.context = () => {
117
235
  return { correlationId: 'ABCD1234' }
118
236
  }
119
237
 
238
+ try {
239
+ const { mockGlobal } = setup()
240
+ mockGlobal.LOGLEVEL = 'debug'
241
+ const context = { model: 'Product' }
242
+ const logger = new Logger({ global: mockGlobal, context })
243
+
244
+ logger.info('Logging something...')
245
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
246
+ timestamp: '2025-02-15T00:00:00.000Z',
247
+ level: 'info',
248
+ correlationId: 'ABCD1234',
249
+ model: 'Product',
250
+ message: 'Logging something...'
251
+ })
252
+ } finally {
253
+ Logger.context = originalContext
254
+ }
255
+ })
256
+
257
+ it('includes namespace in JSON output and uses the namespace loglevel variable', () => {
120
258
  const { mockGlobal } = setup()
121
- mockGlobal.LOGLEVEL = 'debug'
122
- const context = { model: 'Product' }
123
- const logger = new Logger({ global: mockGlobal, context })
259
+ mockGlobal.CORE_LOGLEVEL = 'info'
260
+ const logger = new Logger({ global: mockGlobal, namespace: 'core' })
124
261
 
125
262
  logger.info('Logging something...')
126
- expect(mockGlobal.infoArgs).toEqual([
263
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
264
+ timestamp: '2025-02-15T00:00:00.000Z',
265
+ level: 'info',
266
+ namespace: 'core',
267
+ message: 'Logging something...'
268
+ })
269
+ })
270
+
271
+ it('merges non-string object payloads into the JSON record', () => {
272
+ const { mockGlobal } = setup()
273
+ mockGlobal.LOGLEVEL = 'info'
274
+ const logger = new Logger({ global: mockGlobal })
275
+
276
+ logger.info({ code: 'E_UNEXPECTED' })
277
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
278
+ timestamp: '2025-02-15T00:00:00.000Z',
279
+ level: 'info',
280
+ code: 'E_UNEXPECTED'
281
+ })
282
+ })
283
+
284
+ it('stores null payloads as positional fields in JSON format', () => {
285
+ const { mockGlobal } = setup()
286
+ mockGlobal.LOGLEVEL = 'info'
287
+ const logger = new Logger({ global: mockGlobal })
288
+
289
+ logger.info(null)
290
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
291
+ timestamp: '2025-02-15T00:00:00.000Z',
292
+ level: 'info',
293
+ arg0: null
294
+ })
295
+ })
296
+
297
+ it('serializes Error payloads in JSON format', () => {
298
+ const { mockGlobal } = setup()
299
+ mockGlobal.LOGLEVEL = 'error'
300
+ const logger = new Logger({ global: mockGlobal })
301
+ const error = new Error('Unexpected failure')
302
+ error.code = 'E_UNEXPECTED'
303
+
304
+ logger.error(error)
305
+ const record = parseRecord(mockGlobal.errorArgs)
306
+
307
+ assert.strictEqual(record.level, 'error')
308
+ assert.strictEqual(record.name, 'Error')
309
+ assert.strictEqual(record.message, 'Unexpected failure')
310
+ assert.strictEqual(record.code, 'E_UNEXPECTED')
311
+ assert.match(record.stack, /Unexpected failure/)
312
+ })
313
+
314
+ it('serializes nested Error values inside object payloads', () => {
315
+ const { mockGlobal } = setup()
316
+ mockGlobal.LOGLEVEL = 'info'
317
+ const logger = new Logger({ global: mockGlobal })
318
+ const nested = new Error('Nested failure')
319
+ nested.code = 'E_NESTED'
320
+
321
+ logger.info({ nested })
322
+ const record = parseRecord(mockGlobal.infoArgs)
323
+
324
+ assert.strictEqual(record.nested.name, 'Error')
325
+ assert.strictEqual(record.nested.message, 'Nested failure')
326
+ assert.strictEqual(record.nested.code, 'E_NESTED')
327
+ assert.match(record.nested.stack, /Nested failure/)
328
+ })
329
+
330
+ it('lets additional payload fields overwrite previously defined keys', () => {
331
+ const { mockGlobal } = setup()
332
+ mockGlobal.LOGLEVEL = 'info'
333
+ const logger = new Logger({ global: mockGlobal })
334
+ const error = new TypeError('Bad input')
335
+ error.field = 'name'
336
+
337
+ logger.info('Validation failed', error)
338
+ const record = parseRecord(mockGlobal.infoArgs)
339
+
340
+ assert.strictEqual(record.message, 'Bad input')
341
+ assert.strictEqual(record.name, 'TypeError')
342
+ assert.strictEqual(record.field, 'name')
343
+ assert.match(record.stack, /Bad input/)
344
+ })
345
+
346
+ it('serializes special values and circular references in merged JSON fields', () => {
347
+ const { mockGlobal } = setup()
348
+ mockGlobal.LOGLEVEL = 'info'
349
+ const logger = new Logger({ global: mockGlobal })
350
+ const payload = {
351
+ count: 10n,
352
+ token: Symbol('auth')
353
+ }
354
+ payload.self = payload
355
+
356
+ logger.info('Special payload', (function () {}), function namedFn () {}, payload)
357
+ const record = parseRecord(mockGlobal.infoArgs)
358
+
359
+ assert.strictEqual(record.message, 'Special payload')
360
+ assert.strictEqual(record.arg1, '[Function anonymous]')
361
+ assert.strictEqual(record.arg2, '[Function namedFn]')
362
+ assert.strictEqual(record.count, '10')
363
+ assert.strictEqual(record.token, 'Symbol(auth)')
364
+ assert.strictEqual(record.self.count, '10')
365
+ assert.strictEqual(record.self.token, 'Symbol(auth)')
366
+ assert.strictEqual(record.self.self, '[Circular]')
367
+ })
368
+
369
+ it('stores array payloads as positional fields in JSON format', () => {
370
+ const { mockGlobal } = setup()
371
+ mockGlobal.LOGLEVEL = 'info'
372
+ const logger = new Logger({ global: mockGlobal })
373
+
374
+ logger.info('Array payload', [1, 2, 3])
375
+ assert.deepStrictEqual(parseRecord(mockGlobal.infoArgs), {
376
+ timestamp: '2025-02-15T00:00:00.000Z',
377
+ level: 'info',
378
+ message: 'Array payload',
379
+ arg1: [1, 2, 3]
380
+ })
381
+ })
382
+
383
+ it('supports plain formatting for backward compatibility', () => {
384
+ const { mockGlobal } = setup()
385
+ mockGlobal.LOGLEVEL = 'info'
386
+ const context = { correlationId: 'ABCD1234' }
387
+ const logger = new Logger({ global: mockGlobal, context, format: 'plain' })
388
+
389
+ logger.info('Logging something...', 0, false)
390
+ assert.deepStrictEqual(mockGlobal.infoArgs, [
127
391
  '2025-02-15T00:00:00.000Z [INFO]',
128
- JSON.stringify({ correlationId: 'ABCD1234', model: 'Product' }),
129
- 'Logging something...'])
392
+ JSON.stringify(context),
393
+ 'Logging something...',
394
+ 0,
395
+ false
396
+ ])
397
+ })
398
+
399
+ it('supports plain formatting without context labels', () => {
400
+ const { mockGlobal } = setup()
401
+ mockGlobal.LOGLEVEL = 'info'
402
+ const logger = new Logger({ global: mockGlobal, format: 'plain' })
403
+
404
+ logger.info()
405
+ assert.deepStrictEqual(mockGlobal.infoArgs, [
406
+ '2025-02-15T00:00:00.000Z [INFO]'
407
+ ])
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
+ }
130
550
 
131
- })
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
+ })
@@ -1,9 +1,10 @@
1
- import { it, expect } from '@jest/globals'
1
+ import assert from 'node:assert/strict'
2
+ import { it } from 'node:test'
2
3
  import { Translator, t, lt } from './translator.js'
3
4
 
4
5
  it('can be instantiated', () => {
5
6
  const translator = new Translator()
6
- expect(translator).toBeTruthy()
7
+ assert.ok(translator)
7
8
  })
8
9
 
9
10
  it('defines a translate function with options', () => {
@@ -11,7 +12,7 @@ it('defines a translate function with options', () => {
11
12
 
12
13
  const result = translator.translate('any.key')
13
14
 
14
- expect(result).toBe('any.key')
15
+ assert.strictEqual(result, 'any.key')
15
16
  })
16
17
 
17
18
  it('defines a t shortcut for function translate', () => {
@@ -19,30 +20,36 @@ it('defines a t shortcut for function translate', () => {
19
20
 
20
21
  const result = t('any.key')
21
22
 
22
- expect(result).toBe('any.key')
23
+ assert.strictEqual(result, 'any.key')
23
24
  })
24
25
 
25
26
  it('defines a global t shortcut translate', () => {
26
27
  const originalTranslate = Translator.translate
27
- Translator.translate = (key, _options) => {
28
- return `translated:${key}`
29
- }
28
+ try {
29
+ Translator.translate = (key, _options) => {
30
+ return `translated:${key}`
31
+ }
30
32
 
31
- const result = t('any.key')
33
+ const result = t('any.key')
32
34
 
33
- expect(result).toBe('translated:any.key')
34
- Translator.translate = originalTranslate
35
+ assert.strictEqual(result, 'translated:any.key')
36
+ } finally {
37
+ Translator.translate = originalTranslate
38
+ }
35
39
  })
36
40
 
37
41
  it('defines a global lt lazy-translation function', () => {
38
42
  const originalTranslate = Translator.translate
39
- Translator.translate = (key, _options) => {
40
- return `translated:${key}`
41
- }
43
+ try {
44
+ Translator.translate = (key, _options) => {
45
+ return `translated:${key}`
46
+ }
42
47
 
43
- const result = lt('any.key')
48
+ const result = lt('any.key')
44
49
 
45
- expect(result).toBeInstanceOf(Function)
46
- expect(result()).toBe('translated:any.key')
47
- Translator.translate = originalTranslate
50
+ assert.ok(result instanceof Function)
51
+ assert.strictEqual(result(), 'translated:any.key')
52
+ } finally {
53
+ Translator.translate = originalTranslate
54
+ }
48
55
  })
@@ -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,12 +1,11 @@
1
1
  {
2
2
  "name": "@knowark/loggarkjs",
3
- "version": "0.3.11",
3
+ "version": "0.5.0",
4
4
  "description": "Utilitarian Logging Library",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "options": "NODE_OPTIONS='--experimental-vm-modules --no-warnings'",
9
- "test": "npm run options -- npx jest --coverage"
8
+ "test": "node --test --experimental-test-coverage --test-coverage-exclude='**/*.test.js'"
10
9
  },
11
10
  "repository": {
12
11
  "type": "git",
@@ -21,8 +20,5 @@
21
20
  "bugs": {
22
21
  "url": "https://github.com/knowark/loggarkjs/issues"
23
22
  },
24
- "homepage": "https://github.com/knowark/loggarkjs#readme",
25
- "devDependencies": {
26
- "jest": "^29.7.0"
27
- }
23
+ "homepage": "https://github.com/knowark/loggarkjs#readme"
28
24
  }
@@ -1,7 +0,0 @@
1
- {
2
- "workbench.colorCustomizations": {
3
- "activityBar.background": "#2C2D34",
4
- "titleBar.activeBackground": "#3E3F49",
5
- "titleBar.activeForeground": "#FAFAFB"
6
- }
7
- }