@singhak/nodeui-core 0.3.1 → 0.5.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 +113 -111
- package/dist/index.cjs +1089 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +283 -5
- package/dist/index.d.ts +283 -5
- package/dist/index.mjs +1080 -57
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
- package/static/assets/{index-DlUszrsn.css → index-BrEFUVm9.css} +1 -1
- package/static/assets/index-CH4-6sZB.js +12 -0
- package/static/index.html +14 -14
- package/static/assets/index-Cu0xtlkD.js +0 -11
package/dist/index.cjs
CHANGED
|
@@ -34,15 +34,19 @@ __export(index_exports, {
|
|
|
34
34
|
ConfirmationStore: () => ConfirmationStore,
|
|
35
35
|
CpuProvider: () => CpuProvider,
|
|
36
36
|
DEFAULT_LOG_SIZE: () => DEFAULT_LOG_SIZE,
|
|
37
|
+
DEFAULT_REQUEST_DETAIL: () => DEFAULT_REQUEST_DETAIL,
|
|
37
38
|
EnvProvider: () => EnvProvider,
|
|
39
|
+
ErrorsProvider: () => ErrorsProvider,
|
|
38
40
|
EventLoopLagProvider: () => EventLoopLagProvider,
|
|
39
41
|
HealthProvider: () => HealthProvider,
|
|
40
42
|
HeapSnapshotProvider: () => HeapSnapshotProvider,
|
|
41
43
|
LogsProvider: () => LogsProvider,
|
|
42
44
|
MemoryProvider: () => MemoryProvider,
|
|
43
45
|
MetricsProvider: () => MetricsProvider,
|
|
46
|
+
N_PLUS_ONE_THRESHOLD: () => N_PLUS_ONE_THRESHOLD,
|
|
44
47
|
OutgoingProvider: () => OutgoingProvider,
|
|
45
48
|
ProviderRegistry: () => ProviderRegistry,
|
|
49
|
+
QueriesProvider: () => QueriesProvider,
|
|
46
50
|
RequestsProvider: () => RequestsProvider,
|
|
47
51
|
RingBuffer: () => RingBuffer,
|
|
48
52
|
RoutesProvider: () => RoutesProvider,
|
|
@@ -52,20 +56,28 @@ __export(index_exports, {
|
|
|
52
56
|
StartupTracker: () => StartupTracker,
|
|
53
57
|
createGuard: () => createGuard,
|
|
54
58
|
createNodeUI: () => createNodeUI,
|
|
59
|
+
currentRequestId: () => currentRequestId,
|
|
60
|
+
enterRequestContext: () => enterRequestContext,
|
|
55
61
|
extractRoutes: () => extractRoutes,
|
|
62
|
+
fingerprintError: () => fingerprintError,
|
|
56
63
|
hostnameFromHostHeader: () => hostnameFromHostHeader,
|
|
57
64
|
interceptConsole: () => interceptConsole,
|
|
58
65
|
interceptOutgoing: () => interceptOutgoing,
|
|
66
|
+
interceptQueries: () => interceptQueries,
|
|
59
67
|
isLoopbackAddress: () => isLoopbackAddress,
|
|
60
68
|
isLoopbackHostname: () => isLoopbackHostname,
|
|
69
|
+
isValidAddressEntry: () => isValidAddressEntry,
|
|
61
70
|
maskSecretText: () => maskSecretText,
|
|
62
71
|
maskSecrets: () => maskSecrets,
|
|
63
72
|
matchesAddress: () => matchesAddress,
|
|
73
|
+
normalizeSql: () => normalizeSql,
|
|
64
74
|
resolveActivation: () => resolveActivation,
|
|
65
75
|
resolveStaticAsset: () => resolveStaticAsset,
|
|
76
|
+
runInRequestContext: () => runInRequestContext,
|
|
66
77
|
serializeEnvelope: () => serializeEnvelope,
|
|
67
78
|
startSse: () => startSse,
|
|
68
|
-
summarizeRequests: () => summarizeRequests
|
|
79
|
+
summarizeRequests: () => summarizeRequests,
|
|
80
|
+
uiAssetsDir: () => uiAssetsDir
|
|
69
81
|
});
|
|
70
82
|
module.exports = __toCommonJS(index_exports);
|
|
71
83
|
|
|
@@ -121,7 +133,10 @@ var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
|
|
121
133
|
var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
|
|
122
134
|
function maskSecretText(text) {
|
|
123
135
|
if (text.length < 6) return text;
|
|
124
|
-
return text.replace(URL_CREDENTIALS_PATTERN, `$1${SECRET_MASKED}$2`).replace(BEARER_PATTERN, `$1 ${SECRET_MASKED}`).replace(JWT_PATTERN, SECRET_MASKED).replace(TEXT_PAIR_PATTERN,
|
|
136
|
+
return text.replace(URL_CREDENTIALS_PATTERN, `$1${SECRET_MASKED}$2`).replace(BEARER_PATTERN, `$1 ${SECRET_MASKED}`).replace(JWT_PATTERN, SECRET_MASKED).replace(TEXT_PAIR_PATTERN, (_m, name, sep2, value) => {
|
|
137
|
+
const quote = value.startsWith('"') || value.startsWith("'") ? value[0] : "";
|
|
138
|
+
return `${name}${sep2}${quote}${SECRET_MASKED}${quote}`;
|
|
139
|
+
});
|
|
125
140
|
}
|
|
126
141
|
function resolveActivation(env) {
|
|
127
142
|
if (env.NODEUI_ENABLED === "true") {
|
|
@@ -159,21 +174,91 @@ function ipv4ToInt(ip) {
|
|
|
159
174
|
}
|
|
160
175
|
return out;
|
|
161
176
|
}
|
|
177
|
+
function ipv6ToBigInt(address) {
|
|
178
|
+
const text = address.split("%")[0] ?? "";
|
|
179
|
+
if (!text.includes(":")) return null;
|
|
180
|
+
let head = text;
|
|
181
|
+
let tailGroups = [];
|
|
182
|
+
const doubleColon = text.indexOf("::");
|
|
183
|
+
if (doubleColon !== -1) {
|
|
184
|
+
if (text.indexOf("::", doubleColon + 1) !== -1) return null;
|
|
185
|
+
head = text.slice(0, doubleColon);
|
|
186
|
+
const tail = text.slice(doubleColon + 2);
|
|
187
|
+
tailGroups = tail === "" ? [] : tail.split(":");
|
|
188
|
+
}
|
|
189
|
+
const headGroups = doubleColon !== -1 ? head === "" ? [] : head.split(":") : text.split(":");
|
|
190
|
+
const groups = doubleColon !== -1 ? [...headGroups, ...tailGroups] : headGroups;
|
|
191
|
+
const last = groups[groups.length - 1];
|
|
192
|
+
const v4 = last?.includes(".") ? ipv4ToInt(last) : null;
|
|
193
|
+
if (last?.includes(".")) {
|
|
194
|
+
if (v4 === null) return null;
|
|
195
|
+
groups.splice(
|
|
196
|
+
groups.length - 1,
|
|
197
|
+
1,
|
|
198
|
+
(v4 >>> 16 & 65535).toString(16),
|
|
199
|
+
(v4 & 65535).toString(16)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const expected = 8;
|
|
203
|
+
if (doubleColon === -1 ? groups.length !== expected : groups.length >= expected) return null;
|
|
204
|
+
const parseGroup = (g) => /^[0-9a-f]{1,4}$/i.test(g) ? BigInt(`0x${g}`) : null;
|
|
205
|
+
const headLen = doubleColon !== -1 ? headGroups.length : groups.length;
|
|
206
|
+
const fill = expected - groups.length;
|
|
207
|
+
const full = [
|
|
208
|
+
...groups.slice(0, headLen),
|
|
209
|
+
...Array(doubleColon !== -1 ? fill : 0).fill("0"),
|
|
210
|
+
...groups.slice(headLen)
|
|
211
|
+
];
|
|
212
|
+
let out = 0n;
|
|
213
|
+
for (const g of full) {
|
|
214
|
+
const n = parseGroup(g);
|
|
215
|
+
if (n === null) return null;
|
|
216
|
+
out = out << 16n | n;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function isValidAddressEntry(entry) {
|
|
221
|
+
const normalized = normalizeIp(entry);
|
|
222
|
+
if (!normalized.includes("/"))
|
|
223
|
+
return ipv4ToInt(normalized) !== null || ipv6ToBigInt(normalized) !== null;
|
|
224
|
+
const [base = "", bitsRaw = ""] = normalized.split("/");
|
|
225
|
+
const bits = Number(bitsRaw);
|
|
226
|
+
if (!/^\d{1,3}$/.test(bitsRaw) || !Number.isInteger(bits)) return false;
|
|
227
|
+
if (ipv4ToInt(base) !== null) return bits <= 32;
|
|
228
|
+
return ipv6ToBigInt(base) !== null && bits <= 128;
|
|
229
|
+
}
|
|
162
230
|
function matchesAddress(address, allowed) {
|
|
163
231
|
if (!address) return false;
|
|
164
232
|
const ip = normalizeIp(address);
|
|
165
|
-
|
|
233
|
+
const ip4 = ipv4ToInt(ip);
|
|
234
|
+
const ip6 = ip4 === null ? ipv6ToBigInt(ip) : null;
|
|
235
|
+
for (const raw of allowed) {
|
|
236
|
+
const entry = normalizeIp(raw);
|
|
166
237
|
if (!entry.includes("/")) {
|
|
167
|
-
if (
|
|
238
|
+
if (entry === ip) return true;
|
|
239
|
+
const e4 = ipv4ToInt(entry);
|
|
240
|
+
if (ip4 !== null && e4 !== null) {
|
|
241
|
+
if (ip4 === e4) return true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const e6 = ipv6ToBigInt(entry);
|
|
245
|
+
if (ip6 !== null && e6 !== null && ip6 === e6) return true;
|
|
168
246
|
continue;
|
|
169
247
|
}
|
|
170
|
-
const [base, bitsRaw] = entry.split("/");
|
|
248
|
+
const [base = "", bitsRaw = ""] = entry.split("/");
|
|
249
|
+
if (!/^\d{1,3}$/.test(bitsRaw)) continue;
|
|
171
250
|
const bits = Number(bitsRaw);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
251
|
+
const b4 = ipv4ToInt(base);
|
|
252
|
+
if (b4 !== null) {
|
|
253
|
+
if (ip4 === null || bits > 32) continue;
|
|
254
|
+
const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
|
|
255
|
+
if ((ip4 & mask) >>> 0 === (b4 & mask) >>> 0) return true;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const b6 = ipv6ToBigInt(base);
|
|
259
|
+
if (b6 === null || ip6 === null || bits > 128) continue;
|
|
260
|
+
const shift = BigInt(128 - bits);
|
|
261
|
+
if (ip6 >> shift === b6 >> shift) return true;
|
|
177
262
|
}
|
|
178
263
|
return false;
|
|
179
264
|
}
|
|
@@ -404,6 +489,9 @@ var MIME_TYPES = {
|
|
|
404
489
|
".woff2": "font/woff2",
|
|
405
490
|
".webmanifest": "application/manifest+json"
|
|
406
491
|
};
|
|
492
|
+
function uiAssetsDir() {
|
|
493
|
+
return (0, import_node_path.resolve)(__dirname, "..", "static");
|
|
494
|
+
}
|
|
407
495
|
function resolveStaticAsset(rootDir, urlPath) {
|
|
408
496
|
const relative = urlPath === "" ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
409
497
|
const candidate = (0, import_node_path.resolve)(rootDir, relative);
|
|
@@ -658,12 +746,12 @@ var LAG_DEGRADED_MS = 200;
|
|
|
658
746
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
659
747
|
var CHECK_CACHE_MS = 5e3;
|
|
660
748
|
function withTimeout(promise, ms) {
|
|
661
|
-
return new Promise((
|
|
749
|
+
return new Promise((resolve2, reject) => {
|
|
662
750
|
const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
663
751
|
promise.then(
|
|
664
752
|
(value) => {
|
|
665
753
|
clearTimeout(timer);
|
|
666
|
-
|
|
754
|
+
resolve2(value);
|
|
667
755
|
},
|
|
668
756
|
(err) => {
|
|
669
757
|
clearTimeout(timer);
|
|
@@ -751,14 +839,355 @@ var HealthProvider = class {
|
|
|
751
839
|
}
|
|
752
840
|
};
|
|
753
841
|
|
|
842
|
+
// src/providers/queries.ts
|
|
843
|
+
var import_node_module = require("module");
|
|
844
|
+
var import_node_path2 = require("path");
|
|
845
|
+
|
|
846
|
+
// src/context.ts
|
|
847
|
+
var import_node_async_hooks = require("async_hooks");
|
|
848
|
+
var storage = new import_node_async_hooks.AsyncLocalStorage();
|
|
849
|
+
var ids = /* @__PURE__ */ new WeakMap();
|
|
850
|
+
var suppression = new import_node_async_hooks.AsyncLocalStorage();
|
|
851
|
+
function runSuppressed(fn) {
|
|
852
|
+
return suppression.run(true, fn);
|
|
853
|
+
}
|
|
854
|
+
function isSuppressed() {
|
|
855
|
+
return suppression.getStore() === true;
|
|
856
|
+
}
|
|
857
|
+
function currentRequestId() {
|
|
858
|
+
return storage.getStore()?.requestId;
|
|
859
|
+
}
|
|
860
|
+
function bindRequestId(req, requestId) {
|
|
861
|
+
ids.set(req, requestId);
|
|
862
|
+
}
|
|
863
|
+
function runWithRequestId(requestId, fn) {
|
|
864
|
+
return storage.run({ requestId }, fn);
|
|
865
|
+
}
|
|
866
|
+
function runInRequestContext(req, fn) {
|
|
867
|
+
const requestId = ids.get(req);
|
|
868
|
+
return requestId === void 0 ? fn() : storage.run({ requestId }, fn);
|
|
869
|
+
}
|
|
870
|
+
function enterRequestContext(req) {
|
|
871
|
+
const requestId = ids.get(req);
|
|
872
|
+
if (requestId !== void 0) storage.enterWith({ requestId });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/providers/queries.ts
|
|
876
|
+
var RESPONSE_PAGE_SIZE = 100;
|
|
877
|
+
var MAX_SQL_CHARS = 2e3;
|
|
878
|
+
var N_PLUS_ONE_THRESHOLD = 5;
|
|
879
|
+
var defaultLoader = (name) => {
|
|
880
|
+
try {
|
|
881
|
+
return (0, import_node_module.createRequire)((0, import_node_path2.join)(process.cwd(), "noop.js"))(name);
|
|
882
|
+
} catch {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
var recorders = /* @__PURE__ */ new Set();
|
|
887
|
+
var restorers = [];
|
|
888
|
+
function normalizeSql(sql) {
|
|
889
|
+
return sql.replace(/'(?:[^']|'')*'/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/\(\s*\?(?:\s*,\s*\?)+\s*\)/g, "(?)").replace(/\s+/g, " ").trim().toLowerCase();
|
|
890
|
+
}
|
|
891
|
+
function emit(entry) {
|
|
892
|
+
for (const record of recorders) {
|
|
893
|
+
try {
|
|
894
|
+
record(entry);
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function sqlOf(arg) {
|
|
900
|
+
if (typeof arg === "string") return arg;
|
|
901
|
+
if (arg && typeof arg === "object") {
|
|
902
|
+
const candidate = arg.text ?? arg.sql;
|
|
903
|
+
if (typeof candidate === "string") return candidate;
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
function rowCountOf(result) {
|
|
908
|
+
if (Array.isArray(result)) {
|
|
909
|
+
const first = result[0];
|
|
910
|
+
return Array.isArray(first) ? first.length : result.length;
|
|
911
|
+
}
|
|
912
|
+
if (result && typeof result === "object") {
|
|
913
|
+
const r = result;
|
|
914
|
+
if (typeof r.rowCount === "number") return r.rowCount;
|
|
915
|
+
if (typeof r.affectedRows === "number") return r.affectedRows;
|
|
916
|
+
}
|
|
917
|
+
return void 0;
|
|
918
|
+
}
|
|
919
|
+
function wrapQueryMethod(proto, method, system, rowCount) {
|
|
920
|
+
const original = proto[method];
|
|
921
|
+
if (typeof original !== "function") return;
|
|
922
|
+
const orig = original;
|
|
923
|
+
const wrapped = function(...args) {
|
|
924
|
+
if (recorders.size === 0) return orig.apply(this, args);
|
|
925
|
+
const sql = sqlOf(args[0]);
|
|
926
|
+
if (sql === null) return orig.apply(this, args);
|
|
927
|
+
const started = process.hrtime.bigint();
|
|
928
|
+
const timestampMs = Date.now();
|
|
929
|
+
const requestId = currentRequestId();
|
|
930
|
+
let done = false;
|
|
931
|
+
const finish = (error, result) => {
|
|
932
|
+
if (done) return;
|
|
933
|
+
done = true;
|
|
934
|
+
emit({
|
|
935
|
+
system,
|
|
936
|
+
sql: sql.slice(0, MAX_SQL_CHARS),
|
|
937
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
938
|
+
timestampMs,
|
|
939
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
940
|
+
...error ? { error: error instanceof Error ? error.message : String(error) } : {},
|
|
941
|
+
...!error && rowCount(result) !== void 0 ? { rowCount: rowCount(result) } : {}
|
|
942
|
+
});
|
|
943
|
+
};
|
|
944
|
+
const last = args.length - 1;
|
|
945
|
+
const hasCallback = typeof args[last] === "function";
|
|
946
|
+
if (hasCallback) {
|
|
947
|
+
const callback = args[last];
|
|
948
|
+
args[last] = function(err, result, ...rest) {
|
|
949
|
+
finish(err, result);
|
|
950
|
+
return callback.call(this, err, result, ...rest);
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const ret = orig.apply(this, args);
|
|
954
|
+
if (!hasCallback && ret && typeof ret.then === "function") {
|
|
955
|
+
ret.then(
|
|
956
|
+
(result) => finish(void 0, result),
|
|
957
|
+
(error) => finish(error ?? new Error("query failed"))
|
|
958
|
+
);
|
|
959
|
+
} else if (!hasCallback && ret && typeof ret.once === "function") {
|
|
960
|
+
const emitter = ret;
|
|
961
|
+
emitter.once("end", (result) => finish(void 0, result));
|
|
962
|
+
emitter.once("error", (error) => finish(error ?? new Error("query failed")));
|
|
963
|
+
}
|
|
964
|
+
return ret;
|
|
965
|
+
};
|
|
966
|
+
proto[method] = wrapped;
|
|
967
|
+
restorers.push(() => {
|
|
968
|
+
if (proto[method] === wrapped) proto[method] = original;
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
function instrumentDrivers(load) {
|
|
972
|
+
const pg = load("pg");
|
|
973
|
+
if (pg?.Client?.prototype) wrapQueryMethod(pg.Client.prototype, "query", "pg", rowCountOf);
|
|
974
|
+
const mysql2 = load("mysql2");
|
|
975
|
+
const proto = mysql2?.Connection?.prototype;
|
|
976
|
+
if (proto) {
|
|
977
|
+
wrapQueryMethod(proto, "query", "mysql2", rowCountOf);
|
|
978
|
+
wrapQueryMethod(proto, "execute", "mysql2", rowCountOf);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function interceptQueries(record, load = defaultLoader) {
|
|
982
|
+
const first = recorders.size === 0;
|
|
983
|
+
recorders.add(record);
|
|
984
|
+
if (first) {
|
|
985
|
+
try {
|
|
986
|
+
instrumentDrivers(load);
|
|
987
|
+
} catch {
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return () => {
|
|
991
|
+
if (!recorders.delete(record) || recorders.size > 0) return;
|
|
992
|
+
while (restorers.length > 0) restorers.pop()?.();
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
var QueriesProvider = class {
|
|
996
|
+
constructor(options) {
|
|
997
|
+
this.options = options;
|
|
998
|
+
this.buffer = new RingBuffer(options.size);
|
|
999
|
+
}
|
|
1000
|
+
options;
|
|
1001
|
+
id = "queries";
|
|
1002
|
+
buffer;
|
|
1003
|
+
nextId = 1;
|
|
1004
|
+
release = null;
|
|
1005
|
+
failed = 0;
|
|
1006
|
+
/** Called with every newly recorded entry (persistence hook). */
|
|
1007
|
+
onRecord;
|
|
1008
|
+
start() {
|
|
1009
|
+
if (this.release) return;
|
|
1010
|
+
this.release = interceptQueries((entry) => this.record(entry), this.options.loader);
|
|
1011
|
+
}
|
|
1012
|
+
stop() {
|
|
1013
|
+
this.release?.();
|
|
1014
|
+
this.release = null;
|
|
1015
|
+
}
|
|
1016
|
+
record(entry) {
|
|
1017
|
+
const full = { id: this.nextId, ...entry };
|
|
1018
|
+
this.buffer.push(full);
|
|
1019
|
+
this.nextId += 1;
|
|
1020
|
+
if (entry.error) this.failed += 1;
|
|
1021
|
+
this.onRecord?.(full);
|
|
1022
|
+
}
|
|
1023
|
+
/** Re-inserts journaled entries without re-emitting them. */
|
|
1024
|
+
restore(entries) {
|
|
1025
|
+
for (const entry of entries) {
|
|
1026
|
+
this.buffer.push(entry);
|
|
1027
|
+
this.nextId = Math.max(this.nextId, entry.id + 1);
|
|
1028
|
+
if (entry.error) this.failed += 1;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
get() {
|
|
1032
|
+
const all = this.buffer.toArray();
|
|
1033
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1034
|
+
const keyOf = (e) => e.requestId === void 0 ? null : `${e.requestId}|${normalizeSql(e.sql)}`;
|
|
1035
|
+
for (const e of all) {
|
|
1036
|
+
const key = keyOf(e);
|
|
1037
|
+
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1038
|
+
}
|
|
1039
|
+
const flagged = /* @__PURE__ */ new Set();
|
|
1040
|
+
const entries = all.map((e) => {
|
|
1041
|
+
const key = keyOf(e);
|
|
1042
|
+
const repeats = key ? counts.get(key) ?? 0 : 0;
|
|
1043
|
+
const nPlusOne = repeats >= N_PLUS_ONE_THRESHOLD;
|
|
1044
|
+
if (nPlusOne && key) flagged.add(key);
|
|
1045
|
+
return {
|
|
1046
|
+
...e,
|
|
1047
|
+
slow: e.durationMs >= this.options.slowQueryMs,
|
|
1048
|
+
...nPlusOne ? { nPlusOne, repeats } : {}
|
|
1049
|
+
};
|
|
1050
|
+
});
|
|
1051
|
+
return {
|
|
1052
|
+
ok: true,
|
|
1053
|
+
data: {
|
|
1054
|
+
total: this.nextId - 1,
|
|
1055
|
+
failed: this.failed,
|
|
1056
|
+
slow: entries.filter((e) => e.slow).length,
|
|
1057
|
+
nPlusOneGroups: flagged.size,
|
|
1058
|
+
slowQueryMs: this.options.slowQueryMs,
|
|
1059
|
+
entries: entries.slice(Math.max(0, entries.length - RESPONSE_PAGE_SIZE))
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
// src/providers/errors.ts
|
|
1066
|
+
var import_node_crypto3 = require("crypto");
|
|
1067
|
+
var MAX_GROUPS = 100;
|
|
1068
|
+
var MAX_STACK_CHARS = 4e3;
|
|
1069
|
+
var MAX_MESSAGE_CHARS = 500;
|
|
1070
|
+
var FINGERPRINT_FRAMES = 3;
|
|
1071
|
+
function toError(value) {
|
|
1072
|
+
if (value instanceof Error) {
|
|
1073
|
+
return { name: value.name, message: value.message, stack: value.stack ?? "" };
|
|
1074
|
+
}
|
|
1075
|
+
let message;
|
|
1076
|
+
try {
|
|
1077
|
+
message = typeof value === "string" ? value : JSON.stringify(value);
|
|
1078
|
+
} catch {
|
|
1079
|
+
message = String(value);
|
|
1080
|
+
}
|
|
1081
|
+
return { name: "NonError", message: message ?? String(value), stack: "" };
|
|
1082
|
+
}
|
|
1083
|
+
function topFrames(stack) {
|
|
1084
|
+
const frames = [];
|
|
1085
|
+
for (const line of stack.split("\n")) {
|
|
1086
|
+
const match = /^\s*at\s+(?:(.+?)\s+\()?(?:.*[\\/])?([^\\/:()]+?)(?::\d+){0,2}\)?\s*$/.exec(
|
|
1087
|
+
line
|
|
1088
|
+
);
|
|
1089
|
+
if (match) frames.push(`${match[1] ?? ""}@${match[2] ?? ""}`);
|
|
1090
|
+
if (frames.length >= FINGERPRINT_FRAMES) break;
|
|
1091
|
+
}
|
|
1092
|
+
return frames;
|
|
1093
|
+
}
|
|
1094
|
+
function fingerprintError(name, message, stack) {
|
|
1095
|
+
const shape = message.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27}\b/gi, "#").replace(/\b[0-9a-f]{24}\b/gi, "#").replace(/\d+/g, "#");
|
|
1096
|
+
return (0, import_node_crypto3.createHash)("sha1").update([name, shape, ...topFrames(stack)].join("\n")).digest("hex").slice(0, 12);
|
|
1097
|
+
}
|
|
1098
|
+
var ErrorsProvider = class {
|
|
1099
|
+
id = "errors";
|
|
1100
|
+
groups = /* @__PURE__ */ new Map();
|
|
1101
|
+
total = 0;
|
|
1102
|
+
listener = null;
|
|
1103
|
+
/** Called with every newly recorded occurrence (persistence hook). */
|
|
1104
|
+
onRecord;
|
|
1105
|
+
/** Starts observing process-level failures. Idempotent. */
|
|
1106
|
+
attach() {
|
|
1107
|
+
if (this.listener) return;
|
|
1108
|
+
this.listener = (err, origin) => this.record(err, origin === "unhandledRejection" ? "rejection" : "uncaught");
|
|
1109
|
+
process.on("uncaughtExceptionMonitor", this.listener);
|
|
1110
|
+
}
|
|
1111
|
+
detach() {
|
|
1112
|
+
if (!this.listener) return;
|
|
1113
|
+
process.removeListener("uncaughtExceptionMonitor", this.listener);
|
|
1114
|
+
this.listener = null;
|
|
1115
|
+
}
|
|
1116
|
+
record(error, source, context = {}) {
|
|
1117
|
+
try {
|
|
1118
|
+
const { name, message, stack } = toError(error);
|
|
1119
|
+
const requestId = context.requestId ?? currentRequestId();
|
|
1120
|
+
const event = {
|
|
1121
|
+
name,
|
|
1122
|
+
message,
|
|
1123
|
+
stack: stack.slice(0, MAX_STACK_CHARS),
|
|
1124
|
+
source,
|
|
1125
|
+
at: Date.now(),
|
|
1126
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
1127
|
+
...context.route ? { route: context.route } : {},
|
|
1128
|
+
...context.status !== void 0 ? { status: context.status } : {}
|
|
1129
|
+
};
|
|
1130
|
+
this.apply(event);
|
|
1131
|
+
this.onRecord?.(event);
|
|
1132
|
+
} catch {
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/** Re-applies journaled occurrences without re-emitting them. */
|
|
1136
|
+
restore(events) {
|
|
1137
|
+
for (const event of events) this.apply(event);
|
|
1138
|
+
}
|
|
1139
|
+
apply(event) {
|
|
1140
|
+
const { name, message, stack, source, at: now, requestId } = event;
|
|
1141
|
+
const context = { route: event.route, status: event.status };
|
|
1142
|
+
const fingerprint = fingerprintError(name, message, stack);
|
|
1143
|
+
const existing = this.groups.get(fingerprint);
|
|
1144
|
+
this.total += 1;
|
|
1145
|
+
if (existing) {
|
|
1146
|
+
existing.count += 1;
|
|
1147
|
+
existing.lastSeenMs = now;
|
|
1148
|
+
existing.message = message.slice(0, MAX_MESSAGE_CHARS);
|
|
1149
|
+
existing.stack = stack.slice(0, MAX_STACK_CHARS);
|
|
1150
|
+
if (requestId !== void 0) existing.lastRequestId = requestId;
|
|
1151
|
+
if (context.route) existing.lastRoute = context.route;
|
|
1152
|
+
if (context.status !== void 0) existing.lastStatus = context.status;
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (this.groups.size >= MAX_GROUPS) {
|
|
1156
|
+
let oldest;
|
|
1157
|
+
for (const g of this.groups.values()) {
|
|
1158
|
+
if (!oldest || g.lastSeenMs < oldest.lastSeenMs) oldest = g;
|
|
1159
|
+
}
|
|
1160
|
+
if (oldest) this.groups.delete(oldest.id);
|
|
1161
|
+
}
|
|
1162
|
+
this.groups.set(fingerprint, {
|
|
1163
|
+
id: fingerprint,
|
|
1164
|
+
name,
|
|
1165
|
+
message: message.slice(0, MAX_MESSAGE_CHARS),
|
|
1166
|
+
stack: stack.slice(0, MAX_STACK_CHARS),
|
|
1167
|
+
source,
|
|
1168
|
+
count: 1,
|
|
1169
|
+
firstSeenMs: now,
|
|
1170
|
+
lastSeenMs: now,
|
|
1171
|
+
...requestId !== void 0 ? { lastRequestId: requestId } : {},
|
|
1172
|
+
...context.route ? { lastRoute: context.route } : {},
|
|
1173
|
+
...context.status !== void 0 ? { lastStatus: context.status } : {}
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
get() {
|
|
1177
|
+
const groups = [...this.groups.values()].sort((a, b) => b.lastSeenMs - a.lastSeenMs);
|
|
1178
|
+
return { ok: true, data: { total: this.total, groups } };
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
|
|
754
1182
|
// src/providers/outgoing.ts
|
|
755
1183
|
var import_node_diagnostics_channel = __toESM(require("diagnostics_channel"));
|
|
756
1184
|
var import_node_http = __toESM(require("http"));
|
|
757
1185
|
var import_node_https = __toESM(require("https"));
|
|
758
|
-
var
|
|
759
|
-
var
|
|
760
|
-
var
|
|
1186
|
+
var import_node_module2 = require("module");
|
|
1187
|
+
var RESPONSE_PAGE_SIZE2 = 100;
|
|
1188
|
+
var recorders2 = /* @__PURE__ */ new Set();
|
|
761
1189
|
var originals = /* @__PURE__ */ new Map();
|
|
1190
|
+
var installed = /* @__PURE__ */ new Map();
|
|
762
1191
|
var undiciUnsubscribe = null;
|
|
763
1192
|
function describeTarget(defaultProtocol, args) {
|
|
764
1193
|
try {
|
|
@@ -787,8 +1216,8 @@ function describeTarget(defaultProtocol, args) {
|
|
|
787
1216
|
return null;
|
|
788
1217
|
}
|
|
789
1218
|
}
|
|
790
|
-
function
|
|
791
|
-
for (const record of
|
|
1219
|
+
function emit2(entry) {
|
|
1220
|
+
for (const record of recorders2) {
|
|
792
1221
|
try {
|
|
793
1222
|
record(entry);
|
|
794
1223
|
} catch {
|
|
@@ -800,21 +1229,24 @@ function wrapModule(mod, protocol) {
|
|
|
800
1229
|
const key = `${protocol}${name}`;
|
|
801
1230
|
const original = mod[name];
|
|
802
1231
|
originals.set(key, original);
|
|
1232
|
+
const state = { live: true };
|
|
803
1233
|
const wrapped = function(...args) {
|
|
804
1234
|
const req = original.apply(this, args);
|
|
805
|
-
const target =
|
|
1235
|
+
const target = state.live && recorders2.size > 0 && !isSuppressed() ? describeTarget(protocol, args) : null;
|
|
806
1236
|
if (target && req && typeof req.once === "function") {
|
|
807
1237
|
const started = process.hrtime.bigint();
|
|
808
1238
|
const timestampMs = Date.now();
|
|
1239
|
+
const requestId = currentRequestId();
|
|
809
1240
|
let done = false;
|
|
810
1241
|
const finish = (status, error) => {
|
|
811
1242
|
if (done) return;
|
|
812
1243
|
done = true;
|
|
813
|
-
|
|
1244
|
+
emit2({
|
|
814
1245
|
...target,
|
|
815
1246
|
status,
|
|
816
1247
|
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
817
1248
|
timestampMs,
|
|
1249
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
818
1250
|
...error ? { error } : {}
|
|
819
1251
|
});
|
|
820
1252
|
};
|
|
@@ -827,17 +1259,23 @@ function wrapModule(mod, protocol) {
|
|
|
827
1259
|
}
|
|
828
1260
|
return req;
|
|
829
1261
|
};
|
|
1262
|
+
installed.set(key, { fn: wrapped, state });
|
|
830
1263
|
mod[name] = wrapped;
|
|
831
1264
|
}
|
|
832
|
-
(0,
|
|
1265
|
+
(0, import_node_module2.syncBuiltinESMExports)();
|
|
833
1266
|
}
|
|
834
1267
|
function unwrapModule(mod, protocol) {
|
|
835
1268
|
for (const name of ["request", "get"]) {
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1269
|
+
const key = `${protocol}${name}`;
|
|
1270
|
+
const original = originals.get(key);
|
|
1271
|
+
const ours = installed.get(key);
|
|
1272
|
+
const target = mod;
|
|
1273
|
+
if (original && ours && target[name] === ours.fn) target[name] = original;
|
|
1274
|
+
if (ours) ours.state.live = false;
|
|
1275
|
+
originals.delete(key);
|
|
1276
|
+
installed.delete(key);
|
|
1277
|
+
}
|
|
1278
|
+
(0, import_node_module2.syncBuiltinESMExports)();
|
|
841
1279
|
}
|
|
842
1280
|
function subscribeUndici() {
|
|
843
1281
|
const started = /* @__PURE__ */ new WeakMap();
|
|
@@ -848,7 +1286,13 @@ function subscribeUndici() {
|
|
|
848
1286
|
});
|
|
849
1287
|
const onCreate = (message) => {
|
|
850
1288
|
const request = message.request;
|
|
851
|
-
if (request
|
|
1289
|
+
if (request && !isSuppressed()) {
|
|
1290
|
+
started.set(request, {
|
|
1291
|
+
at: process.hrtime.bigint(),
|
|
1292
|
+
ts: Date.now(),
|
|
1293
|
+
requestId: currentRequestId()
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
852
1296
|
};
|
|
853
1297
|
const onHeaders = (message) => {
|
|
854
1298
|
const { request, response } = message;
|
|
@@ -861,11 +1305,12 @@ function subscribeUndici() {
|
|
|
861
1305
|
const begin = started.get(request);
|
|
862
1306
|
if (!begin) return;
|
|
863
1307
|
started.delete(request);
|
|
864
|
-
|
|
1308
|
+
emit2({
|
|
865
1309
|
...target(request),
|
|
866
1310
|
status: statuses.get(request) ?? null,
|
|
867
1311
|
durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
|
|
868
1312
|
timestampMs: begin.ts,
|
|
1313
|
+
...begin.requestId !== void 0 ? { requestId: begin.requestId } : {},
|
|
869
1314
|
...error ? { error } : {}
|
|
870
1315
|
});
|
|
871
1316
|
};
|
|
@@ -886,8 +1331,8 @@ function subscribeUndici() {
|
|
|
886
1331
|
};
|
|
887
1332
|
}
|
|
888
1333
|
function interceptOutgoing(record) {
|
|
889
|
-
const first =
|
|
890
|
-
|
|
1334
|
+
const first = recorders2.size === 0;
|
|
1335
|
+
recorders2.add(record);
|
|
891
1336
|
if (first) {
|
|
892
1337
|
wrapModule(import_node_http.default, "http:");
|
|
893
1338
|
wrapModule(import_node_https.default, "https:");
|
|
@@ -898,7 +1343,7 @@ function interceptOutgoing(record) {
|
|
|
898
1343
|
}
|
|
899
1344
|
}
|
|
900
1345
|
return () => {
|
|
901
|
-
if (!
|
|
1346
|
+
if (!recorders2.delete(record) || recorders2.size > 0) return;
|
|
902
1347
|
unwrapModule(import_node_http.default, "http:");
|
|
903
1348
|
unwrapModule(import_node_https.default, "https:");
|
|
904
1349
|
undiciUnsubscribe?.();
|
|
@@ -911,6 +1356,8 @@ var OutgoingProvider = class {
|
|
|
911
1356
|
nextId = 1;
|
|
912
1357
|
failed = 0;
|
|
913
1358
|
release = null;
|
|
1359
|
+
/** Called with every newly recorded entry (persistence hook). */
|
|
1360
|
+
onRecord;
|
|
914
1361
|
constructor(size) {
|
|
915
1362
|
this.buffer = new RingBuffer(size);
|
|
916
1363
|
}
|
|
@@ -923,12 +1370,22 @@ var OutgoingProvider = class {
|
|
|
923
1370
|
this.release = null;
|
|
924
1371
|
}
|
|
925
1372
|
record(entry) {
|
|
926
|
-
|
|
1373
|
+
const full = { id: this.nextId, ...entry };
|
|
1374
|
+
this.buffer.push(full);
|
|
927
1375
|
this.nextId += 1;
|
|
928
1376
|
if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
|
|
1377
|
+
this.onRecord?.(full);
|
|
1378
|
+
}
|
|
1379
|
+
/** Re-inserts journaled entries without re-emitting them. */
|
|
1380
|
+
restore(entries) {
|
|
1381
|
+
for (const entry of entries) {
|
|
1382
|
+
this.buffer.push(entry);
|
|
1383
|
+
this.nextId = Math.max(this.nextId, entry.id + 1);
|
|
1384
|
+
if (entry.error || entry.status !== null && entry.status >= 500) this.failed += 1;
|
|
1385
|
+
}
|
|
929
1386
|
}
|
|
930
1387
|
get() {
|
|
931
|
-
const entries = this.buffer.slice(Math.max(0, this.buffer.length -
|
|
1388
|
+
const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE2));
|
|
932
1389
|
return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
|
|
933
1390
|
}
|
|
934
1391
|
};
|
|
@@ -936,10 +1393,10 @@ var OutgoingProvider = class {
|
|
|
936
1393
|
// src/providers/heap-snapshot.ts
|
|
937
1394
|
var import_promises = require("fs/promises");
|
|
938
1395
|
var import_node_os2 = require("os");
|
|
939
|
-
var
|
|
1396
|
+
var import_node_path3 = require("path");
|
|
940
1397
|
var import_node_v8 = require("v8");
|
|
941
1398
|
function defaultSnapshotDir() {
|
|
942
|
-
return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0,
|
|
1399
|
+
return process.env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "nodeui-heap");
|
|
943
1400
|
}
|
|
944
1401
|
var HeapSnapshotProvider = class {
|
|
945
1402
|
id = "heap-snapshot";
|
|
@@ -951,7 +1408,7 @@ var HeapSnapshotProvider = class {
|
|
|
951
1408
|
const dir = ctx.config.heapSnapshotDir || defaultSnapshotDir();
|
|
952
1409
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
953
1410
|
const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
|
|
954
|
-
const filePath = (0,
|
|
1411
|
+
const filePath = (0, import_node_path3.join)(dir, fileName);
|
|
955
1412
|
try {
|
|
956
1413
|
await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
|
|
957
1414
|
(0, import_node_v8.writeHeapSnapshot)(filePath);
|
|
@@ -978,7 +1435,7 @@ var HeapSnapshotProvider = class {
|
|
|
978
1435
|
};
|
|
979
1436
|
|
|
980
1437
|
// src/providers/requests.ts
|
|
981
|
-
var
|
|
1438
|
+
var RESPONSE_PAGE_SIZE3 = 100;
|
|
982
1439
|
var MAX_ROUTE_STATS = 8;
|
|
983
1440
|
function percentile(sorted, p) {
|
|
984
1441
|
if (sorted.length === 0) return 0;
|
|
@@ -997,7 +1454,7 @@ function summarizeRequests(entries) {
|
|
|
997
1454
|
for (const e of entries) {
|
|
998
1455
|
const bucket = `${Math.floor(e.status / 100)}xx`;
|
|
999
1456
|
if (bucket in byStatus) byStatus[bucket] += 1;
|
|
1000
|
-
const path = routeKey(e.path);
|
|
1457
|
+
const path = e.route ?? routeKey(e.path);
|
|
1001
1458
|
const key = `${e.method} ${path}`;
|
|
1002
1459
|
const group = groups.get(key) ?? { method: e.method, path, ms: [], errors: 0 };
|
|
1003
1460
|
group.ms.push(e.durationMs);
|
|
@@ -1031,18 +1488,34 @@ var RequestsProvider = class {
|
|
|
1031
1488
|
id = "requests";
|
|
1032
1489
|
buffer;
|
|
1033
1490
|
nextId = 1;
|
|
1491
|
+
/** Called with every newly recorded entry (persistence hook). */
|
|
1492
|
+
onRecord;
|
|
1034
1493
|
constructor(size) {
|
|
1035
1494
|
this.buffer = new RingBuffer(size);
|
|
1036
1495
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1496
|
+
/** Reserves an id up front so in-flight work can be attributed to the request. */
|
|
1497
|
+
reserveId() {
|
|
1498
|
+
const id = this.nextId;
|
|
1039
1499
|
this.nextId += 1;
|
|
1500
|
+
return id;
|
|
1501
|
+
}
|
|
1502
|
+
record(entry, id = this.reserveId()) {
|
|
1503
|
+
const full = { id, ...entry };
|
|
1504
|
+
this.buffer.push(full);
|
|
1505
|
+
this.onRecord?.(full);
|
|
1506
|
+
}
|
|
1507
|
+
/** Re-inserts journaled entries without re-emitting them. */
|
|
1508
|
+
restore(entries) {
|
|
1509
|
+
for (const entry of entries) {
|
|
1510
|
+
this.buffer.push(entry);
|
|
1511
|
+
this.nextId = Math.max(this.nextId, entry.id + 1);
|
|
1512
|
+
}
|
|
1040
1513
|
}
|
|
1041
1514
|
get length() {
|
|
1042
1515
|
return this.buffer.length;
|
|
1043
1516
|
}
|
|
1044
1517
|
get() {
|
|
1045
|
-
const entries = this.buffer.slice(Math.max(0, this.buffer.length -
|
|
1518
|
+
const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE3));
|
|
1046
1519
|
const summary = summarizeRequests(this.buffer.toArray());
|
|
1047
1520
|
return { ok: true, data: { total: this.buffer.length, entries, summary } };
|
|
1048
1521
|
}
|
|
@@ -1222,7 +1695,7 @@ function installWrappers() {
|
|
|
1222
1695
|
const wrapper = (...args) => {
|
|
1223
1696
|
for (const push of pushes) {
|
|
1224
1697
|
try {
|
|
1225
|
-
push({ level, message: args.map(formatArg).join(" ") });
|
|
1698
|
+
push({ level, message: args.map(formatArg).join(" "), requestId: currentRequestId() });
|
|
1226
1699
|
} catch {
|
|
1227
1700
|
}
|
|
1228
1701
|
}
|
|
@@ -1266,8 +1739,13 @@ var LogsProvider = class {
|
|
|
1266
1739
|
}
|
|
1267
1740
|
start() {
|
|
1268
1741
|
if (this.release) return;
|
|
1269
|
-
this.release = interceptConsole(({ level, message }) => {
|
|
1270
|
-
this.buffer.push({
|
|
1742
|
+
this.release = interceptConsole(({ level, message, requestId }) => {
|
|
1743
|
+
this.buffer.push({
|
|
1744
|
+
level,
|
|
1745
|
+
message,
|
|
1746
|
+
timestamp: Date.now(),
|
|
1747
|
+
...requestId !== void 0 ? { requestId } : {}
|
|
1748
|
+
});
|
|
1271
1749
|
});
|
|
1272
1750
|
}
|
|
1273
1751
|
stop() {
|
|
@@ -1291,6 +1769,160 @@ var LogsProvider = class {
|
|
|
1291
1769
|
}
|
|
1292
1770
|
};
|
|
1293
1771
|
|
|
1772
|
+
// src/request-detail.ts
|
|
1773
|
+
var DEFAULT_REQUEST_DETAIL = {
|
|
1774
|
+
query: true,
|
|
1775
|
+
headers: true,
|
|
1776
|
+
bodies: false,
|
|
1777
|
+
maxBodyBytes: 4096
|
|
1778
|
+
};
|
|
1779
|
+
function resolveRequestDetail(option, bodiesFromEnv) {
|
|
1780
|
+
if (option === false) {
|
|
1781
|
+
return { query: false, headers: false, bodies: false, maxBodyBytes: 0 };
|
|
1782
|
+
}
|
|
1783
|
+
const given = typeof option === "object" ? option : {};
|
|
1784
|
+
const maxBodyBytes = given.maxBodyBytes ?? DEFAULT_REQUEST_DETAIL.maxBodyBytes;
|
|
1785
|
+
if (!Number.isInteger(maxBodyBytes) || maxBodyBytes <= 0 || maxBodyBytes > 1048576) {
|
|
1786
|
+
throw new Error("captureRequestDetail.maxBodyBytes must be an integer in 1..1048576");
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
query: given.query ?? DEFAULT_REQUEST_DETAIL.query,
|
|
1790
|
+
headers: given.headers ?? DEFAULT_REQUEST_DETAIL.headers,
|
|
1791
|
+
bodies: given.bodies ?? (bodiesFromEnv || DEFAULT_REQUEST_DETAIL.bodies),
|
|
1792
|
+
maxBodyBytes
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
var ALWAYS_REDACT = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
|
|
1796
|
+
var TEXTUAL_TYPE = /json|text|xml|x-www-form-urlencoded|javascript|graphql/i;
|
|
1797
|
+
function queryOf(req) {
|
|
1798
|
+
const raw = req.originalUrl ?? req.url ?? "";
|
|
1799
|
+
const index = raw.indexOf("?");
|
|
1800
|
+
if (index === -1) return void 0;
|
|
1801
|
+
const out = {};
|
|
1802
|
+
for (const [key, value] of new URLSearchParams(raw.slice(index + 1))) {
|
|
1803
|
+
out[key] = SECRET_KEY_PATTERN.test(key) ? SECRET_MASKED : value;
|
|
1804
|
+
}
|
|
1805
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1806
|
+
}
|
|
1807
|
+
function headersOf(req) {
|
|
1808
|
+
const out = {};
|
|
1809
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
1810
|
+
if (value === void 0) continue;
|
|
1811
|
+
out[name] = ALWAYS_REDACT.test(name) || SECRET_KEY_PATTERN.test(name) ? SECRET_MASKED : Array.isArray(value) ? value.join(", ") : value;
|
|
1812
|
+
}
|
|
1813
|
+
return out;
|
|
1814
|
+
}
|
|
1815
|
+
function responseType(res) {
|
|
1816
|
+
const header = res.getHeader("content-type");
|
|
1817
|
+
if (header !== void 0) return String(header);
|
|
1818
|
+
const raw = res._header;
|
|
1819
|
+
const match = typeof raw === "string" ? /^content-type:[ \t]*(.*?)\r?$/im.exec(raw) : null;
|
|
1820
|
+
return match?.[1] ?? "";
|
|
1821
|
+
}
|
|
1822
|
+
var BodyCollector = class {
|
|
1823
|
+
constructor(max) {
|
|
1824
|
+
this.max = max;
|
|
1825
|
+
}
|
|
1826
|
+
max;
|
|
1827
|
+
chunks = [];
|
|
1828
|
+
size = 0;
|
|
1829
|
+
total = 0;
|
|
1830
|
+
add(chunk, encoding) {
|
|
1831
|
+
let buf;
|
|
1832
|
+
if (Buffer.isBuffer(chunk)) buf = chunk;
|
|
1833
|
+
else if (typeof chunk === "string") {
|
|
1834
|
+
buf = Buffer.from(
|
|
1835
|
+
chunk,
|
|
1836
|
+
typeof encoding === "string" ? encoding : "utf8"
|
|
1837
|
+
);
|
|
1838
|
+
} else if (chunk instanceof Uint8Array) buf = Buffer.from(chunk);
|
|
1839
|
+
else return;
|
|
1840
|
+
this.total += buf.length;
|
|
1841
|
+
if (this.size >= this.max) return;
|
|
1842
|
+
const slice = buf.subarray(0, this.max - this.size);
|
|
1843
|
+
this.chunks.push(slice);
|
|
1844
|
+
this.size += slice.length;
|
|
1845
|
+
}
|
|
1846
|
+
get truncated() {
|
|
1847
|
+
return this.total > this.size;
|
|
1848
|
+
}
|
|
1849
|
+
text() {
|
|
1850
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
function maskBody(text, contentType, mask, truncated) {
|
|
1854
|
+
if (!mask) return text;
|
|
1855
|
+
if (!truncated && /json/i.test(contentType)) {
|
|
1856
|
+
try {
|
|
1857
|
+
return JSON.stringify(maskSecrets(JSON.parse(text)));
|
|
1858
|
+
} catch {
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
return maskSecretText(text);
|
|
1862
|
+
}
|
|
1863
|
+
function captureRequestDetail(req, res, settings, mask) {
|
|
1864
|
+
let reqBody = null;
|
|
1865
|
+
let resBody = null;
|
|
1866
|
+
if (settings.bodies) {
|
|
1867
|
+
const reqType = String(req.headers["content-type"] ?? "");
|
|
1868
|
+
if (TEXTUAL_TYPE.test(reqType)) {
|
|
1869
|
+
const collector2 = new BodyCollector(settings.maxBodyBytes);
|
|
1870
|
+
reqBody = collector2;
|
|
1871
|
+
const emit3 = req.emit.bind(req);
|
|
1872
|
+
req.emit = ((event, ...args) => {
|
|
1873
|
+
if (event === "data") {
|
|
1874
|
+
try {
|
|
1875
|
+
collector2.add(args[0]);
|
|
1876
|
+
} catch {
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return emit3(event, ...args);
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
const collector = new BodyCollector(settings.maxBodyBytes);
|
|
1883
|
+
resBody = collector;
|
|
1884
|
+
const write = res.write.bind(res);
|
|
1885
|
+
const end = res.end.bind(res);
|
|
1886
|
+
const textual = () => TEXTUAL_TYPE.test(responseType(res));
|
|
1887
|
+
res.write = ((chunk, ...rest) => {
|
|
1888
|
+
try {
|
|
1889
|
+
if (textual()) collector.add(chunk, rest[0]);
|
|
1890
|
+
} catch {
|
|
1891
|
+
}
|
|
1892
|
+
return write(chunk, ...rest);
|
|
1893
|
+
});
|
|
1894
|
+
res.end = ((chunk, ...rest) => {
|
|
1895
|
+
try {
|
|
1896
|
+
if (chunk && typeof chunk !== "function" && textual()) collector.add(chunk, rest[0]);
|
|
1897
|
+
} catch {
|
|
1898
|
+
}
|
|
1899
|
+
return end(chunk, ...rest);
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
return () => {
|
|
1903
|
+
const detail = {};
|
|
1904
|
+
if (settings.query) {
|
|
1905
|
+
const query = queryOf(req);
|
|
1906
|
+
if (query) detail.query = query;
|
|
1907
|
+
}
|
|
1908
|
+
if (settings.headers) detail.headers = headersOf(req);
|
|
1909
|
+
if (reqBody && reqBody.total > 0) {
|
|
1910
|
+
detail.requestBody = maskBody(
|
|
1911
|
+
reqBody.text(),
|
|
1912
|
+
String(req.headers["content-type"] ?? ""),
|
|
1913
|
+
mask,
|
|
1914
|
+
reqBody.truncated
|
|
1915
|
+
);
|
|
1916
|
+
if (reqBody.truncated) detail.requestBodyTruncated = true;
|
|
1917
|
+
}
|
|
1918
|
+
if (resBody && resBody.total > 0) {
|
|
1919
|
+
detail.responseBody = maskBody(resBody.text(), responseType(res), mask, resBody.truncated);
|
|
1920
|
+
if (resBody.truncated) detail.responseBodyTruncated = true;
|
|
1921
|
+
}
|
|
1922
|
+
return detail;
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1294
1926
|
// src/sse.ts
|
|
1295
1927
|
function startSse(res, extraHeaders = {}) {
|
|
1296
1928
|
let closed = false;
|
|
@@ -1323,8 +1955,260 @@ function startSse(res, extraHeaders = {}) {
|
|
|
1323
1955
|
|
|
1324
1956
|
// src/server.ts
|
|
1325
1957
|
var import_node_os3 = require("os");
|
|
1326
|
-
var
|
|
1327
|
-
|
|
1958
|
+
var import_node_path5 = require("path");
|
|
1959
|
+
|
|
1960
|
+
// src/otlp.ts
|
|
1961
|
+
var import_node_crypto4 = require("crypto");
|
|
1962
|
+
var KIND_SERVER = 2;
|
|
1963
|
+
var KIND_CLIENT = 3;
|
|
1964
|
+
var STATUS_ERROR = 2;
|
|
1965
|
+
var MAX_QUEUE = 1e3;
|
|
1966
|
+
var MAX_BATCH = 200;
|
|
1967
|
+
var MAX_TRACKED_REQUESTS = 1e3;
|
|
1968
|
+
var hex = (bytes) => (0, import_node_crypto4.randomBytes)(bytes).toString("hex");
|
|
1969
|
+
var nanos = (ms) => (BigInt(Math.round(ms * 1e3)) * 1000n).toString();
|
|
1970
|
+
var str = (key, value) => ({ key, value: { stringValue: value } });
|
|
1971
|
+
var int = (key, value) => ({
|
|
1972
|
+
key,
|
|
1973
|
+
value: { intValue: String(Math.round(value)) }
|
|
1974
|
+
});
|
|
1975
|
+
function tracesUrl(endpoint) {
|
|
1976
|
+
const trimmed = endpoint.replace(/\/+$/, "");
|
|
1977
|
+
return trimmed.endsWith("/v1/traces") ? trimmed : `${trimmed}/v1/traces`;
|
|
1978
|
+
}
|
|
1979
|
+
var OtlpExporter = class {
|
|
1980
|
+
constructor(options, env = process.env) {
|
|
1981
|
+
this.options = options;
|
|
1982
|
+
this.url = tracesUrl(options.endpoint);
|
|
1983
|
+
this.serviceName = options.serviceName ?? env.OTEL_SERVICE_NAME ?? "nodeui-app";
|
|
1984
|
+
}
|
|
1985
|
+
options;
|
|
1986
|
+
url;
|
|
1987
|
+
serviceName;
|
|
1988
|
+
queue = [];
|
|
1989
|
+
timer = null;
|
|
1990
|
+
contexts = /* @__PURE__ */ new Map();
|
|
1991
|
+
warned = false;
|
|
1992
|
+
exported = 0;
|
|
1993
|
+
dropped = 0;
|
|
1994
|
+
/** Stable ids for a request so children recorded earlier link to its span. */
|
|
1995
|
+
contextFor(requestId) {
|
|
1996
|
+
let ctx = this.contexts.get(requestId);
|
|
1997
|
+
if (!ctx) {
|
|
1998
|
+
ctx = { traceId: hex(16), spanId: hex(8) };
|
|
1999
|
+
this.contexts.set(requestId, ctx);
|
|
2000
|
+
if (this.contexts.size > MAX_TRACKED_REQUESTS) {
|
|
2001
|
+
const oldest = this.contexts.keys().next().value;
|
|
2002
|
+
if (oldest !== void 0) this.contexts.delete(oldest);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
return ctx;
|
|
2006
|
+
}
|
|
2007
|
+
request(entry) {
|
|
2008
|
+
const ctx = this.contextFor(entry.id);
|
|
2009
|
+
const attributes = [
|
|
2010
|
+
str("http.request.method", entry.method),
|
|
2011
|
+
str("url.path", maskSecretText(entry.path)),
|
|
2012
|
+
int("http.response.status_code", entry.status),
|
|
2013
|
+
str("client.address", entry.ip)
|
|
2014
|
+
];
|
|
2015
|
+
if (entry.route) attributes.push(str("http.route", entry.route));
|
|
2016
|
+
this.push({
|
|
2017
|
+
...ctx,
|
|
2018
|
+
name: `${entry.method} ${entry.route ?? entry.path}`,
|
|
2019
|
+
kind: KIND_SERVER,
|
|
2020
|
+
startTimeUnixNano: nanos(entry.timestampMs),
|
|
2021
|
+
endTimeUnixNano: nanos(entry.timestampMs + entry.durationMs),
|
|
2022
|
+
attributes,
|
|
2023
|
+
...entry.status >= 500 ? { status: { code: STATUS_ERROR } } : {}
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
outgoing(entry) {
|
|
2027
|
+
const attributes = [
|
|
2028
|
+
str("http.request.method", entry.method),
|
|
2029
|
+
str("url.full", maskSecretText(entry.url))
|
|
2030
|
+
];
|
|
2031
|
+
if (entry.status !== null) attributes.push(int("http.response.status_code", entry.status));
|
|
2032
|
+
const failed = Boolean(entry.error) || entry.status !== null && entry.status >= 500;
|
|
2033
|
+
this.child(entry.requestId, {
|
|
2034
|
+
name: `${entry.method} ${entry.url}`,
|
|
2035
|
+
timestampMs: entry.timestampMs,
|
|
2036
|
+
durationMs: entry.durationMs,
|
|
2037
|
+
attributes,
|
|
2038
|
+
...failed ? { status: { code: STATUS_ERROR, message: entry.error } } : {}
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
query(entry) {
|
|
2042
|
+
const statement = maskSecretText(entry.sql);
|
|
2043
|
+
this.child(entry.requestId, {
|
|
2044
|
+
name: `${statement.split(/\s+/)[0]?.toUpperCase() ?? "QUERY"} ${entry.system}`,
|
|
2045
|
+
timestampMs: entry.timestampMs,
|
|
2046
|
+
durationMs: entry.durationMs,
|
|
2047
|
+
attributes: [str("db.system.name", entry.system), str("db.query.text", statement)],
|
|
2048
|
+
...entry.error ? { status: { code: STATUS_ERROR, message: entry.error } } : {}
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
child(requestId, span) {
|
|
2052
|
+
const parent = requestId === void 0 ? void 0 : this.contextFor(requestId);
|
|
2053
|
+
this.push({
|
|
2054
|
+
traceId: parent?.traceId ?? hex(16),
|
|
2055
|
+
spanId: hex(8),
|
|
2056
|
+
...parent ? { parentSpanId: parent.spanId } : {},
|
|
2057
|
+
name: span.name,
|
|
2058
|
+
kind: KIND_CLIENT,
|
|
2059
|
+
startTimeUnixNano: nanos(span.timestampMs),
|
|
2060
|
+
endTimeUnixNano: nanos(span.timestampMs + span.durationMs),
|
|
2061
|
+
attributes: span.attributes,
|
|
2062
|
+
...span.status ? { status: span.status } : {}
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
push(span) {
|
|
2066
|
+
if (this.queue.length >= MAX_QUEUE) {
|
|
2067
|
+
this.queue.shift();
|
|
2068
|
+
this.dropped += 1;
|
|
2069
|
+
}
|
|
2070
|
+
this.queue.push(span);
|
|
2071
|
+
if (!this.timer) {
|
|
2072
|
+
this.timer = setTimeout(() => void this.flush(), this.options.intervalMs ?? 2e3);
|
|
2073
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
body(spans) {
|
|
2077
|
+
return JSON.stringify({
|
|
2078
|
+
resourceSpans: [
|
|
2079
|
+
{
|
|
2080
|
+
resource: { attributes: [str("service.name", this.serviceName)] },
|
|
2081
|
+
scopeSpans: [{ scope: { name: "@singhak/nodeui" }, spans }]
|
|
2082
|
+
}
|
|
2083
|
+
]
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
async flush() {
|
|
2087
|
+
this.timer = null;
|
|
2088
|
+
while (this.queue.length > 0) {
|
|
2089
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
2090
|
+
try {
|
|
2091
|
+
const res = await runSuppressed(
|
|
2092
|
+
() => fetch(this.url, {
|
|
2093
|
+
method: "POST",
|
|
2094
|
+
headers: { "content-type": "application/json", ...this.options.headers },
|
|
2095
|
+
body: this.body(batch),
|
|
2096
|
+
signal: AbortSignal.timeout(5e3)
|
|
2097
|
+
})
|
|
2098
|
+
);
|
|
2099
|
+
if (!res.ok) throw new Error(`collector answered ${res.status}`);
|
|
2100
|
+
this.exported += batch.length;
|
|
2101
|
+
} catch (err) {
|
|
2102
|
+
this.dropped += batch.length;
|
|
2103
|
+
if (!this.warned) {
|
|
2104
|
+
this.warned = true;
|
|
2105
|
+
console.warn(
|
|
2106
|
+
`[nodeui] OTLP export to ${this.url} failed (${err instanceof Error ? err.message : String(err)}); dropping spans.`
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/** Cancels the timer and makes a best-effort final export. */
|
|
2114
|
+
close() {
|
|
2115
|
+
if (this.timer) clearTimeout(this.timer);
|
|
2116
|
+
this.timer = null;
|
|
2117
|
+
void this.flush();
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
// src/persistence.ts
|
|
2122
|
+
var import_node_fs2 = require("fs");
|
|
2123
|
+
var import_node_path4 = require("path");
|
|
2124
|
+
var DEFAULT_PERSIST_MAX_BYTES = 5 * 1024 * 1024;
|
|
2125
|
+
var FLUSH_DELAY_MS = 250;
|
|
2126
|
+
var Persistence = class {
|
|
2127
|
+
constructor(file, maxBytes = DEFAULT_PERSIST_MAX_BYTES) {
|
|
2128
|
+
this.file = file;
|
|
2129
|
+
this.maxBytes = maxBytes;
|
|
2130
|
+
try {
|
|
2131
|
+
(0, import_node_fs2.mkdirSync)((0, import_node_path4.dirname)(file), { recursive: true });
|
|
2132
|
+
} catch {
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
file;
|
|
2136
|
+
maxBytes;
|
|
2137
|
+
pending = [];
|
|
2138
|
+
timer = null;
|
|
2139
|
+
writing = false;
|
|
2140
|
+
append(kind, data) {
|
|
2141
|
+
try {
|
|
2142
|
+
this.pending.push(`${JSON.stringify({ kind, data })}
|
|
2143
|
+
`);
|
|
2144
|
+
} catch {
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
if (!this.timer) {
|
|
2148
|
+
this.timer = setTimeout(() => this.flush(), FLUSH_DELAY_MS);
|
|
2149
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
/** Reads the journal (rotated file first) in chronological order. */
|
|
2153
|
+
load() {
|
|
2154
|
+
const out = [];
|
|
2155
|
+
for (const path of [`${this.file}.1`, this.file]) {
|
|
2156
|
+
let text;
|
|
2157
|
+
try {
|
|
2158
|
+
text = (0, import_node_fs2.readFileSync)(path, "utf8");
|
|
2159
|
+
} catch {
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
for (const line of text.split("\n")) {
|
|
2163
|
+
if (!line) continue;
|
|
2164
|
+
try {
|
|
2165
|
+
const parsed = JSON.parse(line);
|
|
2166
|
+
if (parsed && typeof parsed.kind === "string") out.push(parsed);
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
return out;
|
|
2172
|
+
}
|
|
2173
|
+
rotateIfNeeded() {
|
|
2174
|
+
try {
|
|
2175
|
+
if ((0, import_node_fs2.existsSync)(this.file) && (0, import_node_fs2.statSync)(this.file).size >= this.maxBytes) {
|
|
2176
|
+
(0, import_node_fs2.renameSync)(this.file, `${this.file}.1`);
|
|
2177
|
+
}
|
|
2178
|
+
} catch {
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
flush() {
|
|
2182
|
+
this.timer = null;
|
|
2183
|
+
if (this.writing || this.pending.length === 0) {
|
|
2184
|
+
if (this.pending.length > 0) this.timer = setTimeout(() => this.flush(), FLUSH_DELAY_MS);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
this.rotateIfNeeded();
|
|
2188
|
+
const chunk = this.pending.join("");
|
|
2189
|
+
this.pending = [];
|
|
2190
|
+
this.writing = true;
|
|
2191
|
+
(0, import_node_fs2.appendFile)(this.file, chunk, { mode: 384 }, () => {
|
|
2192
|
+
this.writing = false;
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
/** Writes anything still buffered, synchronously (used on shutdown). */
|
|
2196
|
+
close() {
|
|
2197
|
+
if (this.timer) {
|
|
2198
|
+
clearTimeout(this.timer);
|
|
2199
|
+
this.timer = null;
|
|
2200
|
+
}
|
|
2201
|
+
if (this.pending.length === 0) return;
|
|
2202
|
+
try {
|
|
2203
|
+
this.rotateIfNeeded();
|
|
2204
|
+
(0, import_node_fs2.appendFileSync)(this.file, this.pending.join(""), { mode: 384 });
|
|
2205
|
+
} catch {
|
|
2206
|
+
}
|
|
2207
|
+
this.pending = [];
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
// src/server.ts
|
|
1328
2212
|
var SSE_HEARTBEAT_MS = 15e3;
|
|
1329
2213
|
function serializeEnvelope(envelope, mask = true) {
|
|
1330
2214
|
return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
|
|
@@ -1341,7 +2225,9 @@ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
|
|
|
1341
2225
|
"routes",
|
|
1342
2226
|
"logs",
|
|
1343
2227
|
"metrics",
|
|
1344
|
-
"outgoing"
|
|
2228
|
+
"outgoing",
|
|
2229
|
+
"errors",
|
|
2230
|
+
"queries"
|
|
1345
2231
|
]);
|
|
1346
2232
|
var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
1347
2233
|
var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
|
|
@@ -1356,6 +2242,13 @@ function listFrom(value, raw) {
|
|
|
1356
2242
|
if (value) return value;
|
|
1357
2243
|
return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
1358
2244
|
}
|
|
2245
|
+
function otlpHostname(endpoint) {
|
|
2246
|
+
try {
|
|
2247
|
+
return new URL(endpoint).hostname.replace(/^\[|\]$/g, "");
|
|
2248
|
+
} catch {
|
|
2249
|
+
throw new Error(`nodeui otlp endpoint "${endpoint}" is not a valid URL`);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
1359
2252
|
function pathnameOf(req) {
|
|
1360
2253
|
const raw = req.originalUrl ?? req.url ?? "/";
|
|
1361
2254
|
try {
|
|
@@ -1369,6 +2262,13 @@ function normalizePath(value) {
|
|
|
1369
2262
|
if (!trimmed.startsWith("/")) throw new Error(`NODEUI_PATH must start with "/", got "${value}"`);
|
|
1370
2263
|
return trimmed.replace(/\/+$/, "") || "/";
|
|
1371
2264
|
}
|
|
2265
|
+
function resolveCapture(option, env) {
|
|
2266
|
+
const value = option ?? env ?? "always";
|
|
2267
|
+
if (value !== "always" && value !== "lazy") {
|
|
2268
|
+
throw new Error(`nodeui capture must be 'always' or 'lazy', got "${String(value)}"`);
|
|
2269
|
+
}
|
|
2270
|
+
return value;
|
|
2271
|
+
}
|
|
1372
2272
|
function positiveInt(value, fallback, name) {
|
|
1373
2273
|
const n = value === void 0 ? fallback : value;
|
|
1374
2274
|
if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer`);
|
|
@@ -1404,6 +2304,18 @@ function createNodeUI(options = {}) {
|
|
|
1404
2304
|
enabled: activation.active,
|
|
1405
2305
|
activationReason: activation.reason,
|
|
1406
2306
|
maskSecrets: options.maskSecrets ?? true,
|
|
2307
|
+
capture: resolveCapture(options.capture, env.NODEUI_CAPTURE),
|
|
2308
|
+
persistFile: (typeof options.persist === "string" ? options.persist : options.persist?.file) ?? env.NODEUI_PERSIST_FILE ?? null,
|
|
2309
|
+
persistMaxBytes: positiveInt(
|
|
2310
|
+
typeof options.persist === "object" ? options.persist.maxBytes : void 0,
|
|
2311
|
+
DEFAULT_PERSIST_MAX_BYTES,
|
|
2312
|
+
"persist.maxBytes"
|
|
2313
|
+
),
|
|
2314
|
+
otlp: typeof options.otlp === "string" ? { endpoint: options.otlp } : options.otlp ?? (env.NODEUI_OTLP_ENDPOINT ? { endpoint: env.NODEUI_OTLP_ENDPOINT } : null),
|
|
2315
|
+
requestDetail: resolveRequestDetail(
|
|
2316
|
+
options.captureRequestDetail,
|
|
2317
|
+
env.NODEUI_CAPTURE_BODIES === "true"
|
|
2318
|
+
),
|
|
1407
2319
|
inactivityTimeoutMs: positiveInt(
|
|
1408
2320
|
options.inactivityTimeoutMs,
|
|
1409
2321
|
env.NODEUI_INACTIVITY_TIMEOUT_MS ? Number(env.NODEUI_INACTIVITY_TIMEOUT_MS) : 6e4,
|
|
@@ -1414,7 +2326,7 @@ function createNodeUI(options = {}) {
|
|
|
1414
2326
|
env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
|
|
1415
2327
|
"NODEUI_CONFIRM_TTL_MS"
|
|
1416
2328
|
),
|
|
1417
|
-
heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0,
|
|
2329
|
+
heapSnapshotDir: options.heapSnapshotDir ?? env.NODEUI_HEAP_SNAPSHOT_DIR ?? (0, import_node_path5.join)((0, import_node_os3.tmpdir)(), "nodeui-heap"),
|
|
1418
2330
|
allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
|
|
1419
2331
|
allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
|
|
1420
2332
|
allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
|
|
@@ -1445,6 +2357,15 @@ function createNodeUI(options = {}) {
|
|
|
1445
2357
|
"[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
|
|
1446
2358
|
);
|
|
1447
2359
|
}
|
|
2360
|
+
if (config.enabled) {
|
|
2361
|
+
for (const entry of config.allowedRemoteAddresses) {
|
|
2362
|
+
if (!isValidAddressEntry(entry)) {
|
|
2363
|
+
console.warn(
|
|
2364
|
+
`[nodeui] ignoring allowedRemoteAddresses entry "${entry}": expected an IP or a CIDR such as 172.16.0.0/12 or fd00::/8; it will never match.`
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
1448
2369
|
if (config.enabled && !config.maskSecrets) {
|
|
1449
2370
|
console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
|
|
1450
2371
|
}
|
|
@@ -1475,9 +2396,59 @@ function createNodeUI(options = {}) {
|
|
|
1475
2396
|
registry.register(envProvider);
|
|
1476
2397
|
const routesProvider = new RoutesProvider();
|
|
1477
2398
|
registry.register(routesProvider);
|
|
1478
|
-
|
|
1479
|
-
|
|
2399
|
+
const outgoingProvider = new OutgoingProvider(
|
|
2400
|
+
positiveInt(options.outgoingLogSize, 200, "outgoingLogSize")
|
|
1480
2401
|
);
|
|
2402
|
+
registry.register(outgoingProvider);
|
|
2403
|
+
const queriesProvider = new QueriesProvider({
|
|
2404
|
+
size: positiveInt(options.queryLogSize, 200, "queryLogSize"),
|
|
2405
|
+
slowQueryMs: positiveInt(options.slowQueryMs, 100, "slowQueryMs")
|
|
2406
|
+
});
|
|
2407
|
+
registry.register(queriesProvider);
|
|
2408
|
+
const errorsProvider = new ErrorsProvider();
|
|
2409
|
+
registry.register(errorsProvider);
|
|
2410
|
+
if (config.enabled) errorsProvider.attach();
|
|
2411
|
+
const persistence = config.enabled && config.persistFile ? new Persistence(config.persistFile, config.persistMaxBytes) : null;
|
|
2412
|
+
const exporter = config.enabled && config.otlp ? new OtlpExporter(config.otlp) : null;
|
|
2413
|
+
const pinned = /* @__PURE__ */ new Set();
|
|
2414
|
+
const otlpHost = config.otlp ? otlpHostname(config.otlp.endpoint) : null;
|
|
2415
|
+
if (exporter && config.otlp && !isLoopbackHostname(otlpHost)) {
|
|
2416
|
+
console.warn(
|
|
2417
|
+
`[nodeui] exporting telemetry to non-local OTLP endpoint ${config.otlp.endpoint}; requests, queries and outgoing URLs leave this machine (masked).`
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
if (persistence) {
|
|
2421
|
+
const byKind = (kind) => persistence.load().filter((r) => r.kind === kind).map((r) => r.data);
|
|
2422
|
+
requestsProvider.restore(byKind("request"));
|
|
2423
|
+
outgoingProvider.restore(byKind("outgoing"));
|
|
2424
|
+
queriesProvider.restore(byKind("query"));
|
|
2425
|
+
errorsProvider.restore(byKind("error"));
|
|
2426
|
+
}
|
|
2427
|
+
if (persistence || exporter) {
|
|
2428
|
+
const save = (kind, data) => persistence?.append(kind, config.maskSecrets ? maskSecrets(data) : data);
|
|
2429
|
+
requestsProvider.onRecord = (e) => {
|
|
2430
|
+
save("request", e);
|
|
2431
|
+
exporter?.request(e);
|
|
2432
|
+
};
|
|
2433
|
+
outgoingProvider.onRecord = (e) => {
|
|
2434
|
+
save("outgoing", e);
|
|
2435
|
+
exporter?.outgoing(e);
|
|
2436
|
+
};
|
|
2437
|
+
queriesProvider.onRecord = (e) => {
|
|
2438
|
+
save("query", e);
|
|
2439
|
+
exporter?.query(e);
|
|
2440
|
+
};
|
|
2441
|
+
errorsProvider.onRecord = (e) => save("error", e);
|
|
2442
|
+
}
|
|
2443
|
+
const pinnedProviders = [];
|
|
2444
|
+
if (persistence || exporter || config.enabled && config.capture === "always") {
|
|
2445
|
+
pinnedProviders.push(outgoingProvider, queriesProvider);
|
|
2446
|
+
}
|
|
2447
|
+
if (config.enabled && config.capture === "always") pinnedProviders.push(logsProvider);
|
|
2448
|
+
for (const provider of pinnedProviders) {
|
|
2449
|
+
provider.start?.(ctx);
|
|
2450
|
+
pinned.add(provider.id);
|
|
2451
|
+
}
|
|
1481
2452
|
const pluginMeta = [];
|
|
1482
2453
|
for (const plugin of options.plugins ?? []) {
|
|
1483
2454
|
if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
|
|
@@ -1494,7 +2465,7 @@ function createNodeUI(options = {}) {
|
|
|
1494
2465
|
let sseClients = 0;
|
|
1495
2466
|
function stopAll() {
|
|
1496
2467
|
for (const id of activeProviders) {
|
|
1497
|
-
registry.get(id)?.stop?.(ctx);
|
|
2468
|
+
if (!pinned.has(id)) registry.get(id)?.stop?.(ctx);
|
|
1498
2469
|
}
|
|
1499
2470
|
activeProviders.clear();
|
|
1500
2471
|
if (inactivityTimer) {
|
|
@@ -1666,7 +2637,7 @@ function createNodeUI(options = {}) {
|
|
|
1666
2637
|
}
|
|
1667
2638
|
function serveStatic(req, res, urlPath) {
|
|
1668
2639
|
const relative = urlPath.slice(path.length);
|
|
1669
|
-
const asset = resolveStaticAsset(
|
|
2640
|
+
const asset = resolveStaticAsset(uiAssetsDir(), relative);
|
|
1670
2641
|
if (!asset) {
|
|
1671
2642
|
sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
|
|
1672
2643
|
return;
|
|
@@ -1727,21 +2698,37 @@ function createNodeUI(options = {}) {
|
|
|
1727
2698
|
ctx.store["express-router"] = router;
|
|
1728
2699
|
}
|
|
1729
2700
|
}
|
|
2701
|
+
function routePatternOf(req) {
|
|
2702
|
+
const r = req;
|
|
2703
|
+
if (typeof r.nodeuiRoute === "string") return r.nodeuiRoute;
|
|
2704
|
+
const path2 = r.route?.path;
|
|
2705
|
+
if (typeof path2 !== "string") return void 0;
|
|
2706
|
+
return `${typeof r.baseUrl === "string" ? r.baseUrl : ""}${path2}` || void 0;
|
|
2707
|
+
}
|
|
1730
2708
|
function recordAppRequest(req, res) {
|
|
1731
2709
|
const started = process.hrtime.bigint();
|
|
1732
2710
|
const timestampMs = Date.now();
|
|
2711
|
+
const requestId = requestsProvider.reserveId();
|
|
2712
|
+
bindRequestId(req, requestId);
|
|
2713
|
+
const collectDetail = captureRequestDetail(req, res, config.requestDetail, config.maskSecrets);
|
|
1733
2714
|
res.on("finish", () => {
|
|
1734
2715
|
const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
1735
|
-
requestsProvider.record(
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2716
|
+
requestsProvider.record(
|
|
2717
|
+
{
|
|
2718
|
+
method: req.method ?? "?",
|
|
2719
|
+
path: pathnameOf(req),
|
|
2720
|
+
status: res.statusCode,
|
|
2721
|
+
durationMs,
|
|
2722
|
+
timestampMs,
|
|
2723
|
+
ip: req.socket.remoteAddress ?? "unknown",
|
|
2724
|
+
route: routePatternOf(req),
|
|
2725
|
+
...collectDetail()
|
|
2726
|
+
},
|
|
2727
|
+
requestId
|
|
2728
|
+
);
|
|
1743
2729
|
metricsProvider.record(res.statusCode);
|
|
1744
2730
|
});
|
|
2731
|
+
return requestId;
|
|
1745
2732
|
}
|
|
1746
2733
|
const server = {
|
|
1747
2734
|
get config() {
|
|
@@ -1765,8 +2752,8 @@ function createNodeUI(options = {}) {
|
|
|
1765
2752
|
void handleNodeUIPath(req, res, urlPath);
|
|
1766
2753
|
return;
|
|
1767
2754
|
}
|
|
1768
|
-
recordAppRequest(req, res);
|
|
1769
|
-
next
|
|
2755
|
+
const requestId = recordAppRequest(req, res);
|
|
2756
|
+
runWithRequestId(requestId, next);
|
|
1770
2757
|
};
|
|
1771
2758
|
},
|
|
1772
2759
|
async handle(req, res) {
|
|
@@ -1794,7 +2781,35 @@ function createNodeUI(options = {}) {
|
|
|
1794
2781
|
addLogSource(entry) {
|
|
1795
2782
|
logsProvider.addSource(entry);
|
|
1796
2783
|
},
|
|
2784
|
+
recordQuery(query) {
|
|
2785
|
+
if (!config.enabled) return;
|
|
2786
|
+
queriesProvider.record({
|
|
2787
|
+
...query,
|
|
2788
|
+
sql: query.sql.slice(0, 2e3),
|
|
2789
|
+
timestampMs: Date.now() - query.durationMs,
|
|
2790
|
+
requestId: currentRequestId()
|
|
2791
|
+
});
|
|
2792
|
+
},
|
|
2793
|
+
trackPrisma(client) {
|
|
2794
|
+
if (!config.enabled) return;
|
|
2795
|
+
client.$on(
|
|
2796
|
+
"query",
|
|
2797
|
+
(e) => server.recordQuery({ system: "prisma", sql: e.query, durationMs: Number(e.duration) })
|
|
2798
|
+
);
|
|
2799
|
+
},
|
|
2800
|
+
recordError(error, context) {
|
|
2801
|
+
if (!config.enabled) return;
|
|
2802
|
+
const e = error;
|
|
2803
|
+
const status = e?.status ?? e?.statusCode;
|
|
2804
|
+
if (typeof status === "number" && status >= 400 && status < 500) return;
|
|
2805
|
+
errorsProvider.record(error, "request", context);
|
|
2806
|
+
},
|
|
1797
2807
|
shutdown() {
|
|
2808
|
+
errorsProvider.detach();
|
|
2809
|
+
persistence?.close();
|
|
2810
|
+
exporter?.close();
|
|
2811
|
+
for (const id of pinned) registry.get(id)?.stop?.(ctx);
|
|
2812
|
+
pinned.clear();
|
|
1798
2813
|
stopAll();
|
|
1799
2814
|
}
|
|
1800
2815
|
};
|
|
@@ -1806,15 +2821,19 @@ function createNodeUI(options = {}) {
|
|
|
1806
2821
|
ConfirmationStore,
|
|
1807
2822
|
CpuProvider,
|
|
1808
2823
|
DEFAULT_LOG_SIZE,
|
|
2824
|
+
DEFAULT_REQUEST_DETAIL,
|
|
1809
2825
|
EnvProvider,
|
|
2826
|
+
ErrorsProvider,
|
|
1810
2827
|
EventLoopLagProvider,
|
|
1811
2828
|
HealthProvider,
|
|
1812
2829
|
HeapSnapshotProvider,
|
|
1813
2830
|
LogsProvider,
|
|
1814
2831
|
MemoryProvider,
|
|
1815
2832
|
MetricsProvider,
|
|
2833
|
+
N_PLUS_ONE_THRESHOLD,
|
|
1816
2834
|
OutgoingProvider,
|
|
1817
2835
|
ProviderRegistry,
|
|
2836
|
+
QueriesProvider,
|
|
1818
2837
|
RequestsProvider,
|
|
1819
2838
|
RingBuffer,
|
|
1820
2839
|
RoutesProvider,
|
|
@@ -1824,19 +2843,27 @@ function createNodeUI(options = {}) {
|
|
|
1824
2843
|
StartupTracker,
|
|
1825
2844
|
createGuard,
|
|
1826
2845
|
createNodeUI,
|
|
2846
|
+
currentRequestId,
|
|
2847
|
+
enterRequestContext,
|
|
1827
2848
|
extractRoutes,
|
|
2849
|
+
fingerprintError,
|
|
1828
2850
|
hostnameFromHostHeader,
|
|
1829
2851
|
interceptConsole,
|
|
1830
2852
|
interceptOutgoing,
|
|
2853
|
+
interceptQueries,
|
|
1831
2854
|
isLoopbackAddress,
|
|
1832
2855
|
isLoopbackHostname,
|
|
2856
|
+
isValidAddressEntry,
|
|
1833
2857
|
maskSecretText,
|
|
1834
2858
|
maskSecrets,
|
|
1835
2859
|
matchesAddress,
|
|
2860
|
+
normalizeSql,
|
|
1836
2861
|
resolveActivation,
|
|
1837
2862
|
resolveStaticAsset,
|
|
2863
|
+
runInRequestContext,
|
|
1838
2864
|
serializeEnvelope,
|
|
1839
2865
|
startSse,
|
|
1840
|
-
summarizeRequests
|
|
2866
|
+
summarizeRequests,
|
|
2867
|
+
uiAssetsDir
|
|
1841
2868
|
});
|
|
1842
2869
|
//# sourceMappingURL=index.cjs.map
|