@kne/fastify-app-manager 0.1.8 → 0.1.9

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 CHANGED
@@ -50,8 +50,8 @@ Client / Admin UI
50
50
  | Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
51
51
  | Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
52
52
  | 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
53
- | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
54
- | Bootstrap | `onReady` 连接 PM2、挂 log bus;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
53
+ | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 由 PM2 落 `appsRoot/{name}/logs`,插件按大小/按天切分归档 |
54
+ | Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
55
55
 
56
56
  ##### 部署时序
57
57
 
@@ -107,7 +107,7 @@ deploy(versionId)
107
107
  | 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
108
108
  | 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
109
109
  | 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated`) |
110
- | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(应用可显式覆盖);子应用 sequelize 须读取该变量建表 |
110
+ | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
111
111
  | 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
112
112
 
113
113
  ##### SQL 迁移
@@ -122,7 +122,9 @@ deploy(versionId)
122
122
  | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
123
123
  | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
124
124
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
125
- | 日志 | 文件落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
125
+ | 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
126
+ | 日志切分与归档 | 按大小(`logMaxSize`)与按天(`logTimezone` 零点,默认 +08:00)切分;归档 gzip、按数量/天数清理;接口可列出、分页读取、下载(单个或多选打包 zip)、删除归档;返回的行时间前缀换算到 `logTimezone` |
127
+ | 负载监控 | 每 5 秒采样 PM2 进程指标(CPU、内存、运行时长、重启次数)与网关请求指标(QPS、每分钟请求数、平均 RT / P95 / P99、5xx 错误率、上游异常、并发);内存保留最近 10 分钟,SSE 连接先回放历史再实时推送 |
126
128
  | 鉴权 | `createAuthenticate` 可注入;默认尝试 `fastify.account.authenticate.admin` |
127
129
  | 数据运维 | 归属表浏览、行 CRUD、只读 query SQL、导出 zip、cleanup(无权限返回 SQL) |
128
130
 
@@ -202,7 +204,15 @@ await fastify.listen({ port: 3000 });
202
204
  | `npmInstallTimeoutMs` | number | 否 | `600000` | `server` 目录 `npm install` 超时 |
203
205
  | `sseReplayLines` | number | 否 | `100` | SSE 连接时回放最近行数 |
204
206
  | `sseHeartbeatMs` | number | 否 | `15000` | SSE 心跳间隔 |
205
- | `logMaxSize` | number | 否 | `52428800` | 预留:单日志文件上限(50MB) |
207
+ | `logMaxSize` | number | 否 | `52428800` | 单日志文件上限(50MB),达到即切分;`0` 关闭按大小切分 |
208
+ | `logTimezone` | string | 否 | `'+08:00'` | 日志时区:固定偏移(`'+08:00'`、`'-05:30'`)或 IANA 名称(`'Asia/Shanghai'`);非法值注册时抛错 |
209
+ | `logRotateDaily` | boolean | 否 | `true` | 按天切分(按 `logTimezone` 的零点) |
210
+ | `logRotateIntervalMs` | number | 否 | `60000` | 切分检查间隔(毫秒);`0` 关闭定时切分 |
211
+ | `logRetentionMaxFiles` | number | 否 | `10` | 每个 stream 最多保留的归档数;`0` 不限 |
212
+ | `logRetentionDays` | number | 否 | `30` | 归档最长保留天数(按文件修改时间);`0` 不限 |
213
+ | `logCompress` | boolean | 否 | `true` | 归档 gzip 压缩为 `.log.gz` |
214
+ | `loadSampleIntervalMs` | number | 否 | `5000` | 负载采样间隔(毫秒):PM2 进程指标 + 网关请求指标;`0` 关闭采样 |
215
+ | `loadHistoryMinutes` | number | 否 | `10` | 每个应用在内存中保留的负载历史时长(分钟),SSE 连接时回放 |
206
216
  | `logRetentionMaxRows` | number | 否 | `10000` | 预留:日志行保留上限 |
207
217
  | `sqlPath` | string | 否 | `'sql'` | 相对 `server/` 的 SQL 目录名 |
208
218
  | `migrateBeforeStart` | boolean | 否 | `false` | 为 true 且版本含迁移时,启动前执行 SQL |
@@ -268,7 +278,7 @@ await fastify.listen({ port: 3000 });
268
278
  |--------|------|------|------|
269
279
  | name | string | 是 | 应用 slug |
270
280
  | env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
271
- | secretEnvKeys | array | 否 | 显式密钥键名;传入则整体替换并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键);省略则沿用原列表 |
281
+ | secretEnvKeys | array | 否 | 新增的显式密钥键名,与原列表合并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键)。密钥类型不可撤销,只有删除该变量才会移出列表 |
272
282
 
273
283
  > **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
274
284
 
@@ -276,7 +286,7 @@ await fastify.listen({ port: 3000 });
276
286
 
277
287
  解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
278
288
 
279
- > **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(可用应用 env 覆盖)。子应用若使用 `@kne/fastify-sequelize@>=4.0.4`,存在该环境变量时默认 `forcePrefix`,连接上所有模型(含 account/message/tenant)表名必须以该前缀开头,无法被 `addModels({ prefix })` 覆盖。就绪后按该前缀自动认领表。
289
+ > **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_`,由系统写回应用 env 并维护:所有返回 env 的接口都不包含该键,`save` / `save-env` / `create` 传入的该键(含 `null` 删除)一律忽略。子应用若使用 `@kne/fastify-sequelize@>=4.0.4`,存在该环境变量时默认 `forcePrefix`,连接上所有模型(含 account/message/tenant)表名必须以该前缀开头,无法被 `addModels({ prefix })` 覆盖。就绪后按该前缀自动认领表。
280
290
 
281
291
  | 方法 | 路径 | 说明 |
282
292
  |------|------|------|
@@ -448,26 +458,82 @@ await fastify.listen({ port: 3000 });
448
458
 
449
459
  ##### GET `{prefix}/app/logs`
450
460
 
451
- 读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine` 向上滚动加载更早日志。
461
+ 读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine` 向上滚动加载更早日志。传 `file` 时读取指定归档(含 `.log.gz`),否则读当前 `stream` 文件。
452
462
 
453
463
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
454
464
  |--------|------|------|--------|------|
455
465
  | name | string | 是 | - | 应用 slug |
456
466
  | stream | string | 否 | `'out'` | `out` / `err` |
467
+ | file | string | 否 | - | 日志文件名(来自 `logs/files`);优先于 `stream`;非法名返回 400,不存在返回 404 |
457
468
  | perPage | number | 否 | `100` | 每页行数(最大 100) |
458
469
  | currentPage | number | 否 | `1` | 页码(1=最新一页;与 `beforeLine` 二选一) |
459
470
  | beforeLine | number | 否 | - | 取行号 `< beforeLine` 的尾部至多 `perPage` 行(上滚历史) |
460
471
 
461
- 返回:
472
+ 返回(行首 PM2 时间前缀已换算到 `logTimezone`):
462
473
 
463
474
  ```json
464
475
  {
465
- "pageData": [{ "line": 12, "content": "..." }],
476
+ "pageData": [{ "line": 12, "content": "2026-10-08T18:30:00: ..." }],
466
477
  "totalCount": 12,
467
478
  "hasMore": true
468
479
  }
469
480
  ```
470
481
 
482
+ ##### GET `{prefix}/app/logs/files`
483
+
484
+ 列出应用 `logs/` 下的当前文件与归档,当前文件在前,归档按修改时间倒序。
485
+
486
+ | 参数名 | 类型 | 必填 | 说明 |
487
+ |--------|------|------|------|
488
+ | name | string | 是 | 应用 slug |
489
+
490
+ 返回:
491
+
492
+ ```json
493
+ {
494
+ "pageData": [
495
+ { "fileName": "out.log", "stream": "out", "size": 1024, "compressed": false, "current": true, "mtime": "2026-10-08T18:30:00+08:00" },
496
+ { "fileName": "out-20261007-000012.log.gz", "stream": "out", "size": 20480, "compressed": true, "current": false, "mtime": "2026-10-08T00:00:40+08:00" }
497
+ ],
498
+ "totalCount": 2
499
+ }
500
+ ```
501
+
502
+ ##### GET `{prefix}/app/logs/download`
503
+
504
+ 流式下载日志文件,`Content-Disposition: attachment; filename="{name}-{file}"`;`.gz` 为 `application/gzip`,其余为 `text/plain`。
505
+
506
+ | 参数名 | 类型 | 必填 | 说明 |
507
+ |--------|------|------|------|
508
+ | name | string | 是 | 应用 slug |
509
+ | file | string | 是 | 日志文件名 |
510
+
511
+ > **注意**:下载内容为磁盘原始文件,行首时间前缀是 PM2 守护进程所在服务器的时区,未做 `logTimezone` 换算。
512
+
513
+ > **大文件**:服务端按 `stat` 时的大小设置 `Content-Length` 并流式输出,正在追加的当前日志只下载到请求时刻的长度。前端应使用浏览器原生下载(`<a href>` 导航,鉴权 token 走 query,与 SSE 一致),不要 `fetch` 后转 Blob,以免整个文件读进内存。
514
+
515
+ ##### GET `{prefix}/app/logs/download-zip`
516
+
517
+ 将多个日志文件流式打包为一个 zip 下载,`Content-Disposition: attachment; filename="{name}-logs-{YYYYMMDD-HHmmss}.zip"`(时间戳按 `logTimezone`)。`.gz` 归档原样存入(不二次压缩),其余文件 deflate 压缩;当前日志只打包到请求时刻的长度。响应为 chunked,无 `Content-Length`。
518
+
519
+ | 参数名 | 类型 | 必填 | 说明 |
520
+ |--------|------|------|------|
521
+ | name | string | 是 | 应用 slug |
522
+ | files | string[] | 是 | 日志文件名,query 中重复传参:`files=a&files=b`;1~100 个,重复项去重 |
523
+
524
+ 任一文件名非法返回 400,不存在返回 404。前端同样用原生 `<a href>` 下载,token 走 query。
525
+
526
+ ##### POST `{prefix}/app/logs/remove`
527
+
528
+ 删除选中的日志归档。
529
+
530
+ | 参数名 | 类型 | 必填 | 说明 |
531
+ |--------|------|------|------|
532
+ | name | string | 是 | 应用 slug |
533
+ | files | array | 是 | 归档文件名列表(至少 1 个);含当前文件 `out.log` / `err.log` 或非法名时整体返回 400 |
534
+
535
+ 返回 `{ "removed": ["out-20261007-000012.log.gz"] }`(已不存在的文件不计入)。
536
+
471
537
  ##### GET `{prefix}/app/logs/stream`
472
538
 
473
539
  SSE 实时日志。连接时先回放最近 `sseReplayLines` 行,再订阅 `log:{name}`。
@@ -483,7 +549,60 @@ SSE 实时日志。连接时先回放最近 `sseReplayLines` 行,再订阅 `lo
483
549
 
484
550
  ```text
485
551
  event: log
486
- data: {"appName":"demo","stream":"out","content":"...","line":1}
552
+ data: {"appName":"demo","stream":"out","content":"2026-10-08T18:30:00: ...","line":1,"loggedAt":"2026-10-08T18:30:00+08:00"}
553
+ ```
554
+
555
+ 实时事件的 `content` 前缀与 `loggedAt` 均按 `logTimezone` 输出;`line` 取当前文件行数,PM2 落盘略晚于 bus 时可能偏小 1 行。
556
+
557
+ ##### GET `{prefix}/app/load`
558
+
559
+ 应用当前负载与内存中的最近历史(最多 `loadHistoryMinutes` 分钟,旧在前)。
560
+
561
+ | 参数名 | 类型 | 必填 | 说明 |
562
+ |--------|------|------|------|
563
+ | name | string | 是 | 应用 slug;不存在返回 404 |
564
+
565
+ 返回:
566
+
567
+ ```json
568
+ {
569
+ "intervalMs": 5000,
570
+ "current": { "name": "demo", "ts": 1791456603533, "sampledAt": "2026-10-08T18:50:03+08:00", "status": "online", "cpu": 7.5, "memory": 52428800, "instances": 1, "pids": [4321], "uptime": 65061, "restarts": 2, "requests": { "qps": 0.8, "rpm": 48, "avgRt": 14, "p95": 18, "p99": 18, "errorRate": 0.25, "errors5xx": 1, "upstreamErrors": 0, "concurrency": 0, "peakConcurrency": 1 } },
571
+ "pageData": []
572
+ }
573
+ ```
574
+
575
+ 样本字段:
576
+
577
+ | 字段 | 说明 |
578
+ |------|------|
579
+ | `ts` / `sampledAt` | 采样时间:毫秒时间戳 / 按 `logTimezone` 的 ISO 字符串 |
580
+ | `status` | PM2 状态;任一实例 `online` 即为 `online`;PM2 中无该进程时为 `offline` |
581
+ | `cpu` / `memory` | 各实例 CPU% 之和 / RSS 字节之和 |
582
+ | `instances` / `pids` | PM2 实例数 / 在线实例 pid |
583
+ | `uptime` | 最早在线实例的运行时长(毫秒),不在线为 `0` |
584
+ | `restarts` | 各实例 PM2 重启次数之和(累计值) |
585
+ | `requests.qps` | 本采样周期请求数 / 周期秒数 |
586
+ | `requests.rpm` | 最近 60 秒请求数折算到每分钟 |
587
+ | `requests.avgRt` / `p95` / `p99` | 最近 60 秒响应时间(毫秒),无请求时为 `null`;分位数为对数分桶近似(误差约 5%) |
588
+ | `requests.errorRate` | 最近 60 秒 5xx 数 / 请求数,无请求时为 `null` |
589
+ | `requests.errors5xx` / `upstreamErrors` | 本周期 5xx 次数 / 上游连接失败、超时、重置次数(后者同时计入 5xx) |
590
+ | `requests.concurrency` / `peakConcurrency` | 采样时刻在途请求数 / 本周期在途峰值 |
591
+
592
+ ##### GET `{prefix}/app/load/stream`
593
+
594
+ SSE 实时负载。连接时先推送一次 `history`,之后每次采样推送 `load`。
595
+
596
+ | 参数名 | 类型 | 必填 | 说明 |
597
+ |--------|------|------|------|
598
+ | name | string | 是 | 应用 slug |
599
+
600
+ ```text
601
+ event: history
602
+ data: {"intervalMs":5000,"pageData":[{...}]}
603
+
604
+ event: load
605
+ data: {"name":"demo","ts":1791456608533,...}
487
606
  ```
488
607
 
489
608
  #### 程序化 API
@@ -506,10 +625,17 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
506
625
  | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
507
626
  | `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
508
627
  | `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
509
- | `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
628
+ | `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
629
+ | `logFiles({ name })` | 列出日志文件 |
630
+ | `resolveAppLogFile({ name, file })` | 校验并返回 `{ path, fileName, size, compressed }`(下载用) |
631
+ | `resolveAppLogFiles({ name, files })` | 批量校验(去重),返回 `{ name, files: [{ path, fileName, size, mtime, compressed }] }`(打包下载用) |
632
+ | `removeLogFiles({ name, files })` | 删除归档,返回 `{ removed }` |
633
+ | `load({ name })` | 返回 `{ intervalMs, current, pageData }` |
634
+ | `getLoadStore()` | 负载环形缓冲:`history(name)` / `latest(name)` / `emitter`(事件 `load:{name}`) |
510
635
  | `findByHost(host, { running? })` | 网关:按 domain 查找;默认只查 `running`,`running: false` 时不限状态 |
511
636
  | `findByPathName(name, { running? })` | 网关:按 name 查找;默认只查 `running`,`running: false` 时不限状态 |
512
- | `appendAppLog(appName, stream, content)` | 追加日志并 emit hub 事件 |
637
+ | `appendAppLog(appName, stream, content)` | 管理端主动写入:追加到当前日志文件并 emit hub 事件 |
638
+ | `emitAppLog(appName, stream, content)` | 只 emit hub 事件(PM2 bus 日志用,不写文件) |
513
639
  | `readLastLines(name, stream, n)` | SSE 回放用 |
514
640
  | `getLogHub()` | 获取 EventEmitter |
515
641
  | `toPublicApp(app)` / `mountPrefix(name)` | 序列化辅助 |
@@ -537,8 +663,10 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
537
663
 
538
664
  | 方法签名 | 说明 |
539
665
  |----------|------|
540
- | `onReady()` | 连接 PM2、挂 log bus,并 **setImmediate** 异步 `reconcile`(不阻塞父应用 ready) |
541
- | `onClose()` | 断开 bus / PM2(由插件 `onClose` 调用) |
666
+ | `onReady()` | 连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步 `reconcile`、一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
667
+ | `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
668
+ | `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
669
+ | `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
542
670
  | `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
543
671
 
544
672
  #### 数据模型
@@ -594,6 +722,46 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
594
722
  | Set-Cookie | `Path=/` 改为 `Path=/app/{name}` |
595
723
  | Body | 对文本类 Content-Type,将 `"/static/`、`"/api/`、`"/account/` 等前缀改写;有 `content-encoding` 时跳过 |
596
724
 
725
+ ##### 日志切分与归档
726
+
727
+ ```
728
+ PM2 守护进程 ──(out_file / error_file)──→ logs/out.log、err.log
729
+ └──(bus log:out / log:err)──→ logHub ──→ SSE
730
+ 定时器(每 logRotateIntervalMs)
731
+ ↓ size >= logMaxSize 或 跨天(logTimezone)
732
+ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip ──→ 保留清理
733
+ ```
734
+
735
+ | 机制 | 说明 |
736
+ |------|------|
737
+ | 写入者 | 仅 PM2 写日志文件;bus 只推 SSE,不再重复落盘 |
738
+ | 切分条件 | 文件非空且 `size >= logMaxSize`,或 `logRotateDaily` 下文件起始日期早于今天(按 `logTimezone`) |
739
+ | 起始时间 | 记录在 `logs/.rotate-state.json`,切分后更新为当前时间;缺失时用文件创建时间,不可用则用修改时间 |
740
+ | 归档命名 | `{stream}-YYYYMMDD-HHmmss.log[.gz]`,时间为该文件起始时间(`logTimezone`);重名追加 `-1`、`-2` |
741
+ | 不丢日志 | rename 后 PM2 仍写原 inode(即归档),`reloadLogs` 重开新文件后才压缩 |
742
+ | 保留 | 每个 stream 按修改时间倒序保留 `logRetentionMaxFiles` 个,且删除超过 `logRetentionDays` 天的归档 |
743
+ | 读取 | 当前文件从末尾分块反向读取,行数增量缓存;`.gz` 流式解压按行号区间读取 |
744
+
745
+ > **时区**:PM2 `time: true` 的行前缀由守护进程按服务器本地时区生成(无偏移,全局共享无法单独设置)。读取接口与 SSE 识别 `YYYY-MM-DDTHH:mm:ss: ` 前缀并换算为 `logTimezone`;磁盘文件与下载内容不改写。换算假设守护进程与宿主进程时区一致。
746
+
747
+ ##### 负载与请求指标
748
+
749
+ ```
750
+ 网关 proxyToApp ──begin / finish──→ requestMetrics(每应用:在途数 + 当前周期直方图)
751
+ 定时器(每 loadSampleIntervalMs)
752
+ pm2.list ──汇总同名实例──┐
753
+ requestMetrics.snapshot ─┴→ 样本 ──→ loadStore 环形缓冲 ──→ SSE load:{name}
754
+ ```
755
+
756
+ | 机制 | 说明 |
757
+ |------|------|
758
+ | 进程指标 | 来自 `pm2.list` 的 `monit` 与 `pm2_env`;不随访问量变化,应用不在 PM2 中时推送 `offline` 样本 |
759
+ | 统计范围 | 仅经网关反代的请求(域名与 `/app/{name}` 两种);应用未运行时网关返回的 503/404 状态页、直连 `127.0.0.1:{port}` 的请求(含健康检查)不统计;WebSocket 不经网关 |
760
+ | 响应时间 | 网关收到请求到响应发送完毕(或客户端断开),包含应用处理与传输时间 |
761
+ | 内存 | 每应用每周期最多约 230 个直方图桶,与请求量无关;历史只在内存,宿主重启后清空 |
762
+ | 多进程 | 宿主多进程部署时各进程分别统计,接口只返回当前进程的数据 |
763
+ | 异常 | fork 模式下 PM2 不上报应用内异常;以 `upstreamErrors`(连不上 / 超时)与 `errors5xx` 作为网关侧异常指标 |
764
+
597
765
  ##### 包校验与安全
598
766
 
599
767
  | 检查 | 失败行为 |
package/index.js CHANGED
@@ -2,6 +2,7 @@ const fp = require('fastify-plugin');
2
2
  const path = require('node:path');
3
3
  const fs = require('fs-extra');
4
4
  const { assertDefaultAppDbSeparated } = require('./libs/utils/dbIdentity');
5
+ const { parseTimezone } = require('./libs/utils/logTime');
5
6
 
6
7
  module.exports = fp(
7
8
  async (fastify, options) => {
@@ -26,6 +27,14 @@ module.exports = fp(
26
27
  sseReplayLines: 100,
27
28
  sseHeartbeatMs: 15000,
28
29
  logMaxSize: 50 * 1024 * 1024,
30
+ logTimezone: '+08:00',
31
+ logRotateDaily: true,
32
+ logRotateIntervalMs: 60 * 1000,
33
+ logRetentionMaxFiles: 10,
34
+ logRetentionDays: 30,
35
+ logCompress: true,
36
+ loadSampleIntervalMs: 5000,
37
+ loadHistoryMinutes: 10,
29
38
  passthroughEnvKeys: [],
30
39
  secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
31
40
  sqlPath: 'sql',
@@ -66,6 +75,8 @@ module.exports = fp(
66
75
  options
67
76
  );
68
77
 
78
+ parseTimezone(options.logTimezone);
79
+
69
80
  if (!options.defaultAppDb) {
70
81
  options.defaultAppDb = {
71
82
  dialect: 'sqlite',
@@ -1,5 +1,8 @@
1
1
  const fp = require('fastify-plugin');
2
+ const fs = require('fs-extra');
3
+ const yazl = require('yazl');
2
4
  const { createSseReply } = require('../utils/logStream');
5
+ const { formatInTz } = require('../utils/logTime');
3
6
 
4
7
  module.exports = fp(async (fastify, options) => {
5
8
  const { services } = fastify[options.name];
@@ -280,6 +283,10 @@ module.exports = fp(async (fastify, options) => {
280
283
  beforeLine: {
281
284
  type: 'number',
282
285
  description: 'Load up to perPage lines with line number < beforeLine (for scroll-up history)'
286
+ },
287
+ file: {
288
+ type: 'string',
289
+ description: 'Archive file name from /app/logs/files; overrides stream'
283
290
  }
284
291
  }
285
292
  }
@@ -288,6 +295,108 @@ module.exports = fp(async (fastify, options) => {
288
295
  async request => services.app.logs(request.query)
289
296
  );
290
297
 
298
+ fastify.get(
299
+ `${options.prefix}/app/logs/files`,
300
+ {
301
+ onRequest: auth(),
302
+ schema: {
303
+ summary: '列出应用日志文件(当前文件与归档)',
304
+ query: {
305
+ type: 'object',
306
+ required: ['name'],
307
+ properties: {
308
+ name: { type: 'string' }
309
+ }
310
+ }
311
+ }
312
+ },
313
+ async request => services.app.logFiles(request.query)
314
+ );
315
+
316
+ fastify.get(
317
+ `${options.prefix}/app/logs/download`,
318
+ {
319
+ onRequest: auth(),
320
+ schema: {
321
+ summary: '下载日志文件',
322
+ query: {
323
+ type: 'object',
324
+ required: ['name', 'file'],
325
+ properties: {
326
+ name: { type: 'string' },
327
+ file: { type: 'string' }
328
+ }
329
+ }
330
+ }
331
+ },
332
+ async (request, reply) => {
333
+ const { name } = request.query;
334
+ const { path: filePath, fileName, size, compressed } = await services.app.resolveAppLogFile(request.query);
335
+ reply.header('Content-Type', compressed ? 'application/gzip' : 'text/plain; charset=utf-8');
336
+ reply.header('Content-Disposition', `attachment; filename="${name}-${fileName}"`);
337
+ reply.header('Content-Length', size);
338
+ if (size === 0) {
339
+ return reply.send('');
340
+ }
341
+ // 当前日志仍在被 PM2 追加,只读到 stat 时的长度,保证与 Content-Length 一致
342
+ return reply.send(fs.createReadStream(filePath, { start: 0, end: size - 1 }));
343
+ }
344
+ );
345
+
346
+ fastify.get(
347
+ `${options.prefix}/app/logs/download-zip`,
348
+ {
349
+ onRequest: auth(),
350
+ schema: {
351
+ summary: '批量打包下载日志文件',
352
+ query: {
353
+ type: 'object',
354
+ required: ['name', 'files'],
355
+ properties: {
356
+ name: { type: 'string' },
357
+ files: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100 }
358
+ }
359
+ }
360
+ }
361
+ },
362
+ async (request, reply) => {
363
+ const { name, files } = await services.app.resolveAppLogFiles(request.query);
364
+ const zipFile = new yazl.ZipFile();
365
+ for (const { path: filePath, fileName, size, mtime, compressed } of files) {
366
+ if (size === 0) {
367
+ zipFile.addBuffer(Buffer.alloc(0), fileName, { mtime });
368
+ continue;
369
+ }
370
+ // .gz 归档已压缩,原样存入;当前日志只打包到 stat 时的长度
371
+ zipFile.addReadStream(fs.createReadStream(filePath, { start: 0, end: size - 1 }), fileName, { mtime, size, compress: !compressed });
372
+ }
373
+ zipFile.end();
374
+ const stamp = formatInTz(new Date(), options.logTimezone, 'YYYYMMDD-HHmmss');
375
+ reply.header('Content-Type', 'application/zip');
376
+ reply.header('Content-Disposition', `attachment; filename="${name}-logs-${stamp}.zip"`);
377
+ return reply.send(zipFile.outputStream);
378
+ }
379
+ );
380
+
381
+ fastify.post(
382
+ `${options.prefix}/app/logs/remove`,
383
+ {
384
+ onRequest: auth(),
385
+ schema: {
386
+ summary: '删除选中的日志归档',
387
+ body: {
388
+ type: 'object',
389
+ required: ['name', 'files'],
390
+ properties: {
391
+ name: { type: 'string' },
392
+ files: { type: 'array', minItems: 1, items: { type: 'string' } }
393
+ }
394
+ }
395
+ }
396
+ },
397
+ async request => services.app.removeLogFiles(request.body)
398
+ );
399
+
291
400
  fastify.get(
292
401
  `${options.prefix}/app/logs/stream`,
293
402
  {
@@ -337,4 +446,54 @@ module.exports = fp(async (fastify, options) => {
337
446
  });
338
447
  }
339
448
  );
449
+
450
+ fastify.get(
451
+ `${options.prefix}/app/load`,
452
+ {
453
+ onRequest: auth(),
454
+ schema: {
455
+ summary: '应用当前负载与最近历史',
456
+ query: {
457
+ type: 'object',
458
+ required: ['name'],
459
+ properties: {
460
+ name: { type: 'string' }
461
+ }
462
+ }
463
+ }
464
+ },
465
+ async request => services.app.load(request.query)
466
+ );
467
+
468
+ fastify.get(
469
+ `${options.prefix}/app/load/stream`,
470
+ {
471
+ onRequest: auth(),
472
+ schema: {
473
+ summary: 'SSE 实时负载',
474
+ query: {
475
+ type: 'object',
476
+ required: ['name'],
477
+ properties: {
478
+ name: { type: 'string' }
479
+ }
480
+ }
481
+ }
482
+ },
483
+ async (request, reply) => {
484
+ const { name } = request.query;
485
+ const { intervalMs, pageData } = await services.app.load({ name });
486
+ const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
487
+ sse.writeEvent('history', { intervalMs, pageData });
488
+
489
+ const store = services.app.getLoadStore();
490
+ const onLoad = sample => sse.writeEvent('load', sample);
491
+ const eventName = `load:${name}`;
492
+ store?.emitter.on(eventName, onLoad);
493
+ request.raw.on('close', () => {
494
+ store?.emitter.off(eventName, onLoad);
495
+ sse.close();
496
+ });
497
+ }
498
+ );
340
499
  });
@@ -22,6 +22,13 @@ module.exports = fp(async (fastify, options) => {
22
22
  const dest = `http://127.0.0.1:${app.port}${targetPath}`;
23
23
  const mountPrefix = `${pathPrefix}/${app.name}`;
24
24
 
25
+ const tracker = fastify[ns].requestMetrics?.begin(app.name);
26
+ if (tracker) {
27
+ const finish = () => tracker.end({ statusCode: reply.raw.statusCode });
28
+ reply.raw.once('finish', finish);
29
+ reply.raw.once('close', finish);
30
+ }
31
+
25
32
  // The gateway runs in onRequest, before body parsing; reply-from only forwards request.body,
26
33
  // so hand it the raw stream or POST/PUT bodies reach the app empty.
27
34
  if (request.method !== 'GET' && request.method !== 'HEAD') {
@@ -29,6 +36,10 @@ module.exports = fp(async (fastify, options) => {
29
36
  }
30
37
 
31
38
  return reply.from(dest, {
39
+ onError: (reply, { error }) => {
40
+ tracker?.markUpstreamError();
41
+ reply.send(error);
42
+ },
32
43
  rewriteRequestHeaders: (req, headers) => {
33
44
  const next = { ...headers };
34
45
  delete next['content-length'];