@kne/fastify-app-manager 0.1.7 → 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.
@@ -1,6 +1,11 @@
1
1
  const path = require('node:path');
2
+ const zlib = require('node:zlib');
2
3
  const fs = require('fs-extra');
3
4
  const { EventEmitter } = require('node:events');
5
+ const { convertLinePrefix } = require('./logTime');
6
+
7
+ const NEWLINE = 0x0a;
8
+ const CHUNK_SIZE = 64 * 1024;
4
9
 
5
10
  const createLogHub = () => {
6
11
  const emitter = new EventEmitter();
@@ -22,38 +27,180 @@ const appendLog = async (filePath, content) => {
22
27
  await fs.appendFile(filePath, content.endsWith('\n') ? content : `${content}\n`);
23
28
  };
24
29
 
25
- const readLogTail = async (filePath, { perPage = 100, currentPage = 1, beforeLine } = {}) => {
30
+ const lineCache = new Map();
31
+ const gzipLineCache = new Map();
32
+
33
+ const countNewlines = buffer => {
34
+ let count = 0;
35
+ for (let idx = buffer.indexOf(NEWLINE); idx !== -1; idx = buffer.indexOf(NEWLINE, idx + 1)) {
36
+ count++;
37
+ }
38
+ return count;
39
+ };
40
+
41
+ /** Counts lines incrementally; the file is only rescanned from 0 after rotation (inode change) or truncation. */
42
+ const getLineInfo = async filePath => {
43
+ let stat;
44
+ try {
45
+ stat = await fs.stat(filePath);
46
+ } catch (e) {
47
+ lineCache.delete(filePath);
48
+ return { count: 0, size: 0 };
49
+ }
50
+ const cached = lineCache.get(filePath);
51
+ let { newlines, lastByte, size } = cached && cached.ino === stat.ino && stat.size >= cached.size ? cached : { newlines: 0, lastByte: null, size: 0 };
52
+ if (stat.size > size) {
53
+ for await (const chunk of fs.createReadStream(filePath, { start: size, end: stat.size - 1 })) {
54
+ newlines += countNewlines(chunk);
55
+ lastByte = chunk[chunk.length - 1];
56
+ }
57
+ size = stat.size;
58
+ }
59
+ lineCache.set(filePath, { ino: stat.ino, size, newlines, lastByte });
60
+ return { count: newlines + (size > 0 && lastByte !== NEWLINE ? 1 : 0), size };
61
+ };
62
+
63
+ const countLines = async filePath => (await getLineInfo(filePath)).count;
64
+
65
+ /** Reads `take` lines ending `skip` lines before the end of the first `size` bytes; newest first. */
66
+ const readLinesFromEnd = async (filePath, { size, skip, take }) => {
67
+ const collected = [];
68
+ let skipped = 0;
69
+ const accept = buffer => {
70
+ if (skipped < skip) {
71
+ skipped++;
72
+ return true;
73
+ }
74
+ collected.push(buffer.toString('utf8').replace(/\r$/, ''));
75
+ return collected.length < take;
76
+ };
77
+
78
+ const handle = await fs.promises.open(filePath, 'r');
79
+ try {
80
+ let pos = size;
81
+ let carry = Buffer.alloc(0);
82
+ let first = true;
83
+ let more = true;
84
+ while (pos > 0 && more) {
85
+ const length = Math.min(CHUNK_SIZE, pos);
86
+ pos -= length;
87
+ const chunk = Buffer.alloc(length);
88
+ await handle.read(chunk, 0, length, pos);
89
+ let data = carry.length ? Buffer.concat([chunk, carry]) : chunk;
90
+ if (first) {
91
+ first = false;
92
+ if (data[data.length - 1] === NEWLINE) {
93
+ data = data.subarray(0, data.length - 1);
94
+ }
95
+ }
96
+ let end = data.length;
97
+ let idx = data.lastIndexOf(NEWLINE);
98
+ while (idx !== -1 && more) {
99
+ more = accept(data.subarray(idx + 1, end));
100
+ end = idx;
101
+ idx = idx > 0 ? data.lastIndexOf(NEWLINE, idx - 1) : -1;
102
+ }
103
+ carry = data.subarray(0, end);
104
+ }
105
+ if (more && pos === 0 && size > 0) {
106
+ accept(carry);
107
+ }
108
+ } finally {
109
+ await handle.close();
110
+ }
111
+ return collected;
112
+ };
113
+
114
+ // Splits on `\n` only (same rule as plain files); `onLine` returns false to stop early.
115
+ const scanGzipLines = async (filePath, onLine) => {
116
+ const input = fs.createReadStream(filePath);
117
+ const gunzip = zlib.createGunzip();
118
+ input.pipe(gunzip);
119
+ let index = 0;
120
+ let carry = Buffer.alloc(0);
121
+ try {
122
+ for await (const chunk of gunzip) {
123
+ const data = carry.length ? Buffer.concat([carry, chunk]) : chunk;
124
+ let begin = 0;
125
+ for (let idx = data.indexOf(NEWLINE); idx !== -1; idx = data.indexOf(NEWLINE, begin)) {
126
+ if (onLine(data.subarray(begin, idx), index++) === false) {
127
+ return index;
128
+ }
129
+ begin = idx + 1;
130
+ }
131
+ carry = data.subarray(begin);
132
+ }
133
+ if (carry.length) {
134
+ onLine(carry, index++);
135
+ }
136
+ return index;
137
+ } finally {
138
+ input.destroy();
139
+ gunzip.destroy();
140
+ }
141
+ };
142
+
143
+ const countGzipLines = async filePath => {
144
+ const stat = await fs.stat(filePath);
145
+ const key = `${stat.mtimeMs}:${stat.size}`;
146
+ const cached = gzipLineCache.get(filePath);
147
+ if (cached && cached.key === key) {
148
+ return cached.count;
149
+ }
150
+ const count = await scanGzipLines(filePath, () => true);
151
+ gzipLineCache.set(filePath, { key, count });
152
+ return count;
153
+ };
154
+
155
+ const readGzipRange = async (filePath, start, end) => {
156
+ const lines = [];
157
+ await scanGzipLines(filePath, (buffer, index) => {
158
+ if (index >= start) {
159
+ lines.push(buffer.toString('utf8').replace(/\r$/, ''));
160
+ }
161
+ return index + 1 < end;
162
+ });
163
+ return lines.reverse();
164
+ };
165
+
166
+ const resolvePageRange = (totalCount, { perPage, currentPage, beforeLine }) => {
26
167
  const size = Math.min(100, Math.max(1, Number(perPage) || 100));
27
168
  const page = Math.max(1, Number(currentPage) || 1);
169
+ if (beforeLine != null && beforeLine !== '' && Number(beforeLine) > 0) {
170
+ // 取 line < beforeLine 的尾部 size 行(0-based slice end = beforeLine-1)
171
+ const end = Math.min(Math.max(0, Number(beforeLine) - 1), totalCount);
172
+ return { start: Math.max(0, end - size), end };
173
+ }
174
+ return {
175
+ start: Math.max(0, totalCount - size * page),
176
+ end: Math.max(0, totalCount - size * (page - 1))
177
+ };
178
+ };
179
+
180
+ const readLogTail = async (filePath, { perPage = 100, currentPage = 1, beforeLine, timezone } = {}) => {
28
181
  if (!(await fs.pathExists(filePath))) {
29
182
  return { pageData: [], totalCount: 0, hasMore: false };
30
183
  }
31
- const text = await fs.readFile(filePath, 'utf8');
32
- const lines = text.split(/\r?\n/).filter((line, idx, arr) => !(idx === arr.length - 1 && line === ''));
33
- const totalCount = lines.length;
34
- let start;
35
- let end;
36
- if (beforeLine != null && beforeLine !== '' && Number(beforeLine) > 0) {
37
- // 取 line < beforeLine 的尾部 size 行(0-based slice end = beforeLine-1)
38
- end = Math.min(Math.max(0, Number(beforeLine) - 1), totalCount);
39
- start = Math.max(0, end - size);
40
- } else {
41
- start = Math.max(0, totalCount - size * page);
42
- end = Math.max(0, totalCount - size * (page - 1));
184
+ const compressed = filePath.endsWith('.gz');
185
+ const info = compressed ? { count: await countGzipLines(filePath) } : await getLineInfo(filePath);
186
+ const totalCount = info.count;
187
+ const { start, end } = resolvePageRange(totalCount, { perPage, currentPage, beforeLine });
188
+ let contents = [];
189
+ if (end > start) {
190
+ contents = compressed ? await readGzipRange(filePath, start, end) : await readLinesFromEnd(filePath, { size: info.size, skip: totalCount - end, take: end - start });
43
191
  }
44
- const pageData = lines.slice(start, end).map((content, i) => ({
45
- line: start + i + 1,
46
- content
47
- }));
48
192
  return {
49
- pageData: pageData.reverse(),
193
+ pageData: contents.map((content, i) => ({
194
+ line: end - i,
195
+ content: convertLinePrefix(content, timezone)
196
+ })),
50
197
  totalCount,
51
198
  hasMore: start > 0
52
199
  };
53
200
  };
54
201
 
55
- const readLastLines = async (filePath, n = 100) => {
56
- const { pageData } = await readLogTail(filePath, { perPage: n, currentPage: 1 });
202
+ const readLastLines = async (filePath, n = 100, { timezone } = {}) => {
203
+ const { pageData } = await readLogTail(filePath, { perPage: n, currentPage: 1, timezone });
57
204
  return pageData.reverse();
58
205
  };
59
206
 
@@ -61,6 +208,7 @@ module.exports = {
61
208
  createLogHub,
62
209
  ensureLogFiles,
63
210
  appendLog,
211
+ countLines,
64
212
  readLogTail,
65
213
  readLastLines
66
214
  };
@@ -0,0 +1,162 @@
1
+ const path = require('node:path');
2
+ const zlib = require('node:zlib');
3
+ const { pipeline } = require('node:stream/promises');
4
+ const fs = require('fs-extra');
5
+ const { formatInTz, dateKeyInTz } = require('./logTime');
6
+
7
+ const LOG_STREAMS = ['out', 'err'];
8
+ const STATE_FILE = '.rotate-state.json';
9
+ const LOG_FILE_RE = /^(out|err)(?:-\d{8}-\d{6}(?:-\d+)?)?\.log(\.gz)?$/;
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+
12
+ const currentFileName = stream => `${stream === 'err' ? 'err' : 'out'}.log`;
13
+
14
+ const isCurrentFileName = fileName => LOG_STREAMS.some(stream => currentFileName(stream) === fileName);
15
+
16
+ const badRequest = message => {
17
+ const err = new Error(message);
18
+ err.statusCode = 400;
19
+ return err;
20
+ };
21
+
22
+ const resolveLogFile = (logsDir, fileName) => {
23
+ if (typeof fileName !== 'string' || path.basename(fileName) !== fileName || !LOG_FILE_RE.test(fileName)) {
24
+ throw badRequest(`invalid log file: ${fileName}`);
25
+ }
26
+ return path.join(logsDir, fileName);
27
+ };
28
+
29
+ const readState = async logsDir => {
30
+ try {
31
+ return (await fs.readJson(path.join(logsDir, STATE_FILE))) || {};
32
+ } catch (e) {
33
+ return {};
34
+ }
35
+ };
36
+
37
+ const writeState = (logsDir, state) => fs.writeJson(path.join(logsDir, STATE_FILE), state);
38
+
39
+ const resolveStartTime = (state, stream, stat) => {
40
+ const recorded = state[stream] ? new Date(state[stream]) : null;
41
+ if (recorded && !Number.isNaN(recorded.getTime())) {
42
+ return recorded;
43
+ }
44
+ return stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime;
45
+ };
46
+
47
+ const uniqueArchivePath = async (logsDir, baseName) => {
48
+ for (let n = 0; ; n++) {
49
+ const target = path.join(logsDir, n ? `${baseName}-${n}.log` : `${baseName}.log`);
50
+ if (!(await fs.pathExists(target)) && !(await fs.pathExists(`${target}.gz`))) {
51
+ return target;
52
+ }
53
+ }
54
+ };
55
+
56
+ /**
57
+ * Renames `{stream}.log` to `{stream}-{startTime}.log` when it exceeds maxSize or its start day
58
+ * (in `timezone`) is before `now`. Caller must reopen the PM2 log handles afterwards.
59
+ */
60
+ const rotateIfNeeded = async ({ logsDir, stream, maxSize, daily = false, timezone, now = new Date() }) => {
61
+ const file = path.join(logsDir, currentFileName(stream));
62
+ let stat;
63
+ try {
64
+ stat = await fs.stat(file);
65
+ } catch (e) {
66
+ return null;
67
+ }
68
+ const state = await readState(logsDir);
69
+ const startedAt = resolveStartTime(state, stream, stat);
70
+ const dayChanged = daily && dateKeyInTz(startedAt, timezone) !== dateKeyInTz(now, timezone);
71
+
72
+ let reason = null;
73
+ if (stat.size > 0 && maxSize > 0 && stat.size >= maxSize) {
74
+ reason = 'size';
75
+ } else if (stat.size > 0 && dayChanged) {
76
+ reason = 'daily';
77
+ }
78
+
79
+ if (!reason) {
80
+ // An empty file carries no content from the old day; restart its clock so today's logs get today's name.
81
+ if (!state[stream] || (stat.size === 0 && dayChanged)) {
82
+ state[stream] = (stat.size === 0 && dayChanged ? now : startedAt).toISOString();
83
+ await writeState(logsDir, state);
84
+ }
85
+ return null;
86
+ }
87
+
88
+ const archive = await uniqueArchivePath(logsDir, `${stream}-${formatInTz(startedAt, timezone, 'YYYYMMDD-HHmmss')}`);
89
+ await fs.rename(file, archive);
90
+ await fs.ensureFile(file);
91
+ state[stream] = now.toISOString();
92
+ await writeState(logsDir, state);
93
+ return { archive, reason };
94
+ };
95
+
96
+ const compressArchive = async file => {
97
+ const target = `${file}.gz`;
98
+ const stat = await fs.stat(file);
99
+ await pipeline(fs.createReadStream(file), zlib.createGzip(), fs.createWriteStream(target));
100
+ await fs.utimes(target, stat.atime, stat.mtime);
101
+ await fs.remove(file);
102
+ return target;
103
+ };
104
+
105
+ const listLogFiles = async logsDir => {
106
+ let names = [];
107
+ try {
108
+ names = await fs.readdir(logsDir);
109
+ } catch (e) {
110
+ return [];
111
+ }
112
+ const files = [];
113
+ for (const fileName of names) {
114
+ const match = LOG_FILE_RE.exec(fileName);
115
+ if (!match) {
116
+ continue;
117
+ }
118
+ let stat;
119
+ try {
120
+ stat = await fs.stat(path.join(logsDir, fileName));
121
+ } catch (e) {
122
+ continue;
123
+ }
124
+ if (!stat.isFile()) {
125
+ continue;
126
+ }
127
+ files.push({
128
+ fileName,
129
+ stream: match[1],
130
+ size: stat.size,
131
+ compressed: !!match[2],
132
+ current: isCurrentFileName(fileName),
133
+ mtime: stat.mtime
134
+ });
135
+ }
136
+ return files.sort((a, b) => Number(b.current) - Number(a.current) || b.mtime - a.mtime);
137
+ };
138
+
139
+ const applyRetention = async ({ logsDir, stream, maxFiles, maxDays, now = new Date() }) => {
140
+ const archives = (await listLogFiles(logsDir)).filter(file => !file.current && (!stream || file.stream === stream));
141
+ const removed = [];
142
+ for (const [index, file] of archives.entries()) {
143
+ const tooMany = maxFiles > 0 && index >= maxFiles;
144
+ const tooOld = maxDays > 0 && now.getTime() - file.mtime.getTime() > maxDays * DAY_MS;
145
+ if (tooMany || tooOld) {
146
+ await fs.remove(path.join(logsDir, file.fileName));
147
+ removed.push(file.fileName);
148
+ }
149
+ }
150
+ return removed;
151
+ };
152
+
153
+ module.exports = {
154
+ LOG_STREAMS,
155
+ currentFileName,
156
+ isCurrentFileName,
157
+ resolveLogFile,
158
+ rotateIfNeeded,
159
+ compressArchive,
160
+ listLogFiles,
161
+ applyRetention
162
+ };
@@ -0,0 +1,132 @@
1
+ const OFFSET_RE = /^([+-])(\d{2}):?(\d{2})$/;
2
+ const LINE_PREFIX_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?=: )/;
3
+
4
+ const formatterCache = new Map();
5
+
6
+ const getFormatter = timeZone => {
7
+ if (!formatterCache.has(timeZone)) {
8
+ formatterCache.set(
9
+ timeZone,
10
+ new Intl.DateTimeFormat('en-US', {
11
+ timeZone,
12
+ hourCycle: 'h23',
13
+ year: 'numeric',
14
+ month: '2-digit',
15
+ day: '2-digit',
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ second: '2-digit'
19
+ })
20
+ );
21
+ }
22
+ return formatterCache.get(timeZone);
23
+ };
24
+
25
+ const parsedCache = new Map();
26
+
27
+ const parseTimezone = tz => {
28
+ if (tz && typeof tz === 'object' && (tz.type === 'offset' || tz.type === 'iana')) {
29
+ return tz;
30
+ }
31
+ const value = tz == null ? '' : String(tz).trim();
32
+ if (!parsedCache.has(value)) {
33
+ parsedCache.set(value, parseTimezoneValue(value));
34
+ }
35
+ return parsedCache.get(value);
36
+ };
37
+
38
+ const parseTimezoneValue = value => {
39
+ const match = OFFSET_RE.exec(value);
40
+ if (match) {
41
+ const hours = Number(match[2]);
42
+ const minutes = Number(match[3]);
43
+ if (hours > 14 || minutes > 59) {
44
+ throw new Error(`invalid logTimezone: ${value}`);
45
+ }
46
+ return { type: 'offset', offsetMinutes: (match[1] === '-' ? -1 : 1) * (hours * 60 + minutes) };
47
+ }
48
+ if (!value) {
49
+ throw new Error('invalid logTimezone: empty');
50
+ }
51
+ try {
52
+ getFormatter(value);
53
+ } catch (e) {
54
+ throw new Error(`invalid logTimezone: ${value}`);
55
+ }
56
+ return { type: 'iana', name: value };
57
+ };
58
+
59
+ const getOffsetMinutes = (date, tz) => {
60
+ const info = parseTimezone(tz);
61
+ if (info.type === 'offset') {
62
+ return info.offsetMinutes;
63
+ }
64
+ const parts = {};
65
+ for (const { type, value } of getFormatter(info.name).formatToParts(date)) {
66
+ parts[type] = value;
67
+ }
68
+ const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second));
69
+ return Math.round((asUtc - Math.floor(date.getTime() / 1000) * 1000) / 60000);
70
+ };
71
+
72
+ const pad = (value, length = 2) => String(value).padStart(length, '0');
73
+
74
+ const formatOffset = minutes => {
75
+ const abs = Math.abs(minutes);
76
+ return `${minutes < 0 ? '-' : '+'}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
77
+ };
78
+
79
+ const formatInTz = (date, tz, pattern = 'YYYY-MM-DDTHH:mm:ss') => {
80
+ const shifted = new Date(date.getTime() + getOffsetMinutes(date, tz) * 60000);
81
+ const tokens = {
82
+ YYYY: pad(shifted.getUTCFullYear(), 4),
83
+ MM: pad(shifted.getUTCMonth() + 1),
84
+ DD: pad(shifted.getUTCDate()),
85
+ HH: pad(shifted.getUTCHours()),
86
+ mm: pad(shifted.getUTCMinutes()),
87
+ ss: pad(shifted.getUTCSeconds())
88
+ };
89
+ return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, token => tokens[token]);
90
+ };
91
+
92
+ const toIsoInTz = (date, tz) => `${formatInTz(date, tz)}${formatOffset(getOffsetMinutes(date, tz))}`;
93
+
94
+ const dateKeyInTz = (date, tz) => formatInTz(date, tz, 'YYYY-MM-DD');
95
+
96
+ // PM2 `time: true` prefixes lines in the daemon's local time without an offset; assume it matches this process.
97
+ const convertLinePrefix = (line, tz) => {
98
+ if (!tz || typeof line !== 'string') {
99
+ return line;
100
+ }
101
+ const match = LINE_PREFIX_RE.exec(line);
102
+ if (!match) {
103
+ return line;
104
+ }
105
+ const [, y, mo, d, h, mi, s] = match.map(Number);
106
+ const date = new Date(y, mo - 1, d, h, mi, s);
107
+ if (Number.isNaN(date.getTime()) || getOffsetMinutes(date, tz) === -date.getTimezoneOffset()) {
108
+ return line;
109
+ }
110
+ return `${formatInTz(date, tz)}${line.slice(match[0].length)}`;
111
+ };
112
+
113
+ const convertLogText = (text, tz) => {
114
+ if (!tz || typeof text !== 'string') {
115
+ return text;
116
+ }
117
+ return text
118
+ .split('\n')
119
+ .map(line => convertLinePrefix(line, tz))
120
+ .join('\n');
121
+ };
122
+
123
+ module.exports = {
124
+ parseTimezone,
125
+ getOffsetMinutes,
126
+ formatOffset,
127
+ formatInTz,
128
+ toIsoInTz,
129
+ dateKeyInTz,
130
+ convertLinePrefix,
131
+ convertLogText
132
+ };
package/libs/utils/pm2.js CHANGED
@@ -82,6 +82,12 @@ const launchBus = () =>
82
82
  pm2.launchBus((err, bus) => (err ? reject(err) : resolve(bus)));
83
83
  });
84
84
 
85
+ // Reopens out_file/error_file for every PM2 process; needed after renaming log files.
86
+ const reloadLogs = () =>
87
+ new Promise((resolve, reject) => {
88
+ pm2.reloadLogs(err => (err ? reject(err) : resolve()));
89
+ });
90
+
85
91
  module.exports = {
86
92
  connect,
87
93
  disconnect,
@@ -92,5 +98,6 @@ module.exports = {
92
98
  restartApp,
93
99
  list,
94
100
  launchBus,
101
+ reloadLogs,
95
102
  raw: pm2
96
103
  };
@@ -0,0 +1,132 @@
1
+ const ROLLING_MS = 60 * 1000;
2
+ const BUCKET_FACTOR = 1.05;
3
+ const LOG_FACTOR = Math.log(BUCKET_FACTOR);
4
+ const MAX_BUCKET = Math.ceil(Math.log(60 * 1000) / LOG_FACTOR);
5
+
6
+ // 对数分桶:桶上界为 1.05^i 毫秒,内存与请求量无关,分位数误差约 5%
7
+ const bucketOf = ms => (ms <= 1 ? 0 : Math.min(MAX_BUCKET, Math.ceil(Math.log(ms) / LOG_FACTOR)));
8
+
9
+ const percentile = (hist, total, maxMs, q) => {
10
+ if (!total) {
11
+ return null;
12
+ }
13
+ const rank = Math.ceil(q * total);
14
+ let seen = 0;
15
+ for (const index of [...hist.keys()].sort((a, b) => a - b)) {
16
+ seen += hist.get(index);
17
+ if (seen >= rank) {
18
+ return Math.round(Math.min(Math.pow(BUCKET_FACTOR, index), maxMs) * 10) / 10;
19
+ }
20
+ }
21
+ return Math.round(maxMs * 10) / 10;
22
+ };
23
+
24
+ const round = (value, digits) => {
25
+ const base = Math.pow(10, digits);
26
+ return Math.round(value * base) / base;
27
+ };
28
+
29
+ const createWindow = peak => ({ count: 0, errors5xx: 0, upstreamErrors: 0, sumMs: 0, maxMs: 0, hist: new Map(), peak });
30
+
31
+ const createRequestMetrics = ({ intervalMs }) => {
32
+ const windowMs = intervalMs > 0 ? intervalMs : 5000;
33
+ const rollingWindows = Math.max(1, Math.ceil(ROLLING_MS / windowMs));
34
+ const states = new Map();
35
+
36
+ const stateOf = name => {
37
+ if (!states.has(name)) {
38
+ states.set(name, { inFlight: 0, window: createWindow(0), recent: [], lastSnapshotAt: null });
39
+ }
40
+ return states.get(name);
41
+ };
42
+
43
+ const begin = name => {
44
+ const state = stateOf(name);
45
+ state.inFlight += 1;
46
+ state.window.peak = Math.max(state.window.peak, state.inFlight);
47
+ const startedAt = process.hrtime.bigint();
48
+ let upstreamError = false;
49
+ let finished = false;
50
+
51
+ return {
52
+ markUpstreamError: () => {
53
+ upstreamError = true;
54
+ },
55
+ end: ({ statusCode } = {}) => {
56
+ if (finished) {
57
+ return;
58
+ }
59
+ finished = true;
60
+ state.inFlight = Math.max(0, state.inFlight - 1);
61
+ const ms = Number(process.hrtime.bigint() - startedAt) / 1e6;
62
+ const { window } = state;
63
+ const bucket = bucketOf(ms);
64
+ window.count += 1;
65
+ window.sumMs += ms;
66
+ window.maxMs = Math.max(window.maxMs, ms);
67
+ window.hist.set(bucket, (window.hist.get(bucket) || 0) + 1);
68
+ if (statusCode >= 500) {
69
+ window.errors5xx += 1;
70
+ }
71
+ if (upstreamError) {
72
+ window.upstreamErrors += 1;
73
+ }
74
+ }
75
+ };
76
+ };
77
+
78
+ const snapshot = (name, { now = Date.now() } = {}) => {
79
+ const state = stateOf(name);
80
+ const elapsed = state.lastSnapshotAt == null ? windowMs : now - state.lastSnapshotAt;
81
+ const durationMs = elapsed > 0 ? elapsed : windowMs;
82
+ const window = state.window;
83
+ state.lastSnapshotAt = now;
84
+ state.recent.push({ durationMs, count: window.count, errors5xx: window.errors5xx, sumMs: window.sumMs, maxMs: window.maxMs, hist: window.hist });
85
+ if (state.recent.length > rollingWindows) {
86
+ state.recent.splice(0, state.recent.length - rollingWindows);
87
+ }
88
+ state.window = createWindow(state.inFlight);
89
+
90
+ const rolling = { durationMs: 0, count: 0, errors5xx: 0, sumMs: 0, maxMs: 0, hist: new Map() };
91
+ for (const item of state.recent) {
92
+ rolling.durationMs += item.durationMs;
93
+ rolling.count += item.count;
94
+ rolling.errors5xx += item.errors5xx;
95
+ rolling.sumMs += item.sumMs;
96
+ rolling.maxMs = Math.max(rolling.maxMs, item.maxMs);
97
+ for (const [index, count] of item.hist) {
98
+ rolling.hist.set(index, (rolling.hist.get(index) || 0) + count);
99
+ }
100
+ }
101
+
102
+ return {
103
+ qps: round(window.count / (durationMs / 1000), 2),
104
+ rpm: Math.round((rolling.count * ROLLING_MS) / rolling.durationMs),
105
+ avgRt: rolling.count ? round(rolling.sumMs / rolling.count, 1) : null,
106
+ p95: percentile(rolling.hist, rolling.count, rolling.maxMs, 0.95),
107
+ p99: percentile(rolling.hist, rolling.count, rolling.maxMs, 0.99),
108
+ errorRate: rolling.count ? round(rolling.errors5xx / rolling.count, 4) : null,
109
+ errors5xx: window.errors5xx,
110
+ upstreamErrors: window.upstreamErrors,
111
+ concurrency: state.inFlight,
112
+ peakConcurrency: Math.max(window.peak, state.inFlight)
113
+ };
114
+ };
115
+
116
+ const retain = names => {
117
+ const keep = new Set(names);
118
+ for (const name of states.keys()) {
119
+ if (!keep.has(name)) {
120
+ states.delete(name);
121
+ }
122
+ }
123
+ };
124
+
125
+ return { begin, snapshot, retain, rollingWindows };
126
+ };
127
+
128
+ module.exports = {
129
+ createRequestMetrics,
130
+ bucketOf,
131
+ percentile
132
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -73,6 +73,7 @@
73
73
  "jsdom": "^29.1.1",
74
74
  "lodash": "^4.18.1",
75
75
  "pm2": "^7.0.4",
76
- "sequelize": "^6.37.7"
76
+ "sequelize": "^6.37.7",
77
+ "yazl": "^3.3.1"
77
78
  }
78
79
  }