@singhak/nodeui-core 0.3.0 → 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 +6 -4
- package/dist/index.cjs +1141 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +313 -6
- package/dist/index.d.ts +313 -6
- package/dist/index.mjs +1131 -58
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
- package/static/assets/index-BrEFUVm9.css +1 -0
- package/static/assets/index-CH4-6sZB.js +12 -0
- package/static/index.html +3 -2
- package/static/assets/index-CJCpPtrr.js +0 -11
- package/static/assets/index-ChbLMdDF.css +0 -1
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,19 +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
|
-
startSse: () => startSse
|
|
78
|
+
startSse: () => startSse,
|
|
79
|
+
summarizeRequests: () => summarizeRequests,
|
|
80
|
+
uiAssetsDir: () => uiAssetsDir
|
|
68
81
|
});
|
|
69
82
|
module.exports = __toCommonJS(index_exports);
|
|
70
83
|
|
|
@@ -120,7 +133,10 @@ var BEARER_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
|
|
120
133
|
var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g;
|
|
121
134
|
function maskSecretText(text) {
|
|
122
135
|
if (text.length < 6) return text;
|
|
123
|
-
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
|
+
});
|
|
124
140
|
}
|
|
125
141
|
function resolveActivation(env) {
|
|
126
142
|
if (env.NODEUI_ENABLED === "true") {
|
|
@@ -158,21 +174,91 @@ function ipv4ToInt(ip) {
|
|
|
158
174
|
}
|
|
159
175
|
return out;
|
|
160
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
|
+
}
|
|
161
230
|
function matchesAddress(address, allowed) {
|
|
162
231
|
if (!address) return false;
|
|
163
232
|
const ip = normalizeIp(address);
|
|
164
|
-
|
|
233
|
+
const ip4 = ipv4ToInt(ip);
|
|
234
|
+
const ip6 = ip4 === null ? ipv6ToBigInt(ip) : null;
|
|
235
|
+
for (const raw of allowed) {
|
|
236
|
+
const entry = normalizeIp(raw);
|
|
165
237
|
if (!entry.includes("/")) {
|
|
166
|
-
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;
|
|
167
246
|
continue;
|
|
168
247
|
}
|
|
169
|
-
const [base, bitsRaw] = entry.split("/");
|
|
248
|
+
const [base = "", bitsRaw = ""] = entry.split("/");
|
|
249
|
+
if (!/^\d{1,3}$/.test(bitsRaw)) continue;
|
|
170
250
|
const bits = Number(bitsRaw);
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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;
|
|
176
262
|
}
|
|
177
263
|
return false;
|
|
178
264
|
}
|
|
@@ -403,6 +489,9 @@ var MIME_TYPES = {
|
|
|
403
489
|
".woff2": "font/woff2",
|
|
404
490
|
".webmanifest": "application/manifest+json"
|
|
405
491
|
};
|
|
492
|
+
function uiAssetsDir() {
|
|
493
|
+
return (0, import_node_path.resolve)(__dirname, "..", "static");
|
|
494
|
+
}
|
|
406
495
|
function resolveStaticAsset(rootDir, urlPath) {
|
|
407
496
|
const relative = urlPath === "" ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
408
497
|
const candidate = (0, import_node_path.resolve)(rootDir, relative);
|
|
@@ -657,12 +746,12 @@ var LAG_DEGRADED_MS = 200;
|
|
|
657
746
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
658
747
|
var CHECK_CACHE_MS = 5e3;
|
|
659
748
|
function withTimeout(promise, ms) {
|
|
660
|
-
return new Promise((
|
|
749
|
+
return new Promise((resolve2, reject) => {
|
|
661
750
|
const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
662
751
|
promise.then(
|
|
663
752
|
(value) => {
|
|
664
753
|
clearTimeout(timer);
|
|
665
|
-
|
|
754
|
+
resolve2(value);
|
|
666
755
|
},
|
|
667
756
|
(err) => {
|
|
668
757
|
clearTimeout(timer);
|
|
@@ -750,14 +839,355 @@ var HealthProvider = class {
|
|
|
750
839
|
}
|
|
751
840
|
};
|
|
752
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
|
+
|
|
753
1182
|
// src/providers/outgoing.ts
|
|
754
1183
|
var import_node_diagnostics_channel = __toESM(require("diagnostics_channel"));
|
|
755
1184
|
var import_node_http = __toESM(require("http"));
|
|
756
1185
|
var import_node_https = __toESM(require("https"));
|
|
757
|
-
var
|
|
758
|
-
var
|
|
759
|
-
var
|
|
1186
|
+
var import_node_module2 = require("module");
|
|
1187
|
+
var RESPONSE_PAGE_SIZE2 = 100;
|
|
1188
|
+
var recorders2 = /* @__PURE__ */ new Set();
|
|
760
1189
|
var originals = /* @__PURE__ */ new Map();
|
|
1190
|
+
var installed = /* @__PURE__ */ new Map();
|
|
761
1191
|
var undiciUnsubscribe = null;
|
|
762
1192
|
function describeTarget(defaultProtocol, args) {
|
|
763
1193
|
try {
|
|
@@ -786,8 +1216,8 @@ function describeTarget(defaultProtocol, args) {
|
|
|
786
1216
|
return null;
|
|
787
1217
|
}
|
|
788
1218
|
}
|
|
789
|
-
function
|
|
790
|
-
for (const record of
|
|
1219
|
+
function emit2(entry) {
|
|
1220
|
+
for (const record of recorders2) {
|
|
791
1221
|
try {
|
|
792
1222
|
record(entry);
|
|
793
1223
|
} catch {
|
|
@@ -799,21 +1229,24 @@ function wrapModule(mod, protocol) {
|
|
|
799
1229
|
const key = `${protocol}${name}`;
|
|
800
1230
|
const original = mod[name];
|
|
801
1231
|
originals.set(key, original);
|
|
1232
|
+
const state = { live: true };
|
|
802
1233
|
const wrapped = function(...args) {
|
|
803
1234
|
const req = original.apply(this, args);
|
|
804
|
-
const target =
|
|
1235
|
+
const target = state.live && recorders2.size > 0 && !isSuppressed() ? describeTarget(protocol, args) : null;
|
|
805
1236
|
if (target && req && typeof req.once === "function") {
|
|
806
1237
|
const started = process.hrtime.bigint();
|
|
807
1238
|
const timestampMs = Date.now();
|
|
1239
|
+
const requestId = currentRequestId();
|
|
808
1240
|
let done = false;
|
|
809
1241
|
const finish = (status, error) => {
|
|
810
1242
|
if (done) return;
|
|
811
1243
|
done = true;
|
|
812
|
-
|
|
1244
|
+
emit2({
|
|
813
1245
|
...target,
|
|
814
1246
|
status,
|
|
815
1247
|
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
816
1248
|
timestampMs,
|
|
1249
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
817
1250
|
...error ? { error } : {}
|
|
818
1251
|
});
|
|
819
1252
|
};
|
|
@@ -826,17 +1259,23 @@ function wrapModule(mod, protocol) {
|
|
|
826
1259
|
}
|
|
827
1260
|
return req;
|
|
828
1261
|
};
|
|
1262
|
+
installed.set(key, { fn: wrapped, state });
|
|
829
1263
|
mod[name] = wrapped;
|
|
830
1264
|
}
|
|
831
|
-
(0,
|
|
1265
|
+
(0, import_node_module2.syncBuiltinESMExports)();
|
|
832
1266
|
}
|
|
833
1267
|
function unwrapModule(mod, protocol) {
|
|
834
1268
|
for (const name of ["request", "get"]) {
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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)();
|
|
840
1279
|
}
|
|
841
1280
|
function subscribeUndici() {
|
|
842
1281
|
const started = /* @__PURE__ */ new WeakMap();
|
|
@@ -847,7 +1286,13 @@ function subscribeUndici() {
|
|
|
847
1286
|
});
|
|
848
1287
|
const onCreate = (message) => {
|
|
849
1288
|
const request = message.request;
|
|
850
|
-
if (request
|
|
1289
|
+
if (request && !isSuppressed()) {
|
|
1290
|
+
started.set(request, {
|
|
1291
|
+
at: process.hrtime.bigint(),
|
|
1292
|
+
ts: Date.now(),
|
|
1293
|
+
requestId: currentRequestId()
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
851
1296
|
};
|
|
852
1297
|
const onHeaders = (message) => {
|
|
853
1298
|
const { request, response } = message;
|
|
@@ -860,11 +1305,12 @@ function subscribeUndici() {
|
|
|
860
1305
|
const begin = started.get(request);
|
|
861
1306
|
if (!begin) return;
|
|
862
1307
|
started.delete(request);
|
|
863
|
-
|
|
1308
|
+
emit2({
|
|
864
1309
|
...target(request),
|
|
865
1310
|
status: statuses.get(request) ?? null,
|
|
866
1311
|
durationMs: Number(process.hrtime.bigint() - begin.at) / 1e6,
|
|
867
1312
|
timestampMs: begin.ts,
|
|
1313
|
+
...begin.requestId !== void 0 ? { requestId: begin.requestId } : {},
|
|
868
1314
|
...error ? { error } : {}
|
|
869
1315
|
});
|
|
870
1316
|
};
|
|
@@ -885,8 +1331,8 @@ function subscribeUndici() {
|
|
|
885
1331
|
};
|
|
886
1332
|
}
|
|
887
1333
|
function interceptOutgoing(record) {
|
|
888
|
-
const first =
|
|
889
|
-
|
|
1334
|
+
const first = recorders2.size === 0;
|
|
1335
|
+
recorders2.add(record);
|
|
890
1336
|
if (first) {
|
|
891
1337
|
wrapModule(import_node_http.default, "http:");
|
|
892
1338
|
wrapModule(import_node_https.default, "https:");
|
|
@@ -897,7 +1343,7 @@ function interceptOutgoing(record) {
|
|
|
897
1343
|
}
|
|
898
1344
|
}
|
|
899
1345
|
return () => {
|
|
900
|
-
if (!
|
|
1346
|
+
if (!recorders2.delete(record) || recorders2.size > 0) return;
|
|
901
1347
|
unwrapModule(import_node_http.default, "http:");
|
|
902
1348
|
unwrapModule(import_node_https.default, "https:");
|
|
903
1349
|
undiciUnsubscribe?.();
|
|
@@ -910,6 +1356,8 @@ var OutgoingProvider = class {
|
|
|
910
1356
|
nextId = 1;
|
|
911
1357
|
failed = 0;
|
|
912
1358
|
release = null;
|
|
1359
|
+
/** Called with every newly recorded entry (persistence hook). */
|
|
1360
|
+
onRecord;
|
|
913
1361
|
constructor(size) {
|
|
914
1362
|
this.buffer = new RingBuffer(size);
|
|
915
1363
|
}
|
|
@@ -922,12 +1370,22 @@ var OutgoingProvider = class {
|
|
|
922
1370
|
this.release = null;
|
|
923
1371
|
}
|
|
924
1372
|
record(entry) {
|
|
925
|
-
|
|
1373
|
+
const full = { id: this.nextId, ...entry };
|
|
1374
|
+
this.buffer.push(full);
|
|
926
1375
|
this.nextId += 1;
|
|
927
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
|
+
}
|
|
928
1386
|
}
|
|
929
1387
|
get() {
|
|
930
|
-
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));
|
|
931
1389
|
return { ok: true, data: { total: this.nextId - 1, failed: this.failed, entries } };
|
|
932
1390
|
}
|
|
933
1391
|
};
|
|
@@ -935,10 +1393,10 @@ var OutgoingProvider = class {
|
|
|
935
1393
|
// src/providers/heap-snapshot.ts
|
|
936
1394
|
var import_promises = require("fs/promises");
|
|
937
1395
|
var import_node_os2 = require("os");
|
|
938
|
-
var
|
|
1396
|
+
var import_node_path3 = require("path");
|
|
939
1397
|
var import_node_v8 = require("v8");
|
|
940
1398
|
function defaultSnapshotDir() {
|
|
941
|
-
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");
|
|
942
1400
|
}
|
|
943
1401
|
var HeapSnapshotProvider = class {
|
|
944
1402
|
id = "heap-snapshot";
|
|
@@ -950,7 +1408,7 @@ var HeapSnapshotProvider = class {
|
|
|
950
1408
|
const dir = ctx.config.heapSnapshotDir || defaultSnapshotDir();
|
|
951
1409
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
952
1410
|
const fileName = `nodeui-heap-${process.pid}-${stamp}.heapsnapshot`;
|
|
953
|
-
const filePath = (0,
|
|
1411
|
+
const filePath = (0, import_node_path3.join)(dir, fileName);
|
|
954
1412
|
try {
|
|
955
1413
|
await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
|
|
956
1414
|
(0, import_node_v8.writeHeapSnapshot)(filePath);
|
|
@@ -977,24 +1435,89 @@ var HeapSnapshotProvider = class {
|
|
|
977
1435
|
};
|
|
978
1436
|
|
|
979
1437
|
// src/providers/requests.ts
|
|
980
|
-
var
|
|
1438
|
+
var RESPONSE_PAGE_SIZE3 = 100;
|
|
1439
|
+
var MAX_ROUTE_STATS = 8;
|
|
1440
|
+
function percentile(sorted, p) {
|
|
1441
|
+
if (sorted.length === 0) return 0;
|
|
1442
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
|
|
1443
|
+
return sorted[index] ?? 0;
|
|
1444
|
+
}
|
|
1445
|
+
function routeKey(path) {
|
|
1446
|
+
return path.split("/").map(
|
|
1447
|
+
(seg) => /^\d+$/.test(seg) || /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(seg) || /^[0-9a-f]{24}$/i.test(seg) ? ":id" : seg
|
|
1448
|
+
).join("/");
|
|
1449
|
+
}
|
|
1450
|
+
function summarizeRequests(entries) {
|
|
1451
|
+
const durations = entries.map((e) => e.durationMs).sort((a, b) => a - b);
|
|
1452
|
+
const byStatus = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0 };
|
|
1453
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1454
|
+
for (const e of entries) {
|
|
1455
|
+
const bucket = `${Math.floor(e.status / 100)}xx`;
|
|
1456
|
+
if (bucket in byStatus) byStatus[bucket] += 1;
|
|
1457
|
+
const path = e.route ?? routeKey(e.path);
|
|
1458
|
+
const key = `${e.method} ${path}`;
|
|
1459
|
+
const group = groups.get(key) ?? { method: e.method, path, ms: [], errors: 0 };
|
|
1460
|
+
group.ms.push(e.durationMs);
|
|
1461
|
+
if (e.status >= 500) group.errors += 1;
|
|
1462
|
+
groups.set(key, group);
|
|
1463
|
+
}
|
|
1464
|
+
const routes = [...groups.values()].map((g) => {
|
|
1465
|
+
const sorted = [...g.ms].sort((a, b) => a - b);
|
|
1466
|
+
return {
|
|
1467
|
+
method: g.method,
|
|
1468
|
+
path: g.path,
|
|
1469
|
+
count: g.ms.length,
|
|
1470
|
+
avgMs: g.ms.reduce((a, b) => a + b, 0) / g.ms.length,
|
|
1471
|
+
p95Ms: percentile(sorted, 0.95),
|
|
1472
|
+
errors: g.errors
|
|
1473
|
+
};
|
|
1474
|
+
}).sort((a, b) => b.p95Ms - a.p95Ms).slice(0, MAX_ROUTE_STATS);
|
|
1475
|
+
return {
|
|
1476
|
+
count: entries.length,
|
|
1477
|
+
errors: byStatus["5xx"],
|
|
1478
|
+
errorRate: entries.length === 0 ? 0 : byStatus["5xx"] / entries.length,
|
|
1479
|
+
avgMs: durations.length === 0 ? 0 : durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
1480
|
+
p50Ms: percentile(durations, 0.5),
|
|
1481
|
+
p95Ms: percentile(durations, 0.95),
|
|
1482
|
+
p99Ms: percentile(durations, 0.99),
|
|
1483
|
+
byStatus,
|
|
1484
|
+
routes
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
981
1487
|
var RequestsProvider = class {
|
|
982
1488
|
id = "requests";
|
|
983
1489
|
buffer;
|
|
984
1490
|
nextId = 1;
|
|
1491
|
+
/** Called with every newly recorded entry (persistence hook). */
|
|
1492
|
+
onRecord;
|
|
985
1493
|
constructor(size) {
|
|
986
1494
|
this.buffer = new RingBuffer(size);
|
|
987
1495
|
}
|
|
988
|
-
|
|
989
|
-
|
|
1496
|
+
/** Reserves an id up front so in-flight work can be attributed to the request. */
|
|
1497
|
+
reserveId() {
|
|
1498
|
+
const id = this.nextId;
|
|
990
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
|
+
}
|
|
991
1513
|
}
|
|
992
1514
|
get length() {
|
|
993
1515
|
return this.buffer.length;
|
|
994
1516
|
}
|
|
995
1517
|
get() {
|
|
996
|
-
const entries = this.buffer.slice(Math.max(0, this.buffer.length -
|
|
997
|
-
|
|
1518
|
+
const entries = this.buffer.slice(Math.max(0, this.buffer.length - RESPONSE_PAGE_SIZE3));
|
|
1519
|
+
const summary = summarizeRequests(this.buffer.toArray());
|
|
1520
|
+
return { ok: true, data: { total: this.buffer.length, entries, summary } };
|
|
998
1521
|
}
|
|
999
1522
|
};
|
|
1000
1523
|
|
|
@@ -1172,7 +1695,7 @@ function installWrappers() {
|
|
|
1172
1695
|
const wrapper = (...args) => {
|
|
1173
1696
|
for (const push of pushes) {
|
|
1174
1697
|
try {
|
|
1175
|
-
push({ level, message: args.map(formatArg).join(" ") });
|
|
1698
|
+
push({ level, message: args.map(formatArg).join(" "), requestId: currentRequestId() });
|
|
1176
1699
|
} catch {
|
|
1177
1700
|
}
|
|
1178
1701
|
}
|
|
@@ -1216,8 +1739,13 @@ var LogsProvider = class {
|
|
|
1216
1739
|
}
|
|
1217
1740
|
start() {
|
|
1218
1741
|
if (this.release) return;
|
|
1219
|
-
this.release = interceptConsole(({ level, message }) => {
|
|
1220
|
-
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
|
+
});
|
|
1221
1749
|
});
|
|
1222
1750
|
}
|
|
1223
1751
|
stop() {
|
|
@@ -1241,6 +1769,160 @@ var LogsProvider = class {
|
|
|
1241
1769
|
}
|
|
1242
1770
|
};
|
|
1243
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
|
+
|
|
1244
1926
|
// src/sse.ts
|
|
1245
1927
|
function startSse(res, extraHeaders = {}) {
|
|
1246
1928
|
let closed = false;
|
|
@@ -1273,8 +1955,260 @@ function startSse(res, extraHeaders = {}) {
|
|
|
1273
1955
|
|
|
1274
1956
|
// src/server.ts
|
|
1275
1957
|
var import_node_os3 = require("os");
|
|
1276
|
-
var
|
|
1277
|
-
|
|
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
|
|
1278
2212
|
var SSE_HEARTBEAT_MS = 15e3;
|
|
1279
2213
|
function serializeEnvelope(envelope, mask = true) {
|
|
1280
2214
|
return JSON.stringify(mask ? maskSecrets(envelope) : envelope);
|
|
@@ -1291,7 +2225,9 @@ var BUILT_IN_PANELS = /* @__PURE__ */ new Set([
|
|
|
1291
2225
|
"routes",
|
|
1292
2226
|
"logs",
|
|
1293
2227
|
"metrics",
|
|
1294
|
-
"outgoing"
|
|
2228
|
+
"outgoing",
|
|
2229
|
+
"errors",
|
|
2230
|
+
"queries"
|
|
1295
2231
|
]);
|
|
1296
2232
|
var PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
1297
2233
|
var RESERVED_API_NAMES = /* @__PURE__ */ new Set(["config", "live", "confirmations"]);
|
|
@@ -1306,6 +2242,13 @@ function listFrom(value, raw) {
|
|
|
1306
2242
|
if (value) return value;
|
|
1307
2243
|
return (raw ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
1308
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
|
+
}
|
|
1309
2252
|
function pathnameOf(req) {
|
|
1310
2253
|
const raw = req.originalUrl ?? req.url ?? "/";
|
|
1311
2254
|
try {
|
|
@@ -1319,6 +2262,13 @@ function normalizePath(value) {
|
|
|
1319
2262
|
if (!trimmed.startsWith("/")) throw new Error(`NODEUI_PATH must start with "/", got "${value}"`);
|
|
1320
2263
|
return trimmed.replace(/\/+$/, "") || "/";
|
|
1321
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
|
+
}
|
|
1322
2272
|
function positiveInt(value, fallback, name) {
|
|
1323
2273
|
const n = value === void 0 ? fallback : value;
|
|
1324
2274
|
if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer`);
|
|
@@ -1354,6 +2304,18 @@ function createNodeUI(options = {}) {
|
|
|
1354
2304
|
enabled: activation.active,
|
|
1355
2305
|
activationReason: activation.reason,
|
|
1356
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
|
+
),
|
|
1357
2319
|
inactivityTimeoutMs: positiveInt(
|
|
1358
2320
|
options.inactivityTimeoutMs,
|
|
1359
2321
|
env.NODEUI_INACTIVITY_TIMEOUT_MS ? Number(env.NODEUI_INACTIVITY_TIMEOUT_MS) : 6e4,
|
|
@@ -1364,7 +2326,7 @@ function createNodeUI(options = {}) {
|
|
|
1364
2326
|
env.NODEUI_CONFIRM_TTL_MS ? Number(env.NODEUI_CONFIRM_TTL_MS) : 6e4,
|
|
1365
2327
|
"NODEUI_CONFIRM_TTL_MS"
|
|
1366
2328
|
),
|
|
1367
|
-
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"),
|
|
1368
2330
|
allowedHosts: listFrom(options.allowedHosts, env.NODEUI_ALLOWED_HOSTS),
|
|
1369
2331
|
allowedOrigins: listFrom(options.allowedOrigins, env.NODEUI_ALLOWED_ORIGINS),
|
|
1370
2332
|
allowedRemoteAddresses: listFrom(options.allowedRemoteAddresses, env.NODEUI_ALLOWED_REMOTE),
|
|
@@ -1395,6 +2357,15 @@ function createNodeUI(options = {}) {
|
|
|
1395
2357
|
"[nodeui] running with NODE_ENV=production. The console exposes environment, logs and heap data; keep it behind loopback and an auth token."
|
|
1396
2358
|
);
|
|
1397
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
|
+
}
|
|
1398
2369
|
if (config.enabled && !config.maskSecrets) {
|
|
1399
2370
|
console.warn("[nodeui] secret masking is disabled; panels may show credentials.");
|
|
1400
2371
|
}
|
|
@@ -1425,9 +2396,59 @@ function createNodeUI(options = {}) {
|
|
|
1425
2396
|
registry.register(envProvider);
|
|
1426
2397
|
const routesProvider = new RoutesProvider();
|
|
1427
2398
|
registry.register(routesProvider);
|
|
1428
|
-
|
|
1429
|
-
|
|
2399
|
+
const outgoingProvider = new OutgoingProvider(
|
|
2400
|
+
positiveInt(options.outgoingLogSize, 200, "outgoingLogSize")
|
|
1430
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
|
+
}
|
|
1431
2452
|
const pluginMeta = [];
|
|
1432
2453
|
for (const plugin of options.plugins ?? []) {
|
|
1433
2454
|
if (!PLUGIN_ID_PATTERN.test(plugin.id) || BUILT_IN_PANELS.has(plugin.id) || RESERVED_API_NAMES.has(plugin.id) || registry.get(plugin.id)) {
|
|
@@ -1444,7 +2465,7 @@ function createNodeUI(options = {}) {
|
|
|
1444
2465
|
let sseClients = 0;
|
|
1445
2466
|
function stopAll() {
|
|
1446
2467
|
for (const id of activeProviders) {
|
|
1447
|
-
registry.get(id)?.stop?.(ctx);
|
|
2468
|
+
if (!pinned.has(id)) registry.get(id)?.stop?.(ctx);
|
|
1448
2469
|
}
|
|
1449
2470
|
activeProviders.clear();
|
|
1450
2471
|
if (inactivityTimer) {
|
|
@@ -1516,7 +2537,7 @@ function createNodeUI(options = {}) {
|
|
|
1516
2537
|
pollIntervalMs: config.pollIntervalMs,
|
|
1517
2538
|
panels: registry.ids(),
|
|
1518
2539
|
masking: { enabled: config.maskSecrets, pattern: SECRET_KEY_PATTERN.source },
|
|
1519
|
-
|
|
2540
|
+
locked: config.authRequired,
|
|
1520
2541
|
plugins: pluginMeta
|
|
1521
2542
|
};
|
|
1522
2543
|
}
|
|
@@ -1616,7 +2637,7 @@ function createNodeUI(options = {}) {
|
|
|
1616
2637
|
}
|
|
1617
2638
|
function serveStatic(req, res, urlPath) {
|
|
1618
2639
|
const relative = urlPath.slice(path.length);
|
|
1619
|
-
const asset = resolveStaticAsset(
|
|
2640
|
+
const asset = resolveStaticAsset(uiAssetsDir(), relative);
|
|
1620
2641
|
if (!asset) {
|
|
1621
2642
|
sendJson(res, 404, { ok: false, error: { code: "not-found", message: "Not found" } });
|
|
1622
2643
|
return;
|
|
@@ -1677,21 +2698,37 @@ function createNodeUI(options = {}) {
|
|
|
1677
2698
|
ctx.store["express-router"] = router;
|
|
1678
2699
|
}
|
|
1679
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
|
+
}
|
|
1680
2708
|
function recordAppRequest(req, res) {
|
|
1681
2709
|
const started = process.hrtime.bigint();
|
|
1682
2710
|
const timestampMs = Date.now();
|
|
2711
|
+
const requestId = requestsProvider.reserveId();
|
|
2712
|
+
bindRequestId(req, requestId);
|
|
2713
|
+
const collectDetail = captureRequestDetail(req, res, config.requestDetail, config.maskSecrets);
|
|
1683
2714
|
res.on("finish", () => {
|
|
1684
2715
|
const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
1685
|
-
requestsProvider.record(
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
+
);
|
|
1693
2729
|
metricsProvider.record(res.statusCode);
|
|
1694
2730
|
});
|
|
2731
|
+
return requestId;
|
|
1695
2732
|
}
|
|
1696
2733
|
const server = {
|
|
1697
2734
|
get config() {
|
|
@@ -1715,8 +2752,8 @@ function createNodeUI(options = {}) {
|
|
|
1715
2752
|
void handleNodeUIPath(req, res, urlPath);
|
|
1716
2753
|
return;
|
|
1717
2754
|
}
|
|
1718
|
-
recordAppRequest(req, res);
|
|
1719
|
-
next
|
|
2755
|
+
const requestId = recordAppRequest(req, res);
|
|
2756
|
+
runWithRequestId(requestId, next);
|
|
1720
2757
|
};
|
|
1721
2758
|
},
|
|
1722
2759
|
async handle(req, res) {
|
|
@@ -1744,7 +2781,35 @@ function createNodeUI(options = {}) {
|
|
|
1744
2781
|
addLogSource(entry) {
|
|
1745
2782
|
logsProvider.addSource(entry);
|
|
1746
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
|
+
},
|
|
1747
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();
|
|
1748
2813
|
stopAll();
|
|
1749
2814
|
}
|
|
1750
2815
|
};
|
|
@@ -1756,15 +2821,19 @@ function createNodeUI(options = {}) {
|
|
|
1756
2821
|
ConfirmationStore,
|
|
1757
2822
|
CpuProvider,
|
|
1758
2823
|
DEFAULT_LOG_SIZE,
|
|
2824
|
+
DEFAULT_REQUEST_DETAIL,
|
|
1759
2825
|
EnvProvider,
|
|
2826
|
+
ErrorsProvider,
|
|
1760
2827
|
EventLoopLagProvider,
|
|
1761
2828
|
HealthProvider,
|
|
1762
2829
|
HeapSnapshotProvider,
|
|
1763
2830
|
LogsProvider,
|
|
1764
2831
|
MemoryProvider,
|
|
1765
2832
|
MetricsProvider,
|
|
2833
|
+
N_PLUS_ONE_THRESHOLD,
|
|
1766
2834
|
OutgoingProvider,
|
|
1767
2835
|
ProviderRegistry,
|
|
2836
|
+
QueriesProvider,
|
|
1768
2837
|
RequestsProvider,
|
|
1769
2838
|
RingBuffer,
|
|
1770
2839
|
RoutesProvider,
|
|
@@ -1774,18 +2843,27 @@ function createNodeUI(options = {}) {
|
|
|
1774
2843
|
StartupTracker,
|
|
1775
2844
|
createGuard,
|
|
1776
2845
|
createNodeUI,
|
|
2846
|
+
currentRequestId,
|
|
2847
|
+
enterRequestContext,
|
|
1777
2848
|
extractRoutes,
|
|
2849
|
+
fingerprintError,
|
|
1778
2850
|
hostnameFromHostHeader,
|
|
1779
2851
|
interceptConsole,
|
|
1780
2852
|
interceptOutgoing,
|
|
2853
|
+
interceptQueries,
|
|
1781
2854
|
isLoopbackAddress,
|
|
1782
2855
|
isLoopbackHostname,
|
|
2856
|
+
isValidAddressEntry,
|
|
1783
2857
|
maskSecretText,
|
|
1784
2858
|
maskSecrets,
|
|
1785
2859
|
matchesAddress,
|
|
2860
|
+
normalizeSql,
|
|
1786
2861
|
resolveActivation,
|
|
1787
2862
|
resolveStaticAsset,
|
|
2863
|
+
runInRequestContext,
|
|
1788
2864
|
serializeEnvelope,
|
|
1789
|
-
startSse
|
|
2865
|
+
startSse,
|
|
2866
|
+
summarizeRequests,
|
|
2867
|
+
uiAssetsDir
|
|
1790
2868
|
});
|
|
1791
2869
|
//# sourceMappingURL=index.cjs.map
|