@onezlinks/session-logger 1.0.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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@onezlinks/session-logger",
3
+ "version": "1.0.0",
4
+ "description": "Session-based file logger with AsyncLocalStorage context propagation for Node.js microservices",
5
+ "main": "src/index.js",
6
+ "types": "types/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "require": "./src/index.js",
10
+ "types": "./types/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "src/",
15
+ "types/",
16
+ "CHANGELOG.md",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "jest --coverage",
22
+ "test:watch": "jest --watch",
23
+ "test:ci": "jest --coverage --ci --forceExit",
24
+ "lint": "eslint src/ __tests__/",
25
+ "lint:fix": "eslint src/ __tests__/ --fix",
26
+ "prepublishOnly": "npm run lint && npm test"
27
+ },
28
+ "keywords": [
29
+ "logger",
30
+ "session",
31
+ "async-local-storage",
32
+ "file-logger",
33
+ "structured-logging",
34
+ "microservices",
35
+ "correlation-id",
36
+ "dual-output",
37
+ "log-rotation"
38
+ ],
39
+ "author": "Onez <onezlinks>",
40
+ "license": "MIT",
41
+ "engines": {
42
+ "node": ">=16.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "eslint": "^8.0.0",
46
+ "jest": "^29.0.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/onezlinks/session-logger.git"
51
+ }
52
+ }
package/src/cleanup.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Log Cleanup & Rotation
3
+ * Removes old log files beyond retention period
4
+ *
5
+ * @module @onezlinks/session-logger/cleanup
6
+ */
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+ const { CONFIG } = require('./constants')
11
+
12
+ /**
13
+ * Clean up old log files beyond retention period
14
+ * @param {string} [category] - Specific category to clean (if omitted, clean all)
15
+ * @param {number} [daysToKeep] - Days to retain logs (default from CONFIG)
16
+ * @returns {Promise<{deleted: string[], errors: string[]}>}
17
+ */
18
+ async function cleanOldLogs(category, daysToKeep) {
19
+ const retentionDays = daysToKeep ?? CONFIG.LOG_RETENTION_DAYS
20
+ const baseDir = path.join(process.cwd(), CONFIG.LOG_BASE_DIR)
21
+ const deleted = []
22
+ const errors = []
23
+
24
+ // Check if base directory exists
25
+ if (!fs.existsSync(baseDir)) {
26
+ return { deleted, errors }
27
+ }
28
+
29
+ // Calculate cutoff date
30
+ const cutoffDate = new Date()
31
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
32
+ const cutoffTimestamp = cutoffDate.getTime()
33
+
34
+ try {
35
+ // Get categories to process
36
+ const categories = category
37
+ ? [category]
38
+ : fs.readdirSync(baseDir).filter((name) => {
39
+ const stat = fs.statSync(path.join(baseDir, name))
40
+ return stat.isDirectory()
41
+ })
42
+
43
+ // Process each category
44
+ for (const cat of categories) {
45
+ const categoryPath = path.join(baseDir, cat)
46
+
47
+ if (!fs.existsSync(categoryPath)) continue
48
+
49
+ // Get date folders (YYYY-MM-DD format)
50
+ const dateFolders = fs.readdirSync(categoryPath).filter((name) => {
51
+ const folderPath = path.join(categoryPath, name)
52
+ const stat = fs.statSync(folderPath)
53
+ return stat.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(name)
54
+ })
55
+
56
+ // Check each date folder
57
+ for (const dateFolder of dateFolders) {
58
+ try {
59
+ const folderDate = new Date(dateFolder)
60
+
61
+ if (folderDate.getTime() < cutoffTimestamp) {
62
+ const folderPath = path.join(categoryPath, dateFolder)
63
+
64
+ // Remove folder and contents
65
+ fs.rmSync(folderPath, { recursive: true, force: true })
66
+ deleted.push(`${cat}/${dateFolder}`)
67
+ }
68
+ } catch (err) {
69
+ errors.push(`${cat}/${dateFolder}: ${err.message}`)
70
+ }
71
+ }
72
+ }
73
+ } catch (err) {
74
+ errors.push(`Cleanup error: ${err.message}`)
75
+ }
76
+
77
+ return { deleted, errors }
78
+ }
79
+
80
+ module.exports = {
81
+ cleanOldLogs
82
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Session Logger Configuration & Constants
3
+ * Global defaults (override via environment variables)
4
+ *
5
+ * @module @onezlinks/session-logger/constants
6
+ */
7
+
8
+ /**
9
+ * Configuration object with lazy environment variable evaluation
10
+ * Values are read from env at access time for testability
11
+ */
12
+ const CONFIG = {
13
+ get LOG_BASE_DIR() {
14
+ return process.env.SESSION_LOG_DIR || 'logs'
15
+ },
16
+
17
+ get LOG_RETENTION_DAYS() {
18
+ return parseInt(process.env.SESSION_LOG_RETENTION_DAYS || '30', 10)
19
+ },
20
+
21
+ get ENABLE_FILE() {
22
+ return process.env.SESSION_LOG_TO_FILE !== 'false'
23
+ },
24
+
25
+ get ENABLE_STDOUT() {
26
+ return process.env.SESSION_LOG_TO_STDOUT !== 'false'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Common tag suggestions (reference only — not enforced)
32
+ * แต่ละ use case สามารถกำหนด custom tags ได้เอง
33
+ */
34
+ const COMMON_TAGS = {
35
+ // AI Pipeline
36
+ PIPELINE: 'Pipeline',
37
+ QUALITY: 'Quality',
38
+ OCR: 'OCR',
39
+ OPENAI: 'OpenAI',
40
+ CLIENT: 'Client',
41
+
42
+ // Reward Redemption
43
+ REDEMPTION: 'Redemption',
44
+ POINTS: 'Points',
45
+ VALIDATION: 'Validation',
46
+ NOTIFICATION: 'Notification',
47
+
48
+ // Survey
49
+ SURVEY: 'Survey',
50
+ STORAGE: 'Storage',
51
+ ANALYTICS: 'Analytics',
52
+
53
+ // Order Processing
54
+ ORDER: 'Order',
55
+ PAYMENT: 'Payment',
56
+ INVENTORY: 'Inventory',
57
+ SHIPPING: 'Shipping',
58
+
59
+ // Messaging
60
+ MESSAGE: 'Message',
61
+ WEBHOOK: 'Webhook',
62
+ RESPONSE: 'Response',
63
+ BROADCAST: 'Broadcast',
64
+
65
+ // Generic
66
+ DATABASE: 'Database',
67
+ CACHE: 'Cache',
68
+ WORKER: 'Worker'
69
+ }
70
+
71
+ module.exports = {
72
+ CONFIG,
73
+ COMMON_TAGS
74
+ }
package/src/context.js ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Session Logger Context Management
3
+ * AsyncLocalStorage-based context with Hybrid API (Wrapper + Manual patterns)
4
+ *
5
+ * @module @onezlinks/session-logger/context
6
+ */
7
+
8
+ const { AsyncLocalStorage } = require('async_hooks')
9
+ const { createFileLogger } = require('./file-logger')
10
+ const { formatCost, formatDuration, formatMetric } = require('./formatter')
11
+ const { CONFIG } = require('./constants')
12
+
13
+ // Global AsyncLocalStorage instance (singleton per process)
14
+ const sessionContext = new AsyncLocalStorage()
15
+
16
+ /**
17
+ * Initialize session (shared by both patterns)
18
+ * @private
19
+ */
20
+ function initializeSession(options) {
21
+ const {
22
+ sessionId,
23
+ category,
24
+ metadata = {},
25
+ logDir,
26
+ enableFile = CONFIG.ENABLE_FILE,
27
+ enableStdout = CONFIG.ENABLE_STDOUT
28
+ } = options
29
+
30
+ // Create unique file identifier: {sessionId_first7}_{ISO8601_compact}_{random3digit}
31
+ const sessionIdShort = sessionId.substring(0, 7)
32
+ const timestamp = new Date()
33
+ .toISOString()
34
+ .replace(/[-:]/g, '')
35
+ .replace(/\.\d{3}Z$/, '')
36
+ const random = Math.floor(Math.random() * 1000)
37
+ .toString()
38
+ .padStart(3, '0')
39
+ const fileId = `${sessionIdShort}_${timestamp}_${random}`
40
+
41
+ // Create file logger
42
+ const fileLogger = enableFile ? createFileLogger(category, fileId, { baseDir: logDir }) : null
43
+
44
+ // Create store
45
+ const store = {
46
+ sessionId,
47
+ category,
48
+ metadata,
49
+ fileConsole: fileLogger?.fileConsole || null,
50
+ logPath: fileLogger?.logPath || null,
51
+ errLogPath: fileLogger?.errLogPath || null,
52
+ startTime: Date.now(),
53
+ stats: { logs: 0, errors: 0, warnings: 0 },
54
+ _fileLogger: fileLogger, // For cleanup
55
+ _enableStdout: enableStdout
56
+ }
57
+
58
+ // Write header
59
+ if (fileLogger) {
60
+ writeHeader(store)
61
+ }
62
+
63
+ return store
64
+ }
65
+
66
+ /**
67
+ * Write session header
68
+ * @private
69
+ */
70
+ function writeHeader(store) {
71
+ const { fileConsole, category, sessionId, metadata } = store
72
+ if (!fileConsole) return
73
+
74
+ const headerLines = [
75
+ '═══════════════════════════════════════════════════════════════',
76
+ ` ${category
77
+ .split('-')
78
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
79
+ .join(' ')} Session`,
80
+ '═══════════════════════════════════════════════════════════════',
81
+ ` Session ID : ${sessionId}`,
82
+ ` Category : ${category}`
83
+ ]
84
+
85
+ // Add metadata fields
86
+ Object.entries(metadata).forEach(([key, value]) => {
87
+ const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')
88
+ headerLines.push(` ${label.padEnd(12)}: ${value}`)
89
+ })
90
+
91
+ headerLines.push(` Started : ${new Date().toISOString()}`)
92
+ headerLines.push('═══════════════════════════════════════════════════════════════')
93
+ headerLines.push('') // Empty line after header
94
+
95
+ fileConsole.log(headerLines.join('\n'))
96
+ }
97
+
98
+ /**
99
+ * Finalize session and write summary
100
+ * @private
101
+ */
102
+ async function finalizeSession(store) {
103
+ if (!store) return
104
+
105
+ const { fileConsole, _fileLogger, startTime, stats } = store
106
+ const duration = Date.now() - startTime
107
+
108
+ // Write summary
109
+ if (fileConsole) {
110
+ const summaryLines = [
111
+ '',
112
+ '═══════════════════════════════════════════════════════════════',
113
+ ' Session Summary',
114
+ '═══════════════════════════════════════════════════════════════',
115
+ ' Status : SUCCESS',
116
+ ` Duration : ${formatDuration(duration)}`,
117
+ ` Log Stats : ${stats.logs} info, ${stats.errors} errors, ${stats.warnings} warnings`,
118
+ '═══════════════════════════════════════════════════════════════'
119
+ ]
120
+
121
+ fileConsole.log(summaryLines.join('\n'))
122
+ }
123
+
124
+ // Close file streams
125
+ if (_fileLogger && typeof _fileLogger.close === 'function') {
126
+ _fileLogger.close()
127
+ }
128
+ }
129
+
130
+ // ═══════════════════════════════════════════════════════════════
131
+ // PUBLIC API — Wrapper Pattern (Recommended)
132
+ // ═══════════════════════════════════════════════════════════════
133
+
134
+ /**
135
+ * Run async function within session logger context (WRAPPER PATTERN)
136
+ * @param {Object} options - Session configuration
137
+ * @param {string} options.sessionId - Unique session identifier
138
+ * @param {string} options.category - Log category/subdirectory
139
+ * @param {Object} [options.metadata={}] - Additional metadata for header
140
+ * @param {string} [options.logDir] - Override base log directory
141
+ * @param {boolean} [options.enableFile=true] - Enable file logging
142
+ * @param {boolean} [options.enableStdout=true] - Enable stdout output
143
+ * @param {Function} asyncFn - Async function to execute within context
144
+ * @returns {Promise<*>} Return value of asyncFn
145
+ */
146
+ async function withSession(options, asyncFn) {
147
+ const store = initializeSession(options)
148
+
149
+ try {
150
+ return await sessionContext.run(store, async () => {
151
+ return await asyncFn()
152
+ })
153
+ } finally {
154
+ await finalizeSession(store)
155
+ }
156
+ }
157
+
158
+ // ═══════════════════════════════════════════════════════════════
159
+ // PUBLIC API — Manual Pattern (Advanced)
160
+ // ═══════════════════════════════════════════════════════════════
161
+
162
+ /**
163
+ * Start a session logger context (MANUAL PATTERN)
164
+ * ⚠️ Must call endSession() in finally block
165
+ * @param {Object} options - Session configuration
166
+ * @param {string} options.sessionId - Unique session identifier
167
+ * @param {string} options.category - Log category/subdirectory
168
+ * @param {Object} [options.metadata={}] - Additional metadata for header
169
+ * @param {string} [options.logDir] - Override base log directory
170
+ * @param {boolean} [options.enableFile=true] - Enable file logging
171
+ * @param {boolean} [options.enableStdout=true] - Enable stdout output
172
+ */
173
+ function startSession(options) {
174
+ const store = initializeSession(options)
175
+ sessionContext.enterWith(store)
176
+ }
177
+
178
+ /**
179
+ * End current session logger context (MANUAL PATTERN)
180
+ * ⚠️ Must be called in finally block after startSession()
181
+ * @returns {Promise<void>}
182
+ */
183
+ async function endSession() {
184
+ const store = sessionContext.getStore()
185
+ if (store) {
186
+ await finalizeSession(store)
187
+ }
188
+ }
189
+
190
+ // ═══════════════════════════════════════════════════════════════
191
+ // PUBLIC API — Context Inspection
192
+ // ═══════════════════════════════════════════════════════════════
193
+
194
+ /**
195
+ * Get current session context
196
+ * @returns {Object|null} Current session context or null
197
+ */
198
+ function getSessionContext() {
199
+ return sessionContext.getStore() || null
200
+ }
201
+
202
+ // ═══════════════════════════════════════════════════════════════
203
+ // PUBLIC API — Logging Functions
204
+ // ═══════════════════════════════════════════════════════════════
205
+
206
+ /**
207
+ * Log info message
208
+ * @param {string} tag - Module/component tag
209
+ * @param {string} message - Log message
210
+ * @param {...*} args - Additional arguments
211
+ */
212
+ function log(tag, message, ...args) {
213
+ const store = sessionContext.getStore()
214
+ const timestamp = new Date().toISOString().substring(11, 23) // HH:mm:ss.SSS
215
+ const formatted = `[${timestamp}] [${tag.padEnd(12)}] ${message}`
216
+
217
+ // Write to file if in context
218
+ if (store?.fileConsole) {
219
+ store.fileConsole.log(formatted, ...args)
220
+ store.stats.logs++
221
+ }
222
+
223
+ // Always write to stdout (dual output)
224
+ if (!store || store._enableStdout) {
225
+ console.log(formatted, ...args)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Alias for log() — semantic clarity
231
+ */
232
+ const info = log
233
+
234
+ /**
235
+ * Log error message
236
+ * @param {string} tag - Module/component tag
237
+ * @param {string} message - Error message
238
+ * @param {...*} args - Additional arguments
239
+ */
240
+ function error(tag, message, ...args) {
241
+ const store = sessionContext.getStore()
242
+ const timestamp = new Date().toISOString().substring(11, 23)
243
+ const formatted = `[${timestamp}] [${tag.padEnd(12)}] ${message}`
244
+
245
+ // Write to error file if in context
246
+ if (store?.fileConsole) {
247
+ store.fileConsole.error(formatted, ...args)
248
+ store.stats.errors++
249
+ }
250
+
251
+ // Always write to stderr (dual output)
252
+ if (!store || store._enableStdout) {
253
+ console.error(formatted, ...args)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Log warning message
259
+ * @param {string} tag - Module/component tag
260
+ * @param {string} message - Warning message
261
+ * @param {...*} args - Additional arguments
262
+ */
263
+ function warn(tag, message, ...args) {
264
+ const store = sessionContext.getStore()
265
+ const timestamp = new Date().toISOString().substring(11, 23)
266
+ const formatted = `[${timestamp}] [${tag.padEnd(12)}] ${message}`
267
+
268
+ // Write to file if in context
269
+ if (store?.fileConsole) {
270
+ store.fileConsole.log(formatted, ...args)
271
+ store.stats.warnings++
272
+ }
273
+
274
+ // Always write to stdout (dual output)
275
+ if (!store || store._enableStdout) {
276
+ console.warn(formatted, ...args)
277
+ }
278
+ }
279
+
280
+ // ═══════════════════════════════════════════════════════════════
281
+ // PUBLIC API — Special Formatters
282
+ // ═══════════════════════════════════════════════════════════════
283
+
284
+ /**
285
+ * Log cost/billing data with consistent formatting
286
+ * @param {string} tag - Module tag
287
+ * @param {Object} costData - Cost data
288
+ * @param {number} costData.totalUSD - Total cost in USD
289
+ * @param {number} costData.totalTHB - Total cost in THB
290
+ * @param {number} [inputUnits=0] - Input units
291
+ * @param {number} [outputUnits=0] - Output units
292
+ * @param {number} [cachedUnits=0] - Cached units
293
+ */
294
+ function logCost(tag, costData, inputUnits = 0, outputUnits = 0, cachedUnits = 0) {
295
+ const formatted = formatCost(costData, inputUnits, outputUnits, cachedUnits)
296
+ log(tag, formatted)
297
+ }
298
+
299
+ /**
300
+ * Log duration/performance metrics
301
+ * @param {string} tag - Module tag
302
+ * @param {number} durationMs - Duration in milliseconds
303
+ * @param {string} [label='Duration'] - Metric label
304
+ */
305
+ function logDuration(tag, durationMs, label = 'Duration') {
306
+ const formatted = `⏱️ ${label}: ${formatDuration(durationMs)}`
307
+ log(tag, formatted)
308
+ }
309
+
310
+ /**
311
+ * Log custom metrics
312
+ * @param {string} tag - Module tag
313
+ * @param {string} metricName - Metric name
314
+ * @param {number|string} value - Metric value
315
+ * @param {string} [unit] - Optional unit
316
+ */
317
+ function logMetric(tag, metricName, value, unit) {
318
+ const formatted = formatMetric(metricName, value, unit)
319
+ log(tag, formatted)
320
+ }
321
+
322
+ module.exports = {
323
+ // Wrapper Pattern (Recommended)
324
+ withSession,
325
+
326
+ // Manual Pattern (Advanced)
327
+ startSession,
328
+ endSession,
329
+
330
+ // Context Inspection
331
+ getSessionContext,
332
+
333
+ // Logging Functions
334
+ log,
335
+ info,
336
+ error,
337
+ warn,
338
+
339
+ // Special Formatters
340
+ logCost,
341
+ logDuration,
342
+ logMetric
343
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * File Logger Factory
3
+ * Creates file-based console.Console instances for session logging
4
+ *
5
+ * @module @onezlinks/session-logger/file-logger
6
+ */
7
+
8
+ const { Console } = require('node:console')
9
+ const fs = require('node:fs')
10
+ const path = require('node:path')
11
+ const { CONFIG } = require('./constants')
12
+
13
+ /**
14
+ * Create a file logger for a specific session
15
+ * @param {string} category - Log category (subdirectory name)
16
+ * @param {string} fileId - Unique file identifier (e.g., sessionId_timestamp)
17
+ * @param {Object} [options={}] - Configuration options
18
+ * @param {string} [options.baseDir] - Override base log directory
19
+ * @returns {Object|null} Logger object or null if creation fails
20
+ * @returns {Console} returns.fileConsole - console.Console instance
21
+ * @returns {string} returns.logPath - Path to main log file
22
+ * @returns {string} returns.errLogPath - Path to error log file
23
+ * @returns {Function} returns.close - Function to close streams
24
+ */
25
+ function createFileLogger(category, fileId, options = {}) {
26
+ const baseDir = options.baseDir || CONFIG.LOG_BASE_DIR
27
+ const dateFolder = new Date().toISOString().split('T')[0] // YYYY-MM-DD
28
+ const dir = path.join(process.cwd(), baseDir, category, dateFolder)
29
+
30
+ // Create directory structure (recursive)
31
+ try {
32
+ fs.mkdirSync(dir, { recursive: true })
33
+ } catch (err) {
34
+ console.error('[Session Logger] Failed to create log directory:', err.message)
35
+ return null // Fallback: no file logging
36
+ }
37
+
38
+ // Create file paths
39
+ const logPath = path.join(dir, `${fileId}.log`)
40
+ const errLogPath = path.join(dir, `${fileId}.err.log`)
41
+
42
+ // Create write streams
43
+ let logStream, errStream
44
+ try {
45
+ logStream = fs.createWriteStream(logPath, { flags: 'a' })
46
+ errStream = fs.createWriteStream(errLogPath, { flags: 'a' })
47
+
48
+ // Prevent unhandled error events from crashing the process
49
+ logStream.on('error', () => {})
50
+ errStream.on('error', () => {})
51
+ } catch (err) {
52
+ console.error('[Session Logger] Failed to create write streams:', err.message)
53
+ return null // Fallback: no file logging
54
+ }
55
+
56
+ // Create console.Console instance
57
+ const fileConsole = new Console({
58
+ stdout: logStream,
59
+ stderr: errStream,
60
+ inspectOptions: { depth: 4, colors: false }
61
+ })
62
+
63
+ return {
64
+ fileConsole,
65
+ logPath,
66
+ errLogPath,
67
+ close() {
68
+ logStream.end()
69
+ errStream.end()
70
+ }
71
+ }
72
+ }
73
+
74
+ module.exports = {
75
+ createFileLogger
76
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Log Formatting Utilities
3
+ * Provides consistent formatting for cost, duration, and metrics
4
+ *
5
+ * @module @onezlinks/session-logger/formatter
6
+ */
7
+
8
+ /**
9
+ * Format cost data for consistent output
10
+ * @param {Object} costData - Cost information
11
+ * @param {number} costData.totalUSD - Total cost in USD
12
+ * @param {number} costData.totalTHB - Total cost in THB
13
+ * @param {number} [inputUnits=0] - Input units (tokens, API calls, etc.)
14
+ * @param {number} [outputUnits=0] - Output units
15
+ * @param {number} [cachedUnits=0] - Cached units
16
+ * @returns {string} Formatted cost string
17
+ */
18
+ function formatCost(costData, inputUnits = 0, outputUnits = 0, cachedUnits = 0) {
19
+ const { totalUSD, totalTHB } = costData
20
+ const usd = totalUSD.toFixed(6)
21
+ const thb = totalTHB.toFixed(4)
22
+ const units =
23
+ cachedUnits > 0 ? `${inputUnits}+${outputUnits} (cached: ${cachedUnits})` : `${inputUnits}+${outputUnits}`
24
+
25
+ return `💰 Cost: $${usd} (~฿${thb}) | Units: ${units}`
26
+ }
27
+
28
+ /**
29
+ * Format duration for readable output
30
+ * @param {number} durationMs - Duration in milliseconds
31
+ * @returns {string} Formatted duration string
32
+ */
33
+ function formatDuration(durationMs) {
34
+ if (durationMs < 1000) {
35
+ return `${durationMs}ms`
36
+ }
37
+ return `${durationMs}ms (${(durationMs / 1000).toFixed(2)}s)`
38
+ }
39
+
40
+ /**
41
+ * Format metric with optional unit
42
+ * @param {string} name - Metric name
43
+ * @param {number|string} value - Metric value
44
+ * @param {string} [unit] - Optional unit (e.g., 'items', '%', 'MB')
45
+ * @returns {string} Formatted metric string
46
+ */
47
+ function formatMetric(name, value, unit) {
48
+ return unit ? `${name}: ${value} ${unit}` : `${name}: ${value}`
49
+ }
50
+
51
+ module.exports = {
52
+ formatCost,
53
+ formatDuration,
54
+ formatMetric
55
+ }