@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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +648 -0
- package/package.json +52 -0
- package/src/cleanup.js +82 -0
- package/src/constants.js +74 -0
- package/src/context.js +343 -0
- package/src/file-logger.js +76 -0
- package/src/formatter.js +55 -0
- package/src/index.js +74 -0
- package/types/index.d.ts +208 -0
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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|
package/src/formatter.js
ADDED
|
@@ -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
|
+
}
|