silgi 0.1.0-beta.13 → 0.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import { ValidationError } from "../core/schema.mjs";
2
2
  import { SilgiError, toSilgiError } from "../core/error.mjs";
3
+ import { AlertEngine } from "./analytics-alerts.mjs";
4
+ import { CostTracker } from "./analytics-cost.mjs";
5
+ import { parseQueryParams, queryErrors, queryRequests, queryTasks } from "./analytics-query.mjs";
6
+ import { AnalyticsSSEHub } from "./analytics-sse.mjs";
7
+ import { TimeSeriesAggregator } from "./analytics-timeseries.mjs";
3
8
  import { useStorage } from "../core/storage.mjs";
4
9
  import { readFileSync } from "node:fs";
5
10
  import { dirname, resolve } from "node:path";
@@ -52,6 +57,158 @@ var RingBuffer = class {
52
57
  return this.#count;
53
58
  }
54
59
  };
60
+ const REDACTED = "[REDACTED]";
61
+ const SENSITIVE_HEADER_KEYS = new Set([
62
+ "authorization",
63
+ "cookie",
64
+ "set-cookie",
65
+ "x-api-key",
66
+ "x-auth-token",
67
+ "proxy-authorization"
68
+ ]);
69
+ function shouldRedactSensitiveData() {
70
+ return process.env.NODE_ENV === "production";
71
+ }
72
+ function redactHeaderValue(key, value) {
73
+ return shouldRedactSensitiveData() && SENSITIVE_HEADER_KEYS.has(key.toLowerCase()) ? REDACTED : value;
74
+ }
75
+ function asRecord(value) {
76
+ return value && typeof value === "object" ? value : null;
77
+ }
78
+ function normalizeString(value, fallback = "") {
79
+ return typeof value === "string" ? value : fallback;
80
+ }
81
+ function normalizeNumber(value, fallback = 0) {
82
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
83
+ }
84
+ function normalizeBoolean(value, fallback = false) {
85
+ return typeof value === "boolean" ? value : fallback;
86
+ }
87
+ function normalizeStringMap(value) {
88
+ const record = asRecord(value);
89
+ if (!record) return {};
90
+ const result = {};
91
+ for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") result[key] = raw;
92
+ return result;
93
+ }
94
+ function normalizeSpanKind(value) {
95
+ switch (value) {
96
+ case "db":
97
+ case "http":
98
+ case "cache":
99
+ case "queue":
100
+ case "email":
101
+ case "ai":
102
+ case "custom": return value;
103
+ default: return "custom";
104
+ }
105
+ }
106
+ function normalizeTraceSpans(value) {
107
+ if (!Array.isArray(value)) return [];
108
+ const spans = [];
109
+ for (const entry of value) {
110
+ const span = asRecord(entry);
111
+ if (!span) continue;
112
+ const attributes = asRecord(span.attributes);
113
+ const normalizedAttributes = {};
114
+ if (attributes) {
115
+ for (const [key, raw] of Object.entries(attributes)) if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") normalizedAttributes[key] = raw;
116
+ }
117
+ spans.push({
118
+ name: normalizeString(span.name, "unknown"),
119
+ kind: normalizeSpanKind(span.kind),
120
+ durationMs: normalizeNumber(span.durationMs),
121
+ startOffsetMs: typeof span.startOffsetMs === "number" ? span.startOffsetMs : void 0,
122
+ detail: typeof span.detail === "string" ? span.detail : void 0,
123
+ input: span.input,
124
+ output: span.output,
125
+ error: typeof span.error === "string" ? span.error : void 0,
126
+ attributes: Object.keys(normalizedAttributes).length > 0 ? normalizedAttributes : void 0
127
+ });
128
+ }
129
+ return spans;
130
+ }
131
+ function inferPathFromUrl(url) {
132
+ if (!url) return "";
133
+ try {
134
+ return new URL(url).pathname || "";
135
+ } catch {
136
+ return "";
137
+ }
138
+ }
139
+ function normalizeProcedureCall(value, fallback) {
140
+ const record = asRecord(value);
141
+ return {
142
+ procedure: normalizeString(record?.procedure, fallback.path.replace(/^\//, "") || fallback.path || "request"),
143
+ durationMs: normalizeNumber(record?.durationMs, fallback.durationMs),
144
+ status: normalizeNumber(record?.status, fallback.status),
145
+ input: record?.input ?? null,
146
+ output: record?.output ?? null,
147
+ spans: normalizeTraceSpans(record?.spans),
148
+ error: typeof record?.error === "string" ? record.error : void 0
149
+ };
150
+ }
151
+ function normalizeRequestEntry(value, fallbackId) {
152
+ const record = asRecord(value);
153
+ if (!record) return null;
154
+ const url = normalizeString(record.url);
155
+ const path = normalizeString(record.path, inferPathFromUrl(url));
156
+ const method = normalizeString(record.method, "GET");
157
+ const status = normalizeNumber(record.status, 200);
158
+ const durationMs = normalizeNumber(record.durationMs);
159
+ const procedureFallback = {
160
+ path: path || "/",
161
+ durationMs,
162
+ status
163
+ };
164
+ const proceduresRaw = Array.isArray(record.procedures) ? record.procedures : [];
165
+ const procedures = proceduresRaw.length > 0 ? proceduresRaw.map((entry) => normalizeProcedureCall(entry, procedureFallback)) : [normalizeProcedureCall(null, procedureFallback)];
166
+ return {
167
+ id: normalizeNumber(record.id, fallbackId),
168
+ requestId: normalizeString(record.requestId, String(normalizeNumber(record.id, fallbackId))),
169
+ sessionId: normalizeString(record.sessionId),
170
+ timestamp: normalizeNumber(record.timestamp),
171
+ durationMs,
172
+ method,
173
+ url: url || path,
174
+ path,
175
+ ip: normalizeString(record.ip),
176
+ headers: normalizeStringMap(record.headers),
177
+ responseHeaders: normalizeStringMap(record.responseHeaders),
178
+ userAgent: normalizeString(record.userAgent),
179
+ status,
180
+ procedures,
181
+ isBatch: normalizeBoolean(record.isBatch, procedures.length > 1),
182
+ traceId: normalizeString(record.traceId) || void 0,
183
+ parentRequestId: normalizeString(record.parentRequestId) || void 0
184
+ };
185
+ }
186
+ function normalizeErrorEntry(value, fallbackId) {
187
+ const record = asRecord(value);
188
+ if (!record) return null;
189
+ return {
190
+ id: normalizeNumber(record.id, fallbackId),
191
+ requestId: normalizeString(record.requestId),
192
+ timestamp: normalizeNumber(record.timestamp),
193
+ procedure: normalizeString(record.procedure, "request"),
194
+ error: normalizeString(record.error, "Unknown error"),
195
+ code: normalizeString(record.code, "INTERNAL_SERVER_ERROR"),
196
+ status: normalizeNumber(record.status, 500),
197
+ stack: normalizeString(record.stack),
198
+ input: record.input ?? null,
199
+ headers: normalizeStringMap(record.headers),
200
+ durationMs: normalizeNumber(record.durationMs),
201
+ spans: normalizeTraceSpans(record.spans)
202
+ };
203
+ }
204
+ function normalizeRequestEntries(value) {
205
+ if (!Array.isArray(value)) return [];
206
+ return value.map((entry, index) => normalizeRequestEntry(entry, index + 1)).filter((entry) => entry !== null);
207
+ }
208
+ function normalizeErrorEntries(value) {
209
+ if (!Array.isArray(value)) return [];
210
+ return value.map((entry, index) => normalizeErrorEntry(entry, index + 1)).filter((entry) => entry !== null);
211
+ }
55
212
  let _lastTime = 0;
56
213
  let _counter = 0;
57
214
  function generateRequestId() {
@@ -147,14 +304,12 @@ var AnalyticsStore = class {
147
304
  #storage;
148
305
  #pendingRequests = [];
149
306
  #pendingErrors = [];
150
- #maxRequests;
151
- #maxErrors;
307
+ #retentionMs;
152
308
  #timer = null;
153
309
  #flushing = false;
154
- constructor(maxRequests, maxErrors, flushInterval) {
310
+ constructor(flushInterval, retentionDays) {
155
311
  this.#storage = useStorage("data");
156
- this.#maxRequests = maxRequests;
157
- this.#maxErrors = maxErrors;
312
+ this.#retentionMs = retentionDays * 864e5;
158
313
  this.#timer = setInterval(() => this.flush(), flushInterval);
159
314
  if (typeof this.#timer === "object" && "unref" in this.#timer) this.#timer.unref();
160
315
  }
@@ -171,12 +326,13 @@ var AnalyticsStore = class {
171
326
  if (requests.length === 0 && errors.length === 0) return;
172
327
  this.#flushing = true;
173
328
  try {
329
+ const cutoff = Date.now() - this.#retentionMs;
174
330
  if (requests.length > 0) {
175
- const merged = [...await this.#storage.getItem("analytics:requests") ?? [], ...requests].slice(-this.#maxRequests);
331
+ const merged = [...normalizeRequestEntries(await this.#storage.getItem("analytics:requests")).filter((entry) => isTrackedRequestPath(entry.path)), ...requests].filter((e) => e.timestamp >= cutoff);
176
332
  await this.#storage.setItem("analytics:requests", merged);
177
333
  }
178
334
  if (errors.length > 0) {
179
- const merged = [...await this.#storage.getItem("analytics:errors") ?? [], ...errors].slice(-this.#maxErrors);
335
+ const merged = [...normalizeErrorEntries(await this.#storage.getItem("analytics:errors")).filter((entry) => isTrackedRequestPath(entry.procedure)), ...errors].filter((e) => e.timestamp >= cutoff);
180
336
  await this.#storage.setItem("analytics:errors", merged);
181
337
  }
182
338
  } catch {
@@ -187,14 +343,18 @@ var AnalyticsStore = class {
187
343
  }
188
344
  }
189
345
  async getRequests() {
190
- const stored = await this.#storage.getItem("analytics:requests") ?? [];
191
- if (this.#pendingRequests.length === 0) return stored;
192
- return [...stored, ...this.#pendingRequests].slice(-this.#maxRequests);
346
+ const cutoff = Date.now() - this.#retentionMs;
347
+ const stored = normalizeRequestEntries(await this.#storage.getItem("analytics:requests")).filter((entry) => isTrackedRequestPath(entry.path) && entry.timestamp >= cutoff);
348
+ const pending = this.#pendingRequests.filter((entry) => isTrackedRequestPath(entry.path) && entry.timestamp >= cutoff);
349
+ if (pending.length === 0) return stored;
350
+ return [...stored, ...pending];
193
351
  }
194
352
  async getErrors() {
195
- const stored = await this.#storage.getItem("analytics:errors") ?? [];
196
- if (this.#pendingErrors.length === 0) return stored;
197
- return [...stored, ...this.#pendingErrors].slice(-this.#maxErrors);
353
+ const cutoff = Date.now() - this.#retentionMs;
354
+ const stored = normalizeErrorEntries(await this.#storage.getItem("analytics:errors")).filter((entry) => isTrackedRequestPath(entry.procedure) && entry.timestamp >= cutoff);
355
+ const pending = this.#pendingErrors.filter((entry) => isTrackedRequestPath(entry.procedure) && entry.timestamp >= cutoff);
356
+ if (pending.length === 0) return stored;
357
+ return [...stored, ...pending];
198
358
  }
199
359
  async hydrate() {
200
360
  try {
@@ -217,12 +377,27 @@ var AnalyticsStore = class {
217
377
  });
218
378
  } catch {}
219
379
  }
380
+ async loadHiddenPaths() {
381
+ try {
382
+ const paths = await this.#storage.getItem("analytics:hiddenPaths");
383
+ return Array.isArray(paths) ? paths : [];
384
+ } catch {
385
+ return [];
386
+ }
387
+ }
388
+ saveHiddenPaths(paths) {
389
+ this.#storage.setItem("analytics:hiddenPaths", paths).catch(() => {});
390
+ }
220
391
  async dispose() {
221
392
  if (this.#timer) clearInterval(this.#timer);
222
393
  this.#timer = null;
223
394
  await this.flush();
224
395
  }
225
396
  };
397
+ /** Internal in-memory buffer caps — not user-configurable */
398
+ const MEM_MAX_REQUESTS = 1e4;
399
+ const MEM_MAX_ERRORS = 1e4;
400
+ const MEM_MAX_TASKS = 1e4;
226
401
  var AnalyticsCollector = class {
227
402
  #procedures = /* @__PURE__ */ new Map();
228
403
  #startTime = Date.now();
@@ -230,9 +405,6 @@ var AnalyticsCollector = class {
230
405
  #totalErrors = 0;
231
406
  #bufferSize;
232
407
  #historySeconds;
233
- #maxErrors;
234
- #maxRequests;
235
- #maxTasks;
236
408
  #timeSeries = [];
237
409
  #currentWindow;
238
410
  #errors = [];
@@ -244,22 +416,65 @@ var AnalyticsCollector = class {
244
416
  #taskStats = /* @__PURE__ */ new Map();
245
417
  #store;
246
418
  #counterFlushCounter = 0;
419
+ /** Server-side ignore — from config, prevents recording entirely */
420
+ #ignorePaths;
421
+ /** Client-side hide — from dashboard, filters display only */
422
+ #hiddenPaths = /* @__PURE__ */ new Set();
423
+ /** SSE hub for real-time streaming */
424
+ sseHub;
425
+ /** Multi-tier time-series aggregation */
426
+ timeseries;
427
+ /** Alert engine */
428
+ alertEngine = null;
429
+ /** Cost tracker */
430
+ costTracker;
247
431
  constructor(options = {}) {
248
432
  this.#bufferSize = options.bufferSize ?? 1024;
249
433
  this.#historySeconds = options.historySeconds ?? 120;
250
- this.#maxErrors = options.maxErrors ?? 100;
251
- this.#maxRequests = options.maxRequests ?? 200;
252
- this.#maxTasks = options.maxTasks ?? 200;
434
+ this.#ignorePaths = new Set((options.ignorePaths ?? []).map((p) => p.startsWith("/") ? p.slice(1) : p));
253
435
  this.#currentWindow = {
254
436
  time: Math.floor(Date.now() / 1e3),
255
437
  count: 0,
256
438
  errors: 0
257
439
  };
258
- this.#store = new AnalyticsStore(this.#maxRequests, this.#maxErrors, options.flushInterval ?? 5e3);
440
+ this.sseHub = new AnalyticsSSEHub();
441
+ this.sseHub.startStatsBroadcast(() => this.toJSON());
442
+ this.timeseries = new TimeSeriesAggregator();
443
+ this.costTracker = new CostTracker(options.budgets, (rule, current) => {
444
+ if (this.alertEngine) console.warn(`[silgi:budget] "${rule.name}" exceeded: $${current.toFixed(2)} / $${rule.limit}`);
445
+ });
446
+ if (options.alerts && options.alerts.length > 0) this.alertEngine = new AlertEngine(options.alerts);
447
+ this.#store = new AnalyticsStore(options.flushInterval ?? 5e3, options.retentionDays ?? 30);
259
448
  this.#store.hydrate().then((c) => {
260
449
  this.#totalRequests += c.totalRequests;
261
450
  this.#totalErrors += c.totalErrors;
262
451
  });
452
+ this.#store.loadHiddenPaths().then((paths) => {
453
+ for (const p of paths) this.#hiddenPaths.add(p);
454
+ });
455
+ }
456
+ /** Check if a path is server-side ignored (from config). */
457
+ isIgnored(pathname) {
458
+ if (this.#ignorePaths.size === 0) return false;
459
+ return matchesPathPrefix(pathname, this.#ignorePaths);
460
+ }
461
+ /** Check if a path is hidden in the dashboard (from runtime API). */
462
+ isHidden(pathname) {
463
+ if (this.#hiddenPaths.size === 0) return false;
464
+ return matchesPathPrefix(pathname, this.#hiddenPaths);
465
+ }
466
+ addHiddenPath(path) {
467
+ const normalized = path.startsWith("/") ? path.slice(1) : path;
468
+ this.#hiddenPaths.add(normalized);
469
+ this.#store.saveHiddenPaths([...this.#hiddenPaths]);
470
+ }
471
+ removeHiddenPath(path) {
472
+ const normalized = path.startsWith("/") ? path.slice(1) : path;
473
+ this.#hiddenPaths.delete(normalized);
474
+ this.#store.saveHiddenPaths([...this.#hiddenPaths]);
475
+ }
476
+ getHiddenPaths() {
477
+ return [...this.#hiddenPaths];
263
478
  }
264
479
  record(path, durationMs) {
265
480
  this.#totalRequests++;
@@ -267,6 +482,8 @@ var AnalyticsCollector = class {
267
482
  entry.count++;
268
483
  entry.latencies.push(durationMs);
269
484
  this.#tick(false);
485
+ this.timeseries.record(durationMs, false);
486
+ this.alertEngine?.record(durationMs, false, path);
270
487
  }
271
488
  recordError(path, durationMs, errorMsg) {
272
489
  this.#totalRequests++;
@@ -278,6 +495,8 @@ var AnalyticsCollector = class {
278
495
  entry.lastError = errorMsg;
279
496
  entry.lastErrorTime = Date.now();
280
497
  this.#tick(true);
498
+ this.timeseries.record(durationMs, true);
499
+ this.alertEngine?.record(durationMs, true, path);
281
500
  }
282
501
  recordDetailedError(entry) {
283
502
  const full = {
@@ -285,8 +504,12 @@ var AnalyticsCollector = class {
285
504
  id: this.#nextErrorId++
286
505
  };
287
506
  this.#errors.push(full);
288
- if (this.#errors.length > this.#maxErrors) this.#errors.shift();
507
+ if (this.#errors.length > MEM_MAX_ERRORS) this.#errors.shift();
289
508
  this.#store.enqueueError(full);
509
+ this.sseHub.broadcast({
510
+ type: "error",
511
+ data: full
512
+ });
290
513
  }
291
514
  recordDetailedRequest(entry) {
292
515
  const full = {
@@ -294,8 +517,12 @@ var AnalyticsCollector = class {
294
517
  id: this.#nextRequestId++
295
518
  };
296
519
  this.#requests.push(full);
297
- if (this.#requests.length > this.#maxRequests) this.#requests.shift();
520
+ if (this.#requests.length > MEM_MAX_REQUESTS) this.#requests.shift();
298
521
  this.#store.enqueueRequest(full);
522
+ this.sseHub.broadcast({
523
+ type: "request",
524
+ data: full
525
+ });
299
526
  this.#flushCountersIfNeeded();
300
527
  }
301
528
  recordTask(entry) {
@@ -304,7 +531,11 @@ var AnalyticsCollector = class {
304
531
  id: this.#nextTaskId++
305
532
  };
306
533
  this.#taskExecutions.push(full);
307
- if (this.#taskExecutions.length > this.#maxTasks) this.#taskExecutions.shift();
534
+ if (this.#taskExecutions.length > MEM_MAX_TASKS) this.#taskExecutions.shift();
535
+ this.sseHub.broadcast({
536
+ type: "task",
537
+ data: full
538
+ });
308
539
  let stats = this.#taskStats.get(entry.taskName);
309
540
  if (!stats) {
310
541
  stats = {
@@ -437,14 +668,18 @@ const SESSION_MAX_AGE = 365 * 24 * 60 * 60;
437
668
  var RequestAccumulator = class {
438
669
  requestId;
439
670
  sessionId;
671
+ traceId;
672
+ parentRequestId;
440
673
  /** True if a new session cookie needs to be set. */
441
674
  isNewSession;
442
675
  t0;
443
676
  #request;
444
677
  #procedures = [];
445
678
  #collector;
446
- constructor(request, collector) {
679
+ constructor(request, collector, traceId, parentRequestId) {
447
680
  this.requestId = generateRequestId();
681
+ this.traceId = traceId ?? this.requestId;
682
+ this.parentRequestId = parentRequestId;
448
683
  this.t0 = performance.now();
449
684
  this.#request = request;
450
685
  this.#collector = collector;
@@ -472,11 +707,11 @@ var RequestAccumulator = class {
472
707
  const durationMs = round(performance.now() - this.t0);
473
708
  const headers = {};
474
709
  this.#request.headers.forEach((v, k) => {
475
- headers[k] = k === "authorization" || k === "cookie" ? "[REDACTED]" : v;
710
+ headers[k] = redactHeaderValue(k, v);
476
711
  });
477
712
  const responseHeaders = {};
478
713
  res.headers.forEach((v, k) => {
479
- responseHeaders[k] = k === "set-cookie" ? "[REDACTED]" : v;
714
+ responseHeaders[k] = redactHeaderValue(k, v);
480
715
  });
481
716
  let worstStatus = 200;
482
717
  for (const p of this.#procedures) if (p.status > worstStatus) worstStatus = p.status;
@@ -490,6 +725,7 @@ var RequestAccumulator = class {
490
725
  timestamp: Date.now(),
491
726
  durationMs,
492
727
  method: this.#request.method,
728
+ url: this.#request.url,
493
729
  path,
494
730
  ip: headers["x-forwarded-for"] || headers["x-real-ip"] || "",
495
731
  headers,
@@ -497,7 +733,9 @@ var RequestAccumulator = class {
497
733
  userAgent: this.#request.headers.get("user-agent") ?? "",
498
734
  status: worstStatus,
499
735
  procedures: this.#procedures,
500
- isBatch: this.#procedures.length > 1
736
+ isBatch: this.#procedures.length > 1,
737
+ traceId: this.traceId,
738
+ parentRequestId: this.parentRequestId
501
739
  });
502
740
  }
503
741
  /** Whether any procedures have been recorded. */
@@ -517,8 +755,7 @@ function errorToMarkdown(e) {
517
755
  if (e.stack) md += `### Stack Trace\n\n\`\`\`\n${e.stack}\n\`\`\`\n\n`;
518
756
  if (Object.keys(e.headers).length > 0) {
519
757
  md += `### Request Headers\n\n`;
520
- for (const [k, v] of Object.entries(e.headers)) if (k === "authorization") md += `- \`${k}\`: \`[REDACTED]\`\n`;
521
- else md += `- \`${k}\`: \`${v}\`\n`;
758
+ for (const [k, v] of Object.entries(e.headers)) md += `- \`${k}\`: \`${redactHeaderValue(k, v)}\`\n`;
522
759
  md += "\n";
523
760
  }
524
761
  if (e.spans.length > 0) {
@@ -541,6 +778,7 @@ function requestToMarkdown(r) {
541
778
  md += `| Request ID | \`${r.requestId}\` |\n`;
542
779
  md += `| Session ID | \`${r.sessionId}\` |\n`;
543
780
  md += `| Method | ${r.method} |\n`;
781
+ md += `| URL | \`${r.url}\` |\n`;
544
782
  md += `| Path | \`${r.path}\` |\n`;
545
783
  md += `| Status | ${r.status} |\n`;
546
784
  md += `| Duration | ${r.durationMs}ms |\n`;
@@ -554,6 +792,7 @@ function requestToMarkdown(r) {
554
792
  const pEmoji = p.status >= 400 ? "⚠️" : "✅";
555
793
  md += `### ${pEmoji} ${i + 1}. \`${p.procedure}\` → ${p.status} (${p.durationMs}ms)\n\n`;
556
794
  if (p.input !== void 0 && p.input !== null) md += `#### Input\n\n\`\`\`json\n${safeStringify(p.input)}\n\`\`\`\n\n`;
795
+ if (p.output !== void 0 && p.output !== null) md += `#### Output\n\n\`\`\`json\n${safeStringify(p.output)}\n\`\`\`\n\n`;
557
796
  if (p.spans.length > 0) {
558
797
  const byKind = /* @__PURE__ */ new Map();
559
798
  for (const s of p.spans) byKind.set(s.kind, (byKind.get(s.kind) ?? 0) + s.durationMs);
@@ -583,6 +822,63 @@ function requestToMarkdown(r) {
583
822
  if (r.durationMs > 100) md += `- ⚠️ This request took ${r.durationMs}ms — what is the bottleneck?\n`;
584
823
  return md;
585
824
  }
825
+ async function captureResponseBody(response) {
826
+ const lowered = (response.headers.get("content-type") ?? "").toLowerCase();
827
+ if (!response.body || lowered.includes("text/event-stream") || lowered.includes("application/octet-stream")) return { output: null };
828
+ try {
829
+ const text = await response.clone().text();
830
+ if (!text) return { output: null };
831
+ let output = text;
832
+ if (lowered.includes("application/json") || lowered.includes("+json")) try {
833
+ output = JSON.parse(text);
834
+ } catch {
835
+ output = text;
836
+ }
837
+ if (response.status < 400) return { output };
838
+ if (output && typeof output === "object") {
839
+ const record = output;
840
+ const message = typeof record.message === "string" ? record.message : null;
841
+ const code = typeof record.code === "string" ? record.code : null;
842
+ if (message && code) return {
843
+ output,
844
+ error: `${code}: ${message}`
845
+ };
846
+ if (message) return {
847
+ output,
848
+ error: message
849
+ };
850
+ }
851
+ return {
852
+ output,
853
+ error: typeof output === "string" ? output : safeStringify(output)
854
+ };
855
+ } catch {
856
+ return { output: null };
857
+ }
858
+ }
859
+ function extractResponseError(output, status, fallback) {
860
+ if (output && typeof output === "object") {
861
+ const record = output;
862
+ const code = typeof record.code === "string" ? record.code : null;
863
+ const message = typeof record.message === "string" ? record.message : null;
864
+ if (code && message) return {
865
+ code,
866
+ message
867
+ };
868
+ if (message) return {
869
+ code: `HTTP_${status}`,
870
+ message
871
+ };
872
+ }
873
+ if (fallback) return {
874
+ code: `HTTP_${status}`,
875
+ message: fallback
876
+ };
877
+ return {
878
+ code: `HTTP_${status}`,
879
+ message: `Request failed with status ${status}`
880
+ };
881
+ }
586
882
  function safeStringify(v) {
587
883
  try {
588
884
  return JSON.stringify(v, null, 2);
@@ -590,6 +886,28 @@ function safeStringify(v) {
590
886
  return String(v);
591
887
  }
592
888
  }
889
+ function matchesPathPrefix(pathname, prefixes) {
890
+ const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
891
+ for (const prefix of prefixes) if (normalized === prefix || normalized.startsWith(prefix + "/")) return true;
892
+ return false;
893
+ }
894
+ function isTrackedRequestPath(pathname) {
895
+ const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
896
+ return normalized === "api" || normalized.startsWith("api/") || normalized === "graphql" || normalized.startsWith("graphql/");
897
+ }
898
+ function isAnalyticsPath(pathname) {
899
+ return pathname === "api/analytics" || pathname.startsWith("api/analytics/");
900
+ }
901
+ function parseAnalyticsDetailPath(pathname, prefix) {
902
+ if (!pathname.startsWith(prefix)) return null;
903
+ const rawId = pathname.slice(prefix.length);
904
+ if (!rawId || rawId.includes("/")) return null;
905
+ const id = Number(rawId);
906
+ return {
907
+ id: Number.isFinite(id) ? id : null,
908
+ rawId: decodeURIComponent(rawId)
909
+ };
910
+ }
593
911
  const __analytics_dirname = dirname(fileURLToPath(import.meta.url));
594
912
  let _dashboardCache;
595
913
  function analyticsHTML() {
@@ -621,15 +939,8 @@ function checkAnalyticsAuth(request, auth) {
621
939
  }
622
940
  function sanitizeHeaders(headers) {
623
941
  const result = {};
624
- const sensitiveKeys = new Set([
625
- "authorization",
626
- "cookie",
627
- "x-api-key",
628
- "x-auth-token",
629
- "proxy-authorization"
630
- ]);
631
942
  headers.forEach((value, key) => {
632
- result[key] = sensitiveKeys.has(key.toLowerCase()) ? "[REDACTED]" : value;
943
+ result[key] = redactHeaderValue(key, value);
633
944
  });
634
945
  return result;
635
946
  }
@@ -676,7 +987,7 @@ location.reload();
676
987
  /** Return auth-failure response for analytics routes. */
677
988
  function analyticsAuthResponse(pathname) {
678
989
  const jsonHeaders = { "content-type": "application/json" };
679
- if (pathname !== "api/analytics") return new Response(JSON.stringify({
990
+ if (pathname !== "api/analytics" && pathname !== "api/analytics/") return new Response(JSON.stringify({
680
991
  code: "UNAUTHORIZED",
681
992
  status: 401,
682
993
  message: "Invalid token"
@@ -693,7 +1004,7 @@ function jsonResponse(data, headers) {
693
1004
  return new Response(JSON.stringify(data), { headers });
694
1005
  }
695
1006
  /** Serve analytics dashboard and API routes. */
696
- async function serveAnalyticsRoute(pathname, collector, dashboardHtml) {
1007
+ async function serveAnalyticsRoute(pathname, request, collector, dashboardHtml) {
697
1008
  const jsonCacheHeaders = {
698
1009
  "content-type": "application/json",
699
1010
  "cache-control": "no-cache"
@@ -702,32 +1013,96 @@ async function serveAnalyticsRoute(pathname, collector, dashboardHtml) {
702
1013
  "content-type": "text/markdown; charset=utf-8",
703
1014
  "cache-control": "no-cache"
704
1015
  };
1016
+ const url = new URL(request.url);
1017
+ if (pathname === "api/analytics" || pathname === "api/analytics/") return new Response(dashboardHtml, { headers: { "content-type": "text/html" } });
705
1018
  if (pathname === "api/analytics/stats") return jsonResponse(collector.toJSON(), jsonCacheHeaders);
706
- if (pathname === "api/analytics/errors") return jsonResponse(await collector.getErrors(), jsonCacheHeaders);
707
- if (pathname === "api/analytics/requests") return jsonResponse(await collector.getRequests(), jsonCacheHeaders);
708
- if (pathname === "api/analytics/tasks") return jsonResponse(await collector.getTaskExecutions(), jsonCacheHeaders);
1019
+ if (pathname === "api/analytics/hidden") {
1020
+ if (request.method === "GET") return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
1021
+ if (request.method === "POST") {
1022
+ const body = await request.json();
1023
+ if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
1024
+ status: 400,
1025
+ headers: jsonCacheHeaders
1026
+ });
1027
+ collector.addHiddenPath(body.path);
1028
+ return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
1029
+ }
1030
+ if (request.method === "DELETE") {
1031
+ const body = await request.json();
1032
+ if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
1033
+ status: 400,
1034
+ headers: jsonCacheHeaders
1035
+ });
1036
+ collector.removeHiddenPath(body.path);
1037
+ return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
1038
+ }
1039
+ }
1040
+ if (pathname === "api/analytics/errors") return jsonResponse(queryErrors((await collector.getErrors()).filter((e) => !collector.isHidden(e.procedure)), parseQueryParams(url.searchParams)), jsonCacheHeaders);
1041
+ if (pathname === "api/analytics/requests") return jsonResponse(queryRequests((await collector.getRequests()).filter((r) => !collector.isHidden(r.path)), parseQueryParams(url.searchParams)), jsonCacheHeaders);
1042
+ if (pathname === "api/analytics/tasks") return jsonResponse(queryTasks(await collector.getTaskExecutions(), parseQueryParams(url.searchParams)), jsonCacheHeaders);
709
1043
  if (pathname === "api/analytics/scheduled") {
710
1044
  const { getScheduledTasks } = await import("../core/task.mjs");
711
1045
  return jsonResponse(getScheduledTasks(), jsonCacheHeaders);
712
1046
  }
1047
+ if (pathname === "api/analytics/stream") {
1048
+ const stream = collector.sseHub.createStream();
1049
+ return new Response(stream, { headers: {
1050
+ "content-type": "text/event-stream",
1051
+ "cache-control": "no-cache",
1052
+ "connection": "keep-alive"
1053
+ } });
1054
+ }
1055
+ if (pathname === "api/analytics/timeseries") {
1056
+ const range = url.searchParams.get("range") || "1h";
1057
+ return jsonResponse(collector.timeseries.query(range), jsonCacheHeaders);
1058
+ }
1059
+ if (pathname === "api/analytics/alerts") return jsonResponse({
1060
+ history: collector.alertEngine?.getHistory() ?? [],
1061
+ states: collector.alertEngine?.getStates() ?? {}
1062
+ }, jsonCacheHeaders);
1063
+ if (pathname === "api/analytics/cost") return jsonResponse(collector.costTracker.getSummary(), jsonCacheHeaders);
1064
+ if (pathname.startsWith("api/analytics/traces/")) {
1065
+ const traceId = decodeURIComponent(pathname.slice(21));
1066
+ if (traceId) return jsonResponse((await collector.getRequests()).filter((r) => r.traceId === traceId), jsonCacheHeaders);
1067
+ }
713
1068
  if (pathname.startsWith("api/analytics/requests/") && pathname.endsWith("/md")) {
714
- const id = Number(pathname.slice(23, -3));
715
- const entry = (await collector.getRequests()).find((r) => r.id === id);
1069
+ const rawId = pathname.slice(23, -3);
1070
+ const parsedId = Number(rawId);
1071
+ const requestId = decodeURIComponent(rawId);
1072
+ const entry = (await collector.getRequests()).find((r) => r.id === parsedId || r.requestId === requestId);
716
1073
  if (entry) return new Response(requestToMarkdown(entry), { headers: mdHeaders });
717
1074
  return new Response("not found", { status: 404 });
718
1075
  }
1076
+ const requestDetail = parseAnalyticsDetailPath(pathname, "api/analytics/requests/");
1077
+ if (requestDetail) {
1078
+ const entry = (await collector.getRequests()).find((r) => r.id === requestDetail.id || r.requestId === requestDetail.rawId);
1079
+ return entry ? jsonResponse(entry, jsonCacheHeaders) : new Response("not found", { status: 404 });
1080
+ }
719
1081
  if (pathname.startsWith("api/analytics/errors/") && pathname.endsWith("/md")) {
720
- const id = Number(pathname.slice(21, -3));
1082
+ const rawId = pathname.slice(21, -3);
1083
+ const id = Number(rawId);
721
1084
  const entry = (await collector.getErrors()).find((e) => e.id === id);
722
1085
  if (entry) return new Response(errorToMarkdown(entry), { headers: mdHeaders });
723
1086
  return new Response("not found", { status: 404 });
724
1087
  }
1088
+ const errorDetail = parseAnalyticsDetailPath(pathname, "api/analytics/errors/");
1089
+ if (errorDetail) {
1090
+ const entry = (await collector.getErrors()).find((e) => e.id === errorDetail.id);
1091
+ return entry ? jsonResponse(entry, jsonCacheHeaders) : new Response("not found", { status: 404 });
1092
+ }
725
1093
  if (pathname === "api/analytics/errors/md") {
726
1094
  const errors = await collector.getErrors();
727
1095
  const md = errors.length === 0 ? "No errors.\n" : `# Errors (${errors.length})\n\n` + errors.map((e) => errorToMarkdown(e)).join("\n\n---\n\n");
728
1096
  return new Response(md, { headers: mdHeaders });
729
1097
  }
730
- return new Response(dashboardHtml, { headers: { "content-type": "text/html" } });
1098
+ return new Response(JSON.stringify({
1099
+ code: "NOT_FOUND",
1100
+ status: 404,
1101
+ message: "Analytics route not found"
1102
+ }), {
1103
+ status: 404,
1104
+ headers: jsonCacheHeaders
1105
+ });
731
1106
  }
732
1107
  /**
733
1108
  * Wrap a fetch handler with analytics collection.
@@ -749,14 +1124,18 @@ function wrapWithAnalytics(handler, options = {}) {
749
1124
  const qMark = url.indexOf("?", pathStart);
750
1125
  const fullPath = qMark === -1 ? url.slice(pathStart) : url.slice(pathStart, qMark);
751
1126
  const pathname = fullPath.length > 1 ? fullPath.slice(1) : "";
752
- if (pathname.startsWith("api/analytics")) {
1127
+ if (isAnalyticsPath(pathname)) {
753
1128
  if (auth) {
754
1129
  const authResult = checkAnalyticsAuth(request, auth);
755
1130
  if (!(authResult instanceof Promise ? await authResult : authResult)) return analyticsAuthResponse(pathname);
756
1131
  }
757
- return serveAnalyticsRoute(pathname, collector, dashboardHtml);
1132
+ return serveAnalyticsRoute(pathname, request, collector, dashboardHtml);
758
1133
  }
759
- const acc = new RequestAccumulator(request, collector);
1134
+ if (!isTrackedRequestPath(pathname) || collector.isIgnored(pathname)) return handler(request);
1135
+ const incomingTraceId = request.headers.get("x-trace-id");
1136
+ const parentRequestId = request.headers.get("x-parent-request-id");
1137
+ const traceId = incomingTraceId || generateRequestId();
1138
+ const acc = new RequestAccumulator(request, collector, traceId, parentRequestId ?? void 0);
760
1139
  const reqTrace = new RequestTrace();
761
1140
  const t0 = performance.now();
762
1141
  analyticsTraceMap.set(request, reqTrace);
@@ -764,15 +1143,37 @@ function wrapWithAnalytics(handler, options = {}) {
764
1143
  try {
765
1144
  response = await handler(request);
766
1145
  const durationMs = round(performance.now() - t0);
1146
+ const captured = await captureResponseBody(response);
1147
+ const procedureInput = reqTrace.procedureInput ?? null;
1148
+ const procedureOutput = reqTrace.procedureOutput ?? captured.output;
1149
+ const procedureSpans = reqTrace.spans ?? [];
767
1150
  collector.record(pathname, durationMs);
768
1151
  acc.addProcedure({
769
1152
  procedure: pathname,
770
1153
  durationMs,
771
1154
  status: response.status,
772
- input: null,
773
- output: null,
774
- spans: reqTrace.spans ?? []
1155
+ input: procedureInput,
1156
+ output: procedureOutput,
1157
+ spans: procedureSpans,
1158
+ error: captured.error
775
1159
  });
1160
+ if (response.status >= 400) {
1161
+ const { code, message } = extractResponseError(procedureOutput, response.status, captured.error);
1162
+ collector.recordError(pathname, durationMs, message);
1163
+ collector.recordDetailedError({
1164
+ requestId: acc.requestId,
1165
+ timestamp: Date.now(),
1166
+ procedure: pathname,
1167
+ error: message,
1168
+ code,
1169
+ status: response.status,
1170
+ stack: "",
1171
+ input: procedureInput,
1172
+ headers: sanitizeHeaders(request.headers),
1173
+ durationMs,
1174
+ spans: procedureSpans
1175
+ });
1176
+ }
776
1177
  } catch (error) {
777
1178
  const durationMs = round(performance.now() - t0);
778
1179
  const errorMsg = error instanceof Error ? error.message : String(error);
@@ -799,6 +1200,7 @@ function wrapWithAnalytics(handler, options = {}) {
799
1200
  }
800
1201
  const headers = new Headers(response.headers);
801
1202
  headers.set("x-request-id", acc.requestId);
1203
+ headers.set("x-trace-id", traceId);
802
1204
  const cookie = acc.getSessionCookie();
803
1205
  if (cookie) headers.append("set-cookie", cookie);
804
1206
  const injected = new Response(response.body, {