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.
@@ -0,0 +1,239 @@
1
+ import { performance } from 'perf_hooks';
2
+ import { Logger } from 'typeorm';
3
+ import { HostMetricsSampler, HostSnapshot } from './host-metrics.sampler';
4
+ import { JsonlDailyWriter, localTs } from '../common/jsonl-daily-writer';
5
+ import { sqlFingerprint, hostTension } from './sql-fingerprint';
6
+
7
+ /**
8
+ * 慢SQL探针 Logger(《慢SQL探针与SQL优化路径方案》§3~§5)
9
+ *
10
+ * 挂载在 TypeORM Logger 层(非 better-sqlite3 专属驱动层)——better-sqlite3 与 mysql
11
+ * 两驱动的 QueryRunner 都内置同一钩子:maxQueryExecutionTime 超时 → logQuerySlow。
12
+ * 切 MySQL 时本文件零改动,只在 MySQL options 侧严禁顺手开 enableQueryTimeout
13
+ * (那会把 maxQueryExecutionTime 变成 server 端杀查询的超时)。
14
+ *
15
+ * 记录落独立 JSONL(data/slowsql/slowsql-YYYY-MM-DD.jsonl),非应用日志——是调试
16
+ * 期间的证据链,供 slowsql-report.mjs 后期分析。三类记录:
17
+ * sql 慢查询样本(内联最近宿主快照 host 字段;《后端日志体系整改方案》§4.2 起
18
+ * 同指纹每日仅前 5 条落全量,其余只进 stats 计数——影响大的就是那几条,
19
+ * 原始证据过量无意义)
20
+ * error 查询报错(先 console.error 保留现 yaml logging:[error] 行为,再落盘)
21
+ * lag 事件循环漂移(500ms 心跳测漂移,内联宿主快照)
22
+ * host 宿主基线(默认 60s 一条,提供「慢时刻 vs 平时」对比基准)
23
+ * stats 每小时汇总(计数含 boot 迁移期,口径为 boot 基线不剔除;含指纹 top 段——
24
+ * 计数全量、样本限量,报告侧 top 表以计数为准)
25
+ *
26
+ * 写盘纪律:长驻 append 流(每日一开,换日轮转;2026-09-24 改造,防故障风暴时
27
+ * 同步写给事件循环加码);每查询开销仅内存计数。
28
+ * 日志器自身永不抛错(在查询路径上,抛错即拖死业务)。
29
+ */
30
+
31
+ export interface SlowSqlLoggerOptions {
32
+ /** 落盘目录(通常 <数据库 dir>/slowsql,随 data volume 挂载) */
33
+ dir: string;
34
+ /** 慢查询阈值 ms(同时作为 TypeORM maxQueryExecutionTime) */
35
+ thresholdMs: number;
36
+ /** 事件循环漂移记录阈值 ms */
37
+ lagMs: number;
38
+ /** 宿主基线落盘周期 s */
39
+ hostSampleSec: number;
40
+ /** 保留天数 */
41
+ retainDays: number;
42
+ /** 单文件上限 MB(超限滚动瘦身,留新去旧) */
43
+ maxFileMb: number;
44
+ /** 查询报错是否同时 console.error(对齐原 yaml database.logging 的 [error] 语义) */
45
+ errorConsole: boolean;
46
+ }
47
+
48
+ /** params 截断:每参数 200 字符、最多 10 个(防大批量参数撑爆行/敏感原文外泄) */
49
+ function truncateParams(params?: any[]): any[] | undefined {
50
+ if (!Array.isArray(params) || params.length === 0) return undefined;
51
+ return params.slice(0, 10).map((p) => {
52
+ if (p === null || p === undefined) return p;
53
+ const text =
54
+ typeof p === 'string' ? p : (() => { try { return JSON.stringify(p); } catch { return String(p); } })();
55
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
56
+ });
57
+ }
58
+
59
+ export class SlowSqlLogger implements Logger {
60
+ /** 同指纹每日采样上限:细节样本够定位即可,之后仅计数(§4.2) */
61
+ private static readonly SAMPLE_PER_FP_PER_DAY = 5;
62
+
63
+ private readonly opts: SlowSqlLoggerOptions;
64
+ private readonly host: HostMetricsSampler;
65
+ private readonly writer: JsonlDailyWriter;
66
+
67
+ // 每小时计数(queries 含 boot 迁移期,口径为 boot 基线)
68
+ private queries = 0;
69
+ private slowCount = 0;
70
+ private slowTotalMs = 0;
71
+ private maxMs = 0;
72
+
73
+ // 指纹聚合(自上次 stats flush 清零;计数全量,与采样与否无关)
74
+ private fpCounters = new Map<string, { count: number; totalMs: number; maxMs: number; tension: number }>();
75
+ // 指纹当日已采样数(换日重置配额)
76
+ private fpSampled = new Map<string, number>();
77
+ private fpDay = '';
78
+
79
+ private lagTimer: NodeJS.Timeout | null = null;
80
+ private lagLast = performance.now();
81
+ private lagReady = false;
82
+ private statsTimer: NodeJS.Timeout | null = null;
83
+ private hostTimer: NodeJS.Timeout | null = null;
84
+
85
+ constructor(opts: SlowSqlLoggerOptions) {
86
+ this.opts = opts;
87
+ // 磁盘水位观测路径 = 慢SQL落盘目录(随 data volume 持久化,即容量风险所在卷)
88
+ this.host = new HostMetricsSampler(6, this.opts.dir).start(10_000);
89
+ // 写盘四件套(按日切/继承长度/超限滚动瘦身/每日清理)已抽 JsonlDailyWriter 与 apilog 共用
90
+ this.writer = new JsonlDailyWriter({
91
+ dir: this.opts.dir,
92
+ prefix: 'slowsql',
93
+ retainDays: this.opts.retainDays,
94
+ maxFileMb: this.opts.maxFileMb
95
+ });
96
+ this.startTimers();
97
+ this.writer.cleanupExpired();
98
+ }
99
+
100
+ /** TypeORM DataSource 关闭时停掉采样/心跳(测试与脚本场景防悬挂) */
101
+ stop(): void {
102
+ this.host.stop();
103
+ for (const t of [this.lagTimer, this.statsTimer, this.hostTimer]) {
104
+ if (t) clearInterval(t);
105
+ }
106
+ this.lagTimer = this.statsTimer = this.hostTimer = null;
107
+ this.writer.stop();
108
+ }
109
+
110
+ // ── TypeORM Logger 接口 ──────────────────────────────────────────────
111
+
112
+ /** 每条查询都被无条件调用:仅内存计数,零 I/O */
113
+ logQuery(_query: string, _parameters?: any[], _queryRunner?: any): void {
114
+ this.queries += 1;
115
+ }
116
+
117
+ /** 慢查询(超 maxQueryExecutionTime):全量计数 + 每指纹每日前 5 条落细节样本 */
118
+ logQuerySlow(time: number, query: string, parameters?: any[], _queryRunner?: any): void {
119
+ this.slowCount += 1;
120
+ this.slowTotalMs += time;
121
+ if (time > this.maxMs) this.maxMs = time;
122
+
123
+ // 指纹计数器:每条慢查询都计(样本限量只影响落盘,不影响计数口径)
124
+ const fp = sqlFingerprint(query);
125
+ let c = this.fpCounters.get(fp);
126
+ if (!c) {
127
+ c = { count: 0, totalMs: 0, maxMs: 0, tension: 0 };
128
+ this.fpCounters.set(fp, c);
129
+ }
130
+ c.count += 1;
131
+ c.totalMs += time;
132
+ if (time > c.maxMs) c.maxMs = time;
133
+ if (hostTension(this.host.latest())) c.tension += 1;
134
+
135
+ // 换日重置采样配额
136
+ const today = localTs().slice(0, 10);
137
+ if (today !== this.fpDay) {
138
+ this.fpDay = today;
139
+ this.fpSampled.clear();
140
+ }
141
+ const sampled = this.fpSampled.get(fp) ?? 0;
142
+ if (sampled < SlowSqlLogger.SAMPLE_PER_FP_PER_DAY) {
143
+ this.fpSampled.set(fp, sampled + 1);
144
+ this.write({
145
+ type: 'sql',
146
+ ts: localTs(),
147
+ fp,
148
+ ms: time,
149
+ sql: query,
150
+ params: truncateParams(parameters),
151
+ host: this.host.latest()
152
+ });
153
+ }
154
+ }
155
+
156
+ /** 查询报错:先 console(保留原 logging:[error] 行为)再落盘 */
157
+ logQueryError(error: string | Error, query: string, parameters?: any[], _queryRunner?: any): void {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ if (this.opts.errorConsole) {
160
+ console.error(`[typeorm] query failed: ${query.slice(0, 200)} -- ${message}`);
161
+ }
162
+ this.write({ type: 'error', ts: localTs(), sql: query, params: truncateParams(parameters), message });
163
+ }
164
+
165
+ // 与现 yaml「只记 error」语义一致,以下静默
166
+ logSchemaBuild(_message: string): void {}
167
+ logMigration(_message: string): void {}
168
+ log(_level: 'info' | 'log' | 'warn', _message: any): void {}
169
+
170
+ // ── 定时器 ──────────────────────────────────────────────────────────
171
+
172
+ private startTimers(): void {
173
+ // 事件循环 lag 心跳:500ms 预期间隔,漂移超 lagMs 落一条 lag 记录(内联宿主快照)。
174
+ // 环形采样器首轮数据未就绪,首拍只校准不判定。
175
+ this.lagTimer = setInterval(() => {
176
+ const now = performance.now();
177
+ const drift = now - this.lagLast - 500;
178
+ this.lagLast = now;
179
+ if (!this.lagReady) {
180
+ this.lagReady = true;
181
+ return;
182
+ }
183
+ if (drift > this.opts.lagMs) {
184
+ this.write({ type: 'lag', ts: localTs(), ms: Math.round(drift), host: this.host.latest() });
185
+ }
186
+ }, 500);
187
+ this.lagTimer.unref();
188
+
189
+ // 每小时汇总
190
+ this.statsTimer = setInterval(() => this.flushStats(), 3_600_000);
191
+ this.statsTimer.unref();
192
+
193
+ // 宿主基线(慢时刻 vs 平时的对比基准)
194
+ this.hostTimer = setInterval(
195
+ () => this.write({ type: 'host', ts: localTs(), ...this.host.latest() }),
196
+ Math.max(10, this.opts.hostSampleSec) * 1000
197
+ );
198
+ this.hostTimer.unref();
199
+
200
+ // 保留清理:每日一次(unref,不阻退出)
201
+ this.writer.startDailyCleanup();
202
+ }
203
+
204
+ /** 落一条 stats 并清零(暴露公开以便验收/测试手动触发) */
205
+ flushStats(): void {
206
+ if (this.queries === 0 && this.slowCount === 0 && this.writer.droppedCount === 0) return;
207
+ const now = new Date();
208
+ const windowStart = new Date(now.getTime() - 3_600_000);
209
+ // 指纹 top 段(计数全量;样本在 sql 记录,报告侧 join 后计数为准、样本供下钻)
210
+ const top = [...this.fpCounters.entries()]
211
+ .map(([fp, c]) => ({ fp, ...c }))
212
+ .sort((a, b) => b.totalMs - a.totalMs)
213
+ .slice(0, 20);
214
+ this.write({
215
+ type: 'stats',
216
+ window: localTs(windowStart),
217
+ queries: this.queries,
218
+ slowCount: this.slowCount,
219
+ slowTotalMs: this.slowTotalMs,
220
+ maxMs: this.maxMs,
221
+ dropped: this.writer.droppedCount,
222
+ top
223
+ });
224
+ this.queries = 0;
225
+ this.slowCount = 0;
226
+ this.slowTotalMs = 0;
227
+ this.maxMs = 0;
228
+ this.writer.droppedCount = 0;
229
+ // 计数器随窗口清零;采样配额(fpSampled)按日控制,不清
230
+ this.fpCounters.clear();
231
+ }
232
+
233
+ // ── JSONL 写盘 ─────────────────────────────────────────────────────
234
+
235
+ /** 异步入队(长驻流;按日切文件,超上限/内存背压丢条计数) */
236
+ private write(record: Record<string, unknown>): void {
237
+ this.writer.write(record);
238
+ }
239
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * SQL 指纹(《后端日志体系整改方案》§4.2,自 scripts/slowsql-report.mjs 收敛为唯一实现)
3
+ *
4
+ * TypeORM 传参走占位符,文本已参数化,只需折叠空白;再兜底洗字面量。
5
+ * 截断 300 字符:超长 IN 列表折叠后仍有数千 '?',既撑爆 stats 记录也无区分度;
6
+ * 探针侧(stats.top 汇总键)与样本记录(fp 字段)共用同一切断口径,报告脚本才能 join。
7
+ * (报告脚本保留一份旧实现兜底 legacy 无 fp 字段的记录,语义一致。)
8
+ */
9
+ export function sqlFingerprint(sql: string): string {
10
+ return sql
11
+ .replace(/\s+/g, ' ')
12
+ .replace(/'[^']*'/g, "'?'")
13
+ .replace(/\b\d+(\.\d+)?\b/g, '?')
14
+ .trim()
15
+ .slice(0, 300);
16
+ }
17
+
18
+ /**
19
+ * 宿主紧张判定(与报告脚本 §5.2 同口径):
20
+ * iowait/steal 超阈 或 PSI io/cpu 超阈 → 该慢时刻「可能是宿主资源问题而非 SQL 问题」。
21
+ */
22
+ export function hostTension(h: {
23
+ iowaitPct?: number;
24
+ stealPct?: number;
25
+ psiIo?: number;
26
+ psiCpu?: number;
27
+ } | null): boolean {
28
+ if (!h) return false;
29
+ if ((h.iowaitPct ?? 0) > 5 || (h.stealPct ?? 0) > 5) return true;
30
+ if ((h.psiIo ?? 0) > 10 || (h.psiCpu ?? 0) > 20) return true;
31
+ return false;
32
+ }
@@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config';
2
2
  import type { AppConfig } from '../config/config.interface';
3
3
  import * as path from 'path';
4
4
  import { Init1740000000000 } from './migrations/0001-init.migration';
5
+ import { SlowSqlLogger } from './slow-sql.logger';
5
6
 
6
7
  /**
7
8
  * 应用 PRAGMA:在驱动 createDatabaseConnection 后、连接被业务用之前调用。
@@ -54,10 +55,30 @@ export const buildTypeOrmOptions = (
54
55
  // 文件名按环境定死(不外配):prod=template.prod.sqlite / dev=template.dev.sqlite,
55
56
  // 与运维脚本 ops/sqlite/config.sh、ops 探针约定一致。仅目录(dir/DB_DIR)可外配。
56
57
  const dbFilename = isProd ? 'template.prod.sqlite' : 'template.dev.sqlite';
58
+ // 慢SQL探针(《慢SQL探针与SQL优化路径方案》):挂 TypeORM Logger 层,驱动无关——
59
+ // better-sqlite3 与 mysql 两驱动的 QueryRunner 都内置同一钩子(maxQueryExecutionTime
60
+ // 超时 → logQuerySlow),将来切 MySQL 本文件探针段零改动。
61
+ const slowSql = config.get<AppConfig['slowSql']>('slowSql');
62
+ const errorConsole =
63
+ db.logging === true || (Array.isArray(db.logging) && db.logging.includes('error'));
64
+ const slowSqlLogger = new SlowSqlLogger({
65
+ dir: path.resolve(process.cwd(), slowSql.dir),
66
+ thresholdMs: slowSql.thresholdMs,
67
+ lagMs: slowSql.lagMs,
68
+ hostSampleSec: slowSql.hostSampleSec,
69
+ retainDays: slowSql.retainDays,
70
+ maxFileMb: slowSql.maxFileMb,
71
+ errorConsole
72
+ });
57
73
  return {
58
74
  type: 'better-sqlite3' as const,
59
75
  database: path.join(db.dir, dbFilename),
60
76
  synchronize: isProd ? false : db.synchronize,
77
+ // 慢SQL探针:logger 实例替换内建 logger(logging 选项随之失效;
78
+ // errorConsole 已按原 database.logging 语义在 SlowSqlLogger 内保留)。
79
+ // maxQueryExecutionTime 是驱动无关顶层 option,超时触发 logQuerySlow。
80
+ logger: slowSqlLogger,
81
+ maxQueryExecutionTime: slowSql.thresholdMs,
61
82
  logging: db.logging as any,
62
83
  autoLoadEntities: true,
63
84
  entities: [path.join(__dirname, '..', 'entities', '*.{ts,js}')],
@@ -3,6 +3,8 @@ import { ClassSerializerInterceptor, Logger, ValidationPipe } from '@nestjs/comm
3
3
  import { ConfigService } from '@nestjs/config';
4
4
  import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
5
5
  import { json, urlencoded } from 'express';
6
+ import { randomUUID } from 'crypto';
7
+ import { performance } from 'perf_hooks';
6
8
  import * as helmet from 'helmet';
7
9
  import { NestExpressApplication } from '@nestjs/platform-express';
8
10
  import { join, isAbsolute } from 'path';
@@ -40,6 +42,17 @@ async function bootstrap() {
40
42
  if (logLevel === 'debug') loggerLevels.add('debug');
41
43
  app.useLogger(new Logger());
42
44
 
45
+ // traceId 中间件(《后端日志体系整改方案》§2.4):挂 req + 回传 X-Trace-Id 响应头,
46
+ // 联调时用户报一个 traceId、grep 一次定位全链路。必须在 guard 之前(普通拦截器
47
+ // 看不到 guard 阶段抛的 401/429,而那正是要登记的失败);
48
+ // traceStart 同时作慢请求计时起点(含 guard/pipe 耗时,口径比拦截器更完整)。
49
+ app.use((req: any, res: any, next: any) => {
50
+ req.traceId = randomUUID().slice(0, 8);
51
+ req.traceStart = performance.now();
52
+ res.setHeader('X-Trace-Id', req.traceId);
53
+ next();
54
+ });
55
+
43
56
  // 全局前缀 /api(注意:静态资源目录 useStaticAssets 在前缀设置之前挂载,不受 /api 前缀影响)
44
57
  app.setGlobalPrefix(cfg.get<AppConfig['app']>('app').globalPrefix, {
45
58
  // 静态资源接口排除前缀
@@ -66,8 +79,17 @@ async function bootstrap() {
66
79
  );
67
80
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
68
81
 
69
- // 请求体大小上限 2MB(文件上传走 multipart 不受此限)
70
- app.use(json({ limit: '2mb' }));
82
+ // 请求体大小上限 2MB(文件上传走 multipart 不受此限)。
83
+ // verify 钩子捕获原文 buffer:apilog 登记失败请求时用 rawBody 保真解析参数
84
+ // (body 已被管道消费/校验器改写时,原文才是真实入参)。
85
+ app.use(
86
+ json({
87
+ limit: '2mb',
88
+ verify: (req: any, _res, buf) => {
89
+ if (Buffer.isBuffer(buf)) req.rawBody = buf;
90
+ }
91
+ })
92
+ );
71
93
  app.use(urlencoded({ extended: true, limit: '2mb' }));
72
94
 
73
95
  // 安全响应头(X-Frame-Options/X-Content-Type-Options/HSTS 等)。