create-ssnode-cli 0.2.4 → 0.2.5
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/package.json +1 -1
- package/templates/backend/config/config.develop.yaml +19 -0
- package/templates/backend/config/config.prod.yaml +19 -0
- package/templates/backend/package.json +1 -1
- package/templates/backend/scripts/slowsql-report.mjs +281 -0
- package/templates/backend/src/app.module.ts +8 -1
- package/templates/backend/src/common/all-exceptions.filter.ts +38 -1
- package/templates/backend/src/common/api-log/api-log.interceptor.ts +52 -0
- package/templates/backend/src/common/api-log/api-log.module.ts +13 -0
- package/templates/backend/src/common/api-log/api-log.service.ts +148 -0
- package/templates/backend/src/common/decorators/skip-transform.decorator.ts +9 -0
- package/templates/backend/src/common/jsonl-daily-writer.ts +282 -0
- package/templates/backend/src/common/transform.interceptor.ts +9 -0
- package/templates/backend/src/config/config.interface.ts +29 -0
- package/templates/backend/src/config/configuration.ts +18 -0
- package/templates/backend/src/database/host-metrics.sampler.ts +275 -0
- package/templates/backend/src/database/slow-sql.logger.ts +239 -0
- package/templates/backend/src/database/sql-fingerprint.ts +32 -0
- package/templates/backend/src/database/sqlite.config.ts +21 -0
- package/templates/backend/src/main.ts +24 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 跳过统一响应包装({ code, message, data })装饰器
|
|
5
|
+
* 用于 SSE 等事件流路由:响应体是 event-stream 事件序列,不能包 JSON。
|
|
6
|
+
* TransformInterceptor 检测此元数据直接放行。
|
|
7
|
+
*/
|
|
8
|
+
export const SKIP_TRANSFORM_KEY = 'skipTransform';
|
|
9
|
+
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 按日切 JSONL 写盘器(《后端日志体系整改方案》§2.5,自 slow-sql.logger 抽公共)
|
|
6
|
+
*
|
|
7
|
+
* slowsql(性能证据)与 apilog(运维调试)共用的写盘纪律:
|
|
8
|
+
* - 按日切文件 <prefix>-YYYY-MM-DD.jsonl,换日/首写 statSync 继承既有长度
|
|
9
|
+
* - 单文件上限 MB,超限滚动瘦身(2026-09-24 由丢新条改留新去旧):从文件头砍
|
|
10
|
+
* ~50%(行边界对齐,临时文件+原子 rename),排障时刻的日志价值最高,宁可丢旧。
|
|
11
|
+
* droppedCount 语义=滚动淘汰的旧行数(不静默:调用方随 stats 留痕)
|
|
12
|
+
* - 每日清理超保留期文件(按文件名日期判定,失败静默——日志器绝不拖死业务)
|
|
13
|
+
* - 长驻 append 流(2026-09-24 由同步 append 改造):每日只开一次 fd,write 全程
|
|
14
|
+
* 零同步 I/O、零 syscall 放大——故障风暴时(DB 挂掉每请求都触发登记)日志器
|
|
15
|
+
* 不再给事件循环加码。write() 签名保持同步 boolean,调用方零改动。
|
|
16
|
+
*
|
|
17
|
+
* 三个善后机制:
|
|
18
|
+
* - 进程 exit 兜底:未 ack(未到内核)的行同步 appendFileSync 补写;极端时序下
|
|
19
|
+
* 可能与已在途的写重复一条,崩溃路径的排障日志容忍重复
|
|
20
|
+
* - 错误自愈:流挂 'error'(磁盘满/只读 fs)→ 销毁置空,下次 write 重开并重新
|
|
21
|
+
* statSync 继承长度
|
|
22
|
+
* - 内存背压:已入队未 ack 行超 4MB 硬顶丢条计数(防磁盘卡死时内存无限涨),
|
|
23
|
+
* 与单日上限共用 droppedCount 口径
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 本地时区偏移时间戳(2026-09-21T14:03:05.120+08:00;E2E 时区回传踩过坑,统一带偏移) */
|
|
27
|
+
export function localTs(d = new Date()): string {
|
|
28
|
+
const pad = (n: number, len = 2) => String(Math.abs(n)).padStart(len, '0');
|
|
29
|
+
const off = -d.getTimezoneOffset();
|
|
30
|
+
const sign = off >= 0 ? '+' : '-';
|
|
31
|
+
return (
|
|
32
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
33
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +
|
|
34
|
+
`${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JsonlDailyWriterOptions {
|
|
39
|
+
/** 落盘目录 */
|
|
40
|
+
dir: string;
|
|
41
|
+
/** 文件名前缀(slowsql / apilog) */
|
|
42
|
+
prefix: string;
|
|
43
|
+
/** 保留天数 */
|
|
44
|
+
retainDays: number;
|
|
45
|
+
/** 单文件上限 MB(超限滚动瘦身,留新去旧) */
|
|
46
|
+
maxFileMb: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 未 ack 行的内存背压硬顶(防磁盘卡死时缓冲无限涨;超限丢条计数) */
|
|
50
|
+
const MAX_PENDING_BYTES = 4 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** 滚动瘦身的淘汰比例(砍头部 50%,留同等余量摊薄重写频率:约每半容量新日志才再触发) */
|
|
53
|
+
const TRIM_KEEP_RATIO = 0.5;
|
|
54
|
+
|
|
55
|
+
/** 本地时区日期键(与文件名 <prefix>-YYYY-MM-DD.jsonl 对齐) */
|
|
56
|
+
function dayKeyOf(d: Date): string {
|
|
57
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class JsonlDailyWriter {
|
|
61
|
+
private readonly opts: JsonlDailyWriterOptions;
|
|
62
|
+
private dayKey = '';
|
|
63
|
+
private writtenBytes = 0;
|
|
64
|
+
private stream: fs.WriteStream | null = null;
|
|
65
|
+
/** 已入队未 ack 的行(write 回调=已到内核;exit 兜底同步补写的依据) */
|
|
66
|
+
private pending: Array<{ line: string; bytes: number }> = [];
|
|
67
|
+
private pendingBytes = 0;
|
|
68
|
+
/** flush() 等待者:pending 清空时唤醒 */
|
|
69
|
+
private flushWaiters: Array<() => void> = [];
|
|
70
|
+
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
71
|
+
/** exit 兜底(process.on 须持引用,stop 时移除防泄漏) */
|
|
72
|
+
private readonly exitHandler = (): void => this.emergencyFlush();
|
|
73
|
+
|
|
74
|
+
/** 滚动淘汰的旧行数(不静默丢弃:调用方随 stats/console 留痕) */
|
|
75
|
+
public droppedCount = 0;
|
|
76
|
+
|
|
77
|
+
constructor(opts: JsonlDailyWriterOptions) {
|
|
78
|
+
this.opts = opts;
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(this.opts.dir, { recursive: true });
|
|
81
|
+
} catch {
|
|
82
|
+
// 目录建不了(只读 fs 等)后续每次写盘都会静默失败——写盘器不拖死业务
|
|
83
|
+
}
|
|
84
|
+
process.on('exit', this.exitHandler);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 写一条(超单文件上限滚动瘦身/内存背压丢条);返回是否接受落盘 */
|
|
88
|
+
write(record: Record<string, unknown>): boolean {
|
|
89
|
+
try {
|
|
90
|
+
const dayKey = dayKeyOf(new Date());
|
|
91
|
+
if (dayKey !== this.dayKey || !this.stream) {
|
|
92
|
+
this.rotate(dayKey);
|
|
93
|
+
if (!this.stream) return false;
|
|
94
|
+
}
|
|
95
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
96
|
+
// 字节计量用 UTF-8 实长(中文一条 3 字节;原 line.length 按 UTF-16 单位少算)
|
|
97
|
+
const bytes = Buffer.byteLength(line);
|
|
98
|
+
const cap = this.opts.maxFileMb * 1024 * 1024;
|
|
99
|
+
if (this.writtenBytes + bytes > cap) {
|
|
100
|
+
// 留新去旧:砍文件头腾地方;trim 内部会关流,成功后 rotate 重开(继承瘦身后长度)
|
|
101
|
+
if (!this.trim() || !this.rotate(dayKey) || !this.stream || this.writtenBytes + bytes > cap) {
|
|
102
|
+
this.droppedCount += 1;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (this.pendingBytes + bytes > MAX_PENDING_BYTES) {
|
|
107
|
+
this.droppedCount += 1;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
// 回调=该行已到内核(后续同步读可见):ack 出队并唤醒 flush 等待者。
|
|
111
|
+
// 带错回调不 ack(行仍留 pending,由 'error' 处理器同步补写),防写失败被误标落盘
|
|
112
|
+
this.stream.write(line, (err?: Error | null) => {
|
|
113
|
+
if (!err) this.ack();
|
|
114
|
+
});
|
|
115
|
+
this.pending.push({ line, bytes });
|
|
116
|
+
this.pendingBytes += bytes;
|
|
117
|
+
this.writtenBytes += bytes;
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
// 写盘失败(磁盘满/只读 fs):静默——写盘器绝不拖死业务
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 等所有已入队行到内核(E2E/脚本断言文件内容前用;kill -9 仍拦不住,那是流的固有代价) */
|
|
126
|
+
flush(): Promise<void> {
|
|
127
|
+
if (this.pending.length === 0) return Promise.resolve();
|
|
128
|
+
return new Promise<void>((resolve) => this.flushWaiters.push(resolve));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 删除超过保留期的 <prefix>-YYYY-MM-DD.jsonl(按文件名日期判定;失败静默);返回删除数 */
|
|
132
|
+
cleanupExpired(): number {
|
|
133
|
+
let removed = 0;
|
|
134
|
+
try {
|
|
135
|
+
const cutoff = Date.now() - this.opts.retainDays * 24 * 3_600_000;
|
|
136
|
+
const pattern = new RegExp(`^${this.opts.prefix}-(\\d{4})-(\\d{2})-(\\d{2})\\.jsonl$`);
|
|
137
|
+
for (const name of fs.readdirSync(this.opts.dir)) {
|
|
138
|
+
const m = name.match(pattern);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const fileDate = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
|
|
141
|
+
if (fileDate < cutoff) {
|
|
142
|
+
try {
|
|
143
|
+
fs.unlinkSync(path.join(this.opts.dir, name));
|
|
144
|
+
removed += 1;
|
|
145
|
+
} catch {
|
|
146
|
+
// 单文件删除失败不中断其余清理
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// 目录不可读等:静默,下次再试
|
|
152
|
+
}
|
|
153
|
+
return removed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 启动每日自动清理(unref,不阻退出) */
|
|
157
|
+
startDailyCleanup(): void {
|
|
158
|
+
if (this.cleanupTimer) return;
|
|
159
|
+
this.cleanupTimer = setInterval(() => this.cleanupExpired(), 24 * 3_600_000);
|
|
160
|
+
this.cleanupTimer.unref();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 关流冲刷并停掉定时器(测试与脚本场景防悬挂);返回后文件内容确定性可见 */
|
|
164
|
+
stop(): void {
|
|
165
|
+
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
|
166
|
+
this.cleanupTimer = null;
|
|
167
|
+
process.removeListener('exit', this.exitHandler);
|
|
168
|
+
this.emergencyFlush();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── 内部 ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** 换日/首写/错误重开:旧流冲干净 → 继承新文件长度 → 开新 append 流;返回流是否可用 */
|
|
174
|
+
private rotate(dayKey: string): boolean {
|
|
175
|
+
// 先冲干净旧流:pending 全清零后,后续 emergencyFlush 只会写当前日的行
|
|
176
|
+
// (换日本就每日一次,这一次同步补写无性能意义可言)
|
|
177
|
+
this.emergencyFlush();
|
|
178
|
+
this.dayKey = dayKey;
|
|
179
|
+
// 换日/首写:继承既有文件长度计数
|
|
180
|
+
try {
|
|
181
|
+
const st = fs.statSync(path.join(this.opts.dir, `${this.opts.prefix}-${dayKey}.jsonl`));
|
|
182
|
+
this.writtenBytes = st.size;
|
|
183
|
+
} catch {
|
|
184
|
+
this.writtenBytes = 0;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
this.stream = fs.createWriteStream(path.join(this.opts.dir, `${this.opts.prefix}-${dayKey}.jsonl`), {
|
|
188
|
+
flags: 'a'
|
|
189
|
+
});
|
|
190
|
+
// 磁盘满/只读 fs 等:销毁置空,本次已入队行同步补写,下次 write 重开(重新 statSync 继承长度)
|
|
191
|
+
this.stream.on('error', () => this.emergencyFlush());
|
|
192
|
+
} catch {
|
|
193
|
+
this.stream = null;
|
|
194
|
+
}
|
|
195
|
+
return this.stream !== null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 滚动瘦身(留新去旧):从文件头砍 ~50%,切点对齐行边界('\n' 之后),
|
|
200
|
+
* 临时文件+原子 rename 覆盖(中途崩溃原文件完好,不会出现半截行)。
|
|
201
|
+
* 淘汰行数计入 droppedCount。失败返回 false(调用方退回丢新条,绝不拖死业务)。
|
|
202
|
+
*/
|
|
203
|
+
private trim(): boolean {
|
|
204
|
+
if (!this.dayKey) return false;
|
|
205
|
+
const file = path.join(this.opts.dir, `${this.opts.prefix}-${this.dayKey}.jsonl`);
|
|
206
|
+
try {
|
|
207
|
+
// 先把未 ack 行落盘,文件才是完整现场;顺带关流(rename 期间不再有并发写入)
|
|
208
|
+
this.emergencyFlush();
|
|
209
|
+
let content: Buffer;
|
|
210
|
+
try {
|
|
211
|
+
content = fs.readFileSync(file);
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (content.length === 0) {
|
|
216
|
+
this.writtenBytes = 0;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
// 切点:target 之后的第一个换行;找不到(单行巨行占满后半)退到首个换行——
|
|
220
|
+
// 保证至少前进一行,否则瘦身无意义。整文件无换行则放弃(返回 false 丢新条)
|
|
221
|
+
const target = Math.floor(content.length * TRIM_KEEP_RATIO);
|
|
222
|
+
let cut = target > 0 ? content.indexOf(0x0a, target) : -1;
|
|
223
|
+
if (cut === -1) cut = content.indexOf(0x0a);
|
|
224
|
+
if (cut === -1) return false;
|
|
225
|
+
const tail = content.subarray(cut + 1);
|
|
226
|
+
// 淘汰行数计数(不静默:droppedCount 语义=滚动淘汰的旧行)
|
|
227
|
+
let trimmedLines = 0;
|
|
228
|
+
for (let i = 0; i <= cut; i++) {
|
|
229
|
+
if (content[i] === 0x0a) trimmedLines += 1;
|
|
230
|
+
}
|
|
231
|
+
const tmp = `${file}.trim-${process.pid}.tmp`;
|
|
232
|
+
fs.writeFileSync(tmp, tail);
|
|
233
|
+
fs.renameSync(tmp, file);
|
|
234
|
+
this.writtenBytes = tail.length;
|
|
235
|
+
this.droppedCount += trimmedLines;
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
// 瘦身失败(磁盘满/只读 fs/读不了):退回丢新条
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 兜底冲刷:销毁流(丢弃其内部缓冲)→ 未 ack 行同步 appendFileSync → 唤醒等待者 */
|
|
244
|
+
private emergencyFlush(): void {
|
|
245
|
+
const stream = this.stream;
|
|
246
|
+
this.stream = null;
|
|
247
|
+
if (stream) {
|
|
248
|
+
try {
|
|
249
|
+
stream.destroy();
|
|
250
|
+
} catch {
|
|
251
|
+
// 已关/已销毁
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const pending = this.pending;
|
|
255
|
+
this.pending = [];
|
|
256
|
+
this.pendingBytes = 0;
|
|
257
|
+
if (pending.length > 0) {
|
|
258
|
+
try {
|
|
259
|
+
fs.appendFileSync(
|
|
260
|
+
path.join(this.opts.dir, `${this.opts.prefix}-${this.dayKey}.jsonl`),
|
|
261
|
+
pending.map((p) => p.line).join('')
|
|
262
|
+
);
|
|
263
|
+
} catch {
|
|
264
|
+
// 磁盘满/只读 fs:与运行期同口径静默
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const waiters = this.flushWaiters;
|
|
268
|
+
this.flushWaiters = [];
|
|
269
|
+
for (const w of waiters) w();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 单行 ack:write 回调已确认到内核;pending 清空时唤醒 flush 等待者 */
|
|
273
|
+
private ack(): void {
|
|
274
|
+
const head = this.pending.shift();
|
|
275
|
+
if (head) this.pendingBytes -= head.bytes;
|
|
276
|
+
if (this.pending.length === 0 && this.flushWaiters.length > 0) {
|
|
277
|
+
const waiters = this.flushWaiters;
|
|
278
|
+
this.flushWaiters = [];
|
|
279
|
+
for (const w of waiters) w();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -4,19 +4,28 @@ import {
|
|
|
4
4
|
Injectable,
|
|
5
5
|
NestInterceptor
|
|
6
6
|
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
7
8
|
import { Observable, map } from 'rxjs';
|
|
9
|
+
import { SKIP_TRANSFORM_KEY } from './decorators/skip-transform.decorator';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* 统一响应拦截器
|
|
11
13
|
* 控制器/服务返回任意对象 -> 包成 { code:200, message:'success', data }
|
|
12
14
|
* 二进制流(Stream) -> 原样输出(文件下载)
|
|
13
15
|
* null/undefined -> { code:200, message:'success', data:null }
|
|
16
|
+
* @SkipTransform() 标记的路由(SSE 事件流)-> 原样输出
|
|
14
17
|
*
|
|
15
18
|
* 服务层统一返回纯数据(含 string,如 token),由这里统一包装。
|
|
16
19
|
*/
|
|
17
20
|
@Injectable()
|
|
18
21
|
export class TransformInterceptor implements NestInterceptor {
|
|
22
|
+
constructor(private readonly reflector: Reflector) {}
|
|
23
|
+
|
|
19
24
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
25
|
+
// SSE 等事件流路由:跳过包装,事件对象原样透传
|
|
26
|
+
if (this.reflector.get<boolean>(SKIP_TRANSFORM_KEY, context.getHandler())) {
|
|
27
|
+
return next.handle();
|
|
28
|
+
}
|
|
20
29
|
return next.handle().pipe(
|
|
21
30
|
map((data) => {
|
|
22
31
|
if (data === undefined || data === null) {
|
|
@@ -69,4 +69,33 @@ export interface AppConfig {
|
|
|
69
69
|
/** 时间戳允许偏差(秒),默认 300(±5min) */
|
|
70
70
|
tsWindow: number;
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* 慢SQL探针(《慢SQL探针与SQL优化路径方案》):挂 TypeORM Logger 层,驱动无关
|
|
74
|
+
* (better-sqlite3/mysql 同钩子 maxQueryExecutionTime → logQuerySlow)
|
|
75
|
+
*/
|
|
76
|
+
slowSql: {
|
|
77
|
+
/** 落盘目录(cwd 相对;docker 下随 /app/data volume,默认 data/slowsql) */
|
|
78
|
+
dir: string;
|
|
79
|
+
/** 慢查询阈值 ms,同时作为 TypeORM maxQueryExecutionTime */
|
|
80
|
+
thresholdMs: number;
|
|
81
|
+
/** 事件循环漂移记录阈值 ms */
|
|
82
|
+
lagMs: number;
|
|
83
|
+
/** 宿主基线落盘周期 s(内存环形采样固定 10s 不外配) */
|
|
84
|
+
hostSampleSec: number;
|
|
85
|
+
/** 日志保留天数 */
|
|
86
|
+
retainDays: number;
|
|
87
|
+
/** 单日文件上限 MB(超限滚动瘦身留新去旧,丢条计数随 stats 留痕) */
|
|
88
|
+
maxFileMb: number;
|
|
89
|
+
};
|
|
90
|
+
/** 运维调试日志(《后端日志体系整改方案》§2):API 失败(≥400)/慢请求登记,JSONL 落盘 */
|
|
91
|
+
apiLog: {
|
|
92
|
+
/** 落盘目录(cwd 相对;docker 下随 /app/data volume,默认 data/apilog) */
|
|
93
|
+
dir: string;
|
|
94
|
+
/** 慢请求阈值 ms(超时记 slow;2xx 成功不记) */
|
|
95
|
+
slowMs: number;
|
|
96
|
+
/** 日志保留天数 */
|
|
97
|
+
retainDays: number;
|
|
98
|
+
/** 单日文件上限 MB(超限滚动瘦身留新去旧,每 500 条 warn 留痕) */
|
|
99
|
+
maxFileMb: number;
|
|
100
|
+
};
|
|
72
101
|
}
|
|
@@ -114,6 +114,24 @@ export default (): AppConfig => {
|
|
|
114
114
|
enabled: envBool('SSO_ENABLED', config.sso.enabled) as boolean,
|
|
115
115
|
ticketKey: envStr('SSO_TICKET_KEY', config.sso.ticketKey) as string,
|
|
116
116
|
tsWindow: envInt('SSO_TS_WINDOW', config.sso.tsWindow) as number
|
|
117
|
+
},
|
|
118
|
+
// 慢SQL探针 + 运维调试日志:与全局「env > yaml > 内置默认」语义一致,
|
|
119
|
+
// 老配置文件不带 slowSql/apiLog 段也能起。
|
|
120
|
+
slowSql: {
|
|
121
|
+
// 落盘目录默认 cwd/data/slowsql(docker=volume /app/data/slowsql,随数据卷持久化)
|
|
122
|
+
dir: envStr('SLOW_SQL_DIR', config.slowSql?.dir ?? 'data/slowsql') as string,
|
|
123
|
+
thresholdMs: envInt('SLOW_SQL_THRESHOLD_MS', config.slowSql?.thresholdMs ?? 50) as number,
|
|
124
|
+
lagMs: envInt('SLOW_SQL_LAG_MS', config.slowSql?.lagMs ?? 200) as number,
|
|
125
|
+
hostSampleSec: envInt('SLOW_SQL_HOST_SAMPLE_SEC', config.slowSql?.hostSampleSec ?? 60) as number,
|
|
126
|
+
retainDays: envInt('SLOW_SQL_RETAIN_DAYS', config.slowSql?.retainDays ?? 14) as number,
|
|
127
|
+
maxFileMb: envInt('SLOW_SQL_MAX_FILE_MB', config.slowSql?.maxFileMb ?? 10) as number
|
|
128
|
+
},
|
|
129
|
+
// 运维调试日志:只记 ≥400 失败与 >slowMs 慢请求,2xx 成功不记
|
|
130
|
+
apiLog: {
|
|
131
|
+
dir: envStr('API_LOG_DIR', config.apiLog?.dir ?? 'data/apilog') as string,
|
|
132
|
+
slowMs: envInt('API_LOG_SLOW_MS', config.apiLog?.slowMs ?? 1000) as number,
|
|
133
|
+
retainDays: envInt('API_LOG_RETAIN_DAYS', config.apiLog?.retainDays ?? 14) as number,
|
|
134
|
+
maxFileMb: envInt('API_LOG_MAX_FILE_MB', config.apiLog?.maxFileMb ?? 10) as number
|
|
117
135
|
}
|
|
118
136
|
};
|
|
119
137
|
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 宿主资源采样器(《慢SQL探针与SQL优化路径方案》§5)
|
|
6
|
+
*
|
|
7
|
+
* 场景:同库同 SQL 本机飞快、线上偶发慢,且服务器混部多个服务——慢的可能不是 SQL,
|
|
8
|
+
* 是宿主 CPU/磁盘被别的服务抢走。本采样器提供「宿主视角」,与慢 SQL / lag 记录
|
|
9
|
+
* 时刻对齐后才构成归因证据(§5.2 判别矩阵)。
|
|
10
|
+
*
|
|
11
|
+
* 容器视角口径(刻意为之):
|
|
12
|
+
* - 容器内读 /proc/stat、/proc/meminfo、os.loadavg() 拿到的是宿主整机值——
|
|
13
|
+
* 混部场景要的正是这个视角(邻居服务在抢什么);
|
|
14
|
+
* - cgroup memory.current/max 补「容器自身被限额挤压」的判断;
|
|
15
|
+
* - 自进程 CPU(process.cpuUsage 差值)是关键对照:自进程 CPU 低 + 宿主
|
|
16
|
+
* iowait/steal 高 = 被邻居抢;反之 = 自己的锅。
|
|
17
|
+
*
|
|
18
|
+
* 平台降级:macOS(本机 dev)无 /proc,iowait/steal/PSI/cgroup 缺省(null),
|
|
19
|
+
* 退到 os.loadavg/freemem;目标是线上 Linux,dev 不报错即可。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 单次采样快照(嵌入慢 SQL / lag 记录的 host 字段,或独立落 host 基线记录) */
|
|
23
|
+
export interface HostSnapshot {
|
|
24
|
+
/** 宿主 CPU 总占用 %(/proc/stat 差值;首轮 null) */
|
|
25
|
+
cpuPct: number | null;
|
|
26
|
+
/** 宿主 iowait %——磁盘被抢的直接指标 */
|
|
27
|
+
iowaitPct: number | null;
|
|
28
|
+
/** 宿主 steal %(虚拟机被宿主/邻居抢走的时间片) */
|
|
29
|
+
stealPct: number | null;
|
|
30
|
+
/** 宿主 load 1/5/15 */
|
|
31
|
+
load: [number, number, number] | null;
|
|
32
|
+
/** 宿主可用内存 MB */
|
|
33
|
+
memAvailMb: number | null;
|
|
34
|
+
/** 宿主 swap 已用 MB */
|
|
35
|
+
swapUsedMb: number | null;
|
|
36
|
+
/** 容器内存用量 MB(cgroup) */
|
|
37
|
+
cgMemMb: number | null;
|
|
38
|
+
/** 容器内存限额 MB("max"/ Unlimited 时 null) */
|
|
39
|
+
cgMemMaxMb: number | null;
|
|
40
|
+
/** 自进程 CPU %(单核口径,>100 即多核) */
|
|
41
|
+
procCpuPct: number | null;
|
|
42
|
+
/** 自进程 RSS MB */
|
|
43
|
+
rssMb: number | null;
|
|
44
|
+
/** PSI(内核 ≥4.20):cpu/io/memory 的 some avg10 */
|
|
45
|
+
psiCpu: number | null;
|
|
46
|
+
psiIo: number | null;
|
|
47
|
+
psiMem: number | null;
|
|
48
|
+
/** 数据卷磁盘用量 %(《后端日志体系整改方案》§6:日志/时序表/uploads/DB 的容量风险
|
|
49
|
+
* 最终都指向磁盘,却是此前唯一无日志证据的指标;statfs 双平台可得,读不到则 null) */
|
|
50
|
+
diskUsedPct: number | null;
|
|
51
|
+
/** 数据卷磁盘剩余 MB */
|
|
52
|
+
diskAvailMb: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isLinux = process.platform === 'linux';
|
|
56
|
+
const MB = 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
/** 读小文件文本,失败返回 null(路径不存在/权限等一律降级) */
|
|
59
|
+
function readText(file: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(file, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** /proc/stat 首行 cpu 聚合 tick(顺序固定:user nice system idle iowait irq softirq steal) */
|
|
68
|
+
function parseProcStat(text: string): number[] | null {
|
|
69
|
+
const line = text.split('\n', 1)[0]?.trim();
|
|
70
|
+
if (!line?.startsWith('cpu ')) return null;
|
|
71
|
+
const cols = line.slice(4).trim().split(/\s+/).map(Number);
|
|
72
|
+
return cols.length >= 8 && cols.every((n) => Number.isFinite(n)) ? cols : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** /proc/pressure/xxx → "some avg10=.. avg60=.. avg300=.. full ..." 取 some avg10 */
|
|
76
|
+
function parsePsi(text: string | null): number | null {
|
|
77
|
+
if (!text) return null;
|
|
78
|
+
const m = text.match(/some\s+avg10=([\d.]+)/);
|
|
79
|
+
return m ? Number(m[1]) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** cgroup 内存用量/限额(v2 优先,v1 fallback;均读不到则 null) */
|
|
83
|
+
function readCgroupMem(): { usedMb: number | null; maxMb: number | null } {
|
|
84
|
+
// v2
|
|
85
|
+
let cur = readText('/sys/fs/cgroup/memory.current');
|
|
86
|
+
let max = readText('/sys/fs/cgroup/memory.max');
|
|
87
|
+
// v1
|
|
88
|
+
if (cur === null || max === null) {
|
|
89
|
+
cur = readText('/sys/fs/cgroup/memory/memory.usage_in_bytes');
|
|
90
|
+
max = readText('/sys/fs/cgroup/memory/memory.limit_in_bytes');
|
|
91
|
+
}
|
|
92
|
+
const usedMb = cur !== null ? Math.round(Number(cur) / MB) : null;
|
|
93
|
+
// v2 无限额时值为字面量 "max"
|
|
94
|
+
const maxMb = max !== null && /^\d+$/.test(max.trim()) ? Math.round(Number(max) / MB) : null;
|
|
95
|
+
return { usedMb: Number.isFinite(usedMb) ? usedMb : null, maxMb };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** /proc/meminfo 取 MemAvailable / SwapFree / SwapTotal(kB → MB) */
|
|
99
|
+
function readMeminfo(): { memAvailMb: number | null; swapUsedMb: number | null } {
|
|
100
|
+
const text = readText('/proc/meminfo');
|
|
101
|
+
if (!text) return { memAvailMb: null, swapUsedMb: null };
|
|
102
|
+
const map = new Map<string, number>();
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const m = line.match(/^(\w+):\s+(\d+)\s*kB/);
|
|
105
|
+
if (m) map.set(m[1], Number(m[2]));
|
|
106
|
+
}
|
|
107
|
+
const avail = map.has('MemAvailable') ? map.get('MemAvailable') : map.get('MemFree');
|
|
108
|
+
const swapTotal = map.get('SwapTotal') ?? 0;
|
|
109
|
+
const swapFree = map.get('SwapFree') ?? 0;
|
|
110
|
+
return {
|
|
111
|
+
memAvailMb: avail !== undefined ? Math.round(avail / 1024) : null,
|
|
112
|
+
swapUsedMb: Math.round((swapTotal - swapFree) / 1024)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** statfs 磁盘用量(watchPath 所在文件系统;读不到/不支持则 null 降级) */
|
|
117
|
+
function readDisk(watchPath: string): { usedPct: number | null; availMb: number | null } {
|
|
118
|
+
try {
|
|
119
|
+
const st = (fs as any).statfsSync(watchPath) as {
|
|
120
|
+
blocks: number;
|
|
121
|
+
bsize: number;
|
|
122
|
+
bavail: number;
|
|
123
|
+
} | null;
|
|
124
|
+
if (!st || !st.blocks || !st.bsize) return { usedPct: null, availMb: null };
|
|
125
|
+
const totalBytes = st.blocks * st.bsize;
|
|
126
|
+
const availBytes = st.bavail * st.bsize;
|
|
127
|
+
return {
|
|
128
|
+
usedPct: Number((((totalBytes - availBytes) / totalBytes) * 100).toFixed(1)),
|
|
129
|
+
availMb: Math.round(availBytes / MB)
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return { usedPct: null, availMb: null };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class HostMetricsSampler {
|
|
137
|
+
/** 环形缓冲(最近 ringSize 条),慢事件内联取 latest */
|
|
138
|
+
private ring: HostSnapshot[] = [];
|
|
139
|
+
private timer: NodeJS.Timeout | null = null;
|
|
140
|
+
/** 上一次 /proc/stat 原始 tick(差值计算用;首轮采样得 null 占位) */
|
|
141
|
+
private lastStat: number[] | null = null;
|
|
142
|
+
private lastStatAt = 0;
|
|
143
|
+
/** 上一次 process.cpuUsage 快照 */
|
|
144
|
+
private lastProc: { user: number; system: number } | null = null;
|
|
145
|
+
private lastProcAt = 0;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param ringSize 环形缓冲条数
|
|
149
|
+
* @param diskWatchPath 磁盘水位观测路径(数据卷;默认 cwd,随 data volume 持久化语义)
|
|
150
|
+
*/
|
|
151
|
+
constructor(
|
|
152
|
+
private readonly ringSize = 6,
|
|
153
|
+
private readonly diskWatchPath: string = process.cwd()
|
|
154
|
+
) {}
|
|
155
|
+
|
|
156
|
+
/** 启动周期采样(默认 10s;定时器 unref,不阻止进程退出) */
|
|
157
|
+
start(intervalMs = 10_000): this {
|
|
158
|
+
if (this.timer) return this;
|
|
159
|
+
this.sample();
|
|
160
|
+
this.timer = setInterval(() => this.sample(), intervalMs);
|
|
161
|
+
this.timer.unref();
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
stop(): void {
|
|
166
|
+
if (this.timer) clearInterval(this.timer);
|
|
167
|
+
this.timer = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 最近一次快照(未启动/首轮前返回全 null 结构,字段缺省原则) */
|
|
171
|
+
latest(): HostSnapshot {
|
|
172
|
+
return this.ring[this.ring.length - 1] || HostMetricsSampler.emptySnapshot();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private static emptySnapshot(): HostSnapshot {
|
|
176
|
+
return {
|
|
177
|
+
cpuPct: null,
|
|
178
|
+
iowaitPct: null,
|
|
179
|
+
stealPct: null,
|
|
180
|
+
load: null,
|
|
181
|
+
memAvailMb: null,
|
|
182
|
+
swapUsedMb: null,
|
|
183
|
+
cgMemMb: null,
|
|
184
|
+
cgMemMaxMb: null,
|
|
185
|
+
procCpuPct: null,
|
|
186
|
+
rssMb: null,
|
|
187
|
+
psiCpu: null,
|
|
188
|
+
psiIo: null,
|
|
189
|
+
psiMem: null,
|
|
190
|
+
diskUsedPct: null,
|
|
191
|
+
diskAvailMb: null
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private sample(): void {
|
|
196
|
+
let snap: HostSnapshot;
|
|
197
|
+
try {
|
|
198
|
+
snap = this.buildSnapshot();
|
|
199
|
+
} catch {
|
|
200
|
+
// 采样自身永不抛错(它在探针写盘路径上),降级为空快照
|
|
201
|
+
snap = HostMetricsSampler.emptySnapshot();
|
|
202
|
+
}
|
|
203
|
+
this.ring.push(snap);
|
|
204
|
+
if (this.ring.length > this.ringSize) this.ring.shift();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private buildSnapshot(): HostSnapshot {
|
|
208
|
+
const snap = HostMetricsSampler.emptySnapshot();
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
|
|
211
|
+
// 宿主 load(双平台可得)
|
|
212
|
+
const load = os.loadavg() as number[];
|
|
213
|
+
snap.load = [Number(load[0].toFixed(2)), Number(load[1].toFixed(2)), Number(load[2].toFixed(2))];
|
|
214
|
+
|
|
215
|
+
if (isLinux) {
|
|
216
|
+
// CPU/iowait/steal:/proc/stat 相邻两次差值
|
|
217
|
+
const statText = readText('/proc/stat');
|
|
218
|
+
const cols = statText ? parseProcStat(statText) : null;
|
|
219
|
+
if (cols) {
|
|
220
|
+
const prev = this.lastStat;
|
|
221
|
+
const elapsed = now - this.lastStatAt;
|
|
222
|
+
if (prev && elapsed > 0) {
|
|
223
|
+
const total = (a: number[], b: number[]) =>
|
|
224
|
+
a.reduce((s, v, i) => s + Math.max(0, v - b[i]), 0);
|
|
225
|
+
const dTotal = total(cols, prev);
|
|
226
|
+
if (dTotal > 0) {
|
|
227
|
+
const pct = (i: number) => Number(((Math.max(0, cols[i] - prev[i]) / dTotal) * 100).toFixed(1));
|
|
228
|
+
snap.cpuPct = Number((100 - pct(3) - pct(4)).toFixed(1)); // 非idle非iowait
|
|
229
|
+
snap.iowaitPct = pct(4);
|
|
230
|
+
snap.stealPct = pct(7);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
this.lastStat = cols;
|
|
234
|
+
this.lastStatAt = now;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 内存/swap(宿主整机)
|
|
238
|
+
const mem = readMeminfo();
|
|
239
|
+
snap.memAvailMb = mem.memAvailMb;
|
|
240
|
+
snap.swapUsedMb = mem.swapUsedMb;
|
|
241
|
+
|
|
242
|
+
// 容器限额视角
|
|
243
|
+
const cg = readCgroupMem();
|
|
244
|
+
snap.cgMemMb = cg.usedMb;
|
|
245
|
+
snap.cgMemMaxMb = cg.maxMb;
|
|
246
|
+
|
|
247
|
+
// PSI(有则直接量化宿主压力)
|
|
248
|
+
snap.psiCpu = parsePsi(readText('/proc/pressure/cpu'));
|
|
249
|
+
snap.psiIo = parsePsi(readText('/proc/pressure/io'));
|
|
250
|
+
snap.psiMem = parsePsi(readText('/proc/pressure/memory'));
|
|
251
|
+
} else {
|
|
252
|
+
// macOS/dev 降级
|
|
253
|
+
snap.memAvailMb = Math.round(os.freemem() / MB);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 自进程 CPU(单核口径)与 RSS——「自己算得多」vs「被别人抢」的对照
|
|
257
|
+
const proc = process.cpuUsage();
|
|
258
|
+
const pPrev = this.lastProc;
|
|
259
|
+
const pElapsed = now - this.lastProcAt;
|
|
260
|
+
if (pPrev && pElapsed > 0) {
|
|
261
|
+
const usedUs = proc.user - pPrev.user + (proc.system - pPrev.system);
|
|
262
|
+
snap.procCpuPct = Number(((usedUs / 1000 / pElapsed) * 100).toFixed(1));
|
|
263
|
+
}
|
|
264
|
+
this.lastProc = proc;
|
|
265
|
+
this.lastProcAt = now;
|
|
266
|
+
snap.rssMb = Math.round(process.memoryUsage().rss / MB);
|
|
267
|
+
|
|
268
|
+
// 数据卷磁盘水位(双平台 statfs;读不到则 null)
|
|
269
|
+
const disk = readDisk(this.diskWatchPath);
|
|
270
|
+
snap.diskUsedPct = disk.usedPct;
|
|
271
|
+
snap.diskAvailMb = disk.availMb;
|
|
272
|
+
|
|
273
|
+
return snap;
|
|
274
|
+
}
|
|
275
|
+
}
|