@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 +63 -0
- package/lib/index.d.ts +4 -2
- package/lib/logger.js +152 -11
- package/lib/logger.test.js +516 -55
- package/lib/translator.test.js +24 -17
- package/lib/utilities/journald-socket.js +51 -0
- package/lib/utilities/journald-socket.test.js +105 -0
- package/package.json +3 -7
- package/.vscode/settings.json +0 -7
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
package/lib/logger.test.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
assert.ok(logger)
|
|
25
47
|
})
|
|
26
48
|
|
|
27
|
-
it('
|
|
49
|
+
it('returns null context when no static or instance context is available', () => {
|
|
28
50
|
const logger = new Logger()
|
|
29
|
-
|
|
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
|
-
|
|
59
|
+
assert.ok(!mockGlobal.logArgs)
|
|
38
60
|
|
|
39
61
|
logger.error('Logging something...')
|
|
40
|
-
|
|
62
|
+
assert.ok(!mockGlobal.errorArgs)
|
|
41
63
|
|
|
42
64
|
logger.warn('Logging something...')
|
|
43
|
-
|
|
65
|
+
assert.ok(!mockGlobal.warnArgs)
|
|
44
66
|
|
|
45
67
|
logger.info('Logging something...')
|
|
46
|
-
|
|
68
|
+
assert.ok(!mockGlobal.infoArgs)
|
|
47
69
|
|
|
48
70
|
logger.debug('Logging something...')
|
|
49
|
-
|
|
71
|
+
assert.ok(!mockGlobal.debugArgs)
|
|
50
72
|
})
|
|
51
73
|
|
|
52
|
-
it('
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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('
|
|
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.
|
|
85
|
-
|
|
86
|
-
'2025-02-15T00:00:00.000Z
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
97
|
-
|
|
98
|
-
'2025-02-15T00:00:00.000Z
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
'2025-02-15T00:00:00.000Z
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
122
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
+
})
|
package/lib/translator.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
try {
|
|
29
|
+
Translator.translate = (key, _options) => {
|
|
30
|
+
return `translated:${key}`
|
|
31
|
+
}
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
const result = t('any.key')
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
try {
|
|
44
|
+
Translator.translate = (key, _options) => {
|
|
45
|
+
return `translated:${key}`
|
|
46
|
+
}
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
const result = lt('any.key')
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Utilitarian Logging Library",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"
|
|
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
|
}
|