@vafast/request-logger 0.3.4 → 0.4.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/dist/index.d.mts +20 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +87 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -67,6 +67,18 @@ interface LogData {
|
|
|
67
67
|
request: RequestData;
|
|
68
68
|
response: ResponseData;
|
|
69
69
|
}
|
|
70
|
+
/** 熔断器配置 */
|
|
71
|
+
interface CircuitBreakerConfig {
|
|
72
|
+
/** 触发熔断的连续失败次数,默认 5 */
|
|
73
|
+
failureThreshold?: number;
|
|
74
|
+
/** 熔断恢复时间(毫秒),默认 60000(1分钟) */
|
|
75
|
+
resetTimeout?: number;
|
|
76
|
+
}
|
|
77
|
+
/** 错误节流配置 */
|
|
78
|
+
interface ErrorThrottleConfig {
|
|
79
|
+
/** 同类错误的节流间隔(毫秒),默认 60000(1分钟) */
|
|
80
|
+
interval?: number;
|
|
81
|
+
}
|
|
70
82
|
/** 请求日志配置 */
|
|
71
83
|
interface RequestLoggerOptions {
|
|
72
84
|
/** 日志服务 URL */
|
|
@@ -80,11 +92,17 @@ interface RequestLoggerOptions {
|
|
|
80
92
|
/** 敏感数据清洗配置 */
|
|
81
93
|
sanitize?: SanitizeConfig;
|
|
82
94
|
/** 错误回调 */
|
|
83
|
-
onError?: (error: Error
|
|
95
|
+
onError?: (error: Error, context: {
|
|
96
|
+
droppedCount: number;
|
|
97
|
+
}) => void;
|
|
84
98
|
/** 是否启用 @default true */
|
|
85
99
|
enabled?: boolean;
|
|
86
100
|
/** 排除的路径列表(精确匹配或正则),这些路径不记录日志 */
|
|
87
101
|
excludePaths?: (string | RegExp)[];
|
|
102
|
+
/** 熔断器配置 */
|
|
103
|
+
circuitBreaker?: CircuitBreakerConfig;
|
|
104
|
+
/** 错误节流配置 */
|
|
105
|
+
errorThrottle?: ErrorThrottleConfig;
|
|
88
106
|
}
|
|
89
107
|
/**
|
|
90
108
|
* 请求日志中间件
|
|
@@ -104,5 +122,5 @@ declare function requestLogger(options: RequestLoggerOptions): vafast0.TypedMidd
|
|
|
104
122
|
/** @deprecated 使用 requestLogger 代替 */
|
|
105
123
|
declare const createRequestLogger: typeof requestLogger;
|
|
106
124
|
//#endregion
|
|
107
|
-
export { LogData, RequestData, RequestLoggerOptions, ResponseData, type SanitizeConfig, createRequestLogger, requestLogger as default, requestLogger, sanitize, sanitizeHeaders };
|
|
125
|
+
export { CircuitBreakerConfig, ErrorThrottleConfig, LogData, RequestData, RequestLoggerOptions, ResponseData, type SanitizeConfig, createRequestLogger, requestLogger as default, requestLogger, sanitize, sanitizeHeaders };
|
|
108
126
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/sanitize.ts","../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AAQA;AA+DA;AAAkC,UA/DjB,cAAA,CA+DiB;EAAY;EAA4B,YAAA,CAAA,EAAA,MAAA,EAAA;EAAC;EA+D3D,UAAA,CAAA,EAAA,MAAe,EAAA;EACpB;EACA,WAAA,CAAA,EAAA,MAAA;EACR;EAAM,QAAA,CAAA,EAAA,MAAA;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/sanitize.ts","../src/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AAQA;AA+DA;AAAkC,UA/DjB,cAAA,CA+DiB;EAAY;EAA4B,YAAA,CAAA,EAAA,MAAA,EAAA;EAAC;EA+D3D,UAAA,CAAA,EAAA,MAAe,EAAA;EACpB;EACA,WAAA,CAAA,EAAA,MAAA;EACR;EAAM,QAAA,CAAA,EAAA,MAAA;;;;AC7GT;;;;;AAoBA;AAGA;AAMA;AAQiB,iBDMD,QCNoB,CAAA,CAAA,CAAA,CAAA,IAAA,EDMF,CCNE,EAAA,MAAA,CAAA,EDMU,cCNV,EAAA,KAAA,CAAA,EAAA,MAAA,CAAA,EDMsC,CCNtC;AAMpC;;;;;;;;AAyHA;AA4CA;iBDtGgB,eAAA,UACL,iCACA,iBACR;;;;UC7Gc,WAAA;EDpBA,MAAA,EAAA,MAAA;EA+DD,GAAA,EAAA,MAAQ;EAAU,IAAA,EAAA,MAAA;EAAY,OAAA,ECvCnC,MDuCmC,CAAA,MAAA,EAAA,MAAA,CAAA;EAA4B,IAAA,EAAA,OAAA;EAAC,KAAA,ECrClE,MDqCkE,CAAA,MAAA,EAAA,MAAA,CAAA;EA+D3D,MAAA,EAAA,MAAA;EACL,QAAA,EAAA,MAAA;EACA,MAAA,CAAA,EAAA,MAAA;EACR,KAAA,CAAA,EAAA,MAAA;EAAM,QAAA,CAAA,EAAA,MAAA;;;;EC7GQ,OAAA,CAAA,EAAA,MAAW;EAIjB,SAAA,EAYE,IAZF;;;AAYM,KAIL,YAAA,GAJK,OAAA;AAIjB;AAGiB,UAAA,OAAA,CACN;EAKM,OAAA,EALN,WAKM;EAQA,QAAA,EAZL,YAYwB;AAMpC;;AAUa,UAxBI,oBAAA,CAwBJ;EAEO;EAIO,gBAAA,CAAA,EAAA,MAAA;EAER;EAED,YAAA,CAAA,EAAA,MAAA;;AAqGlB;AA4Ca,UA3KI,mBAAA,CA2Ke;;;;;UArKf,oBAAA;;;;;;YAML;;;;aAIC;;oBAEO;;;;;;2BAIO;;mBAER;;kBAED;;;;;;;;;;;;;;;;iBAqGF,aAAA,UAAuB,uBAAoB,OAAA,CAAA;;cA4C9C,4BAAmB"}
|
package/dist/index.mjs
CHANGED
|
@@ -106,17 +106,19 @@ function sanitizeHeaders(headers, config) {
|
|
|
106
106
|
//#region src/index.ts
|
|
107
107
|
/**
|
|
108
108
|
* @vafast/request-logger - API 请求日志中间件
|
|
109
|
-
*
|
|
109
|
+
*
|
|
110
110
|
* 特性:
|
|
111
111
|
* - 自动敏感数据脱敏
|
|
112
112
|
* - HTTP 远程日志服务
|
|
113
113
|
* - 异步非阻塞记录
|
|
114
114
|
* - 路由级别日志控制(路由定义中设置 log: false)
|
|
115
|
-
*
|
|
115
|
+
* - 熔断器:连续失败后暂停上报,避免无谓等待
|
|
116
|
+
* - 错误节流:同类错误在一段时间内只打一次日志
|
|
117
|
+
*
|
|
116
118
|
* @example
|
|
117
119
|
* ```typescript
|
|
118
120
|
* import { requestLogger } from '@vafast/request-logger'
|
|
119
|
-
*
|
|
121
|
+
*
|
|
120
122
|
* server.use(requestLogger({
|
|
121
123
|
* url: 'http://log-server:9005/api/logs/ingest',
|
|
122
124
|
* service: 'auth-server',
|
|
@@ -124,13 +126,80 @@ function sanitizeHeaders(headers, config) {
|
|
|
124
126
|
* }))
|
|
125
127
|
* ```
|
|
126
128
|
*/
|
|
129
|
+
var CircuitBreaker = class {
|
|
130
|
+
state = "closed";
|
|
131
|
+
failureCount = 0;
|
|
132
|
+
lastFailureTime = 0;
|
|
133
|
+
failureThreshold;
|
|
134
|
+
resetTimeout;
|
|
135
|
+
constructor(config = {}) {
|
|
136
|
+
this.failureThreshold = config.failureThreshold ?? 5;
|
|
137
|
+
this.resetTimeout = config.resetTimeout ?? 6e4;
|
|
138
|
+
}
|
|
139
|
+
/** 检查是否允许请求 */
|
|
140
|
+
canRequest() {
|
|
141
|
+
if (this.state === "closed") return true;
|
|
142
|
+
if (this.state === "open") {
|
|
143
|
+
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
|
|
144
|
+
this.state = "half-open";
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
/** 记录成功 */
|
|
152
|
+
recordSuccess() {
|
|
153
|
+
this.failureCount = 0;
|
|
154
|
+
this.state = "closed";
|
|
155
|
+
}
|
|
156
|
+
/** 记录失败 */
|
|
157
|
+
recordFailure() {
|
|
158
|
+
this.failureCount++;
|
|
159
|
+
this.lastFailureTime = Date.now();
|
|
160
|
+
if (this.failureCount >= this.failureThreshold) this.state = "open";
|
|
161
|
+
}
|
|
162
|
+
/** 获取当前状态信息 */
|
|
163
|
+
getStatus() {
|
|
164
|
+
return {
|
|
165
|
+
state: this.state,
|
|
166
|
+
failureCount: this.failureCount
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var ErrorThrottle = class {
|
|
171
|
+
lastErrorTime = 0;
|
|
172
|
+
droppedCount = 0;
|
|
173
|
+
interval;
|
|
174
|
+
constructor(config = {}) {
|
|
175
|
+
this.interval = config.interval ?? 6e4;
|
|
176
|
+
}
|
|
177
|
+
/** 检查是否应该打印错误,返回 { shouldLog, droppedCount } */
|
|
178
|
+
shouldLog() {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (now - this.lastErrorTime >= this.interval) {
|
|
181
|
+
const dropped = this.droppedCount;
|
|
182
|
+
this.lastErrorTime = now;
|
|
183
|
+
this.droppedCount = 0;
|
|
184
|
+
return {
|
|
185
|
+
shouldLog: true,
|
|
186
|
+
droppedCount: dropped
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
this.droppedCount++;
|
|
190
|
+
return {
|
|
191
|
+
shouldLog: false,
|
|
192
|
+
droppedCount: 0
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
};
|
|
127
196
|
/**
|
|
128
197
|
* 请求日志中间件
|
|
129
|
-
*
|
|
198
|
+
*
|
|
130
199
|
* @example
|
|
131
200
|
* ```typescript
|
|
132
201
|
* import { requestLogger } from '@vafast/request-logger'
|
|
133
|
-
*
|
|
202
|
+
*
|
|
134
203
|
* server.use(requestLogger({
|
|
135
204
|
* url: 'http://log-server:9005/api/logs/ingest',
|
|
136
205
|
* service: 'auth-server',
|
|
@@ -139,7 +208,9 @@ function sanitizeHeaders(headers, config) {
|
|
|
139
208
|
* ```
|
|
140
209
|
*/
|
|
141
210
|
function requestLogger(options) {
|
|
142
|
-
const { url, service, headers = {}, timeout = 5e3, sanitize: sanitizeConfig, onError = console.error, enabled = true, excludePaths = [] } = options;
|
|
211
|
+
const { url, service, headers = {}, timeout = 5e3, sanitize: sanitizeConfig, onError = console.error, enabled = true, excludePaths = [], circuitBreaker: circuitBreakerConfig, errorThrottle: errorThrottleConfig } = options;
|
|
212
|
+
const circuitBreaker = new CircuitBreaker(circuitBreakerConfig);
|
|
213
|
+
const errorThrottle = new ErrorThrottle(errorThrottleConfig);
|
|
143
214
|
return defineMiddleware(async (req, next) => {
|
|
144
215
|
if (!enabled) return next();
|
|
145
216
|
const startTime = Date.now();
|
|
@@ -151,8 +222,10 @@ function requestLogger(options) {
|
|
|
151
222
|
timeout,
|
|
152
223
|
sanitizeConfig,
|
|
153
224
|
onError,
|
|
154
|
-
excludePaths
|
|
155
|
-
|
|
225
|
+
excludePaths,
|
|
226
|
+
circuitBreaker,
|
|
227
|
+
errorThrottle
|
|
228
|
+
}).catch(() => {});
|
|
156
229
|
return response;
|
|
157
230
|
});
|
|
158
231
|
}
|
|
@@ -187,11 +260,12 @@ async function fetchWithTimeout(targetUrl, options, timeout) {
|
|
|
187
260
|
}
|
|
188
261
|
}
|
|
189
262
|
async function recordLog(req, response, startTime, options) {
|
|
190
|
-
const { url: logUrl, service, headers: customHeaders, timeout, sanitizeConfig, onError, excludePaths } = options;
|
|
263
|
+
const { url: logUrl, service, headers: customHeaders, timeout, sanitizeConfig, onError, excludePaths, circuitBreaker, errorThrottle } = options;
|
|
191
264
|
const reqUrl = new URL(req.url);
|
|
192
265
|
const path = reqUrl.pathname;
|
|
193
266
|
if (isPathExcluded(path, excludePaths)) return;
|
|
194
267
|
if (shouldSkipLog(req.method, path)) return;
|
|
268
|
+
if (!circuitBreaker.canRequest()) return;
|
|
195
269
|
let body = null;
|
|
196
270
|
try {
|
|
197
271
|
if ((req.headers.get("content-type") || "").includes("application/json")) body = await req.clone().json();
|
|
@@ -230,8 +304,11 @@ async function recordLog(req, response, startTime, options) {
|
|
|
230
304
|
body: JSON.stringify(logBody)
|
|
231
305
|
}, timeout);
|
|
232
306
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
307
|
+
circuitBreaker.recordSuccess();
|
|
233
308
|
} catch (error) {
|
|
234
|
-
|
|
309
|
+
circuitBreaker.recordFailure();
|
|
310
|
+
const { shouldLog, droppedCount } = errorThrottle.shouldLog();
|
|
311
|
+
if (shouldLog) onError(error, { droppedCount });
|
|
235
312
|
}
|
|
236
313
|
}
|
|
237
314
|
var src_default = requestLogger;
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/sanitize.ts","../src/index.ts"],"sourcesContent":["/**\n * 敏感数据清洗工具\n * \n * 用于在记录日志前移除或脱敏敏感信息\n */\n\n// ============ Types ============\n\nexport interface SanitizeConfig {\n /** 需要完全移除的字段(小写) */\n removeFields?: string[]\n /** 需要脱敏的字段(小写,部分匹配) */\n maskFields?: string[]\n /** 脱敏占位符 @default '[REDACTED]' */\n placeholder?: string\n /** 最大递归深度 @default 10 */\n maxDepth?: number\n}\n\n// ============ Default Config ============\n\n/** 默认需要完全移除的敏感字段 */\nconst DEFAULT_REMOVE_FIELDS = [\n 'password',\n 'newpassword',\n 'oldpassword',\n 'confirmpassword',\n 'secret',\n 'secretkey',\n 'privatekey',\n 'apisecret',\n 'clientsecret',\n]\n\n/** 默认需要脱敏的字段(保留部分信息) */\nconst DEFAULT_MASK_FIELDS = [\n 'token',\n 'accesstoken',\n 'refreshtoken',\n 'authorization',\n 'apikey',\n 'api_key',\n 'x-api-key',\n 'idtoken',\n 'sessiontoken',\n 'bearer',\n]\n\nconst DEFAULT_PLACEHOLDER = '[REDACTED]'\nconst DEFAULT_MAX_DEPTH = 10\n\n// ============ Sanitize Functions ============\n\n/**\n * 部分脱敏(保留前4后4位)\n */\nfunction partialMask(value: string, placeholder: string): string {\n if (value.length <= 8) return placeholder\n return value.slice(0, 4) + '****' + value.slice(-4)\n}\n\n/**\n * 深度清洗对象中的敏感数据\n * \n * @example\n * ```typescript\n * const data = { password: '123456', token: 'eyJhbG...' }\n * const sanitized = sanitize(data)\n * // { password: '[REDACTED]', token: 'eyJh****...' }\n * ```\n */\nexport function sanitize<T>(data: T, config?: SanitizeConfig, depth = 0): T {\n const {\n removeFields = DEFAULT_REMOVE_FIELDS,\n maskFields = DEFAULT_MASK_FIELDS,\n placeholder = DEFAULT_PLACEHOLDER,\n maxDepth = DEFAULT_MAX_DEPTH,\n } = config ?? {}\n\n // 防止无限递归\n if (depth > maxDepth) return data\n \n if (data === null || data === undefined) {\n return data\n }\n\n // 处理数组\n if (Array.isArray(data)) {\n return data.map(item => sanitize(item, config, depth + 1)) as T\n }\n\n // 处理对象\n if (typeof data === 'object') {\n const result: Record<string, unknown> = {}\n \n for (const [key, value] of Object.entries(data)) {\n const lowerKey = key.toLowerCase()\n \n // 完全移除的字段\n if (removeFields.some(field => lowerKey === field)) {\n result[key] = placeholder\n continue\n }\n \n // 部分脱敏的字段\n if (maskFields.some(field => lowerKey.includes(field))) {\n if (typeof value === 'string') {\n result[key] = partialMask(value, placeholder)\n } else {\n result[key] = placeholder\n }\n continue\n }\n \n // 递归处理嵌套对象\n result[key] = sanitize(value, config, depth + 1)\n }\n \n return result as T\n }\n\n return data\n}\n\n/**\n * 清洗 HTTP 请求头\n * \n * @example\n * ```typescript\n * const headers = { Authorization: 'Bearer eyJhbG...', Cookie: 'session=xxx' }\n * const sanitized = sanitizeHeaders(headers)\n * // { Authorization: 'Bearer eyJh****...', Cookie: '[REDACTED]' }\n * ```\n */\nexport function sanitizeHeaders(\n headers: Record<string, string>,\n config?: SanitizeConfig\n): Record<string, string> {\n const {\n maskFields = DEFAULT_MASK_FIELDS,\n placeholder = DEFAULT_PLACEHOLDER,\n } = config ?? {}\n\n const result: Record<string, string> = {}\n \n for (const [key, value] of Object.entries(headers)) {\n const lowerKey = key.toLowerCase()\n \n // Authorization 头部分脱敏\n if (lowerKey === 'authorization') {\n if (value.startsWith('Bearer ')) {\n result[key] = 'Bearer ' + partialMask(value.slice(7), placeholder)\n } else {\n result[key] = partialMask(value, placeholder)\n }\n continue\n }\n \n // Cookie 完全脱敏\n if (lowerKey === 'cookie' || lowerKey === 'set-cookie') {\n result[key] = placeholder\n continue\n }\n \n // API Key 相关头脱敏\n if (maskFields.some(field => lowerKey.includes(field))) {\n result[key] = partialMask(value, placeholder)\n continue\n }\n \n result[key] = value\n }\n \n return result\n}\n\n/**\n * 检查值是否为敏感字段\n */\nexport function isSensitiveField(fieldName: string, config?: SanitizeConfig): boolean {\n const {\n removeFields = DEFAULT_REMOVE_FIELDS,\n maskFields = DEFAULT_MASK_FIELDS,\n } = config ?? {}\n\n const lowerName = fieldName.toLowerCase()\n \n return (\n removeFields.some(field => lowerName === field) ||\n maskFields.some(field => lowerName.includes(field))\n )\n}\n\n","/**\n * @vafast/request-logger - API 请求日志中间件\n * \n * 特性:\n * - 自动敏感数据脱敏\n * - HTTP 远程日志服务\n * - 异步非阻塞记录\n * - 路由级别日志控制(路由定义中设置 log: false)\n * \n * @example\n * ```typescript\n * import { requestLogger } from '@vafast/request-logger'\n * \n * server.use(requestLogger({\n * url: 'http://log-server:9005/api/logs/ingest',\n * service: 'auth-server',\n * getUserId: (req) => req.__locals?.userInfo?.id,\n * }))\n * ```\n */\nimport { defineMiddleware, getRoute } from 'vafast'\nimport { sanitize, sanitizeHeaders, type SanitizeConfig } from './sanitize'\n\n// ============ Types ============\n\n/** 请求信息 */\nexport interface RequestData {\n method: string\n url: string\n path: string\n headers: Record<string, string>\n body: unknown\n query: Record<string, string>\n status: number\n duration: number\n userId?: string\n appId?: string\n authType?: string\n service?: string\n ip?: string\n userAgent?: string\n traceId?: string\n createdAt: Date\n}\n\n/** 响应数据(完整响应体) */\nexport type ResponseData = unknown\n\n/** 完整日志数据 */\nexport interface LogData {\n request: RequestData\n response: ResponseData\n}\n\n/** 请求日志配置 */\nexport interface RequestLoggerOptions {\n /** 日志服务 URL */\n url: string\n /** 服务标识(如 auth-server、ones-server) */\n service: string\n /** 自定义请求头(如认证信息) */\n headers?: Record<string, string>\n /** 超时时间(毫秒),默认 5000 */\n timeout?: number\n /** 敏感数据清洗配置 */\n sanitize?: SanitizeConfig\n /** 错误回调 */\n onError?: (error: Error) => void\n /** 是否启用 @default true */\n enabled?: boolean\n /** 排除的路径列表(精确匹配或正则),这些路径不记录日志 */\n excludePaths?: (string | RegExp)[]\n}\n\n\n// ============ Middleware ============\n\n/**\n * 请求日志中间件\n * \n * @example\n * ```typescript\n * import { requestLogger } from '@vafast/request-logger'\n * \n * server.use(requestLogger({\n * url: 'http://log-server:9005/api/logs/ingest',\n * service: 'auth-server',\n * auth: { apiKeyId: 'xxx', apiKeySecret: 'yyy' },\n * }))\n * ```\n */\nexport function requestLogger(options: RequestLoggerOptions) {\n const {\n url,\n service,\n headers = {},\n timeout = 5000,\n sanitize: sanitizeConfig,\n onError = console.error,\n enabled = true,\n excludePaths = [],\n } = options\n\n return defineMiddleware(async (req, next) => {\n if (!enabled) return next()\n\n const startTime = Date.now()\n const response = await next()\n\n // 异步记录日志,不阻塞响应\n recordLog(req, response, startTime, {\n url,\n service,\n headers,\n timeout,\n sanitizeConfig,\n onError,\n excludePaths,\n }).catch(onError)\n\n return response\n })\n}\n\n/** @deprecated 使用 requestLogger 代替 */\nexport const createRequestLogger = requestLogger\n\n// ============ Internal ============\n\ninterface RecordLogOptions {\n url: string\n service: string\n headers: Record<string, string>\n timeout: number\n sanitizeConfig?: SanitizeConfig\n onError: (error: Error) => void\n excludePaths: (string | RegExp)[]\n}\n\n/** 检查路由是否配置了 log: false */\nfunction shouldSkipLog(method: string, path: string): boolean {\n try {\n const route = getRoute<{ log?: boolean }>(method, path)\n return route?.log === false\n } catch {\n return false\n }\n}\n\n/** 检查路径是否在排除列表中 */\nfunction isPathExcluded(path: string, excludePaths: (string | RegExp)[]): boolean {\n return excludePaths.some(pattern => {\n if (typeof pattern === 'string') {\n return path === pattern || path.startsWith(pattern + '/')\n }\n return pattern.test(path)\n })\n}\n\n/** 带超时的 fetch */\nasync function fetchWithTimeout(\n targetUrl: string,\n options: RequestInit,\n timeout: number\n): Promise<Response> {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), timeout)\n\n try {\n return await fetch(targetUrl, { ...options, signal: controller.signal })\n } finally {\n clearTimeout(timeoutId)\n }\n}\n\nasync function recordLog(\n req: Request,\n response: Response,\n startTime: number,\n options: RecordLogOptions\n) {\n const { url: logUrl, service, headers: customHeaders, timeout, sanitizeConfig, onError, excludePaths } = options\n\n const reqUrl = new URL(req.url)\n const path = reqUrl.pathname\n\n // 检查路径是否在排除列表中\n if (isPathExcluded(path, excludePaths)) return\n\n // 检查路由是否禁用日志\n if (shouldSkipLog(req.method, path)) return\n\n // 解析请求体\n let body: unknown = null\n try {\n const contentType = req.headers.get('content-type') || ''\n if (contentType.includes('application/json')) {\n body = await req.clone().json()\n }\n } catch {\n // 忽略\n }\n\n // 解析响应体\n let responseData: ResponseData = null\n try {\n responseData = await response.clone().json()\n } catch {\n // 忽略(非 JSON 响应)\n }\n\n // 提取请求头\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n // 清洗敏感数据\n const sanitizedHeaders = sanitizeHeaders(headers, sanitizeConfig)\n const sanitizedBody = sanitize(body, sanitizeConfig)\n const sanitizedResponseData = sanitize(responseData, sanitizeConfig)\n\n // 构建日志数据(业务字段由 log-server 从 headers 解析)\n const logBody = {\n method: req.method,\n url: req.url,\n path,\n headers: sanitizedHeaders,\n body: sanitizedBody,\n query: Object.fromEntries(reqUrl.searchParams),\n status: response.status,\n duration: Date.now() - startTime,\n service,\n createdAt: new Date().toISOString(),\n response: sanitizedResponseData, // 直接存储完整响应数据\n }\n\n // 发送到日志服务\n try {\n const res = await fetchWithTimeout(\n logUrl,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(logBody),\n },\n timeout\n )\n\n if (!res.ok) {\n throw new Error(`HTTP ${res.status}: ${res.statusText}`)\n }\n } catch (error) {\n onError(error as Error)\n }\n}\n\n// ============ Re-exports ============\n\nexport { sanitize, sanitizeHeaders, type SanitizeConfig } from './sanitize'\nexport default requestLogger\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/sanitize.ts","../src/index.ts"],"sourcesContent":["/**\n * 敏感数据清洗工具\n * \n * 用于在记录日志前移除或脱敏敏感信息\n */\n\n// ============ Types ============\n\nexport interface SanitizeConfig {\n /** 需要完全移除的字段(小写) */\n removeFields?: string[]\n /** 需要脱敏的字段(小写,部分匹配) */\n maskFields?: string[]\n /** 脱敏占位符 @default '[REDACTED]' */\n placeholder?: string\n /** 最大递归深度 @default 10 */\n maxDepth?: number\n}\n\n// ============ Default Config ============\n\n/** 默认需要完全移除的敏感字段 */\nconst DEFAULT_REMOVE_FIELDS = [\n 'password',\n 'newpassword',\n 'oldpassword',\n 'confirmpassword',\n 'secret',\n 'secretkey',\n 'privatekey',\n 'apisecret',\n 'clientsecret',\n]\n\n/** 默认需要脱敏的字段(保留部分信息) */\nconst DEFAULT_MASK_FIELDS = [\n 'token',\n 'accesstoken',\n 'refreshtoken',\n 'authorization',\n 'apikey',\n 'api_key',\n 'x-api-key',\n 'idtoken',\n 'sessiontoken',\n 'bearer',\n]\n\nconst DEFAULT_PLACEHOLDER = '[REDACTED]'\nconst DEFAULT_MAX_DEPTH = 10\n\n// ============ Sanitize Functions ============\n\n/**\n * 部分脱敏(保留前4后4位)\n */\nfunction partialMask(value: string, placeholder: string): string {\n if (value.length <= 8) return placeholder\n return value.slice(0, 4) + '****' + value.slice(-4)\n}\n\n/**\n * 深度清洗对象中的敏感数据\n * \n * @example\n * ```typescript\n * const data = { password: '123456', token: 'eyJhbG...' }\n * const sanitized = sanitize(data)\n * // { password: '[REDACTED]', token: 'eyJh****...' }\n * ```\n */\nexport function sanitize<T>(data: T, config?: SanitizeConfig, depth = 0): T {\n const {\n removeFields = DEFAULT_REMOVE_FIELDS,\n maskFields = DEFAULT_MASK_FIELDS,\n placeholder = DEFAULT_PLACEHOLDER,\n maxDepth = DEFAULT_MAX_DEPTH,\n } = config ?? {}\n\n // 防止无限递归\n if (depth > maxDepth) return data\n \n if (data === null || data === undefined) {\n return data\n }\n\n // 处理数组\n if (Array.isArray(data)) {\n return data.map(item => sanitize(item, config, depth + 1)) as T\n }\n\n // 处理对象\n if (typeof data === 'object') {\n const result: Record<string, unknown> = {}\n \n for (const [key, value] of Object.entries(data)) {\n const lowerKey = key.toLowerCase()\n \n // 完全移除的字段\n if (removeFields.some(field => lowerKey === field)) {\n result[key] = placeholder\n continue\n }\n \n // 部分脱敏的字段\n if (maskFields.some(field => lowerKey.includes(field))) {\n if (typeof value === 'string') {\n result[key] = partialMask(value, placeholder)\n } else {\n result[key] = placeholder\n }\n continue\n }\n \n // 递归处理嵌套对象\n result[key] = sanitize(value, config, depth + 1)\n }\n \n return result as T\n }\n\n return data\n}\n\n/**\n * 清洗 HTTP 请求头\n * \n * @example\n * ```typescript\n * const headers = { Authorization: 'Bearer eyJhbG...', Cookie: 'session=xxx' }\n * const sanitized = sanitizeHeaders(headers)\n * // { Authorization: 'Bearer eyJh****...', Cookie: '[REDACTED]' }\n * ```\n */\nexport function sanitizeHeaders(\n headers: Record<string, string>,\n config?: SanitizeConfig\n): Record<string, string> {\n const {\n maskFields = DEFAULT_MASK_FIELDS,\n placeholder = DEFAULT_PLACEHOLDER,\n } = config ?? {}\n\n const result: Record<string, string> = {}\n \n for (const [key, value] of Object.entries(headers)) {\n const lowerKey = key.toLowerCase()\n \n // Authorization 头部分脱敏\n if (lowerKey === 'authorization') {\n if (value.startsWith('Bearer ')) {\n result[key] = 'Bearer ' + partialMask(value.slice(7), placeholder)\n } else {\n result[key] = partialMask(value, placeholder)\n }\n continue\n }\n \n // Cookie 完全脱敏\n if (lowerKey === 'cookie' || lowerKey === 'set-cookie') {\n result[key] = placeholder\n continue\n }\n \n // API Key 相关头脱敏\n if (maskFields.some(field => lowerKey.includes(field))) {\n result[key] = partialMask(value, placeholder)\n continue\n }\n \n result[key] = value\n }\n \n return result\n}\n\n/**\n * 检查值是否为敏感字段\n */\nexport function isSensitiveField(fieldName: string, config?: SanitizeConfig): boolean {\n const {\n removeFields = DEFAULT_REMOVE_FIELDS,\n maskFields = DEFAULT_MASK_FIELDS,\n } = config ?? {}\n\n const lowerName = fieldName.toLowerCase()\n \n return (\n removeFields.some(field => lowerName === field) ||\n maskFields.some(field => lowerName.includes(field))\n )\n}\n\n","/**\n * @vafast/request-logger - API 请求日志中间件\n *\n * 特性:\n * - 自动敏感数据脱敏\n * - HTTP 远程日志服务\n * - 异步非阻塞记录\n * - 路由级别日志控制(路由定义中设置 log: false)\n * - 熔断器:连续失败后暂停上报,避免无谓等待\n * - 错误节流:同类错误在一段时间内只打一次日志\n *\n * @example\n * ```typescript\n * import { requestLogger } from '@vafast/request-logger'\n *\n * server.use(requestLogger({\n * url: 'http://log-server:9005/api/logs/ingest',\n * service: 'auth-server',\n * getUserId: (req) => req.__locals?.userInfo?.id,\n * }))\n * ```\n */\nimport { defineMiddleware, getRoute } from 'vafast'\nimport { sanitize, sanitizeHeaders, type SanitizeConfig } from './sanitize'\n\n// ============ Types ============\n\n/** 请求信息 */\nexport interface RequestData {\n method: string\n url: string\n path: string\n headers: Record<string, string>\n body: unknown\n query: Record<string, string>\n status: number\n duration: number\n userId?: string\n appId?: string\n authType?: string\n service?: string\n ip?: string\n userAgent?: string\n traceId?: string\n createdAt: Date\n}\n\n/** 响应数据(完整响应体) */\nexport type ResponseData = unknown\n\n/** 完整日志数据 */\nexport interface LogData {\n request: RequestData\n response: ResponseData\n}\n\n/** 熔断器配置 */\nexport interface CircuitBreakerConfig {\n /** 触发熔断的连续失败次数,默认 5 */\n failureThreshold?: number\n /** 熔断恢复时间(毫秒),默认 60000(1分钟) */\n resetTimeout?: number\n}\n\n/** 错误节流配置 */\nexport interface ErrorThrottleConfig {\n /** 同类错误的节流间隔(毫秒),默认 60000(1分钟) */\n interval?: number\n}\n\n/** 请求日志配置 */\nexport interface RequestLoggerOptions {\n /** 日志服务 URL */\n url: string\n /** 服务标识(如 auth-server、ones-server) */\n service: string\n /** 自定义请求头(如认证信息) */\n headers?: Record<string, string>\n /** 超时时间(毫秒),默认 5000 */\n timeout?: number\n /** 敏感数据清洗配置 */\n sanitize?: SanitizeConfig\n /** 错误回调 */\n onError?: (error: Error, context: { droppedCount: number }) => void\n /** 是否启用 @default true */\n enabled?: boolean\n /** 排除的路径列表(精确匹配或正则),这些路径不记录日志 */\n excludePaths?: (string | RegExp)[]\n /** 熔断器配置 */\n circuitBreaker?: CircuitBreakerConfig\n /** 错误节流配置 */\n errorThrottle?: ErrorThrottleConfig\n}\n\n// ============ Circuit Breaker ============\n\ntype CircuitState = 'closed' | 'open' | 'half-open'\n\nclass CircuitBreaker {\n private state: CircuitState = 'closed'\n private failureCount = 0\n private lastFailureTime = 0\n private readonly failureThreshold: number\n private readonly resetTimeout: number\n\n constructor(config: CircuitBreakerConfig = {}) {\n this.failureThreshold = config.failureThreshold ?? 5\n this.resetTimeout = config.resetTimeout ?? 60000\n }\n\n /** 检查是否允许请求 */\n canRequest(): boolean {\n if (this.state === 'closed') return true\n\n if (this.state === 'open') {\n // 检查是否到了恢复时间\n if (Date.now() - this.lastFailureTime >= this.resetTimeout) {\n this.state = 'half-open'\n return true\n }\n return false\n }\n\n // half-open 状态允许一个请求通过测试\n return true\n }\n\n /** 记录成功 */\n recordSuccess(): void {\n this.failureCount = 0\n this.state = 'closed'\n }\n\n /** 记录失败 */\n recordFailure(): void {\n this.failureCount++\n this.lastFailureTime = Date.now()\n\n if (this.failureCount >= this.failureThreshold) {\n this.state = 'open'\n }\n }\n\n /** 获取当前状态信息 */\n getStatus(): { state: CircuitState; failureCount: number } {\n return { state: this.state, failureCount: this.failureCount }\n }\n}\n\n// ============ Error Throttle ============\n\nclass ErrorThrottle {\n private lastErrorTime = 0\n private droppedCount = 0\n private readonly interval: number\n\n constructor(config: ErrorThrottleConfig = {}) {\n this.interval = config.interval ?? 60000\n }\n\n /** 检查是否应该打印错误,返回 { shouldLog, droppedCount } */\n shouldLog(): { shouldLog: boolean; droppedCount: number } {\n const now = Date.now()\n\n if (now - this.lastErrorTime >= this.interval) {\n const dropped = this.droppedCount\n this.lastErrorTime = now\n this.droppedCount = 0\n return { shouldLog: true, droppedCount: dropped }\n }\n\n this.droppedCount++\n return { shouldLog: false, droppedCount: 0 }\n }\n}\n\n// ============ Middleware ============\n\n/**\n * 请求日志中间件\n *\n * @example\n * ```typescript\n * import { requestLogger } from '@vafast/request-logger'\n *\n * server.use(requestLogger({\n * url: 'http://log-server:9005/api/logs/ingest',\n * service: 'auth-server',\n * auth: { apiKeyId: 'xxx', apiKeySecret: 'yyy' },\n * }))\n * ```\n */\nexport function requestLogger(options: RequestLoggerOptions) {\n const {\n url,\n service,\n headers = {},\n timeout = 5000,\n sanitize: sanitizeConfig,\n onError = console.error,\n enabled = true,\n excludePaths = [],\n circuitBreaker: circuitBreakerConfig,\n errorThrottle: errorThrottleConfig,\n } = options\n\n // 创建熔断器和错误节流器实例\n const circuitBreaker = new CircuitBreaker(circuitBreakerConfig)\n const errorThrottle = new ErrorThrottle(errorThrottleConfig)\n\n return defineMiddleware(async (req, next) => {\n if (!enabled) return next()\n\n const startTime = Date.now()\n const response = await next()\n\n // 异步记录日志,不阻塞响应\n recordLog(req, response, startTime, {\n url,\n service,\n headers,\n timeout,\n sanitizeConfig,\n onError,\n excludePaths,\n circuitBreaker,\n errorThrottle,\n }).catch(() => {\n // 错误已在 recordLog 内部处理,这里静默忽略\n })\n\n return response\n })\n}\n\n/** @deprecated 使用 requestLogger 代替 */\nexport const createRequestLogger = requestLogger\n\n// ============ Internal ============\n\ninterface RecordLogOptions {\n url: string\n service: string\n headers: Record<string, string>\n timeout: number\n sanitizeConfig?: SanitizeConfig\n onError: (error: Error, context: { droppedCount: number }) => void\n excludePaths: (string | RegExp)[]\n circuitBreaker: CircuitBreaker\n errorThrottle: ErrorThrottle\n}\n\n/** 检查路由是否配置了 log: false */\nfunction shouldSkipLog(method: string, path: string): boolean {\n try {\n const route = getRoute<{ log?: boolean }>(method, path)\n return route?.log === false\n } catch {\n return false\n }\n}\n\n/** 检查路径是否在排除列表中 */\nfunction isPathExcluded(\n path: string,\n excludePaths: (string | RegExp)[]\n): boolean {\n return excludePaths.some((pattern) => {\n if (typeof pattern === 'string') {\n return path === pattern || path.startsWith(pattern + '/')\n }\n return pattern.test(path)\n })\n}\n\n/** 带超时的 fetch */\nasync function fetchWithTimeout(\n targetUrl: string,\n options: RequestInit,\n timeout: number\n): Promise<Response> {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), timeout)\n\n try {\n return await fetch(targetUrl, { ...options, signal: controller.signal })\n } finally {\n clearTimeout(timeoutId)\n }\n}\n\nasync function recordLog(\n req: Request,\n response: Response,\n startTime: number,\n options: RecordLogOptions\n) {\n const {\n url: logUrl,\n service,\n headers: customHeaders,\n timeout,\n sanitizeConfig,\n onError,\n excludePaths,\n circuitBreaker,\n errorThrottle,\n } = options\n\n const reqUrl = new URL(req.url)\n const path = reqUrl.pathname\n\n // 检查路径是否在排除列表中\n if (isPathExcluded(path, excludePaths)) return\n\n // 检查路由是否禁用日志\n if (shouldSkipLog(req.method, path)) return\n\n // 熔断器检查:如果熔断打开,直接跳过上报\n if (!circuitBreaker.canRequest()) {\n return\n }\n\n // 解析请求体\n let body: unknown = null\n try {\n const contentType = req.headers.get('content-type') || ''\n if (contentType.includes('application/json')) {\n body = await req.clone().json()\n }\n } catch {\n // 忽略\n }\n\n // 解析响应体\n let responseData: ResponseData = null\n try {\n responseData = await response.clone().json()\n } catch {\n // 忽略(非 JSON 响应)\n }\n\n // 提取请求头\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n // 清洗敏感数据\n const sanitizedHeaders = sanitizeHeaders(headers, sanitizeConfig)\n const sanitizedBody = sanitize(body, sanitizeConfig)\n const sanitizedResponseData = sanitize(responseData, sanitizeConfig)\n\n // 构建日志数据(业务字段由 log-server 从 headers 解析)\n const logBody = {\n method: req.method,\n url: req.url,\n path,\n headers: sanitizedHeaders,\n body: sanitizedBody,\n query: Object.fromEntries(reqUrl.searchParams),\n status: response.status,\n duration: Date.now() - startTime,\n service,\n createdAt: new Date().toISOString(),\n response: sanitizedResponseData, // 直接存储完整响应数据\n }\n\n // 发送到日志服务\n try {\n const res = await fetchWithTimeout(\n logUrl,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...customHeaders },\n body: JSON.stringify(logBody),\n },\n timeout\n )\n\n if (!res.ok) {\n throw new Error(`HTTP ${res.status}: ${res.statusText}`)\n }\n\n // 成功:重置熔断器\n circuitBreaker.recordSuccess()\n } catch (error) {\n // 失败:记录到熔断器\n circuitBreaker.recordFailure()\n\n // 错误节流:检查是否应该打印\n const { shouldLog, droppedCount } = errorThrottle.shouldLog()\n if (shouldLog) {\n onError(error as Error, { droppedCount })\n }\n }\n}\n\n// ============ Re-exports ============\n\nexport { sanitize, sanitizeHeaders, type SanitizeConfig } from './sanitize'\nexport default requestLogger\n"],"mappings":"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"}
|