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
package/package.json
CHANGED
|
@@ -47,6 +47,25 @@ upload:
|
|
|
47
47
|
- .mp4
|
|
48
48
|
maxSize: 10485760 # 10MB
|
|
49
49
|
|
|
50
|
+
# 慢SQL探针:挂 TypeORM Logger 层,记录落 data/slowsql/slowsql-YYYY-MM-DD.jsonl
|
|
51
|
+
# (sql 慢查询样本按指纹每日限 5 条/error/lag 事件循环漂移/host 宿主基线/stats 每小时汇总)
|
|
52
|
+
# 分析:node scripts/slowsql-report.mjs
|
|
53
|
+
slowSql:
|
|
54
|
+
thresholdMs: 50 # 慢查询阈值 ms
|
|
55
|
+
lagMs: 200 # 事件循环漂移记录阈值
|
|
56
|
+
hostSampleSec: 60 # 宿主基线落盘周期(内存环形采样固定 10s 不外配)
|
|
57
|
+
retainDays: 14 # 日志保留天数
|
|
58
|
+
maxFileMb: 10 # 单日文件上限 MB(超限滚动瘦身留新去旧,丢条计数随 stats 留痕)
|
|
59
|
+
|
|
60
|
+
# 运维调试日志:只记 ≥400 失败与 >slowMs 慢请求,2xx 成功不记。
|
|
61
|
+
# 失败含 query/body(脱敏截断:password/token/sign 等→***)与失败结果(5xx 带异常 detail);
|
|
62
|
+
# 慢请求只记 method/url/耗时/用户,query/body 同口径脱敏。
|
|
63
|
+
# 落 data/apilog/apilog-YYYY-MM-DD.jsonl,traceId 经 X-Trace-Id 响应头回传,报 traceId 即可全链路 grep。
|
|
64
|
+
apiLog:
|
|
65
|
+
slowMs: 1000 # 慢请求阈值 ms
|
|
66
|
+
retainDays: 14 # 保留天数
|
|
67
|
+
maxFileMb: 10 # 单日文件上限(超限丢条,每 500 条 warn 留痕)
|
|
68
|
+
|
|
50
69
|
# 超管账号:yaml 为权威,启动时 user.service 把 DB reconcile 到此值
|
|
51
70
|
# (改这里 + 重启 即完成轮换)。dev/prod 真值均入库 yaml 直读;ADMIN_PASSWORD env 为可选覆盖。
|
|
52
71
|
admin:
|
|
@@ -46,6 +46,25 @@ upload:
|
|
|
46
46
|
- .mp4
|
|
47
47
|
maxSize: 10485760
|
|
48
48
|
|
|
49
|
+
# 慢SQL探针:挂 TypeORM Logger 层,记录落 data/slowsql/slowsql-YYYY-MM-DD.jsonl
|
|
50
|
+
# (sql 慢查询样本按指纹每日限 5 条/error/lag 事件循环漂移/host 宿主基线/stats 每小时汇总)
|
|
51
|
+
# 分析:node scripts/slowsql-report.mjs
|
|
52
|
+
slowSql:
|
|
53
|
+
thresholdMs: 50 # 慢查询阈值 ms
|
|
54
|
+
lagMs: 200 # 事件循环漂移记录阈值
|
|
55
|
+
hostSampleSec: 60 # 宿主基线落盘周期(内存环形采样固定 10s 不外配)
|
|
56
|
+
retainDays: 14 # 日志保留天数
|
|
57
|
+
maxFileMb: 10 # 单日文件上限 MB(超限滚动瘦身留新去旧,丢条计数随 stats 留痕)
|
|
58
|
+
|
|
59
|
+
# 运维调试日志:只记 ≥400 失败与 >slowMs 慢请求,2xx 成功不记。
|
|
60
|
+
# 失败含 query/body(脱敏截断:password/token/sign 等→***)与失败结果(5xx 带异常 detail);
|
|
61
|
+
# 慢请求只记 method/url/耗时/用户,query/body 同口径脱敏。
|
|
62
|
+
# 落 data/apilog/apilog-YYYY-MM-DD.jsonl,traceId 经 X-Trace-Id 响应头回传,报 traceId 即可全链路 grep。
|
|
63
|
+
apiLog:
|
|
64
|
+
slowMs: 1000 # 慢请求阈值 ms
|
|
65
|
+
retainDays: 14 # 保留天数
|
|
66
|
+
maxFileMb: 10 # 单日文件上限(超限丢条,每 500 条 warn 留痕)
|
|
67
|
+
|
|
49
68
|
# 超管账号:yaml 为权威,启动时 user.service 把 DB reconcile 到此值
|
|
50
69
|
# (改 current/config/config.prod.yaml 或 ADMIN_PASSWORD env + 重启 即完成轮换,如三月一换)。
|
|
51
70
|
# prod 真值已入库本 yaml(systemd/pm2 直读);ADMIN_PASSWORD env 为可选覆盖(docker 经 .env、
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 慢SQL探针分析报告(《慢SQL探针与SQL优化路径方案》§3.2/§5.2)
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/slowsql-report.mjs [目录或文件...] [选项]
|
|
7
|
+
* --top N 慢 SQL 指纹 top-N(默认 20)
|
|
8
|
+
* --explain <db> 对 top-N 指纹输出可执行的 EXPLAIN 语句,不连库(sqlite 侧用
|
|
9
|
+
* EXPLAIN QUERY PLAN,MySQL 侧 EXPLAIN 后人工看 type=ALL;
|
|
10
|
+
* <db> 为占位值,仅作开关用,不会被当目标路径读取)
|
|
11
|
+
* --out <file> 输出 markdown 到文件(默认 stdout)
|
|
12
|
+
*
|
|
13
|
+
* 读 data/slowsql/slowsql-YYYY-MM-DD.jsonl,聚合输出:
|
|
14
|
+
* 1. 总览(时间范围/查询量/慢查询/lag/错误)
|
|
15
|
+
* 2. 慢 SQL 指纹 top 表(含「宿主紧张占比」标注)
|
|
16
|
+
* 3. lag / 宿主基线摘要
|
|
17
|
+
* 4. §5.2 归因判别矩阵的自动结论
|
|
18
|
+
*
|
|
19
|
+
* 这是「证据 → 优化」闭环的一环:报告直接就是下一份优化方案的立项依据。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const flag = (name) => {
|
|
27
|
+
const i = args.indexOf(`--${name}`);
|
|
28
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
29
|
+
};
|
|
30
|
+
const TOP_N = Number(flag('top') || 20);
|
|
31
|
+
const EXPLAIN_DB = flag('explain');
|
|
32
|
+
const DIALECT = flag('dialect') || 'mysql';
|
|
33
|
+
const OUT = flag('out');
|
|
34
|
+
// flag 及其取值从位置参数里剔除(否则 --explain 的 db 路径会被当目标再读一遍)
|
|
35
|
+
const flagValues = new Set([flag('top'), flag('explain'), flag('dialect'), flag('out')]);
|
|
36
|
+
const targets = args.filter((a) => !a.startsWith('--') && !flagValues.has(a));
|
|
37
|
+
|
|
38
|
+
const DEFAULT_DIR = path.join(process.cwd(), 'data', 'slowsql');
|
|
39
|
+
const files = targets.length
|
|
40
|
+
? targets.flatMap((t) => (fs.statSync(t).isDirectory() ? fs.readdirSync(t).map((f) => path.join(t, f)) : [t]))
|
|
41
|
+
: fs.existsSync(DEFAULT_DIR)
|
|
42
|
+
? fs.readdirSync(DEFAULT_DIR).map((f) => path.join(DEFAULT_DIR, f))
|
|
43
|
+
: [];
|
|
44
|
+
|
|
45
|
+
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl')).sort();
|
|
46
|
+
if (!jsonlFiles.length) {
|
|
47
|
+
console.error(`未找到 JSONL 文件(${DEFAULT_DIR});可用参数指定目录/文件`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 读取与聚合 ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** SQL 指纹:legacy 记录兜底(新记录由探针侧带 fp 字段,两处实现语义一致;
|
|
54
|
+
* 权威实现在 backend/src/database/sql-fingerprint.ts,采样与计数共用) */
|
|
55
|
+
function fingerprint(sql) {
|
|
56
|
+
return sql
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.replace(/'[^']*'/g, "'?'")
|
|
59
|
+
.replace(/\b\d+(\.\d+)?\b/g, '?')
|
|
60
|
+
.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 宿主紧张判定:iowait/steal 超阈 或 PSI io/cpu 超阈(§5.2 第二行) */
|
|
64
|
+
function hostTension(h) {
|
|
65
|
+
if (!h) return false;
|
|
66
|
+
if ((h.iowaitPct ?? 0) > 5 || (h.stealPct ?? 0) > 5) return true;
|
|
67
|
+
if ((h.psiIo ?? 0) > 10 || (h.psiCpu ?? 0) > 20) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lags = [];
|
|
72
|
+
const hosts = [];
|
|
73
|
+
const stats = [];
|
|
74
|
+
const errors = [];
|
|
75
|
+
const sqls = [];
|
|
76
|
+
let firstTs = null;
|
|
77
|
+
let lastTs = null;
|
|
78
|
+
|
|
79
|
+
for (const file of jsonlFiles) {
|
|
80
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
let r;
|
|
84
|
+
try {
|
|
85
|
+
r = JSON.parse(line);
|
|
86
|
+
} catch {
|
|
87
|
+
continue; // 坏行跳过(手工查看/截断等)
|
|
88
|
+
}
|
|
89
|
+
if (r.ts) {
|
|
90
|
+
if (!firstTs) firstTs = r.ts;
|
|
91
|
+
lastTs = r.ts;
|
|
92
|
+
}
|
|
93
|
+
if (r.type === 'sql') {
|
|
94
|
+
sqls.push(r);
|
|
95
|
+
} else if (r.type === 'lag') {
|
|
96
|
+
lags.push(r);
|
|
97
|
+
} else if (r.type === 'host') {
|
|
98
|
+
hosts.push(r);
|
|
99
|
+
} else if (r.type === 'stats') {
|
|
100
|
+
stats.push(r);
|
|
101
|
+
} else if (r.type === 'error') {
|
|
102
|
+
errors.push(r);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 指纹聚合(《后端日志体系整改方案》§4.2 口径:计数优先、样本供下钻)──
|
|
108
|
+
// stats.top 计数是全量(探针侧每条慢查询都计,采样与否无关);sql 记录只是
|
|
109
|
+
// 每指纹每日前 5 条细节样本。合并规则:计数取 stats.top;legacy 记录(无 top)
|
|
110
|
+
// 退回逐条累计。样本贡献 sql 文本/params(供 EXPLAIN 下钻)。
|
|
111
|
+
const fpMap = new Map(); // 指纹 → { count, totalMs, maxMs, tension, sql, params, counted }
|
|
112
|
+
for (const s of stats) {
|
|
113
|
+
for (const t of s.top || []) {
|
|
114
|
+
if (!t.fp) continue;
|
|
115
|
+
const e =
|
|
116
|
+
fpMap.get(t.fp) || { count: 0, totalMs: 0, maxMs: 0, tension: 0, sql: '', params: null, counted: true };
|
|
117
|
+
e.count += t.count || 0;
|
|
118
|
+
e.totalMs += t.totalMs || 0;
|
|
119
|
+
e.maxMs = Math.max(e.maxMs, t.maxMs || 0);
|
|
120
|
+
e.tension += t.tension || 0;
|
|
121
|
+
e.counted = true; // 有计数器背书的条目不再吃样本计数
|
|
122
|
+
fpMap.set(t.fp, e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const r of sqls) {
|
|
126
|
+
const key = r.fp || fingerprint(r.sql || '');
|
|
127
|
+
const e =
|
|
128
|
+
fpMap.get(key) || { count: 0, totalMs: 0, maxMs: 0, tension: 0, sql: '', params: null, counted: false };
|
|
129
|
+
if (!e.counted) {
|
|
130
|
+
// legacy(无 stats.top):每条样本即一次发生
|
|
131
|
+
e.count += 1;
|
|
132
|
+
e.totalMs += r.ms || 0;
|
|
133
|
+
e.maxMs = Math.max(e.maxMs, r.ms || 0);
|
|
134
|
+
if (hostTension(r.host)) e.tension += 1;
|
|
135
|
+
}
|
|
136
|
+
if (!e.sql) {
|
|
137
|
+
e.sql = r.sql;
|
|
138
|
+
e.params = r.params;
|
|
139
|
+
}
|
|
140
|
+
fpMap.set(key, e);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fps = [...fpMap.values()].sort((a, b) => b.totalMs - a.totalMs);
|
|
144
|
+
const top = fps.slice(0, TOP_N);
|
|
145
|
+
const slowCount = fps.reduce((s, e) => s + e.count, 0);
|
|
146
|
+
const slowTotalMs = fps.reduce((s, e) => s + e.totalMs, 0);
|
|
147
|
+
const totalQueries = stats.reduce((s, r) => s + (r.queries || 0), 0);
|
|
148
|
+
const dropped = stats.reduce((s, r) => s + (r.dropped || 0), 0);
|
|
149
|
+
|
|
150
|
+
// lag 与慢 SQL 的分钟级对齐(§5.2 判别矩阵)
|
|
151
|
+
// 采样口径:分子分母都用样本(计数器无分钟粒度),样本虽是每指纹前 5 条,分钟分布不失真
|
|
152
|
+
const lagMinutes = new Set(lags.map((r) => (r.ts || '').slice(0, 16)));
|
|
153
|
+
const slowInLagMinute = lagMinutes.size
|
|
154
|
+
? sqls.filter((r) => lagMinutes.has((r.ts || '').slice(0, 16))).length
|
|
155
|
+
: 0;
|
|
156
|
+
|
|
157
|
+
// 宿主基线摘要
|
|
158
|
+
const avg = (xs) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null);
|
|
159
|
+
const hostAvg = {
|
|
160
|
+
cpuPct: avg(hosts.map((h) => h.cpuPct).filter((v) => v != null)),
|
|
161
|
+
iowaitPct: avg(hosts.map((h) => h.iowaitPct).filter((v) => v != null)),
|
|
162
|
+
stealPct: avg(hosts.map((h) => h.stealPct).filter((v) => v != null)),
|
|
163
|
+
procCpuPct: avg(hosts.map((h) => h.procCpuPct).filter((v) => v != null)),
|
|
164
|
+
psiIo: avg(hosts.map((h) => h.psiIo).filter((v) => v != null))
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ── EXPLAIN(只输出可执行语句,不连库;MySQL 侧执行后人工看 type=ALL,sqlite 用 EXPLAIN QUERY PLAN) ──
|
|
168
|
+
|
|
169
|
+
let explainLines = [];
|
|
170
|
+
if (EXPLAIN_DB) {
|
|
171
|
+
explainLines = top.slice(0, 10).map((e) => ({
|
|
172
|
+
fp: e,
|
|
173
|
+
lines: [`- 请在数据库侧执行(sqlite 可用 EXPLAIN QUERY PLAN): EXPLAIN ${e.sql.replace(/\?/g, 'NULL').slice(0, 500)};`]
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── §5.2 判别矩阵自动结论 ─────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function verdict() {
|
|
180
|
+
const v = [];
|
|
181
|
+
if (!slowCount && !lags.length) return ['证据不足(无慢查询、无 lag)——探针刚上线或期间无阻塞。'];
|
|
182
|
+
const tensionRatio = slowCount
|
|
183
|
+
? top.reduce((s, e) => s + e.tension, 0) / Math.max(1, top.reduce((s, e) => s + e.count, 0))
|
|
184
|
+
: 0;
|
|
185
|
+
const iowaitHigh = (hostAvg.iowaitPct ?? 0) > 5 || (hostAvg.psiIo ?? 0) > 10;
|
|
186
|
+
const stealHigh = (hostAvg.stealPct ?? 0) > 5;
|
|
187
|
+
if (tensionRatio > 0.5 || iowaitHigh || stealHigh) {
|
|
188
|
+
v.push(
|
|
189
|
+
`**宿主资源紧张嫌疑大**(慢事件内联宿主紧张占比 ${(tensionRatio * 100).toFixed(0)}%` +
|
|
190
|
+
`${iowaitHigh ? ',基线 iowait/PSI-io 偏高' : ''}${stealHigh ? ',steal 偏高' : ''})` +
|
|
191
|
+
`——先查 §5.4(vmstat/PSI/docker stats),SQL 优化可能是白做。`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const sampleBase = Math.max(1, sqls.length); // 样本基(计数无分钟粒度,§4.2)
|
|
195
|
+
if (lags.length && slowInLagMinute / sampleBase > 0.5 && !iowaitHigh && !stealHigh) {
|
|
196
|
+
v.push(
|
|
197
|
+
'慢 SQL 与 lag 高度同分钟且宿主空闲——**SQL 本身慢**(索引/扫描问题),走 SQL 侧优化;配合 --explain 看 SCAN。'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (lags.length && sqls.length && slowInLagMinute / sampleBase < 0.2) {
|
|
201
|
+
v.push(
|
|
202
|
+
'lag 多但慢 SQL 稀疏——**JS 侧重活嫌疑**(大结果集聚合/序列化,SQL 不慢),对应审计方案批次二类问题。'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!lags.length && slowCount) {
|
|
206
|
+
v.push('慢 SQL 未伴随事件循环 lag(异步/低频)——单条慢但未阻塞,优先级看 totalMs 排序即可。');
|
|
207
|
+
}
|
|
208
|
+
return v;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── 输出 ──────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
const fmt = (v, unit = '') => (v == null ? '-' : `${Number(v.toFixed(1))}${unit}`);
|
|
214
|
+
const md = [];
|
|
215
|
+
md.push(`# 慢SQL探针分析报告`);
|
|
216
|
+
md.push('');
|
|
217
|
+
md.push(`- 数据源:${jsonlFiles.map((f) => path.basename(f)).join(', ')}`);
|
|
218
|
+
md.push(`- 时间范围:${firstTs || '-'} ~ ${lastTs || '-'}`);
|
|
219
|
+
md.push(`- 查询量(仅 stats 窗口统计):${totalQueries};慢查询:${slowCount} 次 / ${fps.length} 指纹 / 累计 ${fmt(slowTotalMs)}ms;lag:${lags.length} 次;查询错误:${errors.length} 次${dropped ? `;⚠️ 丢条:${dropped}` : ''}`);
|
|
220
|
+
md.push('');
|
|
221
|
+
|
|
222
|
+
md.push(`## 慢 SQL 指纹 Top${top.length}(按 totalMs)`);
|
|
223
|
+
md.push('');
|
|
224
|
+
if (top.length) {
|
|
225
|
+
md.push(`| # | 累计ms | 次数 | 最大ms | 宿主紧张 | 指纹 |`);
|
|
226
|
+
md.push(`|---|---|---|---|---|---|`);
|
|
227
|
+
top.forEach((e, i) => {
|
|
228
|
+
const fp = fingerprint(e.sql).slice(0, 120).replace(/\|/g, '\\|');
|
|
229
|
+
const t = e.tension ? `${e.tension}/${e.count}` : '0';
|
|
230
|
+
md.push(`| ${i + 1} | ${Math.round(e.totalMs)} | ${e.count} | ${Math.round(e.maxMs)} | ${t} | \`${fp}\` |`);
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
md.push('无慢查询记录。');
|
|
234
|
+
}
|
|
235
|
+
md.push('');
|
|
236
|
+
|
|
237
|
+
if (explainLines.length) {
|
|
238
|
+
md.push(`## EXPLAIN(占位符已回填样本/NULL,只看计划不看值)`);
|
|
239
|
+
md.push('');
|
|
240
|
+
for (const { fp, lines } of explainLines) {
|
|
241
|
+
md.push(`**${fingerprint(fp.sql).slice(0, 100)}**`);
|
|
242
|
+
md.push(...lines);
|
|
243
|
+
md.push('');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
md.push(`## 事件循环 lag`);
|
|
248
|
+
md.push('');
|
|
249
|
+
if (lags.length) {
|
|
250
|
+
const maxLag = Math.max(...lags.map((r) => r.ms));
|
|
251
|
+
md.push(`- 共 ${lags.length} 次,最大 ${maxLag}ms;慢 SQL 样本落在 lag 同分钟的占比 ${((slowInLagMinute / Math.max(1, sqls.length)) * 100).toFixed(0)}%`);
|
|
252
|
+
const byHour = new Map();
|
|
253
|
+
for (const r of lags) {
|
|
254
|
+
const h = (r.ts || '').slice(0, 13);
|
|
255
|
+
byHour.set(h, (byHour.get(h) || 0) + 1);
|
|
256
|
+
}
|
|
257
|
+
md.push(`- 按小时分布:${[...byHour.entries()].map(([h, c]) => `${h}时=${c}`).join(', ')}`);
|
|
258
|
+
} else {
|
|
259
|
+
md.push('- 无 lag 记录(事件循环未被阻塞)。');
|
|
260
|
+
}
|
|
261
|
+
md.push('');
|
|
262
|
+
|
|
263
|
+
md.push(`## 宿主基线(${hosts.length} 条采样)`);
|
|
264
|
+
md.push('');
|
|
265
|
+
md.push(`- CPU 均值 ${fmt(hostAvg.cpuPct, '%')} / iowait 均值 ${fmt(hostAvg.iowaitPct, '%')} / steal 均值 ${fmt(hostAvg.stealPct, '%')}`);
|
|
266
|
+
md.push(`- 自进程 CPU 均值 ${fmt(hostAvg.procCpuPct, '%')}(单核口径)`);
|
|
267
|
+
md.push(`- PSI io 均值 ${fmt(hostAvg.psiIo)}`);
|
|
268
|
+
md.push('');
|
|
269
|
+
|
|
270
|
+
md.push(`## 归因判别(§5.2 矩阵)`);
|
|
271
|
+
md.push('');
|
|
272
|
+
for (const v of verdict()) md.push(`- ${v}`);
|
|
273
|
+
md.push('');
|
|
274
|
+
|
|
275
|
+
const out = md.join('\n');
|
|
276
|
+
if (OUT) {
|
|
277
|
+
fs.writeFileSync(OUT, out);
|
|
278
|
+
console.log(`已写入 ${OUT}`);
|
|
279
|
+
} else {
|
|
280
|
+
console.log(out);
|
|
281
|
+
}
|
|
@@ -15,6 +15,9 @@ import { AllExceptionsFilter } from './common/all-exceptions.filter';
|
|
|
15
15
|
import { TransformInterceptor } from './common/transform.interceptor';
|
|
16
16
|
import { AppThrottlerGuard } from './common/throttle.guard';
|
|
17
17
|
import { AppSignModule } from './common/app-sign.module';
|
|
18
|
+
// 运维调试日志(《后端日志体系整改方案》):失败登记(过滤器)+ 慢请求登记(拦截器)
|
|
19
|
+
import { ApiLogModule } from './common/api-log/api-log.module';
|
|
20
|
+
import { ApiLogInterceptor } from './common/api-log/api-log.interceptor';
|
|
18
21
|
|
|
19
22
|
import { AuthModule } from './modules/auth/auth.module';
|
|
20
23
|
import { UserModule } from './modules/user/user.module';
|
|
@@ -32,6 +35,8 @@ import { OpsModule } from './modules/ops/ops.module';
|
|
|
32
35
|
isGlobal: true,
|
|
33
36
|
load: [configuration]
|
|
34
37
|
}),
|
|
38
|
+
// @Global:AllExceptionsFilter 与 ApiLogInterceptor 共用一个写盘器
|
|
39
|
+
ApiLogModule,
|
|
35
40
|
ThrottlerModule.forRootAsync({
|
|
36
41
|
inject: [ConfigService],
|
|
37
42
|
useFactory: (config: ConfigService<AppConfig, true>) => {
|
|
@@ -69,10 +74,12 @@ import { OpsModule } from './modules/ops/ops.module';
|
|
|
69
74
|
AppSignModule
|
|
70
75
|
],
|
|
71
76
|
providers: [
|
|
72
|
-
// 统一异常过滤器
|
|
77
|
+
// 统一异常过滤器(内联失败登记:≥400 落 apilog)
|
|
73
78
|
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
|
74
79
|
// 统一响应拦截器
|
|
75
80
|
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
|
81
|
+
// 慢请求登记(失败登记在过滤器;SSE @SkipTransform 跳过)
|
|
82
|
+
{ provide: APP_INTERCEPTOR, useClass: ApiLogInterceptor },
|
|
76
83
|
// 限流守卫
|
|
77
84
|
{ provide: APP_GUARD, useClass: AppThrottlerGuard }
|
|
78
85
|
]
|
|
@@ -6,26 +6,35 @@ import {
|
|
|
6
6
|
HttpStatus,
|
|
7
7
|
Logger
|
|
8
8
|
} from '@nestjs/common';
|
|
9
|
+
import { performance } from 'perf_hooks';
|
|
9
10
|
import { Request, Response } from 'express';
|
|
10
11
|
import { ResponseResult } from './response';
|
|
12
|
+
import { ApiLogService } from './api-log/api-log.service';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* 全局异常过滤器
|
|
14
16
|
* 把所有异常统一包成 { code, message, data } 响应体
|
|
15
17
|
* - HttpException: 用其 status 作 code,message 用其 message
|
|
16
18
|
* - 其他错误: 500
|
|
19
|
+
*
|
|
20
|
+
* 失败登记(《后端日志体系整改方案》§2):过滤器是所有异常(含 guard 阶段的 401/429、
|
|
21
|
+
* 路由 404、校验 400、服务层 500)的唯一必经出口,在此落 apilog——带参数、带失败结果,
|
|
22
|
+
* 供联调事后追溯。之前只有 500 留痕,4xx 全静默。
|
|
17
23
|
*/
|
|
18
24
|
@Catch()
|
|
19
25
|
export class AllExceptionsFilter implements ExceptionFilter {
|
|
20
26
|
private readonly logger = new Logger('Exception');
|
|
21
27
|
|
|
28
|
+
constructor(private readonly apiLog: ApiLogService) {}
|
|
29
|
+
|
|
22
30
|
catch(exception: unknown, host: ArgumentsHost) {
|
|
23
31
|
const ctx = host.switchToHttp();
|
|
24
32
|
const response = ctx.getResponse<Response>();
|
|
25
|
-
const request = ctx.getRequest<Request>();
|
|
33
|
+
const request = ctx.getRequest<Request & { traceId?: string; traceStart?: number }>();
|
|
26
34
|
|
|
27
35
|
let code = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
28
36
|
let message = '服务器内部错误';
|
|
37
|
+
let detail: string | undefined;
|
|
29
38
|
|
|
30
39
|
if (exception instanceof HttpException) {
|
|
31
40
|
code = exception.getStatus();
|
|
@@ -38,11 +47,18 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|
|
38
47
|
if (Array.isArray(message)) {
|
|
39
48
|
message = message[0];
|
|
40
49
|
}
|
|
50
|
+
// 路由未命中(NotFoundException 默认 message 是 "Cannot GET /xxx")会原样回显请求
|
|
51
|
+
// URL,注入 payload 即被漏扫器判为反射 XSS;统一收敛为笼统文案,业务自定义 404 文案不受影响。
|
|
52
|
+
if (code === 404 && typeof message === 'string' && /^Cannot \w+ \//.test(message)) {
|
|
53
|
+
message = '接口不存在';
|
|
54
|
+
}
|
|
41
55
|
} else if (exception instanceof Error) {
|
|
42
56
|
// 非 HttpException(数据库/文件系统等)的 message 可能含 SQL 片段、绝对路径,
|
|
43
57
|
// 只入日志,不回传客户端
|
|
44
58
|
message = '服务器内部错误';
|
|
45
59
|
this.logger.error(`${request.method} ${request.url} -> ${exception.message}`, exception.stack);
|
|
60
|
+
// apilog 的 5xx detail:异常 message + stack 首行(客户端看不到的真相只落盘)
|
|
61
|
+
detail = `${exception.message} @ ${(exception.stack || '').split('\n')[1]?.trim() || ''}`;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
// 限流(429)使用更友好的文案
|
|
@@ -50,6 +66,27 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|
|
50
66
|
message = '请求过于频繁,请稍后再试(每分钟 60 次)';
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
// 失败登记:参数脱敏在 ApiLogService.record 内统一处理
|
|
70
|
+
try {
|
|
71
|
+
this.apiLog.record(
|
|
72
|
+
{
|
|
73
|
+
type: 'fail',
|
|
74
|
+
traceId: request?.traceId,
|
|
75
|
+
method: request?.method || '',
|
|
76
|
+
url: request?.originalUrl || request?.url || '',
|
|
77
|
+
ip: ApiLogService.ipOf(request),
|
|
78
|
+
user: (request as any)?.user?.username || '',
|
|
79
|
+
ms: request?.traceStart ? Math.round(performance.now() - request.traceStart) : -1,
|
|
80
|
+
code,
|
|
81
|
+
message: String(message),
|
|
82
|
+
detail
|
|
83
|
+
},
|
|
84
|
+
request
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
// 登记失败不吞业务响应
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
const status = code >= 100 && code < 600 ? code : 200;
|
|
54
91
|
response
|
|
55
92
|
.status(status)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { performance } from 'perf_hooks';
|
|
4
|
+
import { Observable, tap } from 'rxjs';
|
|
5
|
+
import { SKIP_TRANSFORM_KEY } from '../decorators/skip-transform.decorator';
|
|
6
|
+
import { ApiLogService } from './api-log.service';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 慢请求登记拦截器(《后端日志体系整改方案》§2.2)
|
|
10
|
+
*
|
|
11
|
+
* 只管 slow(耗时 > apiLog.slowMs);fail(≥400)由 AllExceptionsFilter 统一落——
|
|
12
|
+
* 拦截器看不到 guard 阶段抛的 401/429(守卫先于拦截器),过滤器是所有异常的必经出口。
|
|
13
|
+
*
|
|
14
|
+
* SSE/文件下载(@SkipTransform)跳过:SSE 长连接的「总时长」无性能语义,只会刷噪音。
|
|
15
|
+
* 耗时起点 traceStart 由 main.ts 的 traceId 中间件挂 req(比拦截器更早,含 guard 耗时)。
|
|
16
|
+
*/
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class ApiLogInterceptor implements NestInterceptor {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly reflector: Reflector,
|
|
21
|
+
private readonly apiLog: ApiLogService
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
25
|
+
if (this.reflector.get<boolean>(SKIP_TRANSFORM_KEY, context.getHandler())) {
|
|
26
|
+
return next.handle();
|
|
27
|
+
}
|
|
28
|
+
const req = context.switchToHttp().getRequest<any>();
|
|
29
|
+
const start = req?.traceStart ?? performance.now();
|
|
30
|
+
|
|
31
|
+
return next.handle().pipe(
|
|
32
|
+
tap(() => {
|
|
33
|
+
// 异常不进 tap(错误走过滤器登记),这里只记「成功但慢」
|
|
34
|
+
const ms = Math.round(performance.now() - start);
|
|
35
|
+
if (ms >= this.apiLog.slowThresholdMs && req) {
|
|
36
|
+
this.apiLog.record(
|
|
37
|
+
{
|
|
38
|
+
type: 'slow',
|
|
39
|
+
traceId: req.traceId,
|
|
40
|
+
method: req.method,
|
|
41
|
+
url: req.originalUrl || req.url,
|
|
42
|
+
ip: ApiLogService.ipOf(req),
|
|
43
|
+
user: req.user?.username || '',
|
|
44
|
+
ms
|
|
45
|
+
},
|
|
46
|
+
req
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { ApiLogService } from './api-log.service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 运维调试日志(《后端日志体系整改方案》§2):@Global 横切——
|
|
6
|
+
* AllExceptionsFilter(失败登记)与 ApiLogInterceptor(慢请求登记)共用一个写盘器。
|
|
7
|
+
*/
|
|
8
|
+
@Global()
|
|
9
|
+
@Module({
|
|
10
|
+
providers: [ApiLogService],
|
|
11
|
+
exports: [ApiLogService]
|
|
12
|
+
})
|
|
13
|
+
export class ApiLogModule {}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { JsonlDailyWriter, localTs } from '../jsonl-daily-writer';
|
|
5
|
+
import type { AppConfig } from '../../config/config.interface';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 运维调试日志(《后端日志体系整改方案》§2)
|
|
9
|
+
*
|
|
10
|
+
* API 失败登记(≥400)与慢请求登记(>slowMs)落 data/apilog/apilog-YYYY-MM-DD.jsonl,
|
|
11
|
+
* 2xx 成功请求不记(内网单体下全量访问日志是纯噪音)。traceId 由 main.ts 中间件
|
|
12
|
+
* 挂到 req 并回传 X-Trace-Id 响应头——联调时用户报一个 traceId,grep 一次定位全链路。
|
|
13
|
+
*
|
|
14
|
+
* 落盘而非 DB:故障场景下 DB 本身可能就是挂的原因(自指死锁),文件零依赖。
|
|
15
|
+
* 参数口径(已拍板「不过度存储」):query 全量 + body 脱敏截断(敏感 key→***,
|
|
16
|
+
* 每字段 200 字符/最多 10 个),multipart 只记字段不记文件内容,不记成功响应体。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 敏感 key(小写匹配,含嵌套层):值一律打码,安全整改成果不倒退 */
|
|
20
|
+
const MASK_KEYS = new Set([
|
|
21
|
+
'password',
|
|
22
|
+
'oldpassword',
|
|
23
|
+
'newpassword',
|
|
24
|
+
'verifycode',
|
|
25
|
+
'captcha',
|
|
26
|
+
'token',
|
|
27
|
+
'accesstoken',
|
|
28
|
+
'refreshtoken',
|
|
29
|
+
'authorization',
|
|
30
|
+
'cookie',
|
|
31
|
+
'secret',
|
|
32
|
+
'appsecret',
|
|
33
|
+
'signkey',
|
|
34
|
+
'apikey',
|
|
35
|
+
'sign'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** 单条记录公共字段 */
|
|
39
|
+
export interface ApiLogEntry {
|
|
40
|
+
type: 'fail' | 'slow';
|
|
41
|
+
traceId?: string;
|
|
42
|
+
method: string;
|
|
43
|
+
url: string;
|
|
44
|
+
ip: string;
|
|
45
|
+
user?: string;
|
|
46
|
+
ms: number;
|
|
47
|
+
/** fail 专用:响应 code + message(5xx 追加 detail 异常真相) */
|
|
48
|
+
code?: number;
|
|
49
|
+
message?: string;
|
|
50
|
+
detail?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Injectable()
|
|
54
|
+
export class ApiLogService implements OnModuleDestroy {
|
|
55
|
+
private readonly logger = new Logger('ApiLog');
|
|
56
|
+
private readonly writer: JsonlDailyWriter;
|
|
57
|
+
private readonly slowMs: number;
|
|
58
|
+
private lastDropWarned = 0;
|
|
59
|
+
|
|
60
|
+
constructor(cfg: ConfigService<AppConfig, true>) {
|
|
61
|
+
const c = cfg.get<AppConfig['apiLog']>('apiLog');
|
|
62
|
+
this.slowMs = c.slowMs;
|
|
63
|
+
this.writer = new JsonlDailyWriter({
|
|
64
|
+
dir: path.resolve(process.cwd(), c.dir),
|
|
65
|
+
prefix: 'apilog',
|
|
66
|
+
retainDays: c.retainDays,
|
|
67
|
+
maxFileMb: c.maxFileMb
|
|
68
|
+
});
|
|
69
|
+
this.writer.cleanupExpired();
|
|
70
|
+
this.writer.startDailyCleanup();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 优雅停机关流冲刷(SIGTERM→app.close() 链路;进程 exit 另有同步兜底双保险) */
|
|
74
|
+
onModuleDestroy(): void {
|
|
75
|
+
this.writer.stop();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 慢请求阈值 ms(拦截器判定用) */
|
|
79
|
+
get slowThresholdMs(): number {
|
|
80
|
+
return this.slowMs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 登记一条(失败或慢);请求参数统一在此脱敏截断 */
|
|
84
|
+
record(entry: ApiLogEntry, req: { query?: any; body?: any; rawBody?: any }): void {
|
|
85
|
+
const written = this.writer.write({
|
|
86
|
+
ts: localTs(),
|
|
87
|
+
...entry,
|
|
88
|
+
query: this.sanitize(req.query),
|
|
89
|
+
body: this.extractBody(req)
|
|
90
|
+
});
|
|
91
|
+
if (!written) {
|
|
92
|
+
// 滚动淘汰/背压丢条:每 500 条 warn 一次留痕(不静默)
|
|
93
|
+
const dropped = this.writer.droppedCount;
|
|
94
|
+
if (dropped - this.lastDropWarned >= 500) {
|
|
95
|
+
this.lastDropWarned = dropped;
|
|
96
|
+
this.logger.warn(`apilog 滚动淘汰旧日志累计 ${dropped} 条(单文件容量滚动兜底生效,详见方案 §2.4)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 提取客户端 IP(与限流/登录审计同口径:trust proxy 时 req.ip 已按 XFF 解析) */
|
|
102
|
+
static ipOf(req: any): string {
|
|
103
|
+
try {
|
|
104
|
+
return req?.ip || req?.socket?.remoteAddress || req?.connection?.remoteAddress || '';
|
|
105
|
+
} catch {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** body 提取:json 走 rawBody 保真解析(失败降级截断原文),multipart 取 multer 字段 */
|
|
111
|
+
private extractBody(req: { rawBody?: any; body?: any }): unknown {
|
|
112
|
+
const raw = req?.rawBody;
|
|
113
|
+
if (Buffer.isBuffer(raw)) {
|
|
114
|
+
try {
|
|
115
|
+
return this.sanitize(JSON.parse(raw.toString('utf8')));
|
|
116
|
+
} catch {
|
|
117
|
+
// 非 JSON(已消费/损坏):截断原文,只求留痕
|
|
118
|
+
return raw.toString('utf8').slice(0, 200);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// multipart(上传)/urlencoded:body 为字段对象(文件内容不记)
|
|
122
|
+
return this.sanitize(req?.body);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 脱敏 + 截断:敏感 key 值→***;字符串 200 字符;对象最多 10 键;递归深度 3 */
|
|
126
|
+
private sanitize(value: unknown, depth = 0): unknown {
|
|
127
|
+
if (value === null || value === undefined) return value;
|
|
128
|
+
if (typeof value === 'string') {
|
|
129
|
+
return value.length > 200 ? `${value.slice(0, 200)}…` : value;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value !== 'object') return value;
|
|
132
|
+
if (depth >= 3) return '[深对象截断]';
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return value.slice(0, 10).map((v) => this.sanitize(v, depth + 1));
|
|
135
|
+
}
|
|
136
|
+
const out: Record<string, unknown> = {};
|
|
137
|
+
let count = 0;
|
|
138
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
139
|
+
if (count >= 10) {
|
|
140
|
+
out['[字段截断]'] = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
out[k] = MASK_KEYS.has(k.toLowerCase()) ? '***' : this.sanitize(v, depth + 1);
|
|
144
|
+
count += 1;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
}
|