queen-mq 0.12.3 → 0.12.4
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/client-v2/LOGGING.md +63 -30
- package/client-v2/Queen.js +8 -7
- package/client-v2/buffer/BufferManager.js +12 -23
- package/client-v2/builders/QueueBuilder.js +0 -2
- package/client-v2/consumer/ConsumerManager.js +2 -3
- package/client-v2/http/HttpClient.js +0 -1
- package/client-v2/utils/defaults.js +2 -1
- package/client-v2/utils/logger.js +46 -14
- package/package.json +1 -1
- package/test-v2/logger.js +312 -0
- package/test-v2/run.js +3 -1
package/client-v2/LOGGING.md
CHANGED
|
@@ -2,9 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
The Queen Client V2 includes comprehensive operation logging that captures every significant action performed by the client.
|
|
5
|
+
The Queen Client V2 includes comprehensive operation logging that captures every significant action performed by the client. You can either:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1. **Inject a custom logger** (pino, winston, bunyan, etc.) via the `logger` config option -- recommended for production.
|
|
8
|
+
2. **Use the built-in console logger** gated by the `QUEEN_CLIENT_LOG` environment variable -- handy for quick debugging.
|
|
9
|
+
|
|
10
|
+
## Custom Logger (Recommended)
|
|
11
|
+
|
|
12
|
+
Pass any logger instance that implements `info()`, `warn()`, and `error()` methods. When a custom logger is configured, it is always active (level filtering is controlled by your logger, not by `QUEEN_CLIENT_LOG`).
|
|
13
|
+
|
|
14
|
+
### Pino
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
import pino from 'pino'
|
|
18
|
+
import { Queen } from '@punkish/queen'
|
|
19
|
+
|
|
20
|
+
const queen = new Queen({
|
|
21
|
+
urls: ['http://localhost:6632'],
|
|
22
|
+
logger: pino()
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Winston
|
|
27
|
+
|
|
28
|
+
```javascript
|
|
29
|
+
import winston from 'winston'
|
|
30
|
+
import { Queen } from '@punkish/queen'
|
|
31
|
+
|
|
32
|
+
const winstonLogger = winston.createLogger({
|
|
33
|
+
level: 'info',
|
|
34
|
+
transports: [new winston.transports.Console()]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const queen = new Queen({
|
|
38
|
+
urls: ['http://localhost:6632'],
|
|
39
|
+
logger: winstonLogger
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Custom implementation
|
|
44
|
+
|
|
45
|
+
Any object with `info`, `warn`, and `error` methods works:
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
const queen = new Queen({
|
|
49
|
+
urls: ['http://localhost:6632'],
|
|
50
|
+
logger: {
|
|
51
|
+
info: (msg) => myLogSink('INFO', msg),
|
|
52
|
+
warn: (msg) => myLogSink('WARN', msg),
|
|
53
|
+
error: (msg) => myLogSink('ERROR', msg)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The `debug()` method is optional. If your logger provides it, verbose internal details (buffer flush progress, server responses, etc.) will be emitted at `debug` level. Otherwise they fall back to `info`.
|
|
59
|
+
|
|
60
|
+
## Built-in Console Logger
|
|
61
|
+
|
|
62
|
+
If no custom logger is provided, the built-in console logger can be enabled via environment variable:
|
|
8
63
|
|
|
9
64
|
```bash
|
|
10
65
|
export QUEEN_CLIENT_LOG=true
|
|
@@ -158,11 +213,13 @@ pm2 restart my-app
|
|
|
158
213
|
|
|
159
214
|
## Performance Impact
|
|
160
215
|
|
|
161
|
-
- **
|
|
162
|
-
- **
|
|
216
|
+
- **No logger configured + `QUEEN_CLIENT_LOG` unset (default)**: Zero performance impact - all logging calls are no-ops
|
|
217
|
+
- **Built-in console logger enabled**: Minimal impact
|
|
218
|
+
- **Custom logger**: Depends on the logger implementation; pino in async mode has negligible overhead
|
|
163
219
|
|
|
164
220
|
## Log Levels
|
|
165
221
|
|
|
222
|
+
- **DEBUG**: Verbose operational details (buffer flush progress, extracted message counts, server responses)
|
|
166
223
|
- **INFO**: Normal operations (most logs)
|
|
167
224
|
- **WARN**: Recoverable issues (retries, failover, network errors)
|
|
168
225
|
- **ERROR**: Operation failures (push failed, ack failed, etc.)
|
|
@@ -191,31 +248,7 @@ QUEEN_CLIENT_LOG=true node app.js 2>&1 | grep "pop"
|
|
|
191
248
|
|
|
192
249
|
## Integration with Log Aggregation
|
|
193
250
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
### Winston
|
|
197
|
-
```javascript
|
|
198
|
-
import winston from 'winston'
|
|
199
|
-
|
|
200
|
-
// Redirect console.log to Winston
|
|
201
|
-
console.log = winston.info
|
|
202
|
-
console.error = winston.error
|
|
203
|
-
console.warn = winston.warn
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### Pino
|
|
207
|
-
```javascript
|
|
208
|
-
import pino from 'pino'
|
|
209
|
-
const logger = pino()
|
|
210
|
-
|
|
211
|
-
console.log = (msg) => logger.info(msg)
|
|
212
|
-
console.error = (msg) => logger.error(msg)
|
|
213
|
-
console.warn = (msg) => logger.warn(msg)
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### Datadog, CloudWatch, etc.
|
|
217
|
-
|
|
218
|
-
The ISO 8601 timestamps and JSON format are compatible with most log aggregation services.
|
|
251
|
+
When using a custom logger, integration with log aggregation services (Datadog, CloudWatch, Elastic, etc.) depends on your logger's configuration, not on Queen. For example, pino outputs NDJSON by default which is compatible with most log aggregation pipelines out of the box.
|
|
219
252
|
|
|
220
253
|
## Best Practices
|
|
221
254
|
|
|
@@ -236,5 +269,5 @@ The logging system is implemented in `utils/logger.js` and imported by all major
|
|
|
236
269
|
- `QueueBuilder.js` - Queue operations
|
|
237
270
|
- `TransactionBuilder.js` - Atomic transactions
|
|
238
271
|
|
|
239
|
-
|
|
272
|
+
When a custom logger is configured via `new Queen({ logger })`, all internal logging is routed through it. When no custom logger is provided, logging falls back to the built-in console logger gated by `QUEEN_CLIENT_LOG`.
|
|
240
273
|
|
package/client-v2/Queen.js
CHANGED
|
@@ -23,6 +23,11 @@ export class Queen {
|
|
|
23
23
|
#admin = null
|
|
24
24
|
|
|
25
25
|
constructor(config = {}) {
|
|
26
|
+
// Configure custom logger before anything else
|
|
27
|
+
if (config && typeof config === 'object' && !Array.isArray(config) && config.logger) {
|
|
28
|
+
logger.configure(config.logger)
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
logger.log('Queen.constructor', { config: typeof config === 'object' && !Array.isArray(config) ? { ...config, urls: config.urls?.length || 0 } : { type: typeof config } })
|
|
27
32
|
|
|
28
33
|
// Normalize config
|
|
@@ -117,17 +122,17 @@ export class Queen {
|
|
|
117
122
|
|
|
118
123
|
let signalReceivedCount = 0;
|
|
119
124
|
const shutdown = async (signal) => {
|
|
120
|
-
|
|
125
|
+
logger.log('Queen.shutdown', { signal })
|
|
121
126
|
try {
|
|
122
127
|
signalReceivedCount++;
|
|
123
128
|
if (signalReceivedCount > 1) {
|
|
124
|
-
|
|
129
|
+
logger.warn('Queen.shutdown', 'Received multiple shutdown signals, exiting immediately')
|
|
125
130
|
process.exit(1)
|
|
126
131
|
}
|
|
127
132
|
await this.close()
|
|
128
133
|
process.exit(0)
|
|
129
134
|
} catch (error) {
|
|
130
|
-
|
|
135
|
+
logger.error('Queen.shutdown', { error: error.message })
|
|
131
136
|
process.exit(1)
|
|
132
137
|
}
|
|
133
138
|
}
|
|
@@ -480,16 +485,13 @@ export class Queen {
|
|
|
480
485
|
|
|
481
486
|
async close() {
|
|
482
487
|
logger.log('Queen.close', 'Starting shutdown')
|
|
483
|
-
console.log('Closing Queen client...')
|
|
484
488
|
|
|
485
489
|
// Flush all buffers
|
|
486
490
|
try {
|
|
487
491
|
await this.#bufferManager.flushAllBuffers()
|
|
488
492
|
logger.log('Queen.close', 'All buffers flushed')
|
|
489
|
-
console.log('All buffers flushed')
|
|
490
493
|
} catch (error) {
|
|
491
494
|
logger.error('Queen.close', { error: error.message, phase: 'buffer-flush' })
|
|
492
|
-
console.warn('Error flushing buffers:', error)
|
|
493
495
|
}
|
|
494
496
|
|
|
495
497
|
// Cleanup buffer manager
|
|
@@ -502,7 +504,6 @@ export class Queen {
|
|
|
502
504
|
this.#shutdownHandlers = []
|
|
503
505
|
|
|
504
506
|
logger.log('Queen.close', 'Client closed successfully')
|
|
505
|
-
console.log('Queen client closed')
|
|
506
507
|
}
|
|
507
508
|
}
|
|
508
509
|
|
|
@@ -36,13 +36,11 @@ export class BufferManager {
|
|
|
36
36
|
async #flushBuffer(queueAddress) {
|
|
37
37
|
const buffer = this.#buffers.get(queueAddress)
|
|
38
38
|
if (!buffer || buffer.messageCount === 0) {
|
|
39
|
-
logger.
|
|
40
|
-
console.log(`No buffer or empty buffer for ${queueAddress}`)
|
|
39
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'empty' })
|
|
41
40
|
return
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
logger.log('BufferManager.flushBuffer', { queueAddress, messageCount: buffer.messageCount })
|
|
45
|
-
console.log(`Flushing ${buffer.messageCount} messages for ${queueAddress}`)
|
|
46
44
|
buffer.setFlushing(true)
|
|
47
45
|
|
|
48
46
|
// Create a promise for this flush and track it
|
|
@@ -50,13 +48,13 @@ export class BufferManager {
|
|
|
50
48
|
try {
|
|
51
49
|
const messages = buffer.extractMessages()
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, extracted: messages.length })
|
|
54
52
|
|
|
55
53
|
if (messages.length === 0) return
|
|
56
54
|
|
|
57
55
|
// Send to server
|
|
58
56
|
const result = await this.#httpClient.post('/api/v1/push', { items: messages })
|
|
59
|
-
|
|
57
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
60
58
|
|
|
61
59
|
this.#flushCount++
|
|
62
60
|
logger.log('BufferManager.flushBuffer', { queueAddress, status: 'success', messagesSent: messages.length })
|
|
@@ -66,7 +64,6 @@ export class BufferManager {
|
|
|
66
64
|
|
|
67
65
|
} catch (error) {
|
|
68
66
|
logger.error('BufferManager.flushBuffer', { queueAddress, error: error.message })
|
|
69
|
-
console.error(`Flush error for ${queueAddress}:`, error.message)
|
|
70
67
|
buffer.setFlushing(false)
|
|
71
68
|
throw error
|
|
72
69
|
} finally {
|
|
@@ -94,13 +91,13 @@ export class BufferManager {
|
|
|
94
91
|
try {
|
|
95
92
|
const messages = buffer.extractMessages(batchSize)
|
|
96
93
|
|
|
97
|
-
|
|
94
|
+
logger.debug('BufferManager.flushBufferBatch', { queueAddress, extracted: messages.length })
|
|
98
95
|
|
|
99
96
|
if (messages.length === 0) return
|
|
100
97
|
|
|
101
98
|
// Send to server
|
|
102
99
|
const result = await this.#httpClient.post('/api/v1/push', { items: messages })
|
|
103
|
-
|
|
100
|
+
logger.debug('BufferManager.flushBufferBatch', { queueAddress, serverResponse: result ? `${result.length || 'N/A'} items` : 'null' })
|
|
104
101
|
|
|
105
102
|
this.#flushCount++
|
|
106
103
|
|
|
@@ -112,7 +109,7 @@ export class BufferManager {
|
|
|
112
109
|
}
|
|
113
110
|
|
|
114
111
|
} catch (error) {
|
|
115
|
-
|
|
112
|
+
logger.error('BufferManager.flushBufferBatch', { queueAddress, error: error.message })
|
|
116
113
|
buffer.setFlushing(false)
|
|
117
114
|
throw error
|
|
118
115
|
} finally {
|
|
@@ -129,14 +126,10 @@ export class BufferManager {
|
|
|
129
126
|
|
|
130
127
|
async flushBuffer(queueAddress) {
|
|
131
128
|
logger.log('BufferManager.flushBuffer', { queueAddress, activeBuffers: this.#buffers.size, pendingFlushes: this.#pendingFlushes.size })
|
|
132
|
-
console.log(`flushBuffer called for address: ${queueAddress}`)
|
|
133
|
-
console.log(`Active buffers:`, Array.from(this.#buffers.keys()))
|
|
134
|
-
console.log(`Pending flushes:`, this.#pendingFlushes.size)
|
|
135
129
|
|
|
136
130
|
const buffer = this.#buffers.get(queueAddress)
|
|
137
131
|
if (!buffer) {
|
|
138
|
-
logger.
|
|
139
|
-
console.log(`No buffer found for ${queueAddress}`)
|
|
132
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'not-found' })
|
|
140
133
|
await this.#waitForPendingFlushes()
|
|
141
134
|
return
|
|
142
135
|
}
|
|
@@ -149,22 +142,20 @@ export class BufferManager {
|
|
|
149
142
|
|
|
150
143
|
// Flush all messages in batches
|
|
151
144
|
while (buffer.messageCount > 0) {
|
|
152
|
-
|
|
145
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, batchSize, remaining: buffer.messageCount })
|
|
153
146
|
await this.#flushBufferBatch(queueAddress, batchSize)
|
|
154
147
|
}
|
|
155
148
|
|
|
156
149
|
// Wait for all pending flushes to complete
|
|
157
150
|
await this.#waitForPendingFlushes()
|
|
158
151
|
|
|
159
|
-
|
|
152
|
+
logger.debug('BufferManager.flushBuffer', { queueAddress, status: 'completed' })
|
|
160
153
|
}
|
|
161
154
|
|
|
162
155
|
async flushAllBuffers() {
|
|
156
|
+
// Get all queue addresses that have buffers
|
|
163
157
|
const queueAddresses = Array.from(this.#buffers.keys())
|
|
164
158
|
logger.log('BufferManager.flushAllBuffers', { bufferCount: queueAddresses.length, pendingFlushes: this.#pendingFlushes.size })
|
|
165
|
-
console.log(`flushAllBuffers called, pending flushes: ${this.#pendingFlushes.size}`)
|
|
166
|
-
|
|
167
|
-
// Get all queue addresses that have buffers
|
|
168
159
|
|
|
169
160
|
// Flush each buffer in batches
|
|
170
161
|
for (const queueAddress of queueAddresses) {
|
|
@@ -172,15 +163,14 @@ export class BufferManager {
|
|
|
172
163
|
}
|
|
173
164
|
|
|
174
165
|
logger.log('BufferManager.flushAllBuffers', { status: 'completed' })
|
|
175
|
-
console.log(`flushAllBuffers completed`)
|
|
176
166
|
}
|
|
177
167
|
|
|
178
168
|
async #waitForPendingFlushes() {
|
|
179
169
|
if (this.#pendingFlushes.size === 0) return
|
|
180
170
|
|
|
181
|
-
|
|
171
|
+
logger.debug('BufferManager.waitForPendingFlushes', { count: this.#pendingFlushes.size })
|
|
182
172
|
await Promise.all(Array.from(this.#pendingFlushes))
|
|
183
|
-
|
|
173
|
+
logger.debug('BufferManager.waitForPendingFlushes', { status: 'completed' })
|
|
184
174
|
}
|
|
185
175
|
|
|
186
176
|
getStats() {
|
|
@@ -212,4 +202,3 @@ export class BufferManager {
|
|
|
212
202
|
this.#buffers.clear()
|
|
213
203
|
}
|
|
214
204
|
}
|
|
215
|
-
|
|
@@ -306,7 +306,6 @@ export class QueueBuilder {
|
|
|
306
306
|
} catch (error) {
|
|
307
307
|
// Return empty array on error instead of throwing
|
|
308
308
|
logger.error('QueueBuilder.pop', { error: error.message })
|
|
309
|
-
console.warn('Pop failed:', error.message)
|
|
310
309
|
return []
|
|
311
310
|
}
|
|
312
311
|
}
|
|
@@ -745,7 +744,6 @@ class DLQBuilder {
|
|
|
745
744
|
return result || { messages: [], total: 0 }
|
|
746
745
|
} catch (error) {
|
|
747
746
|
logger.error('DLQBuilder.get', { error: error.message })
|
|
748
|
-
console.warn('DLQ query failed:', error.message)
|
|
749
747
|
return { messages: [], total: 0 }
|
|
750
748
|
}
|
|
751
749
|
}
|
|
@@ -222,7 +222,6 @@ export class ConsumerManager {
|
|
|
222
222
|
|
|
223
223
|
if (isNetworkError) {
|
|
224
224
|
logger.warn('ConsumerManager.worker', { workerId, error: 'network', message: error.message })
|
|
225
|
-
console.warn(`Worker ${workerId}: Network error - ${error.message}`)
|
|
226
225
|
// Wait before retry
|
|
227
226
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
228
227
|
continue
|
|
@@ -296,7 +295,7 @@ export class ConsumerManager {
|
|
|
296
295
|
try {
|
|
297
296
|
await this.#queen.renew(messages)
|
|
298
297
|
} catch (error) {
|
|
299
|
-
|
|
298
|
+
logger.error('ConsumerManager.leaseRenewal', { error: error.message })
|
|
300
299
|
}
|
|
301
300
|
}, intervalMillis)
|
|
302
301
|
}
|
|
@@ -367,7 +366,7 @@ export class ConsumerManager {
|
|
|
367
366
|
error: error.message,
|
|
368
367
|
phase: 'trace-failed'
|
|
369
368
|
})
|
|
370
|
-
|
|
369
|
+
logger.warn('ConsumerManager.trace', { transactionId: message.transactionId, error: error.message })
|
|
371
370
|
|
|
372
371
|
return { success: false, error: error.message }
|
|
373
372
|
}
|
|
@@ -196,7 +196,6 @@ export class HttpClient {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
logger.warn('HttpClient.failover', { url, method, path, error: error.message })
|
|
199
|
-
console.warn(`Request failed for ${url}: ${method} ${path} - ${error.message}`)
|
|
200
199
|
|
|
201
200
|
// Don't retry on client errors (4xx)
|
|
202
201
|
if (error.status && error.status >= 400 && error.status < 500) {
|
|
@@ -16,7 +16,8 @@ export const CLIENT_DEFAULTS = {
|
|
|
16
16
|
healthRetryAfterMillis: 5000, // Retry unhealthy backends after 5 seconds
|
|
17
17
|
bearerToken: null, // Bearer token for proxy authentication
|
|
18
18
|
headers: {}, // Custom headers to include in every request
|
|
19
|
-
handleSignals: true
|
|
19
|
+
handleSignals: true, // Register SIGINT/SIGTERM handlers (disable when used as a library)
|
|
20
|
+
logger: null // Custom logger instance (must implement info/warn/error)
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export const QUEUE_DEFAULTS = {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Logger utility for Queen Client v2
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Supports pluggable logger backends (pino, winston, bunyan, etc.).
|
|
5
|
+
* When no custom logger is configured, falls back to console-based logging
|
|
6
|
+
* gated by the QUEEN_CLIENT_LOG environment variable (Node.js)
|
|
7
|
+
* or window.QUEEN_CLIENT_LOG (Browser).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
const LOG_ENABLED = (() => {
|
|
@@ -16,9 +19,8 @@ const LOG_ENABLED = (() => {
|
|
|
16
19
|
return false
|
|
17
20
|
})()
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
+
let customLogger = null
|
|
23
|
+
|
|
22
24
|
function getTimestamp() {
|
|
23
25
|
return new Date().toISOString()
|
|
24
26
|
}
|
|
@@ -33,25 +35,56 @@ function formatLog(operation, details, level = 'INFO') {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
|
-
*
|
|
38
|
+
* Configure a custom logger backend.
|
|
39
|
+
* The logger must implement: info(msg), warn(msg), error(msg).
|
|
40
|
+
* debug(msg) is optional and falls back to info(msg) if missing.
|
|
41
|
+
* When a custom logger is set, it is always active (no env var gating).
|
|
42
|
+
* @param {object} logger - Logger instance (e.g. pino(), winston.createLogger())
|
|
37
43
|
*/
|
|
44
|
+
export function configure(logger) {
|
|
45
|
+
if (logger && typeof logger.info !== 'function') {
|
|
46
|
+
throw new Error('Custom logger must implement info(), warn(), and error() methods')
|
|
47
|
+
}
|
|
48
|
+
customLogger = logger
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
export function log(operation, details) {
|
|
52
|
+
if (customLogger) {
|
|
53
|
+
const detailsStr = typeof details === 'object' ? JSON.stringify(details) : details
|
|
54
|
+
customLogger.info(`[${operation}] ${detailsStr}`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
39
57
|
if (!LOG_ENABLED) return
|
|
40
58
|
console.log(formatLog(operation, details))
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
export function debug(operation, details) {
|
|
62
|
+
if (customLogger) {
|
|
63
|
+
const detailsStr = typeof details === 'object' ? JSON.stringify(details) : details
|
|
64
|
+
const fn = customLogger.debug || customLogger.info
|
|
65
|
+
fn.call(customLogger, `[${operation}] ${detailsStr}`)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (!LOG_ENABLED) return
|
|
69
|
+
console.log(formatLog(operation, details, 'DEBUG'))
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
export function warn(operation, details) {
|
|
73
|
+
if (customLogger) {
|
|
74
|
+
const detailsStr = typeof details === 'object' ? JSON.stringify(details) : details
|
|
75
|
+
customLogger.warn(`[${operation}] ${detailsStr}`)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
47
78
|
if (!LOG_ENABLED) return
|
|
48
79
|
console.warn(formatLog(operation, details, 'WARN'))
|
|
49
80
|
}
|
|
50
81
|
|
|
51
|
-
/**
|
|
52
|
-
* Log an error
|
|
53
|
-
*/
|
|
54
82
|
export function error(operation, details) {
|
|
83
|
+
if (customLogger) {
|
|
84
|
+
const detailsStr = typeof details === 'object' ? JSON.stringify(details) : details
|
|
85
|
+
customLogger.error(`[${operation}] ${detailsStr}`)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
55
88
|
if (!LOG_ENABLED) return
|
|
56
89
|
console.error(formatLog(operation, details, 'ERROR'))
|
|
57
90
|
}
|
|
@@ -60,6 +93,5 @@ export function error(operation, details) {
|
|
|
60
93
|
* Check if logging is enabled
|
|
61
94
|
*/
|
|
62
95
|
export function isEnabled() {
|
|
63
|
-
return LOG_ENABLED
|
|
96
|
+
return customLogger != null || LOG_ENABLED
|
|
64
97
|
}
|
|
65
|
-
|
package/package.json
CHANGED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the pluggable logger system in utils/logger.js:
|
|
5
|
+
* - Default console-based logging (gated by QUEEN_CLIENT_LOG)
|
|
6
|
+
* - Custom logger injection via configure()
|
|
7
|
+
* - Level mapping (log->info, warn->warn, error->error, debug->debug)
|
|
8
|
+
* - debug() fallback to info() when custom logger has no debug()
|
|
9
|
+
* - configure(null) resets to default
|
|
10
|
+
* - configure() validation
|
|
11
|
+
* - isEnabled() behavior
|
|
12
|
+
*
|
|
13
|
+
* These are pure unit tests — no Queen server required.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as logger from '../client-v2/utils/logger.js'
|
|
17
|
+
|
|
18
|
+
function createMockLogger() {
|
|
19
|
+
const calls = { info: [], warn: [], error: [], debug: [] }
|
|
20
|
+
return {
|
|
21
|
+
calls,
|
|
22
|
+
info: (msg) => calls.info.push(msg),
|
|
23
|
+
warn: (msg) => calls.warn.push(msg),
|
|
24
|
+
error: (msg) => calls.error.push(msg),
|
|
25
|
+
debug: (msg) => calls.debug.push(msg)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createMockLoggerWithoutDebug() {
|
|
30
|
+
const calls = { info: [], warn: [], error: [] }
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
info: (msg) => calls.info.push(msg),
|
|
34
|
+
warn: (msg) => calls.warn.push(msg),
|
|
35
|
+
error: (msg) => calls.error.push(msg)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Test that configure() accepts a valid logger and routes log() to info()
|
|
41
|
+
*/
|
|
42
|
+
export async function testLoggerCustomLogRouting(_client) {
|
|
43
|
+
const mock = createMockLogger()
|
|
44
|
+
logger.configure(mock)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
logger.log('TestOp', 'hello')
|
|
48
|
+
logger.log('TestOp', { key: 'value' })
|
|
49
|
+
|
|
50
|
+
if (mock.calls.info.length !== 2) {
|
|
51
|
+
return { success: false, message: `Expected 2 info calls, got ${mock.calls.info.length}` }
|
|
52
|
+
}
|
|
53
|
+
if (!mock.calls.info[0].includes('[TestOp]') || !mock.calls.info[0].includes('hello')) {
|
|
54
|
+
return { success: false, message: `Unexpected info message: ${mock.calls.info[0]}` }
|
|
55
|
+
}
|
|
56
|
+
if (!mock.calls.info[1].includes('"key":"value"')) {
|
|
57
|
+
return { success: false, message: `Object details not serialized: ${mock.calls.info[1]}` }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { success: true, message: 'log() routes to custom logger info()' }
|
|
61
|
+
} finally {
|
|
62
|
+
logger.configure(null)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Test that warn() routes to custom logger warn()
|
|
68
|
+
*/
|
|
69
|
+
export async function testLoggerCustomWarnRouting(_client) {
|
|
70
|
+
const mock = createMockLogger()
|
|
71
|
+
logger.configure(mock)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
logger.warn('WarnOp', 'something bad')
|
|
75
|
+
logger.warn('WarnOp', { code: 42 })
|
|
76
|
+
|
|
77
|
+
if (mock.calls.warn.length !== 2) {
|
|
78
|
+
return { success: false, message: `Expected 2 warn calls, got ${mock.calls.warn.length}` }
|
|
79
|
+
}
|
|
80
|
+
if (!mock.calls.warn[0].includes('[WarnOp]')) {
|
|
81
|
+
return { success: false, message: `Missing operation in warn: ${mock.calls.warn[0]}` }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { success: true, message: 'warn() routes to custom logger warn()' }
|
|
85
|
+
} finally {
|
|
86
|
+
logger.configure(null)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Test that error() routes to custom logger error()
|
|
92
|
+
*/
|
|
93
|
+
export async function testLoggerCustomErrorRouting(_client) {
|
|
94
|
+
const mock = createMockLogger()
|
|
95
|
+
logger.configure(mock)
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
logger.error('ErrOp', { error: 'boom' })
|
|
99
|
+
|
|
100
|
+
if (mock.calls.error.length !== 1) {
|
|
101
|
+
return { success: false, message: `Expected 1 error call, got ${mock.calls.error.length}` }
|
|
102
|
+
}
|
|
103
|
+
if (!mock.calls.error[0].includes('[ErrOp]') || !mock.calls.error[0].includes('boom')) {
|
|
104
|
+
return { success: false, message: `Unexpected error message: ${mock.calls.error[0]}` }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { success: true, message: 'error() routes to custom logger error()' }
|
|
108
|
+
} finally {
|
|
109
|
+
logger.configure(null)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Test that debug() routes to custom logger debug() when available
|
|
115
|
+
*/
|
|
116
|
+
export async function testLoggerCustomDebugRouting(_client) {
|
|
117
|
+
const mock = createMockLogger()
|
|
118
|
+
logger.configure(mock)
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
logger.debug('DebugOp', 'verbose detail')
|
|
122
|
+
|
|
123
|
+
if (mock.calls.debug.length !== 1) {
|
|
124
|
+
return { success: false, message: `Expected 1 debug call, got ${mock.calls.debug.length}` }
|
|
125
|
+
}
|
|
126
|
+
if (!mock.calls.debug[0].includes('[DebugOp]')) {
|
|
127
|
+
return { success: false, message: `Missing operation in debug: ${mock.calls.debug[0]}` }
|
|
128
|
+
}
|
|
129
|
+
// info() should NOT have been called
|
|
130
|
+
if (mock.calls.info.length !== 0) {
|
|
131
|
+
return { success: false, message: `debug() should not fall back to info() when debug() exists` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { success: true, message: 'debug() routes to custom logger debug()' }
|
|
135
|
+
} finally {
|
|
136
|
+
logger.configure(null)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Test that debug() falls back to info() when custom logger has no debug()
|
|
142
|
+
*/
|
|
143
|
+
export async function testLoggerDebugFallbackToInfo(_client) {
|
|
144
|
+
const mock = createMockLoggerWithoutDebug()
|
|
145
|
+
logger.configure(mock)
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
logger.debug('FallbackOp', 'should go to info')
|
|
149
|
+
|
|
150
|
+
if (mock.calls.info.length !== 1) {
|
|
151
|
+
return { success: false, message: `Expected 1 info call (fallback), got ${mock.calls.info.length}` }
|
|
152
|
+
}
|
|
153
|
+
if (!mock.calls.info[0].includes('[FallbackOp]')) {
|
|
154
|
+
return { success: false, message: `Missing operation in fallback: ${mock.calls.info[0]}` }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { success: true, message: 'debug() falls back to info() when no debug() on logger' }
|
|
158
|
+
} finally {
|
|
159
|
+
logger.configure(null)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Test that configure(null) resets to default console behavior
|
|
165
|
+
*/
|
|
166
|
+
export async function testLoggerConfigureNull(_client) {
|
|
167
|
+
const mock = createMockLogger()
|
|
168
|
+
logger.configure(mock)
|
|
169
|
+
|
|
170
|
+
// Verify custom logger is active
|
|
171
|
+
logger.log('Before', 'test')
|
|
172
|
+
if (mock.calls.info.length !== 1) {
|
|
173
|
+
return { success: false, message: 'Custom logger not active before reset' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Reset
|
|
177
|
+
logger.configure(null)
|
|
178
|
+
|
|
179
|
+
// Now log should NOT go to mock anymore
|
|
180
|
+
logger.log('After', 'test')
|
|
181
|
+
if (mock.calls.info.length !== 1) {
|
|
182
|
+
return { success: false, message: `Expected still 1 info call after reset, got ${mock.calls.info.length}` }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { success: true, message: 'configure(null) resets to default' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Test that configure() throws on invalid logger (missing info method)
|
|
190
|
+
*/
|
|
191
|
+
export async function testLoggerConfigureValidation(_client) {
|
|
192
|
+
try {
|
|
193
|
+
logger.configure({ warn: () => {}, error: () => {} })
|
|
194
|
+
return { success: false, message: 'Should have thrown for logger missing info()' }
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (!e.message.includes('info()')) {
|
|
197
|
+
return { success: false, message: `Wrong error message: ${e.message}` }
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
logger.configure(null)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Empty object should also fail
|
|
204
|
+
try {
|
|
205
|
+
logger.configure({})
|
|
206
|
+
return { success: false, message: 'Should have thrown for empty object' }
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// expected
|
|
209
|
+
} finally {
|
|
210
|
+
logger.configure(null)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { success: true, message: 'configure() validates logger interface' }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Test isEnabled() returns true when custom logger is set
|
|
218
|
+
*/
|
|
219
|
+
export async function testLoggerIsEnabledWithCustom(_client) {
|
|
220
|
+
// Without custom logger and without QUEEN_CLIENT_LOG, isEnabled depends on env
|
|
221
|
+
const beforeCustom = logger.isEnabled()
|
|
222
|
+
|
|
223
|
+
const mock = createMockLogger()
|
|
224
|
+
logger.configure(mock)
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
if (!logger.isEnabled()) {
|
|
228
|
+
return { success: false, message: 'isEnabled() should return true with custom logger' }
|
|
229
|
+
}
|
|
230
|
+
return { success: true, message: `isEnabled() returns true with custom logger (was ${beforeCustom} before)` }
|
|
231
|
+
} finally {
|
|
232
|
+
logger.configure(null)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Test that levels are isolated — log() only hits info(), not warn() or error()
|
|
238
|
+
*/
|
|
239
|
+
export async function testLoggerLevelIsolation(_client) {
|
|
240
|
+
const mock = createMockLogger()
|
|
241
|
+
logger.configure(mock)
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
logger.log('Op', 'info msg')
|
|
245
|
+
logger.warn('Op', 'warn msg')
|
|
246
|
+
logger.error('Op', 'error msg')
|
|
247
|
+
logger.debug('Op', 'debug msg')
|
|
248
|
+
|
|
249
|
+
if (mock.calls.info.length !== 1) {
|
|
250
|
+
return { success: false, message: `info: expected 1, got ${mock.calls.info.length}` }
|
|
251
|
+
}
|
|
252
|
+
if (mock.calls.warn.length !== 1) {
|
|
253
|
+
return { success: false, message: `warn: expected 1, got ${mock.calls.warn.length}` }
|
|
254
|
+
}
|
|
255
|
+
if (mock.calls.error.length !== 1) {
|
|
256
|
+
return { success: false, message: `error: expected 1, got ${mock.calls.error.length}` }
|
|
257
|
+
}
|
|
258
|
+
if (mock.calls.debug.length !== 1) {
|
|
259
|
+
return { success: false, message: `debug: expected 1, got ${mock.calls.debug.length}` }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { success: true, message: 'Each level routes to exactly its corresponding method' }
|
|
263
|
+
} finally {
|
|
264
|
+
logger.configure(null)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Test that string details are passed through without JSON.stringify
|
|
270
|
+
*/
|
|
271
|
+
export async function testLoggerStringDetails(_client) {
|
|
272
|
+
const mock = createMockLogger()
|
|
273
|
+
logger.configure(mock)
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
logger.log('Op', 'plain string')
|
|
277
|
+
|
|
278
|
+
const msg = mock.calls.info[0]
|
|
279
|
+
if (!msg.includes('plain string')) {
|
|
280
|
+
return { success: false, message: `String details not passed through: ${msg}` }
|
|
281
|
+
}
|
|
282
|
+
// Should NOT be double-quoted as JSON
|
|
283
|
+
if (msg.includes('"plain string"')) {
|
|
284
|
+
return { success: false, message: `String was JSON-serialized: ${msg}` }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { success: true, message: 'String details passed as-is' }
|
|
288
|
+
} finally {
|
|
289
|
+
logger.configure(null)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Test that object details are JSON-serialized
|
|
295
|
+
*/
|
|
296
|
+
export async function testLoggerObjectDetails(_client) {
|
|
297
|
+
const mock = createMockLogger()
|
|
298
|
+
logger.configure(mock)
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
logger.log('Op', { foo: 'bar', count: 3 })
|
|
302
|
+
|
|
303
|
+
const msg = mock.calls.info[0]
|
|
304
|
+
if (!msg.includes('"foo":"bar"') || !msg.includes('"count":3')) {
|
|
305
|
+
return { success: false, message: `Object not serialized correctly: ${msg}` }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { success: true, message: 'Object details JSON-serialized' }
|
|
309
|
+
} finally {
|
|
310
|
+
logger.configure(null)
|
|
311
|
+
}
|
|
312
|
+
}
|
package/test-v2/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as subscriptionTests from './subscription.js'
|
|
|
12
12
|
import * as maintenanceTests from './maintenance.js'
|
|
13
13
|
import * as retentionTests from './retention.js'
|
|
14
14
|
import * as bootstrapTests from './bootstrap.js'
|
|
15
|
+
import * as loggerTests from './logger.js'
|
|
15
16
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
16
17
|
|
|
17
18
|
|
|
@@ -113,7 +114,8 @@ async function main() {
|
|
|
113
114
|
subscriptionTests,
|
|
114
115
|
retentionTests,
|
|
115
116
|
maintenanceTests,
|
|
116
|
-
bootstrapTests
|
|
117
|
+
bootstrapTests,
|
|
118
|
+
loggerTests
|
|
117
119
|
]
|
|
118
120
|
|
|
119
121
|
const aiTests = [
|