web_plsql 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +5 -5
- package/src/admin/client/charts.ts +303 -4
- package/src/admin/index.html +283 -69
- package/src/admin/js/api.ts +95 -20
- package/src/admin/js/app.ts +460 -152
- package/src/admin/js/schemas.ts +76 -14
- package/src/admin/js/templates/config.ts +10 -9
- package/src/admin/js/templates/errorRow.ts +8 -5
- package/src/admin/js/templates/index.ts +1 -0
- package/src/admin/js/templates/poolCard.ts +6 -6
- package/src/admin/js/templates/traceRow.ts +24 -0
- package/src/admin/js/types.ts +132 -19
- package/src/admin/js/ui/components.ts +44 -0
- package/src/admin/js/ui/table.ts +173 -0
- package/src/admin/js/ui/views.ts +311 -48
- package/src/admin/js/util/format.ts +22 -3
- package/src/admin/js/util/metrics.ts +24 -0
- package/src/admin/lib/chart.bundle.css +1 -0
- package/src/admin/lib/chart.bundle.js +54 -47
- package/src/admin/style.css +143 -27
- package/src/handler/handlerAdmin.js +121 -26
- package/src/handler/plsql/errorPage.js +0 -4
- package/src/handler/plsql/owaPageStream.js +93 -0
- package/src/handler/plsql/procedure.js +191 -121
- package/src/handler/plsql/procedureNamed.js +17 -3
- package/src/handler/plsql/procedureSanitize.js +24 -0
- package/src/handler/plsql/procedureVariable.js +2 -0
- package/src/handler/plsql/sendResponse.js +41 -3
- package/src/index.js +0 -1
- package/src/server/adminContext.js +23 -0
- package/src/server/server.js +83 -68
- package/src/types.js +1 -1
- package/src/util/statsManager.js +368 -0
- package/src/util/trace.js +2 -1
- package/src/util/traceManager.js +68 -0
- package/src/version.js +1 -1
- package/types/admin/js/schemas.d.ts +384 -0
- package/types/admin/js/types.d.ts +312 -0
- package/types/handler/handlerAdmin.d.ts +70 -0
- package/types/handler/plsql/owaPageStream.d.ts +28 -0
- package/types/handler/plsql/procedure.d.ts +1 -0
- package/types/index.d.ts +0 -1
- package/types/server/adminContext.d.ts +17 -0
- package/types/server/server.d.ts +2 -19
- package/types/types.d.ts +1 -1
- package/types/util/statsManager.d.ts +395 -0
- package/types/util/traceManager.d.ts +35 -0
- package/src/admin/lib/assets/main-zpdhQ1gD.css +0 -1
- package/src/handler/handlerMetrics.js +0 -68
- package/types/handler/handlerMetrics.d.ts +0 -25
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {StatsManager} from '../util/statsManager.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('oracledb').Pool} Pool
|
|
5
|
+
* @typedef {import('../types.js').configType} configType
|
|
6
|
+
* @typedef {import('../handler/plsql/procedureNamed.js').argsType} argsType
|
|
7
|
+
* @typedef {import('../util/cache.js').Cache<unknown>} Cache
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Global Admin Context
|
|
12
|
+
*/
|
|
13
|
+
export const AdminContext = {
|
|
14
|
+
startTime: new Date(),
|
|
15
|
+
/** @type {configType | null} */
|
|
16
|
+
config: null,
|
|
17
|
+
/** @type {Pool[]} */
|
|
18
|
+
pools: [],
|
|
19
|
+
/** @type {Array<{poolName: string, procedureNameCache: Cache, argumentCache: Cache}>} */
|
|
20
|
+
caches: [],
|
|
21
|
+
paused: false,
|
|
22
|
+
statsManager: new StatsManager(),
|
|
23
|
+
};
|
package/src/server/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import {handlerWebPlSql} from '../handler/plsql/handlerPlSql.js';
|
|
|
16
16
|
import {handlerAdmin} from '../handler/handlerAdmin.js';
|
|
17
17
|
import {readFileSyncUtf8, getJsonFile} from '../util/file.js';
|
|
18
18
|
import {showConfig} from './config.js';
|
|
19
|
-
import {
|
|
19
|
+
import {AdminContext} from './adminContext.js';
|
|
20
20
|
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
22
|
const __dirname = path.dirname(__filename);
|
|
@@ -35,30 +35,11 @@ const __dirname = path.dirname(__filename);
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* @typedef {import('express').RequestHandler & {
|
|
38
|
-
* procedureNameCache: Cache<string>;
|
|
39
|
-
* argumentCache: Cache<argsType>;
|
|
38
|
+
* procedureNameCache: import('../util/cache.js').Cache<string>;
|
|
39
|
+
* argumentCache: import('../util/cache.js').Cache<argsType>;
|
|
40
40
|
* }} ExtendedRequestHandler
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Global Admin Context
|
|
45
|
-
*/
|
|
46
|
-
export const AdminContext = {
|
|
47
|
-
startTime: new Date(),
|
|
48
|
-
/** @type {configType | null} */
|
|
49
|
-
config: null,
|
|
50
|
-
/** @type {Pool[]} */
|
|
51
|
-
pools: [],
|
|
52
|
-
/** @type {Array<{poolName: string, procedureNameCache: Cache<string>, argumentCache: Cache<argsType>}>} */
|
|
53
|
-
caches: [],
|
|
54
|
-
paused: false,
|
|
55
|
-
metrics: {
|
|
56
|
-
requestCount: 0,
|
|
57
|
-
errorCount: 0,
|
|
58
|
-
totalDuration: 0,
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
|
|
62
43
|
/**
|
|
63
44
|
* @typedef {object} webServer - Web server interface.
|
|
64
45
|
* @property {configType} config - Configuration object.
|
|
@@ -74,42 +55,6 @@ export const AdminContext = {
|
|
|
74
55
|
* @property {string} certFilename - cert filename.
|
|
75
56
|
*/
|
|
76
57
|
|
|
77
|
-
/**
|
|
78
|
-
* Admin basic auth middleware
|
|
79
|
-
* @param {Request} req - The request.
|
|
80
|
-
* @param {Response} res - The response.
|
|
81
|
-
* @param {NextFunction} next - The next function.
|
|
82
|
-
*/
|
|
83
|
-
const adminAuth = (req, res, next) => {
|
|
84
|
-
const adminRoute = AdminContext.config?.adminRoute ?? '/admin';
|
|
85
|
-
|
|
86
|
-
// Simple pause check for all PL/SQL routes (not admin)
|
|
87
|
-
if (AdminContext.paused && !req.path.startsWith(adminRoute)) {
|
|
88
|
-
res.status(503).send('Server Paused');
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Basic Auth for Admin Route
|
|
93
|
-
if (req.path.startsWith(adminRoute)) {
|
|
94
|
-
const user = AdminContext.config?.adminUser;
|
|
95
|
-
const pass = AdminContext.config?.adminPassword;
|
|
96
|
-
|
|
97
|
-
if (user && pass) {
|
|
98
|
-
const auth = {login: user, password: pass};
|
|
99
|
-
const b64auth = (req.headers.authorization ?? '').split(' ')[1] ?? '';
|
|
100
|
-
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
|
|
101
|
-
|
|
102
|
-
if (login !== auth.login || password !== auth.password) {
|
|
103
|
-
res.set('WWW-Authenticate', 'Basic realm="Admin Console"');
|
|
104
|
-
res.status(401).send('Authentication required.');
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
next();
|
|
111
|
-
};
|
|
112
|
-
|
|
113
58
|
/**
|
|
114
59
|
* Create HTTPS server.
|
|
115
60
|
* @param {Express} app - express application
|
|
@@ -144,8 +89,43 @@ export const startServer = async (config, ssl) => {
|
|
|
144
89
|
// Create express app
|
|
145
90
|
const app = express();
|
|
146
91
|
|
|
92
|
+
// Default middleware
|
|
93
|
+
app.use(handlerUpload(internalConfig.uploadFileSizeLimit));
|
|
94
|
+
app.use(express.json({limit: '50mb'}));
|
|
95
|
+
app.use(express.urlencoded({limit: '50mb', extended: true}));
|
|
96
|
+
app.use(cookieParser());
|
|
97
|
+
app.use(compression());
|
|
98
|
+
|
|
147
99
|
// Pause & Admin Auth middleware
|
|
148
|
-
app.use(
|
|
100
|
+
app.use((req, res, next) => {
|
|
101
|
+
const adminRoute = internalConfig.adminRoute ?? '/admin';
|
|
102
|
+
|
|
103
|
+
// Simple pause check for all PL/SQL routes (not admin)
|
|
104
|
+
if (AdminContext.paused && !req.path.startsWith(adminRoute)) {
|
|
105
|
+
res.status(503).send('Server Paused');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Basic Auth for Admin Route
|
|
110
|
+
if (req.path.startsWith(adminRoute)) {
|
|
111
|
+
const user = internalConfig.adminUser;
|
|
112
|
+
const pass = internalConfig.adminPassword;
|
|
113
|
+
|
|
114
|
+
if (user && pass) {
|
|
115
|
+
const auth = {login: user, password: pass};
|
|
116
|
+
const b64auth = (req.headers.authorization ?? '').split(' ')[1] ?? '';
|
|
117
|
+
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
|
|
118
|
+
|
|
119
|
+
if (login !== auth.login || password !== auth.password) {
|
|
120
|
+
res.set('WWW-Authenticate', 'Basic realm="Admin Console"');
|
|
121
|
+
res.status(401).send('Authentication required.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
next();
|
|
128
|
+
});
|
|
149
129
|
|
|
150
130
|
// Access log
|
|
151
131
|
if (internalConfig.loggerFilename.length > 0) {
|
|
@@ -155,6 +135,17 @@ export const startServer = async (config, ssl) => {
|
|
|
155
135
|
// Admin console
|
|
156
136
|
const adminRoute = internalConfig.adminRoute ?? '/admin';
|
|
157
137
|
const adminDirectory = path.resolve(__dirname, '../admin');
|
|
138
|
+
|
|
139
|
+
// Ensure trailing slash for admin route to support relative paths
|
|
140
|
+
app.get(adminRoute, (req, res, next) => {
|
|
141
|
+
const [path] = req.originalUrl.split('?');
|
|
142
|
+
if (path === adminRoute) {
|
|
143
|
+
const query = req.originalUrl.split('?')[1];
|
|
144
|
+
return res.redirect(adminRoute + '/' + (query ? '?' + query : ''));
|
|
145
|
+
}
|
|
146
|
+
next();
|
|
147
|
+
});
|
|
148
|
+
|
|
158
149
|
app.use(adminRoute, handlerAdmin);
|
|
159
150
|
app.use(adminRoute, express.static(adminDirectory));
|
|
160
151
|
|
|
@@ -163,13 +154,6 @@ export const startServer = async (config, ssl) => {
|
|
|
163
154
|
app.use(i.route, express.static(i.directoryPath));
|
|
164
155
|
}
|
|
165
156
|
|
|
166
|
-
// Default middleware
|
|
167
|
-
app.use(handlerUpload(internalConfig.uploadFileSizeLimit));
|
|
168
|
-
app.use(express.json({limit: '50mb'}));
|
|
169
|
-
app.use(express.urlencoded({limit: '50mb', extended: true}));
|
|
170
|
-
app.use(cookieParser());
|
|
171
|
-
app.use(compression());
|
|
172
|
-
|
|
173
157
|
/** @type {Pool[]} */
|
|
174
158
|
const connectionPools = [];
|
|
175
159
|
AdminContext.pools = connectionPools;
|
|
@@ -191,17 +175,46 @@ export const startServer = async (config, ssl) => {
|
|
|
191
175
|
});
|
|
192
176
|
|
|
193
177
|
app.use([`${i.route}/:name`, i.route], (req, res, next) => {
|
|
194
|
-
AdminContext.metrics.requestCount++;
|
|
195
178
|
const start = process.hrtime();
|
|
196
179
|
res.on('finish', () => {
|
|
197
180
|
const diff = process.hrtime(start);
|
|
198
181
|
const duration = diff[0] * 1000 + diff[1] / 1_000_000;
|
|
199
|
-
AdminContext.
|
|
182
|
+
AdminContext.statsManager.recordRequest(duration, res.statusCode >= 400);
|
|
200
183
|
});
|
|
201
184
|
handler(req, res, next);
|
|
202
185
|
});
|
|
203
186
|
}
|
|
204
187
|
|
|
188
|
+
// Update pools in StatsManager on each rotation
|
|
189
|
+
const originalRotate = AdminContext.statsManager.rotateBucket.bind(AdminContext.statsManager);
|
|
190
|
+
AdminContext.statsManager.rotateBucket = () => {
|
|
191
|
+
const poolSnapshots = AdminContext.pools.map((pool, index) => {
|
|
192
|
+
const cache = AdminContext.caches[index];
|
|
193
|
+
const name = cache?.poolName ?? `pool-${index}`;
|
|
194
|
+
const procStats = cache?.procedureNameCache.getStats();
|
|
195
|
+
const argStats = cache?.argumentCache.getStats();
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
name,
|
|
199
|
+
connectionsOpen: pool.connectionsOpen,
|
|
200
|
+
connectionsInUse: pool.connectionsInUse,
|
|
201
|
+
cache: {
|
|
202
|
+
procedureName: {
|
|
203
|
+
size: cache?.procedureNameCache.keys().length ?? 0,
|
|
204
|
+
hits: procStats?.hits ?? 0,
|
|
205
|
+
misses: procStats?.misses ?? 0,
|
|
206
|
+
},
|
|
207
|
+
argument: {
|
|
208
|
+
size: cache?.argumentCache.keys().length ?? 0,
|
|
209
|
+
hits: argStats?.hits ?? 0,
|
|
210
|
+
misses: argStats?.misses ?? 0,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
originalRotate(poolSnapshots);
|
|
216
|
+
};
|
|
217
|
+
|
|
205
218
|
// create server
|
|
206
219
|
debug('startServer: createServer');
|
|
207
220
|
const server = createServer(app, ssl);
|
|
@@ -226,6 +239,8 @@ export const startServer = async (config, ssl) => {
|
|
|
226
239
|
const shutdown = async () => {
|
|
227
240
|
debug('startServer: onShutdown');
|
|
228
241
|
|
|
242
|
+
AdminContext.statsManager.stop();
|
|
243
|
+
|
|
229
244
|
await poolsClose(connectionPools);
|
|
230
245
|
|
|
231
246
|
server.close(() => {
|
package/src/types.js
CHANGED
|
@@ -133,7 +133,7 @@ export const z$configType = z.strictObject({
|
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* @typedef {object} pageType - The page.
|
|
136
|
-
* @property {string} body - The body of the page.
|
|
136
|
+
* @property {string | import('node:stream').Readable} body - The body of the page.
|
|
137
137
|
* @property {object} head - The head of the page.
|
|
138
138
|
* @property {cookieType[]} head.cookies - The cookies.
|
|
139
139
|
* @property {string} [head.contentType] - The content type.
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import debugModule from 'debug';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
const debug = debugModule('webplsql:statsManager');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} StatsConfig
|
|
8
|
+
* @property {number} intervalMs - Duration of each statistical bucket (default: 5000ms).
|
|
9
|
+
* @property {number} maxHistoryPoints - Number of buckets to keep in the ring buffer (default: 200).
|
|
10
|
+
* @property {boolean} sampleSystem - Whether to automatically sample CPU/Memory (default: true).
|
|
11
|
+
* @property {boolean} samplePools - Whether to automatically sample Oracle pool utilization (default: true).
|
|
12
|
+
* @property {number} percentilePrecision - Max number of samples per bucket for P95/P99 calculation (default: 1000).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} CacheStats
|
|
17
|
+
* @property {number} size - Number of entries.
|
|
18
|
+
* @property {number} hits - Number of hits.
|
|
19
|
+
* @property {number} misses - Number of misses.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} PoolCacheSnapshot
|
|
24
|
+
* @property {CacheStats} procedureName - Procedure name cache stats.
|
|
25
|
+
* @property {CacheStats} argument - Argument cache stats.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} PoolSnapshot
|
|
30
|
+
* @property {string} name - The pool name.
|
|
31
|
+
* @property {number} connectionsInUse - Number of active connections.
|
|
32
|
+
* @property {number} connectionsOpen - Number of open connections.
|
|
33
|
+
* @property {PoolCacheSnapshot} [cache] - Cache statistics.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} Bucket
|
|
38
|
+
* @property {number} timestamp - End time of the bucket.
|
|
39
|
+
* @property {number} requests - Number of requests.
|
|
40
|
+
* @property {number} errors - Number of errors.
|
|
41
|
+
* @property {number} durationMin - Minimum duration.
|
|
42
|
+
* @property {number} durationMax - Maximum duration.
|
|
43
|
+
* @property {number} durationAvg - Average duration.
|
|
44
|
+
* @property {number} durationP95 - 95th percentile duration.
|
|
45
|
+
* @property {number} durationP99 - 99th percentile duration.
|
|
46
|
+
* @property {object} system - System metrics.
|
|
47
|
+
* @property {number} system.cpu - CPU usage percentage.
|
|
48
|
+
* @property {number} system.heapUsed - Heap used in bytes.
|
|
49
|
+
* @property {number} system.heapTotal - Heap total in bytes.
|
|
50
|
+
* @property {number} system.rss - RSS in bytes.
|
|
51
|
+
* @property {number} system.external - External memory in bytes.
|
|
52
|
+
* @property {PoolSnapshot[]} pools - Pool utilization snapshots.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} CurrentBucket
|
|
57
|
+
* @property {number} count - Number of requests.
|
|
58
|
+
* @property {number} errors - Number of errors.
|
|
59
|
+
* @property {number} durationSum - Sum of durations.
|
|
60
|
+
* @property {number} durationMin - Minimum duration.
|
|
61
|
+
* @property {number} durationMax - Maximum duration.
|
|
62
|
+
* @property {number[]} durations - List of durations for percentile calculation.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} MemoryLifetime
|
|
67
|
+
* @property {number} heapUsedMax - Max heap used.
|
|
68
|
+
* @property {number} heapTotalMax - Max heap total.
|
|
69
|
+
* @property {number} rssMax - Max RSS.
|
|
70
|
+
* @property {number} externalMax - Max external.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {object} LifetimeStats
|
|
75
|
+
* @property {number} totalRequests - Total requests.
|
|
76
|
+
* @property {number} totalErrors - Total errors.
|
|
77
|
+
* @property {number} minDuration - Min duration.
|
|
78
|
+
* @property {number} maxDuration - Max duration.
|
|
79
|
+
* @property {number} totalDuration - Total duration.
|
|
80
|
+
* @property {number} maxRequestsPerSecond - Max requests per second.
|
|
81
|
+
* @property {MemoryLifetime} memory - Memory extremes.
|
|
82
|
+
* @property {object} cpu - CPU extremes.
|
|
83
|
+
* @property {number} cpu.max - Max CPU.
|
|
84
|
+
* @property {number} cpu.userMax - Max user CPU.
|
|
85
|
+
* @property {number} cpu.systemMax - Max system CPU.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} StatsSummary
|
|
90
|
+
* @property {Date} startTime - Server start time.
|
|
91
|
+
* @property {number} totalRequests - Total requests handled.
|
|
92
|
+
* @property {number} totalErrors - Total errors encountered.
|
|
93
|
+
* @property {number} avgResponseTime - Lifetime average response time.
|
|
94
|
+
* @property {number} minResponseTime - Lifetime minimum response time.
|
|
95
|
+
* @property {number} maxResponseTime - Lifetime maximum response time.
|
|
96
|
+
* @property {number} maxRequestsPerSecond - Lifetime maximum requests per second.
|
|
97
|
+
* @property {MemoryLifetime} maxMemory - Lifetime memory extremes.
|
|
98
|
+
* @property {object} cpu - CPU extremes.
|
|
99
|
+
* @property {number} cpu.max - Max CPU usage percentage.
|
|
100
|
+
* @property {number} cpu.userMax - Max user CPU usage in microseconds.
|
|
101
|
+
* @property {number} cpu.systemMax - Max system CPU usage in microseconds.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Manager for statistical data collection and temporal bucketing.
|
|
106
|
+
*/
|
|
107
|
+
export class StatsManager {
|
|
108
|
+
/**
|
|
109
|
+
* @param {Partial<StatsConfig>} config - Configuration.
|
|
110
|
+
*/
|
|
111
|
+
constructor(config = {}) {
|
|
112
|
+
/** @type {StatsConfig} */
|
|
113
|
+
this.config = {
|
|
114
|
+
intervalMs: 5000,
|
|
115
|
+
maxHistoryPoints: 1000,
|
|
116
|
+
sampleSystem: true,
|
|
117
|
+
samplePools: true,
|
|
118
|
+
percentilePrecision: 1000,
|
|
119
|
+
...config,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
this.startTime = new Date();
|
|
123
|
+
|
|
124
|
+
/** @type {Bucket[]} */
|
|
125
|
+
this.history = [];
|
|
126
|
+
|
|
127
|
+
/** @type {LifetimeStats} */
|
|
128
|
+
this.lifetime = {
|
|
129
|
+
totalRequests: 0,
|
|
130
|
+
totalErrors: 0,
|
|
131
|
+
minDuration: -1,
|
|
132
|
+
maxDuration: -1,
|
|
133
|
+
totalDuration: 0,
|
|
134
|
+
maxRequestsPerSecond: 0,
|
|
135
|
+
memory: {
|
|
136
|
+
heapUsedMax: 0,
|
|
137
|
+
heapTotalMax: 0,
|
|
138
|
+
rssMax: 0,
|
|
139
|
+
externalMax: 0,
|
|
140
|
+
},
|
|
141
|
+
cpu: {
|
|
142
|
+
max: 0,
|
|
143
|
+
userMax: 0,
|
|
144
|
+
systemMax: 0,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** @type {CurrentBucket} */
|
|
149
|
+
this._currentBucket = {
|
|
150
|
+
count: 0,
|
|
151
|
+
errors: 0,
|
|
152
|
+
durations: [],
|
|
153
|
+
durationSum: 0,
|
|
154
|
+
durationMin: -1,
|
|
155
|
+
durationMax: -1,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this._lastCpuTimes = this._getSystemCpuTimes();
|
|
159
|
+
|
|
160
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
161
|
+
this._timer = undefined;
|
|
162
|
+
if (this.config.sampleSystem) {
|
|
163
|
+
this._timer = setInterval(() => {
|
|
164
|
+
this.rotateBucket();
|
|
165
|
+
}, this.config.intervalMs);
|
|
166
|
+
if (this._timer && typeof this._timer.unref === 'function') {
|
|
167
|
+
this._timer.unref();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reset the current bucket accumulator.
|
|
174
|
+
* @private
|
|
175
|
+
*/
|
|
176
|
+
_resetBucket() {
|
|
177
|
+
this._currentBucket = {
|
|
178
|
+
count: 0,
|
|
179
|
+
errors: 0,
|
|
180
|
+
durations: [],
|
|
181
|
+
durationSum: 0,
|
|
182
|
+
durationMin: -1,
|
|
183
|
+
durationMax: -1,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record a request event.
|
|
189
|
+
* @param {number} duration - Duration in milliseconds.
|
|
190
|
+
* @param {boolean} isError - Whether the request was an error.
|
|
191
|
+
*/
|
|
192
|
+
recordRequest(duration, isError = false) {
|
|
193
|
+
this.lifetime.totalRequests++;
|
|
194
|
+
if (isError) {
|
|
195
|
+
this.lifetime.totalErrors++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.lifetime.totalDuration += duration;
|
|
199
|
+
if (this.lifetime.minDuration < 0 || duration < this.lifetime.minDuration) {
|
|
200
|
+
this.lifetime.minDuration = duration;
|
|
201
|
+
}
|
|
202
|
+
if (this.lifetime.maxDuration < 0 || duration > this.lifetime.maxDuration) {
|
|
203
|
+
this.lifetime.maxDuration = duration;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const b = this._currentBucket;
|
|
207
|
+
b.count++;
|
|
208
|
+
if (isError) {
|
|
209
|
+
b.errors++;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
b.durationSum += duration;
|
|
213
|
+
if (b.durationMin < 0 || duration < b.durationMin) {
|
|
214
|
+
b.durationMin = duration;
|
|
215
|
+
}
|
|
216
|
+
if (b.durationMax < 0 || duration > b.durationMax) {
|
|
217
|
+
b.durationMax = duration;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (b.durations.length < this.config.percentilePrecision) {
|
|
221
|
+
b.durations.push(duration);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get system CPU times.
|
|
227
|
+
* @private
|
|
228
|
+
* @returns {{user: number, nice: number, sys: number, idle: number, irq: number, total: number}} System CPU times.
|
|
229
|
+
*/
|
|
230
|
+
_getSystemCpuTimes() {
|
|
231
|
+
const cpus = os.cpus();
|
|
232
|
+
let user = 0;
|
|
233
|
+
let nice = 0;
|
|
234
|
+
let sys = 0;
|
|
235
|
+
let idle = 0;
|
|
236
|
+
let irq = 0;
|
|
237
|
+
|
|
238
|
+
for (const cpu of cpus) {
|
|
239
|
+
user += cpu.times.user;
|
|
240
|
+
nice += cpu.times.nice;
|
|
241
|
+
sys += cpu.times.sys;
|
|
242
|
+
idle += cpu.times.idle;
|
|
243
|
+
irq += cpu.times.irq;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const total = user + nice + sys + idle + irq;
|
|
247
|
+
return {user, nice, sys, idle, irq, total};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Calculate CPU usage percentage since last call.
|
|
252
|
+
* @private
|
|
253
|
+
* @returns {number} CPU usage percentage (0-100).
|
|
254
|
+
*/
|
|
255
|
+
_calculateCpuUsage() {
|
|
256
|
+
const current = this._getSystemCpuTimes();
|
|
257
|
+
const last = this._lastCpuTimes || {user: 0, nice: 0, sys: 0, idle: 0, irq: 0, total: 0};
|
|
258
|
+
|
|
259
|
+
const deltaTotal = current.total - last.total;
|
|
260
|
+
const deltaIdle = current.idle - last.idle;
|
|
261
|
+
|
|
262
|
+
this._lastCpuTimes = current;
|
|
263
|
+
|
|
264
|
+
if (deltaTotal <= 0) return 0;
|
|
265
|
+
|
|
266
|
+
const percent = ((deltaTotal - deltaIdle) / deltaTotal) * 100;
|
|
267
|
+
return Math.min(100, Math.max(0, percent));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Rotate the current bucket into history and start a new one.
|
|
272
|
+
* @param {PoolSnapshot[]} [poolSnapshots] - Optional pool snapshots to include.
|
|
273
|
+
*/
|
|
274
|
+
rotateBucket(poolSnapshots = []) {
|
|
275
|
+
const b = this._currentBucket;
|
|
276
|
+
const memUsage = process.memoryUsage();
|
|
277
|
+
const systemMemoryUsed = os.totalmem() - os.freemem();
|
|
278
|
+
const cpuUsage = process.cpuUsage();
|
|
279
|
+
const cpu = this._calculateCpuUsage();
|
|
280
|
+
|
|
281
|
+
// Update lifetime extremes
|
|
282
|
+
const reqPerSec = b.count / (this.config.intervalMs / 1000);
|
|
283
|
+
this.lifetime.maxRequestsPerSecond = Math.max(this.lifetime.maxRequestsPerSecond, reqPerSec);
|
|
284
|
+
this.lifetime.memory.heapUsedMax = Math.max(this.lifetime.memory.heapUsedMax, memUsage.heapUsed);
|
|
285
|
+
this.lifetime.memory.heapTotalMax = Math.max(this.lifetime.memory.heapTotalMax, memUsage.heapTotal);
|
|
286
|
+
this.lifetime.memory.rssMax = Math.max(this.lifetime.memory.rssMax, systemMemoryUsed);
|
|
287
|
+
this.lifetime.memory.externalMax = Math.max(this.lifetime.memory.externalMax, memUsage.external);
|
|
288
|
+
this.lifetime.cpu.max = Math.max(this.lifetime.cpu.max, cpu);
|
|
289
|
+
this.lifetime.cpu.userMax = Math.max(this.lifetime.cpu.userMax, cpuUsage.user);
|
|
290
|
+
this.lifetime.cpu.systemMax = Math.max(this.lifetime.cpu.systemMax, cpuUsage.system);
|
|
291
|
+
|
|
292
|
+
let p95 = 0;
|
|
293
|
+
let p99 = 0;
|
|
294
|
+
if (b.durations.length > 0) {
|
|
295
|
+
const sorted = [...b.durations].sort((x, y) => x - y);
|
|
296
|
+
const p95Idx = Math.floor(sorted.length * 0.95);
|
|
297
|
+
const p99Idx = Math.floor(sorted.length * 0.99);
|
|
298
|
+
const lastIdx = sorted.length - 1;
|
|
299
|
+
|
|
300
|
+
p95 = sorted[p95Idx] ?? sorted[lastIdx] ?? 0;
|
|
301
|
+
p99 = sorted[p99Idx] ?? sorted[lastIdx] ?? 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @type {Bucket} */
|
|
305
|
+
const bucket = {
|
|
306
|
+
timestamp: Date.now(),
|
|
307
|
+
requests: b.count,
|
|
308
|
+
errors: b.errors,
|
|
309
|
+
durationMin: b.durationMin < 0 ? 0 : b.durationMin,
|
|
310
|
+
durationMax: b.durationMax < 0 ? 0 : b.durationMax,
|
|
311
|
+
durationAvg: b.count > 0 ? b.durationSum / b.count : 0,
|
|
312
|
+
durationP95: p95,
|
|
313
|
+
durationP99: p99,
|
|
314
|
+
system: {
|
|
315
|
+
cpu,
|
|
316
|
+
heapUsed: memUsage.heapUsed,
|
|
317
|
+
heapTotal: memUsage.heapTotal,
|
|
318
|
+
rss: systemMemoryUsed,
|
|
319
|
+
external: memUsage.external,
|
|
320
|
+
},
|
|
321
|
+
pools: poolSnapshots,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
this.history.push(bucket);
|
|
325
|
+
if (this.history.length > this.config.maxHistoryPoints) {
|
|
326
|
+
this.history.shift();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this._resetBucket();
|
|
330
|
+
debug('Bucket rotated: %j', bucket);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Stop the background timer.
|
|
335
|
+
*/
|
|
336
|
+
stop() {
|
|
337
|
+
if (this._timer) {
|
|
338
|
+
clearInterval(this._timer);
|
|
339
|
+
this._timer = undefined;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get lifetime summary.
|
|
345
|
+
* @returns {StatsSummary} Summary.
|
|
346
|
+
*/
|
|
347
|
+
getSummary() {
|
|
348
|
+
return {
|
|
349
|
+
startTime: this.startTime,
|
|
350
|
+
totalRequests: this.lifetime.totalRequests,
|
|
351
|
+
totalErrors: this.lifetime.totalErrors,
|
|
352
|
+
avgResponseTime: this.lifetime.totalRequests > 0 ? this.lifetime.totalDuration / this.lifetime.totalRequests : 0,
|
|
353
|
+
minResponseTime: this.lifetime.minDuration,
|
|
354
|
+
maxResponseTime: this.lifetime.maxDuration,
|
|
355
|
+
maxRequestsPerSecond: this.lifetime.maxRequestsPerSecond,
|
|
356
|
+
maxMemory: this.lifetime.memory,
|
|
357
|
+
cpu: this.lifetime.cpu,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get history buffer.
|
|
363
|
+
* @returns {Bucket[]} The history buffer.
|
|
364
|
+
*/
|
|
365
|
+
getHistory() {
|
|
366
|
+
return this.history;
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/util/trace.js
CHANGED
|
@@ -279,7 +279,8 @@ export const getFormattedMessage = (para) => {
|
|
|
279
279
|
|
|
280
280
|
// header
|
|
281
281
|
const url = typeof para.req?.originalUrl === 'string' && para.req.originalUrl.length > 0 ? ` on ${para.req.originalUrl}` : '';
|
|
282
|
-
const
|
|
282
|
+
const type = (para.type ?? 'trace').toUpperCase();
|
|
283
|
+
const header = `${type} at ${timestamp.toUTCString()}${url}`;
|
|
283
284
|
const output = {
|
|
284
285
|
html: `<h1>${header}</h1>`,
|
|
285
286
|
text: `\n\n${SEPARATOR_H1}\n== ${header}\n${SEPARATOR_H1}\n`,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../admin/js/types.js').TraceEntry} TraceEntry
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class TraceManager {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.enabled = false;
|
|
11
|
+
this.filename = 'trace.json.log';
|
|
12
|
+
this.maxEntries = 1000;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Toggle tracing
|
|
17
|
+
* @param {boolean} enabled - New state
|
|
18
|
+
*/
|
|
19
|
+
setEnabled(enabled) {
|
|
20
|
+
this.enabled = enabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is tracing enabled?
|
|
25
|
+
* @returns {boolean} - The state
|
|
26
|
+
*/
|
|
27
|
+
isEnabled() {
|
|
28
|
+
return this.enabled;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add a trace entry
|
|
33
|
+
* @param {object} entry - The trace entry
|
|
34
|
+
*/
|
|
35
|
+
addTrace(entry) {
|
|
36
|
+
if (!this.enabled) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const line = JSON.stringify(entry) + '\n';
|
|
40
|
+
fs.appendFileSync(this.filename, line);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('TraceManager: error writing trace', err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clear all traces
|
|
48
|
+
*/
|
|
49
|
+
clear() {
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(this.filename)) {
|
|
52
|
+
fs.truncateSync(this.filename, 0);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('TraceManager: error clearing traces', err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the full path to the trace file
|
|
61
|
+
* @returns {string} - The path
|
|
62
|
+
*/
|
|
63
|
+
getFilePath() {
|
|
64
|
+
return path.resolve(this.filename);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const traceManager = new TraceManager();
|
package/src/version.js
CHANGED