@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/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, `$1$2${SECRET_MASKED}`);
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
- for (const entry of allowed) {
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 (normalizeIp(entry) === ip) return true;
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 a = ipv4ToInt(ip);
173
- const b = ipv4ToInt(base ?? "");
174
- if (a === null || b === null || !Number.isInteger(bits) || bits < 0 || bits > 32) continue;
175
- const mask = bits === 0 ? 0 : 4294967295 << 32 - bits >>> 0;
176
- if ((a & mask) >>> 0 === (b & mask) >>> 0) return true;
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((resolve3, reject) => {
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
- resolve3(value);
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 import_node_module = require("module");
759
- var RESPONSE_PAGE_SIZE = 100;
760
- var recorders = /* @__PURE__ */ new Set();
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 emit(entry) {
791
- for (const record of recorders) {
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 = recorders.size > 0 ? describeTarget(protocol, args) : null;
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
- emit({
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, import_node_module.syncBuiltinESMExports)();
1265
+ (0, import_node_module2.syncBuiltinESMExports)();
833
1266
  }
834
1267
  function unwrapModule(mod, protocol) {
835
1268
  for (const name of ["request", "get"]) {
836
- const original = originals.get(`${protocol}${name}`);
837
- if (original) mod[name] = original;
838
- originals.delete(`${protocol}${name}`);
839
- }
840
- (0, import_node_module.syncBuiltinESMExports)();
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) started.set(request, { at: process.hrtime.bigint(), ts: Date.now() });
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
- emit({
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 = recorders.size === 0;
890
- recorders.add(record);
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 (!recorders.delete(record) || recorders.size > 0) return;
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
- this.buffer.push({ id: this.nextId, ...entry });
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 - RESPONSE_PAGE_SIZE));
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 import_node_path2 = require("path");
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, import_node_path2.join)((0, import_node_os2.tmpdir)(), "nodeui-heap");
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, import_node_path2.join)(dir, fileName);
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 RESPONSE_PAGE_SIZE2 = 100;
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
- record(entry) {
1038
- this.buffer.push({ id: this.nextId, ...entry });
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 - RESPONSE_PAGE_SIZE2));
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({ level, message, timestamp: Date.now() });
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 import_node_path3 = require("path");
1327
- var STATIC_ROOT = (0, import_node_path3.resolve)(__dirname, "..", "static");
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, import_node_path3.join)((0, import_node_os3.tmpdir)(), "nodeui-heap"),
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
- registry.register(
1479
- new OutgoingProvider(positiveInt(options.outgoingLogSize, 200, "outgoingLogSize"))
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(STATIC_ROOT, relative);
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
- method: req.method ?? "?",
1737
- path: pathnameOf(req),
1738
- status: res.statusCode,
1739
- durationMs,
1740
- timestampMs,
1741
- ip: req.socket.remoteAddress ?? "unknown"
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