@pori15/logixlysia 0.0.1
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 +49 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +8 -0
- package/package.json +45 -0
- package/src/Error/errors.ts +258 -0
- package/src/Error/type.ts +51 -0
- package/src/extensions/banner.ts +26 -0
- package/src/extensions/index.ts +28 -0
- package/src/helpers/status.ts +58 -0
- package/src/index.ts +141 -0
- package/src/interfaces.ts +136 -0
- package/src/logger/create-logger.ts +246 -0
- package/src/logger/handle-http-error.ts +62 -0
- package/src/logger/index.ts +125 -0
- package/src/output/file.ts +85 -0
- package/src/output/fs.ts +5 -0
- package/src/output/index.ts +58 -0
- package/src/output/rotation-manager.ts +122 -0
- package/src/utils/error.ts +15 -0
- package/src/utils/handle-error.ts +289 -0
- package/src/utils/rotation.ts +91 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { getStatusCode } from '../helpers/status'
|
|
3
|
+
import type {
|
|
4
|
+
LogLevel,
|
|
5
|
+
Options,
|
|
6
|
+
Pino,
|
|
7
|
+
StoreData,
|
|
8
|
+
|
|
9
|
+
} from '../interfaces'
|
|
10
|
+
|
|
11
|
+
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
|
12
|
+
const pad3 = (value: number): string => String(value).padStart(3, '0')
|
|
13
|
+
|
|
14
|
+
const shouldUseColors = (options: Options): boolean => {
|
|
15
|
+
const config = options.config
|
|
16
|
+
const enabledByConfig = config?.useColors ?? true
|
|
17
|
+
|
|
18
|
+
// Avoid ANSI sequences in non-interactive output (pipes, CI logs, files).
|
|
19
|
+
const isTty = typeof process !== 'undefined' && process.stdout?.isTTY === true
|
|
20
|
+
return enabledByConfig && isTty
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const formatTimestamp = (date: Date, pattern?: string): string => {
|
|
24
|
+
if (!pattern) {
|
|
25
|
+
return date.toISOString()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const yyyy = String(date.getFullYear())
|
|
29
|
+
const mm = pad2(date.getMonth() + 1)
|
|
30
|
+
const dd = pad2(date.getDate())
|
|
31
|
+
const HH = pad2(date.getHours())
|
|
32
|
+
const MM = pad2(date.getMinutes())
|
|
33
|
+
const ss = pad2(date.getSeconds())
|
|
34
|
+
const SSS = pad3(date.getMilliseconds())
|
|
35
|
+
|
|
36
|
+
return pattern
|
|
37
|
+
.replaceAll('yyyy', yyyy)
|
|
38
|
+
.replaceAll('mm', mm)
|
|
39
|
+
.replaceAll('dd', dd)
|
|
40
|
+
.replaceAll('HH', HH)
|
|
41
|
+
.replaceAll('MM', MM)
|
|
42
|
+
.replaceAll('ss', ss)
|
|
43
|
+
.replaceAll('SSS', SSS)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const getIp = (request: Request): string => {
|
|
47
|
+
const forwarded = request.headers.get('x-forwarded-for')
|
|
48
|
+
if (forwarded) {
|
|
49
|
+
return forwarded.split(',')[0]?.trim() ?? ''
|
|
50
|
+
}
|
|
51
|
+
return request.headers.get('x-real-ip') ?? ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const getColoredLevel = (level: LogLevel, useColors: boolean): string => {
|
|
55
|
+
if (!useColors) {
|
|
56
|
+
return level
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (level === 'ERROR') {
|
|
60
|
+
return chalk.bgRed.black(level)
|
|
61
|
+
}
|
|
62
|
+
if (level === 'WARNING') {
|
|
63
|
+
return chalk.bgYellow.black(level)
|
|
64
|
+
}
|
|
65
|
+
if (level === 'DEBUG') {
|
|
66
|
+
return chalk.bgBlue.black(level)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return chalk.bgGreen.black(level)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const getColoredMethod = (method: string, useColors: boolean): string => {
|
|
73
|
+
if (!useColors) {
|
|
74
|
+
return method
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const upper = method.toUpperCase()
|
|
78
|
+
if (upper === 'GET') {
|
|
79
|
+
return chalk.green.bold(upper)
|
|
80
|
+
}
|
|
81
|
+
if (upper === 'POST') {
|
|
82
|
+
return chalk.blue.bold(upper)
|
|
83
|
+
}
|
|
84
|
+
if (upper === 'PUT') {
|
|
85
|
+
return chalk.yellow.bold(upper)
|
|
86
|
+
}
|
|
87
|
+
if (upper === 'PATCH') {
|
|
88
|
+
return chalk.yellowBright.bold(upper)
|
|
89
|
+
}
|
|
90
|
+
if (upper === 'DELETE') {
|
|
91
|
+
return chalk.red.bold(upper)
|
|
92
|
+
}
|
|
93
|
+
if (upper === 'OPTIONS') {
|
|
94
|
+
return chalk.cyan.bold(upper)
|
|
95
|
+
}
|
|
96
|
+
if (upper === 'HEAD') {
|
|
97
|
+
return chalk.greenBright.bold(upper)
|
|
98
|
+
}
|
|
99
|
+
if (upper === 'TRACE') {
|
|
100
|
+
return chalk.magenta.bold(upper)
|
|
101
|
+
}
|
|
102
|
+
if (upper === 'CONNECT') {
|
|
103
|
+
return chalk.cyanBright.bold(upper)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return chalk.white.bold(upper)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const getColoredStatus = (status: string, useColors: boolean): string => {
|
|
110
|
+
if (!useColors) {
|
|
111
|
+
return status
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const numeric = Number.parseInt(status, 10)
|
|
115
|
+
if (!Number.isFinite(numeric)) {
|
|
116
|
+
return status
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (numeric >= 500) {
|
|
120
|
+
return chalk.red(status)
|
|
121
|
+
}
|
|
122
|
+
if (numeric >= 400) {
|
|
123
|
+
return chalk.yellow(status)
|
|
124
|
+
}
|
|
125
|
+
if (numeric >= 300) {
|
|
126
|
+
return chalk.cyan(status)
|
|
127
|
+
}
|
|
128
|
+
if (numeric >= 200) {
|
|
129
|
+
return chalk.green(status)
|
|
130
|
+
}
|
|
131
|
+
return chalk.gray(status)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const getColoredDuration = (duration: string, useColors: boolean): string => {
|
|
135
|
+
if (!useColors) {
|
|
136
|
+
return duration
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return chalk.gray(duration)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const getColoredTimestamp = (timestamp: string, useColors: boolean): string => {
|
|
143
|
+
if (!useColors) {
|
|
144
|
+
return timestamp
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return chalk.bgHex('#FFA500').black(timestamp)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const getColoredPathname = (pathname: string, useColors: boolean): string => {
|
|
151
|
+
if (!useColors) {
|
|
152
|
+
return pathname
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return chalk.whiteBright(pathname)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const getContextString = (value: unknown): string => {
|
|
159
|
+
if (typeof value === 'object' && value !== null) {
|
|
160
|
+
return JSON.stringify(value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return ''
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const formatLine = ({
|
|
167
|
+
level,
|
|
168
|
+
request,
|
|
169
|
+
data,
|
|
170
|
+
store,
|
|
171
|
+
options
|
|
172
|
+
}: {
|
|
173
|
+
level: LogLevel
|
|
174
|
+
request: Request
|
|
175
|
+
data: Record<string, unknown>
|
|
176
|
+
store: StoreData
|
|
177
|
+
options: Options
|
|
178
|
+
}): string => {
|
|
179
|
+
const config = options.config
|
|
180
|
+
const useColors = shouldUseColors(options)
|
|
181
|
+
const format =
|
|
182
|
+
config?.customLogFormat ??
|
|
183
|
+
'🦊 {now} {level} {duration} {method} {pathname} {status} {message} {ip} {context}'
|
|
184
|
+
|
|
185
|
+
const now = new Date()
|
|
186
|
+
const epoch = String(now.getTime())
|
|
187
|
+
const rawTimestamp = formatTimestamp(now, config?.timestamp?.translateTime)
|
|
188
|
+
const timestamp = getColoredTimestamp(rawTimestamp, useColors)
|
|
189
|
+
|
|
190
|
+
const message = typeof data.message === 'string' ? data.message : ''
|
|
191
|
+
const durationMs =
|
|
192
|
+
store.beforeTime === BigInt(0)
|
|
193
|
+
? 0
|
|
194
|
+
: Number(process.hrtime.bigint() - store.beforeTime) / 1_000_000
|
|
195
|
+
|
|
196
|
+
const pathname = new URL(request.url).pathname
|
|
197
|
+
const statusValue = data.status
|
|
198
|
+
const statusCode =
|
|
199
|
+
statusValue === null || statusValue === undefined
|
|
200
|
+
? 200
|
|
201
|
+
: getStatusCode(statusValue)
|
|
202
|
+
const status = String(statusCode)
|
|
203
|
+
const ip = config?.ip === true ? getIp(request) : ''
|
|
204
|
+
const ctxString = getContextString(data.context)
|
|
205
|
+
const coloredLevel = getColoredLevel(level, useColors)
|
|
206
|
+
const coloredMethod = getColoredMethod(request.method, useColors)
|
|
207
|
+
const coloredPathname = getColoredPathname(pathname, useColors)
|
|
208
|
+
const coloredStatus = getColoredStatus(status, useColors)
|
|
209
|
+
const coloredDuration = getColoredDuration(
|
|
210
|
+
`${durationMs.toFixed(2)}ms`,
|
|
211
|
+
useColors
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return format
|
|
215
|
+
.replaceAll('{now}', timestamp)
|
|
216
|
+
.replaceAll('{epoch}', epoch)
|
|
217
|
+
.replaceAll('{level}', coloredLevel)
|
|
218
|
+
.replaceAll('{duration}', coloredDuration)
|
|
219
|
+
.replaceAll('{method}', coloredMethod)
|
|
220
|
+
.replaceAll('{pathname}', coloredPathname)
|
|
221
|
+
.replaceAll('{path}', coloredPathname)
|
|
222
|
+
.replaceAll('{status}', coloredStatus)
|
|
223
|
+
.replaceAll('{message}', message)
|
|
224
|
+
.replaceAll('{ip}', ip)
|
|
225
|
+
.replaceAll('{context}', ctxString)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const logWithPino = (
|
|
229
|
+
logger: Pino,
|
|
230
|
+
level: LogLevel,
|
|
231
|
+
data: Record<string, unknown>
|
|
232
|
+
): void => {
|
|
233
|
+
if (level === 'ERROR') {
|
|
234
|
+
logger.error(data)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (level === 'WARNING') {
|
|
238
|
+
logger.warn(data)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (level === 'DEBUG') {
|
|
242
|
+
logger.debug(data)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
logger.info(data)
|
|
246
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ProblemError } from '../Error/errors'
|
|
2
|
+
import type { LogLevel, Options, StoreData } from '../interfaces'
|
|
3
|
+
import { logToTransports } from '../output'
|
|
4
|
+
import { logToFile } from '../output/file'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const handleHttpError = (
|
|
8
|
+
request: Request,
|
|
9
|
+
problem: ProblemError,
|
|
10
|
+
store: StoreData,
|
|
11
|
+
options: Options
|
|
12
|
+
): void => {
|
|
13
|
+
const config = options.config
|
|
14
|
+
// 1. 准备日志数据:将 RFC 标准字段与日志元数据合并
|
|
15
|
+
const level: LogLevel = 'ERROR';
|
|
16
|
+
const rfcData = problem.toJSON();
|
|
17
|
+
const data = {
|
|
18
|
+
status: problem.status,
|
|
19
|
+
message: problem.detail || problem.title,
|
|
20
|
+
...rfcData
|
|
21
|
+
};
|
|
22
|
+
// 2. 阶段:传输层 (Transports)
|
|
23
|
+
logToTransports({ level, request, data, store, options });
|
|
24
|
+
// 3. 阶段:持久化 (File Logging)
|
|
25
|
+
// 匹配你的接口:useTransportsOnly 和 disableFileLogging 直接在 config 下
|
|
26
|
+
if (!(config?.useTransportsOnly || config?.disableFileLogging)) {
|
|
27
|
+
const filePath = config?.logFilePath;
|
|
28
|
+
if (filePath) {
|
|
29
|
+
logToFile({ filePath, level, request, data, store, options }).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 4. 阶段:控制台输出 (Console/Internal)
|
|
34
|
+
if (config?.useTransportsOnly || config?.disableInternalLogger) return;
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// 处理时间戳显示逻辑
|
|
38
|
+
let timestamp = '';
|
|
39
|
+
if (config?.timestamp) {
|
|
40
|
+
timestamp = `[${new Date().toISOString()}] `;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 1. 安全提取 Method 和 Path
|
|
44
|
+
const method = typeof request === 'string' ? 'REQ' : request.method;
|
|
45
|
+
const urlString = typeof request === 'string' ? request : request.url;
|
|
46
|
+
|
|
47
|
+
let path: string;
|
|
48
|
+
try {
|
|
49
|
+
// 如果是完整 URL 则提取 pathname,如果是相对路径则直接使用
|
|
50
|
+
path = urlString.includes('://')
|
|
51
|
+
? new URL(urlString).pathname
|
|
52
|
+
: urlString;
|
|
53
|
+
} catch {
|
|
54
|
+
path = urlString;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. 语义化终端打印
|
|
58
|
+
// 现在的代码对 string 和 Request 类型都百分之百安全了
|
|
59
|
+
console.error(
|
|
60
|
+
`${timestamp}${level} ${method} ${path} ${problem.status} - ${problem.title}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import pino from 'pino'
|
|
2
|
+
import type {
|
|
3
|
+
Logger,
|
|
4
|
+
LogLevel,
|
|
5
|
+
Options,
|
|
6
|
+
Pino,
|
|
7
|
+
StoreData
|
|
8
|
+
} from '../interfaces'
|
|
9
|
+
import { logToTransports } from '../output'
|
|
10
|
+
import { logToFile } from '../output/file'
|
|
11
|
+
import { formatLine } from './create-logger'
|
|
12
|
+
import { handleHttpError } from './handle-http-error'
|
|
13
|
+
|
|
14
|
+
export const createLogger = (options: Options = {}): Logger => {
|
|
15
|
+
const config = options.config
|
|
16
|
+
|
|
17
|
+
const pinoConfig = config?.pino
|
|
18
|
+
const { prettyPrint, ...pinoOptions } = pinoConfig ?? {}
|
|
19
|
+
|
|
20
|
+
const shouldPrettyPrint =
|
|
21
|
+
prettyPrint === true && pinoOptions.transport === undefined
|
|
22
|
+
|
|
23
|
+
const transport = shouldPrettyPrint
|
|
24
|
+
? pino.transport({
|
|
25
|
+
target: 'pino-pretty',
|
|
26
|
+
options: {
|
|
27
|
+
colorize: process.stdout?.isTTY === true,
|
|
28
|
+
translateTime: config?.timestamp?.translateTime,
|
|
29
|
+
messageKey: pinoOptions.messageKey,
|
|
30
|
+
errorKey: pinoOptions.errorKey
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
: pinoOptions.transport
|
|
34
|
+
|
|
35
|
+
const pinoLogger: Pino = pino({
|
|
36
|
+
...pinoOptions,
|
|
37
|
+
level: pinoOptions.level ?? 'info',
|
|
38
|
+
messageKey: pinoOptions.messageKey,
|
|
39
|
+
errorKey: pinoOptions.errorKey,
|
|
40
|
+
transport
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const log = (
|
|
44
|
+
level: LogLevel,
|
|
45
|
+
request: Request,
|
|
46
|
+
data: Record<string, unknown>,
|
|
47
|
+
store: StoreData
|
|
48
|
+
): void => {
|
|
49
|
+
logToTransports({ level, request, data, store, options })
|
|
50
|
+
|
|
51
|
+
const useTransportsOnly = config?.useTransportsOnly === true
|
|
52
|
+
const disableInternalLogger = config?.disableInternalLogger === true
|
|
53
|
+
const disableFileLogging = config?.disableFileLogging === true
|
|
54
|
+
|
|
55
|
+
if (!(useTransportsOnly || disableFileLogging)) {
|
|
56
|
+
const filePath = config?.logFilePath
|
|
57
|
+
if (filePath) {
|
|
58
|
+
logToFile({ filePath, level, request, data, store, options }).catch(
|
|
59
|
+
() => {
|
|
60
|
+
// Ignore errors
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (useTransportsOnly || disableInternalLogger) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const message = formatLine({ level, request, data, store, options })
|
|
71
|
+
|
|
72
|
+
switch (level) {
|
|
73
|
+
case 'DEBUG': {
|
|
74
|
+
console.debug(message)
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
case 'INFO': {
|
|
78
|
+
console.info(message)
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
case 'WARNING': {
|
|
82
|
+
console.warn(message)
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
case 'ERROR': {
|
|
86
|
+
console.error(message)
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
default: {
|
|
90
|
+
console.log(message)
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const logWithContext = (
|
|
97
|
+
level: LogLevel,
|
|
98
|
+
request: Request,
|
|
99
|
+
message: string,
|
|
100
|
+
context?: Record<string, unknown>
|
|
101
|
+
): void => {
|
|
102
|
+
const store: StoreData = { beforeTime: process.hrtime.bigint() }
|
|
103
|
+
log(level, request, { message, context }, store)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
pino: pinoLogger,
|
|
108
|
+
log,
|
|
109
|
+
handleHttpError: (request, error, store) => {
|
|
110
|
+
handleHttpError(request, error, store, options)
|
|
111
|
+
},
|
|
112
|
+
debug: (request, message, context) => {
|
|
113
|
+
logWithContext('DEBUG', request, message, context)
|
|
114
|
+
},
|
|
115
|
+
info: (request, message, context) => {
|
|
116
|
+
logWithContext('INFO', request, message, context)
|
|
117
|
+
},
|
|
118
|
+
warn: (request, message, context) => {
|
|
119
|
+
logWithContext('WARNING', request, message, context)
|
|
120
|
+
},
|
|
121
|
+
error: (request, message, context) => {
|
|
122
|
+
logWithContext('ERROR', request, message, context)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import type { LogLevel, Options, RequestInfo, StoreData } from '../interfaces'
|
|
4
|
+
import { ensureDir } from './fs'
|
|
5
|
+
import { performRotation, shouldRotate } from './rotation-manager'
|
|
6
|
+
|
|
7
|
+
interface LogToFileInput {
|
|
8
|
+
filePath: string
|
|
9
|
+
level: LogLevel
|
|
10
|
+
request: RequestInfo
|
|
11
|
+
data: Record<string, unknown>
|
|
12
|
+
store: StoreData
|
|
13
|
+
options: Options
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const logToFile = async (
|
|
17
|
+
...args:
|
|
18
|
+
| [LogToFileInput]
|
|
19
|
+
| [
|
|
20
|
+
string,
|
|
21
|
+
LogLevel,
|
|
22
|
+
RequestInfo,
|
|
23
|
+
Record<string, unknown>,
|
|
24
|
+
StoreData,
|
|
25
|
+
Options
|
|
26
|
+
]
|
|
27
|
+
): Promise<void> => {
|
|
28
|
+
const input: LogToFileInput =
|
|
29
|
+
typeof args[0] === 'string'
|
|
30
|
+
? (() => {
|
|
31
|
+
const [
|
|
32
|
+
filePathArg,
|
|
33
|
+
levelArg,
|
|
34
|
+
requestArg,
|
|
35
|
+
dataArg,
|
|
36
|
+
storeArg,
|
|
37
|
+
optionsArg
|
|
38
|
+
] = args as [
|
|
39
|
+
string,
|
|
40
|
+
LogLevel,
|
|
41
|
+
RequestInfo,
|
|
42
|
+
Record<string, unknown>,
|
|
43
|
+
StoreData,
|
|
44
|
+
Options
|
|
45
|
+
]
|
|
46
|
+
return {
|
|
47
|
+
filePath: filePathArg,
|
|
48
|
+
level: levelArg,
|
|
49
|
+
request: requestArg,
|
|
50
|
+
data: dataArg,
|
|
51
|
+
store: storeArg,
|
|
52
|
+
options: optionsArg
|
|
53
|
+
}
|
|
54
|
+
})()
|
|
55
|
+
: args[0]
|
|
56
|
+
|
|
57
|
+
const { filePath, level, request, data, store, options } = input
|
|
58
|
+
const config = options.config
|
|
59
|
+
const useTransportsOnly = config?.useTransportsOnly === true
|
|
60
|
+
const disableFileLogging = config?.disableFileLogging === true
|
|
61
|
+
if (useTransportsOnly || disableFileLogging) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const message = typeof data.message === 'string' ? data.message : ''
|
|
66
|
+
const durationMs =
|
|
67
|
+
store.beforeTime === BigInt(0)
|
|
68
|
+
? 0
|
|
69
|
+
: Number(process.hrtime.bigint() - store.beforeTime) / 1_000_000
|
|
70
|
+
|
|
71
|
+
const line = `${level} ${durationMs.toFixed(2)}ms ${request.method} ${new URL(request.url).pathname} ${message}\n`
|
|
72
|
+
|
|
73
|
+
await ensureDir(dirname(filePath))
|
|
74
|
+
await appendFile(filePath, line, { encoding: 'utf-8' })
|
|
75
|
+
|
|
76
|
+
const rotation = config?.logRotation
|
|
77
|
+
if (!rotation) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const should = await shouldRotate(filePath, rotation)
|
|
82
|
+
if (should) {
|
|
83
|
+
await performRotation(filePath, rotation)
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/output/fs.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LogLevel, Options, Request, StoreData } from '../interfaces'
|
|
2
|
+
|
|
3
|
+
interface LogToTransportsInput {
|
|
4
|
+
level: LogLevel
|
|
5
|
+
request: Request
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
store: StoreData
|
|
8
|
+
options: Options
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const logToTransports = (
|
|
12
|
+
...args:
|
|
13
|
+
| [LogToTransportsInput]
|
|
14
|
+
| [LogLevel, Request, Record<string, unknown>, StoreData, Options]
|
|
15
|
+
): void => {
|
|
16
|
+
const input: LogToTransportsInput =
|
|
17
|
+
typeof args[0] === 'string'
|
|
18
|
+
? {
|
|
19
|
+
level: args[0],
|
|
20
|
+
request: args[1],
|
|
21
|
+
data: args[2],
|
|
22
|
+
store: args[3],
|
|
23
|
+
options: args[4]
|
|
24
|
+
}
|
|
25
|
+
: args[0]
|
|
26
|
+
|
|
27
|
+
const { level, request, data, store, options } = input
|
|
28
|
+
const transports = options.config?.transports ?? []
|
|
29
|
+
if (transports.length === 0) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const message = typeof data.message === 'string' ? data.message : ''
|
|
34
|
+
const meta: Record<string, unknown> = {
|
|
35
|
+
request: {
|
|
36
|
+
method: request.method,
|
|
37
|
+
url: request.url
|
|
38
|
+
},
|
|
39
|
+
...data,
|
|
40
|
+
beforeTime: store.beforeTime
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const transport of transports) {
|
|
44
|
+
try {
|
|
45
|
+
const result = transport.log(level, message, meta)
|
|
46
|
+
if (
|
|
47
|
+
result &&
|
|
48
|
+
typeof (result as { catch?: unknown }).catch === 'function'
|
|
49
|
+
) {
|
|
50
|
+
;(result as Promise<void>).catch(() => {
|
|
51
|
+
// Ignore errors
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Transport failures must never crash application logging.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { gzip } from 'node:zlib'
|
|
4
|
+
import type { LogRotationConfig } from '../interfaces'
|
|
5
|
+
import {
|
|
6
|
+
getRotatedFiles,
|
|
7
|
+
parseRetention,
|
|
8
|
+
parseSize,
|
|
9
|
+
shouldRotateBySize
|
|
10
|
+
} from '../utils/rotation'
|
|
11
|
+
|
|
12
|
+
const gzipAsync = promisify(gzip)
|
|
13
|
+
|
|
14
|
+
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
|
15
|
+
|
|
16
|
+
export const getRotatedFileName = (filePath: string, date: Date): string => {
|
|
17
|
+
const yyyy = date.getFullYear()
|
|
18
|
+
const mm = pad2(date.getMonth() + 1)
|
|
19
|
+
const dd = pad2(date.getDate())
|
|
20
|
+
const HH = pad2(date.getHours())
|
|
21
|
+
const MM = pad2(date.getMinutes())
|
|
22
|
+
const ss = pad2(date.getSeconds())
|
|
23
|
+
return `${filePath}.${yyyy}-${mm}-${dd}-${HH}-${MM}-${ss}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const rotateFile = async (filePath: string): Promise<string> => {
|
|
27
|
+
try {
|
|
28
|
+
const stat = await fs.stat(filePath)
|
|
29
|
+
if (stat.size === 0) {
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rotated = getRotatedFileName(filePath, new Date())
|
|
37
|
+
await fs.rename(filePath, rotated)
|
|
38
|
+
return rotated
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const compressFile = async (filePath: string): Promise<void> => {
|
|
42
|
+
const content = await fs.readFile(filePath)
|
|
43
|
+
const compressed = await gzipAsync(content)
|
|
44
|
+
await fs.writeFile(`${filePath}.gz`, compressed)
|
|
45
|
+
await fs.rm(filePath, { force: true })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const shouldRotate = async (
|
|
49
|
+
filePath: string,
|
|
50
|
+
config: LogRotationConfig
|
|
51
|
+
): Promise<boolean> => {
|
|
52
|
+
if (config.maxSize === undefined) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
const maxSize = parseSize(config.maxSize)
|
|
56
|
+
return await shouldRotateBySize(filePath, maxSize)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cleanupByCount = async (
|
|
60
|
+
filePath: string,
|
|
61
|
+
maxFiles: number
|
|
62
|
+
): Promise<void> => {
|
|
63
|
+
const rotated = await getRotatedFiles(filePath)
|
|
64
|
+
if (rotated.length <= maxFiles) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const stats = await Promise.all(
|
|
69
|
+
rotated.map(async p => ({ path: p, stat: await fs.stat(p) }))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
stats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
|
|
73
|
+
const toDelete = stats.slice(maxFiles)
|
|
74
|
+
await Promise.all(toDelete.map(({ path }) => fs.rm(path, { force: true })))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cleanupByTime = async (
|
|
78
|
+
filePath: string,
|
|
79
|
+
maxAgeMs: number
|
|
80
|
+
): Promise<void> => {
|
|
81
|
+
const rotated = await getRotatedFiles(filePath)
|
|
82
|
+
if (rotated.length === 0) {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
const stats = await Promise.all(
|
|
88
|
+
rotated.map(async p => ({ path: p, stat: await fs.stat(p) }))
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const toDelete = stats.filter(({ stat }) => now - stat.mtimeMs > maxAgeMs)
|
|
92
|
+
await Promise.all(toDelete.map(({ path }) => fs.rm(path, { force: true })))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const performRotation = async (
|
|
96
|
+
filePath: string,
|
|
97
|
+
config: LogRotationConfig
|
|
98
|
+
): Promise<void> => {
|
|
99
|
+
const rotated = await rotateFile(filePath)
|
|
100
|
+
if (!rotated) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const shouldCompress = config.compress === true
|
|
105
|
+
if (shouldCompress) {
|
|
106
|
+
const algo = config.compression ?? 'gzip'
|
|
107
|
+
if (algo === 'gzip') {
|
|
108
|
+
await compressFile(rotated)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (config.maxFiles !== undefined) {
|
|
113
|
+
const retention = parseRetention(config.maxFiles)
|
|
114
|
+
if (retention.type === 'count') {
|
|
115
|
+
await cleanupByCount(filePath, retention.value)
|
|
116
|
+
} else {
|
|
117
|
+
await cleanupByTime(filePath, retention.value)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Optional interval-based rotation cleanup (create interval directories / naming) is not required by tests.
|
|
122
|
+
}
|