@vafast/request-logger 0.4.1 → 0.4.2
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 +99 -19
- package/dist/index.d.mts +9 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +69 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,21 +13,20 @@ npm install @vafast/request-logger
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { requestLogger } from '@vafast/request-logger'
|
|
15
15
|
|
|
16
|
+
// 最简配置 - 开箱即用
|
|
16
17
|
server.use(requestLogger({
|
|
17
18
|
url: 'http://log-server:9005/api/logs/ingest',
|
|
18
19
|
service: 'my-service',
|
|
19
|
-
headers: { Authorization: 'Bearer apiKeyId:apiKeySecret' },
|
|
20
|
-
excludePaths: ['/health', '/metrics'],
|
|
21
|
-
onError: (err, { droppedCount }) => {
|
|
22
|
-
console.warn(
|
|
23
|
-
`日志上报失败: ${err.message}`,
|
|
24
|
-
droppedCount > 0 ? `(已忽略 ${droppedCount} 条)` : ''
|
|
25
|
-
)
|
|
26
|
-
},
|
|
27
20
|
}))
|
|
28
21
|
```
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
**开箱即用特性**:
|
|
24
|
+
- ✅ stdout 双写(K8s 友好)
|
|
25
|
+
- ✅ 智能日志级别(2xx→INFO,4xx→WARN,5xx→ERROR)
|
|
26
|
+
- ✅ 默认排除 `/health`、`/metrics` 等
|
|
27
|
+
- ✅ 自动提取客户端 IP
|
|
28
|
+
- ✅ 自动读取 Request ID
|
|
29
|
+
- ✅ 智能错误处理(节流 + 结构化输出)
|
|
31
30
|
|
|
32
31
|
## 配置
|
|
33
32
|
|
|
@@ -40,9 +39,20 @@ server.use(requestLogger({
|
|
|
40
39
|
| `headers` | `Record<string, string>` | 否 | `{}` | 自定义请求头(如认证) |
|
|
41
40
|
| `timeout` | `number` | 否 | `5000` | 超时时间(毫秒) |
|
|
42
41
|
| `sanitize` | `SanitizeConfig` | 否 | - | 敏感数据清洗配置 |
|
|
43
|
-
| `onError` | `(err, ctx) => void` | 否 |
|
|
42
|
+
| `onError` | `(err, ctx) => void` | 否 | 内置智能处理 | 错误回调,`ctx.droppedCount` 为被节流忽略的错误数 |
|
|
44
43
|
| `enabled` | `boolean` | 否 | `true` | 是否启用 |
|
|
45
|
-
| `excludePaths` | `(string \| RegExp)[]` | 否 | `[]` |
|
|
44
|
+
| `excludePaths` | `(string \| RegExp)[]` | 否 | `[]` | 排除的路径列表(在默认排除基础上追加) |
|
|
45
|
+
| `useDefaultExcludePaths` | `boolean` | 否 | `true` | 是否使用默认排除路径 |
|
|
46
|
+
| `sampleRate` | `number` | 否 | `1` | 日志采样率 (0-1),1 = 全部,0.1 = 10% |
|
|
47
|
+
| `requestIdHeader` | `string` | 否 | `'x-request-id'` | Request ID 的 header 名称 |
|
|
48
|
+
|
|
49
|
+
### 默认排除路径
|
|
50
|
+
|
|
51
|
+
以下路径默认不记录日志(可通过 `useDefaultExcludePaths: false` 关闭):
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
/health, /healthz, /ready, /readiness, /liveness, /metrics, /favicon.ico
|
|
55
|
+
```
|
|
46
56
|
|
|
47
57
|
### 熔断器配置 (Circuit Breaker)
|
|
48
58
|
|
|
@@ -76,10 +86,10 @@ requestLogger({
|
|
|
76
86
|
|
|
77
87
|
| 参数 | 类型 | 默认值 | 说明 |
|
|
78
88
|
|------|------|--------|------|
|
|
79
|
-
| `stdout.enabled` | `boolean` | `
|
|
89
|
+
| `stdout.enabled` | `boolean` | `true` | 是否启用 stdout 输出 |
|
|
80
90
|
| `stdout.format` | `'json' \| 'text'` | `'json'` | 输出格式 |
|
|
81
|
-
| `stdout.includeBody` | `boolean` | `
|
|
82
|
-
| `stdout.includeResponse` | `boolean` | `false` |
|
|
91
|
+
| `stdout.includeBody` | `boolean` | `true` | 是否包含请求体(已脱敏) |
|
|
92
|
+
| `stdout.includeResponse` | `boolean` | `false` | 是否包含响应体(可能很大) |
|
|
83
93
|
|
|
84
94
|
```typescript
|
|
85
95
|
requestLogger({
|
|
@@ -88,8 +98,8 @@ requestLogger({
|
|
|
88
98
|
stdout: {
|
|
89
99
|
enabled: true, // 启用双写
|
|
90
100
|
format: 'json', // JSON 格式(K8s 友好)
|
|
91
|
-
includeBody:
|
|
92
|
-
includeResponse: false,
|
|
101
|
+
// includeBody: true, // 默认包含请求体(已脱敏)
|
|
102
|
+
// includeResponse: false, // 默认不含响应体(可能很大)
|
|
93
103
|
},
|
|
94
104
|
})
|
|
95
105
|
```
|
|
@@ -97,9 +107,17 @@ requestLogger({
|
|
|
97
107
|
**stdout 输出格式**(精简版,兼容 pino/K8s):
|
|
98
108
|
|
|
99
109
|
```json
|
|
100
|
-
{"level":30,"time":1706123456789,"service":"auth-server","method":"POST","path":"/api/users","status":200,"duration":50,"msg":"POST /api/users 200 50ms"}
|
|
110
|
+
{"level":30,"time":1706123456789,"service":"auth-server","method":"POST","path":"/api/users","status":200,"duration":50,"requestId":"abc-123","clientIp":"1.2.3.4","msg":"POST /api/users 200 50ms"}
|
|
101
111
|
```
|
|
102
112
|
|
|
113
|
+
**日志级别根据状态码自动设置**:
|
|
114
|
+
|
|
115
|
+
| 状态码 | 级别 | pino level |
|
|
116
|
+
|--------|------|------------|
|
|
117
|
+
| 2xx | INFO | 30 |
|
|
118
|
+
| 4xx | WARN | 40 |
|
|
119
|
+
| 5xx | ERROR | 50 |
|
|
120
|
+
|
|
103
121
|
**架构图**:
|
|
104
122
|
|
|
105
123
|
```
|
|
@@ -211,9 +229,36 @@ requestLogger({
|
|
|
211
229
|
service: 'my-service',
|
|
212
230
|
createdAt: '2024-01-01T00:00:00.000Z',
|
|
213
231
|
response: { success: true, message: 'OK' },
|
|
232
|
+
clientIp: '1.2.3.4', // 可选:从 X-Forwarded-For 等提取
|
|
233
|
+
requestId: 'abc-123-def-456', // 可选:分布式追踪 ID
|
|
214
234
|
}
|
|
215
235
|
```
|
|
216
236
|
|
|
237
|
+
### 客户端 IP 提取
|
|
238
|
+
|
|
239
|
+
自动从以下 header 提取(按优先级):
|
|
240
|
+
|
|
241
|
+
1. `X-Forwarded-For`(第一个 IP)
|
|
242
|
+
2. `X-Real-IP`
|
|
243
|
+
3. `CF-Connecting-IP`(Cloudflare)
|
|
244
|
+
4. `True-Client-IP`(Akamai)
|
|
245
|
+
|
|
246
|
+
### Request ID 支持
|
|
247
|
+
|
|
248
|
+
支持分布式追踪,自动从以下位置获取:
|
|
249
|
+
|
|
250
|
+
1. `req.id`(如果使用了 `@vafast/request-id` 中间件)
|
|
251
|
+
2. 指定的 header(默认 `x-request-id`)
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
import { requestId } from '@vafast/request-id'
|
|
255
|
+
import { requestLogger } from '@vafast/request-logger'
|
|
256
|
+
|
|
257
|
+
// 推荐:配合 request-id 中间件使用
|
|
258
|
+
app.use(requestId()) // 先生成/读取 ID
|
|
259
|
+
app.use(requestLogger({ ... })) // 自动读取 req.id
|
|
260
|
+
```
|
|
261
|
+
|
|
217
262
|
## 敏感数据脱敏
|
|
218
263
|
|
|
219
264
|
默认自动脱敏以下字段:
|
|
@@ -240,16 +285,44 @@ requestLogger({
|
|
|
240
285
|
|
|
241
286
|
- **异步非阻塞**:不影响响应速度
|
|
242
287
|
- **stdout 双写**:同时输出到 stdout,支持 K8s 日志采集
|
|
288
|
+
- **智能日志级别**:根据状态码自动设置 INFO/WARN/ERROR
|
|
243
289
|
- **熔断器**:日志服务故障时自动熔断,避免雪崩
|
|
244
290
|
- **错误节流**:相同错误不刷屏,带统计计数
|
|
291
|
+
- **默认排除健康检查**:`/health`、`/metrics` 等路径默认不记录
|
|
245
292
|
- **路径排除**:支持精确匹配、前缀匹配、正则匹配
|
|
293
|
+
- **日志采样**:高流量场景下只记录部分请求
|
|
294
|
+
- **客户端 IP 提取**:自动从 X-Forwarded-For 等获取真实 IP
|
|
295
|
+
- **Request ID 支持**:分布式追踪,兼容 `@vafast/request-id`
|
|
246
296
|
- **敏感数据脱敏**:自动清洗密码、Token 等敏感字段
|
|
247
297
|
- **路由级别控制**:可在路由定义中禁用日志
|
|
248
298
|
- **支持多租户**:通过 headers 传递 appId
|
|
249
|
-
- **支持分布式追踪**:通过 headers 传递 traceId
|
|
250
299
|
|
|
251
300
|
## 完整示例
|
|
252
301
|
|
|
302
|
+
### 最简配置(推荐)
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { requestId } from '@vafast/request-id'
|
|
306
|
+
import { requestLogger } from '@vafast/request-logger'
|
|
307
|
+
|
|
308
|
+
// 推荐配合 request-id 使用
|
|
309
|
+
app.use(requestId())
|
|
310
|
+
app.use(requestLogger({
|
|
311
|
+
url: 'http://log-server:9005/api/logs/ingest',
|
|
312
|
+
service: 'auth-server',
|
|
313
|
+
}))
|
|
314
|
+
|
|
315
|
+
// 开箱即用:
|
|
316
|
+
// ✅ stdout 双写默认开启
|
|
317
|
+
// ✅ 智能日志级别 (2xx/4xx/5xx)
|
|
318
|
+
// ✅ 健康检查路径默认排除
|
|
319
|
+
// ✅ 客户端 IP 自动提取
|
|
320
|
+
// ✅ Request ID 自动读取
|
|
321
|
+
// ✅ 智能 onError 处理
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### 完整配置
|
|
325
|
+
|
|
253
326
|
```typescript
|
|
254
327
|
import { requestLogger } from '@vafast/request-logger'
|
|
255
328
|
import { logger } from './logger'
|
|
@@ -260,7 +333,13 @@ server.use(requestLogger({
|
|
|
260
333
|
headers: { Authorization: 'Bearer ak_xxx:sk_xxx' },
|
|
261
334
|
timeout: 5000,
|
|
262
335
|
enabled: true,
|
|
263
|
-
|
|
336
|
+
// 路径排除(追加到默认排除列表)
|
|
337
|
+
excludePaths: ['/verifyApiKey'],
|
|
338
|
+
useDefaultExcludePaths: true, // 使用默认排除(/health 等)
|
|
339
|
+
// 日志采样(高流量场景)
|
|
340
|
+
sampleRate: 1, // 1 = 100%,0.1 = 10%
|
|
341
|
+
// Request ID header
|
|
342
|
+
requestIdHeader: 'x-request-id',
|
|
264
343
|
// stdout 双写(K8s 日志采集)
|
|
265
344
|
stdout: {
|
|
266
345
|
enabled: true,
|
|
@@ -275,6 +354,7 @@ server.use(requestLogger({
|
|
|
275
354
|
errorThrottle: {
|
|
276
355
|
interval: 60000,
|
|
277
356
|
},
|
|
357
|
+
// 自定义错误处理(可选,默认已有智能处理)
|
|
278
358
|
onError: (err: Error, { droppedCount }: { droppedCount: number }) =>
|
|
279
359
|
logger.warn(
|
|
280
360
|
{
|
package/dist/index.d.mts
CHANGED
|
@@ -81,14 +81,14 @@ interface ErrorThrottleConfig {
|
|
|
81
81
|
}
|
|
82
82
|
/** stdout 双写配置 */
|
|
83
83
|
interface StdoutConfig {
|
|
84
|
-
/** 是否启用 stdout 输出 @default
|
|
84
|
+
/** 是否启用 stdout 输出 @default true */
|
|
85
85
|
enabled?: boolean;
|
|
86
86
|
/** 输出格式 @default 'json' */
|
|
87
87
|
format?: 'json' | 'text';
|
|
88
|
+
/** 是否包含请求体 @default true */
|
|
89
|
+
includeBody?: boolean;
|
|
88
90
|
/** 是否包含响应体(可能很大)@default false */
|
|
89
91
|
includeResponse?: boolean;
|
|
90
|
-
/** 是否包含请求体 @default false */
|
|
91
|
-
includeBody?: boolean;
|
|
92
92
|
}
|
|
93
93
|
/** 请求日志配置 */
|
|
94
94
|
interface RequestLoggerOptions {
|
|
@@ -110,12 +110,18 @@ interface RequestLoggerOptions {
|
|
|
110
110
|
enabled?: boolean;
|
|
111
111
|
/** 排除的路径列表(精确匹配或正则),这些路径不记录日志 */
|
|
112
112
|
excludePaths?: (string | RegExp)[];
|
|
113
|
+
/** 是否使用默认排除路径(/health, /metrics 等)@default true */
|
|
114
|
+
useDefaultExcludePaths?: boolean;
|
|
113
115
|
/** 熔断器配置 */
|
|
114
116
|
circuitBreaker?: CircuitBreakerConfig;
|
|
115
117
|
/** 错误节流配置 */
|
|
116
118
|
errorThrottle?: ErrorThrottleConfig;
|
|
117
119
|
/** stdout 双写配置(用于 K8s 日志采集) */
|
|
118
120
|
stdout?: StdoutConfig;
|
|
121
|
+
/** 日志采样率 (0-1),1 表示记录所有请求,0.1 表示只记录 10% @default 1 */
|
|
122
|
+
sampleRate?: number;
|
|
123
|
+
/** 请求 ID 的 header 名称,用于分布式追踪 @default 'x-request-id' */
|
|
124
|
+
requestIdHeader?: string;
|
|
119
125
|
}
|
|
120
126
|
/**
|
|
121
127
|
* 请求日志中间件
|
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;;;;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;
|
|
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;AAuBA;;;;;;;;;AAyJgB,iBDjHA,eAAA,CCiHuB,OAAA,EDhH5B,MCgHgD,CAAA,MAAA,EAAA,MAAA,CAAA,EAAA,MAAA,CAAA,ED/GhD,cC+GgD,CAAA,ED9GxD,MC8GwD,CAAA,MAAA,EAAA,MAAA,CAAA;;;;UA3N1C,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;AAuBA;AAMY,UA3CK,oBAAA,CA2CL;EAIC;EAEO,gBAAA,CAAA,EAAA,MAAA;EAIO;EAIR,YAAA,CAAA,EAAA,MAAA;;;AAII,UArDN,mBAAA,CAqDM;EAiIP;EA4DH,QAAA,CAAA,EAAA,MAAA;;;UA5OI,YAAA;;;;;;;;;;;UAuBA,oBAAA;;;;;;YAML;;;;aAIC;;oBAEO;;;;;;2BAIO;;;;mBAIR;;kBAED;;WAEP;;;;;;;;;;;;;;;;;;;;iBAiIK,aAAA,UAAuB,uBAAoB,OAAA,CAAA;;cA4D9C,4BAAmB"}
|
package/dist/index.mjs
CHANGED
|
@@ -126,6 +126,16 @@ function sanitizeHeaders(headers, config) {
|
|
|
126
126
|
* }))
|
|
127
127
|
* ```
|
|
128
128
|
*/
|
|
129
|
+
/** 默认排除的路径(健康检查等) */
|
|
130
|
+
const DEFAULT_EXCLUDE_PATHS = [
|
|
131
|
+
"/health",
|
|
132
|
+
"/healthz",
|
|
133
|
+
"/ready",
|
|
134
|
+
"/readiness",
|
|
135
|
+
"/liveness",
|
|
136
|
+
"/metrics",
|
|
137
|
+
"/favicon.ico"
|
|
138
|
+
];
|
|
129
139
|
var CircuitBreaker = class {
|
|
130
140
|
state = "closed";
|
|
131
141
|
failureCount = 0;
|
|
@@ -194,6 +204,24 @@ var ErrorThrottle = class {
|
|
|
194
204
|
}
|
|
195
205
|
};
|
|
196
206
|
/**
|
|
207
|
+
* 默认错误处理函数
|
|
208
|
+
* - 输出结构化 JSON 到 stdout(K8s 友好)
|
|
209
|
+
* - 使用 warn 级别(level: 40)
|
|
210
|
+
* - 包含 droppedCount 信息
|
|
211
|
+
*/
|
|
212
|
+
function defaultOnError(error, context) {
|
|
213
|
+
const { droppedCount } = context;
|
|
214
|
+
const log = {
|
|
215
|
+
level: 40,
|
|
216
|
+
time: Date.now(),
|
|
217
|
+
errorName: error.name,
|
|
218
|
+
errorMessage: error.message,
|
|
219
|
+
droppedCount,
|
|
220
|
+
msg: droppedCount > 0 ? `request-logger 上报失败 (已忽略 ${droppedCount} 条相同错误)` : "request-logger 上报失败"
|
|
221
|
+
};
|
|
222
|
+
console.log(JSON.stringify(log));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
197
225
|
* 请求日志中间件
|
|
198
226
|
*
|
|
199
227
|
* @example
|
|
@@ -208,13 +236,15 @@ var ErrorThrottle = class {
|
|
|
208
236
|
* ```
|
|
209
237
|
*/
|
|
210
238
|
function requestLogger(options) {
|
|
211
|
-
const { url, service, headers = {}, timeout = 5e3, sanitize: sanitizeConfig, onError =
|
|
239
|
+
const { url, service, headers = {}, timeout = 5e3, sanitize: sanitizeConfig, onError = defaultOnError, enabled = true, excludePaths = [], useDefaultExcludePaths = true, circuitBreaker: circuitBreakerConfig, errorThrottle: errorThrottleConfig, stdout: stdoutConfig, sampleRate = 1, requestIdHeader = "x-request-id" } = options;
|
|
240
|
+
const allExcludePaths = useDefaultExcludePaths ? [...DEFAULT_EXCLUDE_PATHS, ...excludePaths] : excludePaths;
|
|
212
241
|
const circuitBreaker = new CircuitBreaker(circuitBreakerConfig);
|
|
213
242
|
const errorThrottle = new ErrorThrottle(errorThrottleConfig);
|
|
214
243
|
return defineMiddleware(async (req, next) => {
|
|
215
244
|
if (!enabled) return next();
|
|
216
245
|
const startTime = Date.now();
|
|
217
246
|
const response = await next();
|
|
247
|
+
if (sampleRate < 1 && Math.random() > sampleRate) return response;
|
|
218
248
|
recordLog(req, response, startTime, {
|
|
219
249
|
url,
|
|
220
250
|
service,
|
|
@@ -222,10 +252,11 @@ function requestLogger(options) {
|
|
|
222
252
|
timeout,
|
|
223
253
|
sanitizeConfig,
|
|
224
254
|
onError,
|
|
225
|
-
excludePaths,
|
|
255
|
+
excludePaths: allExcludePaths,
|
|
226
256
|
circuitBreaker,
|
|
227
257
|
errorThrottle,
|
|
228
|
-
stdoutConfig
|
|
258
|
+
stdoutConfig,
|
|
259
|
+
requestIdHeader
|
|
229
260
|
}).catch(() => {});
|
|
230
261
|
return response;
|
|
231
262
|
});
|
|
@@ -260,8 +291,26 @@ async function fetchWithTimeout(targetUrl, options, timeout) {
|
|
|
260
291
|
clearTimeout(timeoutId);
|
|
261
292
|
}
|
|
262
293
|
}
|
|
294
|
+
/** 从请求中提取客户端 IP */
|
|
295
|
+
function getClientIp(req) {
|
|
296
|
+
const forwarded = req.headers.get("x-forwarded-for");
|
|
297
|
+
if (forwarded) return forwarded.split(",")[0].trim();
|
|
298
|
+
return req.headers.get("x-real-ip") ?? req.headers.get("cf-connecting-ip") ?? req.headers.get("true-client-ip") ?? void 0;
|
|
299
|
+
}
|
|
300
|
+
/** 从请求中提取 Request ID */
|
|
301
|
+
function getRequestId(req, headerName) {
|
|
302
|
+
const reqWithId = req;
|
|
303
|
+
if (reqWithId.id) return reqWithId.id;
|
|
304
|
+
return req.headers.get(headerName) ?? void 0;
|
|
305
|
+
}
|
|
306
|
+
/** 根据状态码获取日志级别 */
|
|
307
|
+
function getLogLevel(status) {
|
|
308
|
+
if (status >= 500) return 50;
|
|
309
|
+
if (status >= 400) return 40;
|
|
310
|
+
return 30;
|
|
311
|
+
}
|
|
263
312
|
async function recordLog(req, response, startTime, options) {
|
|
264
|
-
const { url: logUrl, service, headers: customHeaders, timeout, sanitizeConfig, onError, excludePaths, circuitBreaker, errorThrottle, stdoutConfig } = options;
|
|
313
|
+
const { url: logUrl, service, headers: customHeaders, timeout, sanitizeConfig, onError, excludePaths, circuitBreaker, errorThrottle, stdoutConfig, requestIdHeader } = options;
|
|
265
314
|
const reqUrl = new URL(req.url);
|
|
266
315
|
const path = reqUrl.pathname;
|
|
267
316
|
if (isPathExcluded(path, excludePaths)) return;
|
|
@@ -282,6 +331,8 @@ async function recordLog(req, response, startTime, options) {
|
|
|
282
331
|
const sanitizedBody = sanitize(body, sanitizeConfig);
|
|
283
332
|
const sanitizedResponseData = sanitize(responseData, sanitizeConfig);
|
|
284
333
|
const duration = Date.now() - startTime;
|
|
334
|
+
const clientIp = getClientIp(req);
|
|
335
|
+
const requestId = getRequestId(req, requestIdHeader);
|
|
285
336
|
const logBody = {
|
|
286
337
|
method: req.method,
|
|
287
338
|
url: req.url,
|
|
@@ -295,7 +346,9 @@ async function recordLog(req, response, startTime, options) {
|
|
|
295
346
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
296
347
|
response: sanitizedResponseData
|
|
297
348
|
};
|
|
298
|
-
if (
|
|
349
|
+
if (clientIp) logBody.clientIp = clientIp;
|
|
350
|
+
if (requestId) logBody.requestId = requestId;
|
|
351
|
+
if (stdoutConfig?.enabled !== false) writeToStdout(logBody, stdoutConfig ?? {});
|
|
299
352
|
if (!circuitBreaker.canRequest()) return;
|
|
300
353
|
try {
|
|
301
354
|
const res = await fetchWithTimeout(logUrl, {
|
|
@@ -316,21 +369,27 @@ async function recordLog(req, response, startTime, options) {
|
|
|
316
369
|
}
|
|
317
370
|
/** 输出到 stdout(用于 K8s 日志采集) */
|
|
318
371
|
function writeToStdout(logBody, config) {
|
|
319
|
-
const { format = "json",
|
|
372
|
+
const { format = "json", includeBody = true, includeResponse = false } = config;
|
|
373
|
+
const status = logBody.status;
|
|
320
374
|
const stdoutLog = {
|
|
321
|
-
level:
|
|
375
|
+
level: getLogLevel(status),
|
|
322
376
|
time: Date.now(),
|
|
323
377
|
service: logBody.service,
|
|
324
378
|
method: logBody.method,
|
|
325
379
|
path: logBody.path,
|
|
326
|
-
status
|
|
380
|
+
status,
|
|
327
381
|
duration: logBody.duration,
|
|
328
|
-
msg: `${logBody.method} ${logBody.path} ${
|
|
382
|
+
msg: `${logBody.method} ${logBody.path} ${status} ${logBody.duration}ms`
|
|
329
383
|
};
|
|
384
|
+
if (logBody.requestId) stdoutLog.requestId = logBody.requestId;
|
|
385
|
+
if (logBody.clientIp) stdoutLog.clientIp = logBody.clientIp;
|
|
330
386
|
if (includeBody && logBody.body) stdoutLog.body = logBody.body;
|
|
331
387
|
if (includeResponse && logBody.response) stdoutLog.response = logBody.response;
|
|
332
388
|
if (format === "json") console.log(JSON.stringify(stdoutLog));
|
|
333
|
-
else
|
|
389
|
+
else {
|
|
390
|
+
const reqId = logBody.requestId ? ` [${logBody.requestId}]` : "";
|
|
391
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}]${reqId} ${logBody.method} ${logBody.path} ${status} ${logBody.duration}ms`);
|
|
392
|
+
}
|
|
334
393
|
}
|
|
335
394
|
var src_default = requestLogger;
|
|
336
395
|
|
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 * - 错误节流:同类错误在一段时间内只打一次日志\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/** stdout 双写配置 */\nexport interface StdoutConfig {\n /** 是否启用 stdout 输出 @default false */\n enabled?: boolean\n /** 输出格式 @default 'json' */\n format?: 'json' | 'text'\n /** 是否包含响应体(可能很大)@default false */\n includeResponse?: boolean\n /** 是否包含请求体 @default false */\n includeBody?: boolean\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 /** stdout 双写配置(用于 K8s 日志采集) */\n stdout?: StdoutConfig\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 stdout: stdoutConfig,\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 stdoutConfig,\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 stdoutConfig?: StdoutConfig\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 stdoutConfig,\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 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 const duration = Date.now() - startTime\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,\n service,\n createdAt: new Date().toISOString(),\n response: sanitizedResponseData, // 直接存储完整响应数据\n }\n\n // 双写:输出到 stdout(用于 K8s 日志采集)\n if (stdoutConfig?.enabled) {\n writeToStdout(logBody, stdoutConfig)\n }\n\n // 熔断器检查:如果熔断打开,直接跳过 HTTP 上报\n if (!circuitBreaker.canRequest()) {\n return\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/** 输出到 stdout(用于 K8s 日志采集) */\nfunction writeToStdout(\n logBody: Record<string, unknown>,\n config: StdoutConfig\n): void {\n const { format = 'json', includeResponse = false, includeBody = false } =\n config\n\n // 构建精简版日志(避免 stdout 日志过大)\n const stdoutLog: Record<string, unknown> = {\n level: 30, // INFO level (pino 格式)\n time: Date.now(),\n service: logBody.service,\n method: logBody.method,\n path: logBody.path,\n status: logBody.status,\n duration: logBody.duration,\n msg: `${logBody.method} ${logBody.path} ${logBody.status} ${logBody.duration}ms`,\n }\n\n // 可选:包含请求体\n if (includeBody && logBody.body) {\n stdoutLog.body = logBody.body\n }\n\n // 可选:包含响应体\n if (includeResponse && logBody.response) {\n stdoutLog.response = logBody.response\n }\n\n if (format === 'json') {\n console.log(JSON.stringify(stdoutLog))\n } else {\n // text 格式:更易读\n console.log(\n `[${new Date().toISOString()}] ${logBody.method} ${logBody.path} ${logBody.status} ${logBody.duration}ms`\n )\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/** stdout 双写配置 */\nexport interface StdoutConfig {\n /** 是否启用 stdout 输出 @default true */\n enabled?: boolean\n /** 输出格式 @default 'json' */\n format?: 'json' | 'text'\n /** 是否包含请求体 @default true */\n includeBody?: boolean\n /** 是否包含响应体(可能很大)@default false */\n includeResponse?: boolean\n}\n\n/** 默认排除的路径(健康检查等) */\nconst DEFAULT_EXCLUDE_PATHS = [\n '/health',\n '/healthz',\n '/ready',\n '/readiness',\n '/liveness',\n '/metrics',\n '/favicon.ico',\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 /** 是否使用默认排除路径(/health, /metrics 等)@default true */\n useDefaultExcludePaths?: boolean\n /** 熔断器配置 */\n circuitBreaker?: CircuitBreakerConfig\n /** 错误节流配置 */\n errorThrottle?: ErrorThrottleConfig\n /** stdout 双写配置(用于 K8s 日志采集) */\n stdout?: StdoutConfig\n /** 日志采样率 (0-1),1 表示记录所有请求,0.1 表示只记录 10% @default 1 */\n sampleRate?: number\n /** 请求 ID 的 header 名称,用于分布式追踪 @default 'x-request-id' */\n requestIdHeader?: string\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// ============ Default Error Handler ============\n\n/**\n * 默认错误处理函数\n * - 输出结构化 JSON 到 stdout(K8s 友好)\n * - 使用 warn 级别(level: 40)\n * - 包含 droppedCount 信息\n */\nfunction defaultOnError(error: Error, context: { droppedCount: number }): void {\n const { droppedCount } = context\n const log = {\n level: 40, // warn\n time: Date.now(),\n errorName: error.name,\n errorMessage: error.message,\n droppedCount,\n msg:\n droppedCount > 0\n ? `request-logger 上报失败 (已忽略 ${droppedCount} 条相同错误)`\n : 'request-logger 上报失败',\n }\n console.log(JSON.stringify(log))\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 = defaultOnError,\n enabled = true,\n excludePaths = [],\n useDefaultExcludePaths = true,\n circuitBreaker: circuitBreakerConfig,\n errorThrottle: errorThrottleConfig,\n stdout: stdoutConfig,\n sampleRate = 1,\n requestIdHeader = 'x-request-id',\n } = options\n\n // 合并默认排除路径\n const allExcludePaths = useDefaultExcludePaths\n ? [...DEFAULT_EXCLUDE_PATHS, ...excludePaths]\n : excludePaths\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 if (sampleRate < 1 && Math.random() > sampleRate) {\n return response\n }\n\n // 异步记录日志,不阻塞响应\n recordLog(req, response, startTime, {\n url,\n service,\n headers,\n timeout,\n sanitizeConfig,\n onError,\n excludePaths: allExcludePaths,\n circuitBreaker,\n errorThrottle,\n stdoutConfig,\n requestIdHeader,\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 stdoutConfig?: StdoutConfig\n requestIdHeader: string\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\n/** 从请求中提取客户端 IP */\nfunction getClientIp(req: Request): string | undefined {\n // 按优先级尝试获取真实 IP\n const forwarded = req.headers.get('x-forwarded-for')\n if (forwarded) {\n // X-Forwarded-For 可能包含多个 IP,第一个是客户端真实 IP\n return forwarded.split(',')[0].trim()\n }\n return (\n req.headers.get('x-real-ip') ??\n req.headers.get('cf-connecting-ip') ?? // Cloudflare\n req.headers.get('true-client-ip') ?? // Akamai\n undefined\n )\n}\n\n/** 从请求中提取 Request ID */\nfunction getRequestId(req: Request, headerName: string): string | undefined {\n // 优先从 req.id 获取(如果使用了 @vafast/request-id 中间件)\n const reqWithId = req as Request & { id?: string }\n if (reqWithId.id) {\n return reqWithId.id\n }\n // 否则从 header 获取\n return req.headers.get(headerName) ?? undefined\n}\n\n/** 根据状态码获取日志级别 */\nfunction getLogLevel(status: number): number {\n if (status >= 500) return 50 // ERROR\n if (status >= 400) return 40 // WARN\n return 30 // INFO\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 stdoutConfig,\n requestIdHeader,\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 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 const duration = Date.now() - startTime\n\n // 提取客户端 IP 和 Request ID\n const clientIp = getClientIp(req)\n const requestId = getRequestId(req, requestIdHeader)\n\n // 构建日志数据(业务字段由 log-server 从 headers 解析)\n const logBody: Record<string, unknown> = {\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,\n service,\n createdAt: new Date().toISOString(),\n response: sanitizedResponseData, // 直接存储完整响应数据\n }\n\n // 可选字段(只在有值时添加)\n if (clientIp) logBody.clientIp = clientIp\n if (requestId) logBody.requestId = requestId\n\n // 双写:输出到 stdout(用于 K8s 日志采集,默认开启)\n if (stdoutConfig?.enabled !== false) {\n writeToStdout(logBody, stdoutConfig ?? {})\n }\n\n // 熔断器检查:如果熔断打开,直接跳过 HTTP 上报\n if (!circuitBreaker.canRequest()) {\n return\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/** 输出到 stdout(用于 K8s 日志采集) */\nfunction writeToStdout(\n logBody: Record<string, unknown>,\n config: StdoutConfig\n): void {\n const { format = 'json', includeBody = true, includeResponse = false } =\n config\n\n const status = logBody.status as number\n\n // 构建精简版日志(避免 stdout 日志过大)\n const stdoutLog: Record<string, unknown> = {\n level: getLogLevel(status), // 根据状态码设置日志级别\n time: Date.now(),\n service: logBody.service,\n method: logBody.method,\n path: logBody.path,\n status,\n duration: logBody.duration,\n msg: `${logBody.method} ${logBody.path} ${status} ${logBody.duration}ms`,\n }\n\n // 可选字段\n if (logBody.requestId) stdoutLog.requestId = logBody.requestId\n if (logBody.clientIp) stdoutLog.clientIp = logBody.clientIp\n\n // 可选:包含请求体\n if (includeBody && logBody.body) {\n stdoutLog.body = logBody.body\n }\n\n // 可选:包含响应体\n if (includeResponse && logBody.response) {\n stdoutLog.response = logBody.response\n }\n\n if (format === 'json') {\n console.log(JSON.stringify(stdoutLog))\n } else {\n // text 格式:更易读\n const reqId = logBody.requestId ? ` [${logBody.requestId}]` : ''\n console.log(\n `[${new Date().toISOString()}]${reqId} ${logBody.method} ${logBody.path} ${status} ${logBody.duration}ms`\n )\n }\n}\n\n// ============ Re-exports ============\n\nexport { sanitize, sanitizeHeaders, type SanitizeConfig } from './sanitize'\nexport default requestLogger\n"],"mappings":"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"}
|