silgi 0.1.0-beta.13 → 0.1.0-beta.14
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/core/context-bridge.mjs +11 -0
- package/dist/core/handler.mjs +2 -1
- package/dist/integrations/better-auth/index.d.mts +0 -20
- package/dist/integrations/better-auth/index.mjs +3 -4
- package/dist/integrations/drizzle/index.mjs +6 -7
- package/dist/plugins/analytics.d.mts +13 -7
- package/dist/plugins/analytics.mjs +396 -49
- package/lib/dashboard/index.html +53 -56
- package/package.json +1 -1
|
@@ -52,6 +52,156 @@ var RingBuffer = class {
|
|
|
52
52
|
return this.#count;
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
|
+
const REDACTED = "[REDACTED]";
|
|
56
|
+
const SENSITIVE_HEADER_KEYS = new Set([
|
|
57
|
+
"authorization",
|
|
58
|
+
"cookie",
|
|
59
|
+
"set-cookie",
|
|
60
|
+
"x-api-key",
|
|
61
|
+
"x-auth-token",
|
|
62
|
+
"proxy-authorization"
|
|
63
|
+
]);
|
|
64
|
+
function shouldRedactSensitiveData() {
|
|
65
|
+
return process.env.NODE_ENV === "production";
|
|
66
|
+
}
|
|
67
|
+
function redactHeaderValue(key, value) {
|
|
68
|
+
return shouldRedactSensitiveData() && SENSITIVE_HEADER_KEYS.has(key.toLowerCase()) ? REDACTED : value;
|
|
69
|
+
}
|
|
70
|
+
function asRecord(value) {
|
|
71
|
+
return value && typeof value === "object" ? value : null;
|
|
72
|
+
}
|
|
73
|
+
function normalizeString(value, fallback = "") {
|
|
74
|
+
return typeof value === "string" ? value : fallback;
|
|
75
|
+
}
|
|
76
|
+
function normalizeNumber(value, fallback = 0) {
|
|
77
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
78
|
+
}
|
|
79
|
+
function normalizeBoolean(value, fallback = false) {
|
|
80
|
+
return typeof value === "boolean" ? value : fallback;
|
|
81
|
+
}
|
|
82
|
+
function normalizeStringMap(value) {
|
|
83
|
+
const record = asRecord(value);
|
|
84
|
+
if (!record) return {};
|
|
85
|
+
const result = {};
|
|
86
|
+
for (const [key, raw] of Object.entries(record)) if (typeof raw === "string") result[key] = raw;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
function normalizeSpanKind(value) {
|
|
90
|
+
switch (value) {
|
|
91
|
+
case "db":
|
|
92
|
+
case "http":
|
|
93
|
+
case "cache":
|
|
94
|
+
case "queue":
|
|
95
|
+
case "email":
|
|
96
|
+
case "ai":
|
|
97
|
+
case "custom": return value;
|
|
98
|
+
default: return "custom";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function normalizeTraceSpans(value) {
|
|
102
|
+
if (!Array.isArray(value)) return [];
|
|
103
|
+
const spans = [];
|
|
104
|
+
for (const entry of value) {
|
|
105
|
+
const span = asRecord(entry);
|
|
106
|
+
if (!span) continue;
|
|
107
|
+
const attributes = asRecord(span.attributes);
|
|
108
|
+
const normalizedAttributes = {};
|
|
109
|
+
if (attributes) {
|
|
110
|
+
for (const [key, raw] of Object.entries(attributes)) if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") normalizedAttributes[key] = raw;
|
|
111
|
+
}
|
|
112
|
+
spans.push({
|
|
113
|
+
name: normalizeString(span.name, "unknown"),
|
|
114
|
+
kind: normalizeSpanKind(span.kind),
|
|
115
|
+
durationMs: normalizeNumber(span.durationMs),
|
|
116
|
+
startOffsetMs: typeof span.startOffsetMs === "number" ? span.startOffsetMs : void 0,
|
|
117
|
+
detail: typeof span.detail === "string" ? span.detail : void 0,
|
|
118
|
+
input: span.input,
|
|
119
|
+
output: span.output,
|
|
120
|
+
error: typeof span.error === "string" ? span.error : void 0,
|
|
121
|
+
attributes: Object.keys(normalizedAttributes).length > 0 ? normalizedAttributes : void 0
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return spans;
|
|
125
|
+
}
|
|
126
|
+
function inferPathFromUrl(url) {
|
|
127
|
+
if (!url) return "";
|
|
128
|
+
try {
|
|
129
|
+
return new URL(url).pathname || "";
|
|
130
|
+
} catch {
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function normalizeProcedureCall(value, fallback) {
|
|
135
|
+
const record = asRecord(value);
|
|
136
|
+
return {
|
|
137
|
+
procedure: normalizeString(record?.procedure, fallback.path.replace(/^\//, "") || fallback.path || "request"),
|
|
138
|
+
durationMs: normalizeNumber(record?.durationMs, fallback.durationMs),
|
|
139
|
+
status: normalizeNumber(record?.status, fallback.status),
|
|
140
|
+
input: record?.input ?? null,
|
|
141
|
+
output: record?.output ?? null,
|
|
142
|
+
spans: normalizeTraceSpans(record?.spans),
|
|
143
|
+
error: typeof record?.error === "string" ? record.error : void 0
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function normalizeRequestEntry(value, fallbackId) {
|
|
147
|
+
const record = asRecord(value);
|
|
148
|
+
if (!record) return null;
|
|
149
|
+
const url = normalizeString(record.url);
|
|
150
|
+
const path = normalizeString(record.path, inferPathFromUrl(url));
|
|
151
|
+
const method = normalizeString(record.method, "GET");
|
|
152
|
+
const status = normalizeNumber(record.status, 200);
|
|
153
|
+
const durationMs = normalizeNumber(record.durationMs);
|
|
154
|
+
const procedureFallback = {
|
|
155
|
+
path: path || "/",
|
|
156
|
+
durationMs,
|
|
157
|
+
status
|
|
158
|
+
};
|
|
159
|
+
const proceduresRaw = Array.isArray(record.procedures) ? record.procedures : [];
|
|
160
|
+
const procedures = proceduresRaw.length > 0 ? proceduresRaw.map((entry) => normalizeProcedureCall(entry, procedureFallback)) : [normalizeProcedureCall(null, procedureFallback)];
|
|
161
|
+
return {
|
|
162
|
+
id: normalizeNumber(record.id, fallbackId),
|
|
163
|
+
requestId: normalizeString(record.requestId, String(normalizeNumber(record.id, fallbackId))),
|
|
164
|
+
sessionId: normalizeString(record.sessionId),
|
|
165
|
+
timestamp: normalizeNumber(record.timestamp),
|
|
166
|
+
durationMs,
|
|
167
|
+
method,
|
|
168
|
+
url: url || path,
|
|
169
|
+
path,
|
|
170
|
+
ip: normalizeString(record.ip),
|
|
171
|
+
headers: normalizeStringMap(record.headers),
|
|
172
|
+
responseHeaders: normalizeStringMap(record.responseHeaders),
|
|
173
|
+
userAgent: normalizeString(record.userAgent),
|
|
174
|
+
status,
|
|
175
|
+
procedures,
|
|
176
|
+
isBatch: normalizeBoolean(record.isBatch, procedures.length > 1)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function normalizeErrorEntry(value, fallbackId) {
|
|
180
|
+
const record = asRecord(value);
|
|
181
|
+
if (!record) return null;
|
|
182
|
+
return {
|
|
183
|
+
id: normalizeNumber(record.id, fallbackId),
|
|
184
|
+
requestId: normalizeString(record.requestId),
|
|
185
|
+
timestamp: normalizeNumber(record.timestamp),
|
|
186
|
+
procedure: normalizeString(record.procedure, "request"),
|
|
187
|
+
error: normalizeString(record.error, "Unknown error"),
|
|
188
|
+
code: normalizeString(record.code, "INTERNAL_SERVER_ERROR"),
|
|
189
|
+
status: normalizeNumber(record.status, 500),
|
|
190
|
+
stack: normalizeString(record.stack),
|
|
191
|
+
input: record.input ?? null,
|
|
192
|
+
headers: normalizeStringMap(record.headers),
|
|
193
|
+
durationMs: normalizeNumber(record.durationMs),
|
|
194
|
+
spans: normalizeTraceSpans(record.spans)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function normalizeRequestEntries(value) {
|
|
198
|
+
if (!Array.isArray(value)) return [];
|
|
199
|
+
return value.map((entry, index) => normalizeRequestEntry(entry, index + 1)).filter((entry) => entry !== null);
|
|
200
|
+
}
|
|
201
|
+
function normalizeErrorEntries(value) {
|
|
202
|
+
if (!Array.isArray(value)) return [];
|
|
203
|
+
return value.map((entry, index) => normalizeErrorEntry(entry, index + 1)).filter((entry) => entry !== null);
|
|
204
|
+
}
|
|
55
205
|
let _lastTime = 0;
|
|
56
206
|
let _counter = 0;
|
|
57
207
|
function generateRequestId() {
|
|
@@ -147,14 +297,12 @@ var AnalyticsStore = class {
|
|
|
147
297
|
#storage;
|
|
148
298
|
#pendingRequests = [];
|
|
149
299
|
#pendingErrors = [];
|
|
150
|
-
#
|
|
151
|
-
#maxErrors;
|
|
300
|
+
#retentionMs;
|
|
152
301
|
#timer = null;
|
|
153
302
|
#flushing = false;
|
|
154
|
-
constructor(
|
|
303
|
+
constructor(flushInterval, retentionDays) {
|
|
155
304
|
this.#storage = useStorage("data");
|
|
156
|
-
this.#
|
|
157
|
-
this.#maxErrors = maxErrors;
|
|
305
|
+
this.#retentionMs = retentionDays * 864e5;
|
|
158
306
|
this.#timer = setInterval(() => this.flush(), flushInterval);
|
|
159
307
|
if (typeof this.#timer === "object" && "unref" in this.#timer) this.#timer.unref();
|
|
160
308
|
}
|
|
@@ -171,12 +319,13 @@ var AnalyticsStore = class {
|
|
|
171
319
|
if (requests.length === 0 && errors.length === 0) return;
|
|
172
320
|
this.#flushing = true;
|
|
173
321
|
try {
|
|
322
|
+
const cutoff = Date.now() - this.#retentionMs;
|
|
174
323
|
if (requests.length > 0) {
|
|
175
|
-
const merged = [...await this.#storage.getItem("analytics:requests")
|
|
324
|
+
const merged = [...normalizeRequestEntries(await this.#storage.getItem("analytics:requests")).filter((entry) => isTrackedRequestPath(entry.path)), ...requests].filter((e) => e.timestamp >= cutoff);
|
|
176
325
|
await this.#storage.setItem("analytics:requests", merged);
|
|
177
326
|
}
|
|
178
327
|
if (errors.length > 0) {
|
|
179
|
-
const merged = [...await this.#storage.getItem("analytics:errors")
|
|
328
|
+
const merged = [...normalizeErrorEntries(await this.#storage.getItem("analytics:errors")).filter((entry) => isTrackedRequestPath(entry.procedure)), ...errors].filter((e) => e.timestamp >= cutoff);
|
|
180
329
|
await this.#storage.setItem("analytics:errors", merged);
|
|
181
330
|
}
|
|
182
331
|
} catch {
|
|
@@ -187,14 +336,18 @@ var AnalyticsStore = class {
|
|
|
187
336
|
}
|
|
188
337
|
}
|
|
189
338
|
async getRequests() {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
339
|
+
const cutoff = Date.now() - this.#retentionMs;
|
|
340
|
+
const stored = normalizeRequestEntries(await this.#storage.getItem("analytics:requests")).filter((entry) => isTrackedRequestPath(entry.path) && entry.timestamp >= cutoff);
|
|
341
|
+
const pending = this.#pendingRequests.filter((entry) => isTrackedRequestPath(entry.path) && entry.timestamp >= cutoff);
|
|
342
|
+
if (pending.length === 0) return stored;
|
|
343
|
+
return [...stored, ...pending];
|
|
193
344
|
}
|
|
194
345
|
async getErrors() {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
346
|
+
const cutoff = Date.now() - this.#retentionMs;
|
|
347
|
+
const stored = normalizeErrorEntries(await this.#storage.getItem("analytics:errors")).filter((entry) => isTrackedRequestPath(entry.procedure) && entry.timestamp >= cutoff);
|
|
348
|
+
const pending = this.#pendingErrors.filter((entry) => isTrackedRequestPath(entry.procedure) && entry.timestamp >= cutoff);
|
|
349
|
+
if (pending.length === 0) return stored;
|
|
350
|
+
return [...stored, ...pending];
|
|
198
351
|
}
|
|
199
352
|
async hydrate() {
|
|
200
353
|
try {
|
|
@@ -217,12 +370,27 @@ var AnalyticsStore = class {
|
|
|
217
370
|
});
|
|
218
371
|
} catch {}
|
|
219
372
|
}
|
|
373
|
+
async loadHiddenPaths() {
|
|
374
|
+
try {
|
|
375
|
+
const paths = await this.#storage.getItem("analytics:hiddenPaths");
|
|
376
|
+
return Array.isArray(paths) ? paths : [];
|
|
377
|
+
} catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
saveHiddenPaths(paths) {
|
|
382
|
+
this.#storage.setItem("analytics:hiddenPaths", paths).catch(() => {});
|
|
383
|
+
}
|
|
220
384
|
async dispose() {
|
|
221
385
|
if (this.#timer) clearInterval(this.#timer);
|
|
222
386
|
this.#timer = null;
|
|
223
387
|
await this.flush();
|
|
224
388
|
}
|
|
225
389
|
};
|
|
390
|
+
/** Internal in-memory buffer caps — not user-configurable */
|
|
391
|
+
const MEM_MAX_REQUESTS = 1e4;
|
|
392
|
+
const MEM_MAX_ERRORS = 1e4;
|
|
393
|
+
const MEM_MAX_TASKS = 1e4;
|
|
226
394
|
var AnalyticsCollector = class {
|
|
227
395
|
#procedures = /* @__PURE__ */ new Map();
|
|
228
396
|
#startTime = Date.now();
|
|
@@ -230,9 +398,6 @@ var AnalyticsCollector = class {
|
|
|
230
398
|
#totalErrors = 0;
|
|
231
399
|
#bufferSize;
|
|
232
400
|
#historySeconds;
|
|
233
|
-
#maxErrors;
|
|
234
|
-
#maxRequests;
|
|
235
|
-
#maxTasks;
|
|
236
401
|
#timeSeries = [];
|
|
237
402
|
#currentWindow;
|
|
238
403
|
#errors = [];
|
|
@@ -244,22 +409,50 @@ var AnalyticsCollector = class {
|
|
|
244
409
|
#taskStats = /* @__PURE__ */ new Map();
|
|
245
410
|
#store;
|
|
246
411
|
#counterFlushCounter = 0;
|
|
412
|
+
/** Server-side ignore — from config, prevents recording entirely */
|
|
413
|
+
#ignorePaths;
|
|
414
|
+
/** Client-side hide — from dashboard, filters display only */
|
|
415
|
+
#hiddenPaths = /* @__PURE__ */ new Set();
|
|
247
416
|
constructor(options = {}) {
|
|
248
417
|
this.#bufferSize = options.bufferSize ?? 1024;
|
|
249
418
|
this.#historySeconds = options.historySeconds ?? 120;
|
|
250
|
-
this.#
|
|
251
|
-
this.#maxRequests = options.maxRequests ?? 200;
|
|
252
|
-
this.#maxTasks = options.maxTasks ?? 200;
|
|
419
|
+
this.#ignorePaths = new Set((options.ignorePaths ?? []).map((p) => p.startsWith("/") ? p.slice(1) : p));
|
|
253
420
|
this.#currentWindow = {
|
|
254
421
|
time: Math.floor(Date.now() / 1e3),
|
|
255
422
|
count: 0,
|
|
256
423
|
errors: 0
|
|
257
424
|
};
|
|
258
|
-
this.#store = new AnalyticsStore(
|
|
425
|
+
this.#store = new AnalyticsStore(options.flushInterval ?? 5e3, options.retentionDays ?? 30);
|
|
259
426
|
this.#store.hydrate().then((c) => {
|
|
260
427
|
this.#totalRequests += c.totalRequests;
|
|
261
428
|
this.#totalErrors += c.totalErrors;
|
|
262
429
|
});
|
|
430
|
+
this.#store.loadHiddenPaths().then((paths) => {
|
|
431
|
+
for (const p of paths) this.#hiddenPaths.add(p);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/** Check if a path is server-side ignored (from config). */
|
|
435
|
+
isIgnored(pathname) {
|
|
436
|
+
if (this.#ignorePaths.size === 0) return false;
|
|
437
|
+
return matchesPathPrefix(pathname, this.#ignorePaths);
|
|
438
|
+
}
|
|
439
|
+
/** Check if a path is hidden in the dashboard (from runtime API). */
|
|
440
|
+
isHidden(pathname) {
|
|
441
|
+
if (this.#hiddenPaths.size === 0) return false;
|
|
442
|
+
return matchesPathPrefix(pathname, this.#hiddenPaths);
|
|
443
|
+
}
|
|
444
|
+
addHiddenPath(path) {
|
|
445
|
+
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
|
446
|
+
this.#hiddenPaths.add(normalized);
|
|
447
|
+
this.#store.saveHiddenPaths([...this.#hiddenPaths]);
|
|
448
|
+
}
|
|
449
|
+
removeHiddenPath(path) {
|
|
450
|
+
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
|
451
|
+
this.#hiddenPaths.delete(normalized);
|
|
452
|
+
this.#store.saveHiddenPaths([...this.#hiddenPaths]);
|
|
453
|
+
}
|
|
454
|
+
getHiddenPaths() {
|
|
455
|
+
return [...this.#hiddenPaths];
|
|
263
456
|
}
|
|
264
457
|
record(path, durationMs) {
|
|
265
458
|
this.#totalRequests++;
|
|
@@ -285,7 +478,7 @@ var AnalyticsCollector = class {
|
|
|
285
478
|
id: this.#nextErrorId++
|
|
286
479
|
};
|
|
287
480
|
this.#errors.push(full);
|
|
288
|
-
if (this.#errors.length >
|
|
481
|
+
if (this.#errors.length > MEM_MAX_ERRORS) this.#errors.shift();
|
|
289
482
|
this.#store.enqueueError(full);
|
|
290
483
|
}
|
|
291
484
|
recordDetailedRequest(entry) {
|
|
@@ -294,7 +487,7 @@ var AnalyticsCollector = class {
|
|
|
294
487
|
id: this.#nextRequestId++
|
|
295
488
|
};
|
|
296
489
|
this.#requests.push(full);
|
|
297
|
-
if (this.#requests.length >
|
|
490
|
+
if (this.#requests.length > MEM_MAX_REQUESTS) this.#requests.shift();
|
|
298
491
|
this.#store.enqueueRequest(full);
|
|
299
492
|
this.#flushCountersIfNeeded();
|
|
300
493
|
}
|
|
@@ -304,7 +497,7 @@ var AnalyticsCollector = class {
|
|
|
304
497
|
id: this.#nextTaskId++
|
|
305
498
|
};
|
|
306
499
|
this.#taskExecutions.push(full);
|
|
307
|
-
if (this.#taskExecutions.length >
|
|
500
|
+
if (this.#taskExecutions.length > MEM_MAX_TASKS) this.#taskExecutions.shift();
|
|
308
501
|
let stats = this.#taskStats.get(entry.taskName);
|
|
309
502
|
if (!stats) {
|
|
310
503
|
stats = {
|
|
@@ -472,11 +665,11 @@ var RequestAccumulator = class {
|
|
|
472
665
|
const durationMs = round(performance.now() - this.t0);
|
|
473
666
|
const headers = {};
|
|
474
667
|
this.#request.headers.forEach((v, k) => {
|
|
475
|
-
headers[k] = k
|
|
668
|
+
headers[k] = redactHeaderValue(k, v);
|
|
476
669
|
});
|
|
477
670
|
const responseHeaders = {};
|
|
478
671
|
res.headers.forEach((v, k) => {
|
|
479
|
-
responseHeaders[k] = k
|
|
672
|
+
responseHeaders[k] = redactHeaderValue(k, v);
|
|
480
673
|
});
|
|
481
674
|
let worstStatus = 200;
|
|
482
675
|
for (const p of this.#procedures) if (p.status > worstStatus) worstStatus = p.status;
|
|
@@ -490,6 +683,7 @@ var RequestAccumulator = class {
|
|
|
490
683
|
timestamp: Date.now(),
|
|
491
684
|
durationMs,
|
|
492
685
|
method: this.#request.method,
|
|
686
|
+
url: this.#request.url,
|
|
493
687
|
path,
|
|
494
688
|
ip: headers["x-forwarded-for"] || headers["x-real-ip"] || "",
|
|
495
689
|
headers,
|
|
@@ -517,8 +711,7 @@ function errorToMarkdown(e) {
|
|
|
517
711
|
if (e.stack) md += `### Stack Trace\n\n\`\`\`\n${e.stack}\n\`\`\`\n\n`;
|
|
518
712
|
if (Object.keys(e.headers).length > 0) {
|
|
519
713
|
md += `### Request Headers\n\n`;
|
|
520
|
-
for (const [k, v] of Object.entries(e.headers))
|
|
521
|
-
else md += `- \`${k}\`: \`${v}\`\n`;
|
|
714
|
+
for (const [k, v] of Object.entries(e.headers)) md += `- \`${k}\`: \`${redactHeaderValue(k, v)}\`\n`;
|
|
522
715
|
md += "\n";
|
|
523
716
|
}
|
|
524
717
|
if (e.spans.length > 0) {
|
|
@@ -541,6 +734,7 @@ function requestToMarkdown(r) {
|
|
|
541
734
|
md += `| Request ID | \`${r.requestId}\` |\n`;
|
|
542
735
|
md += `| Session ID | \`${r.sessionId}\` |\n`;
|
|
543
736
|
md += `| Method | ${r.method} |\n`;
|
|
737
|
+
md += `| URL | \`${r.url}\` |\n`;
|
|
544
738
|
md += `| Path | \`${r.path}\` |\n`;
|
|
545
739
|
md += `| Status | ${r.status} |\n`;
|
|
546
740
|
md += `| Duration | ${r.durationMs}ms |\n`;
|
|
@@ -554,6 +748,7 @@ function requestToMarkdown(r) {
|
|
|
554
748
|
const pEmoji = p.status >= 400 ? "⚠️" : "✅";
|
|
555
749
|
md += `### ${pEmoji} ${i + 1}. \`${p.procedure}\` → ${p.status} (${p.durationMs}ms)\n\n`;
|
|
556
750
|
if (p.input !== void 0 && p.input !== null) md += `#### Input\n\n\`\`\`json\n${safeStringify(p.input)}\n\`\`\`\n\n`;
|
|
751
|
+
if (p.output !== void 0 && p.output !== null) md += `#### Output\n\n\`\`\`json\n${safeStringify(p.output)}\n\`\`\`\n\n`;
|
|
557
752
|
if (p.spans.length > 0) {
|
|
558
753
|
const byKind = /* @__PURE__ */ new Map();
|
|
559
754
|
for (const s of p.spans) byKind.set(s.kind, (byKind.get(s.kind) ?? 0) + s.durationMs);
|
|
@@ -583,6 +778,63 @@ function requestToMarkdown(r) {
|
|
|
583
778
|
if (r.durationMs > 100) md += `- ⚠️ This request took ${r.durationMs}ms — what is the bottleneck?\n`;
|
|
584
779
|
return md;
|
|
585
780
|
}
|
|
781
|
+
async function captureResponseBody(response) {
|
|
782
|
+
const lowered = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
783
|
+
if (!response.body || lowered.includes("text/event-stream") || lowered.includes("application/octet-stream")) return { output: null };
|
|
784
|
+
try {
|
|
785
|
+
const text = await response.clone().text();
|
|
786
|
+
if (!text) return { output: null };
|
|
787
|
+
let output = text;
|
|
788
|
+
if (lowered.includes("application/json") || lowered.includes("+json")) try {
|
|
789
|
+
output = JSON.parse(text);
|
|
790
|
+
} catch {
|
|
791
|
+
output = text;
|
|
792
|
+
}
|
|
793
|
+
if (response.status < 400) return { output };
|
|
794
|
+
if (output && typeof output === "object") {
|
|
795
|
+
const record = output;
|
|
796
|
+
const message = typeof record.message === "string" ? record.message : null;
|
|
797
|
+
const code = typeof record.code === "string" ? record.code : null;
|
|
798
|
+
if (message && code) return {
|
|
799
|
+
output,
|
|
800
|
+
error: `${code}: ${message}`
|
|
801
|
+
};
|
|
802
|
+
if (message) return {
|
|
803
|
+
output,
|
|
804
|
+
error: message
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
output,
|
|
809
|
+
error: typeof output === "string" ? output : safeStringify(output)
|
|
810
|
+
};
|
|
811
|
+
} catch {
|
|
812
|
+
return { output: null };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function extractResponseError(output, status, fallback) {
|
|
816
|
+
if (output && typeof output === "object") {
|
|
817
|
+
const record = output;
|
|
818
|
+
const code = typeof record.code === "string" ? record.code : null;
|
|
819
|
+
const message = typeof record.message === "string" ? record.message : null;
|
|
820
|
+
if (code && message) return {
|
|
821
|
+
code,
|
|
822
|
+
message
|
|
823
|
+
};
|
|
824
|
+
if (message) return {
|
|
825
|
+
code: `HTTP_${status}`,
|
|
826
|
+
message
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (fallback) return {
|
|
830
|
+
code: `HTTP_${status}`,
|
|
831
|
+
message: fallback
|
|
832
|
+
};
|
|
833
|
+
return {
|
|
834
|
+
code: `HTTP_${status}`,
|
|
835
|
+
message: `Request failed with status ${status}`
|
|
836
|
+
};
|
|
837
|
+
}
|
|
586
838
|
function safeStringify(v) {
|
|
587
839
|
try {
|
|
588
840
|
return JSON.stringify(v, null, 2);
|
|
@@ -590,6 +842,28 @@ function safeStringify(v) {
|
|
|
590
842
|
return String(v);
|
|
591
843
|
}
|
|
592
844
|
}
|
|
845
|
+
function matchesPathPrefix(pathname, prefixes) {
|
|
846
|
+
const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
847
|
+
for (const prefix of prefixes) if (normalized === prefix || normalized.startsWith(prefix + "/")) return true;
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
function isTrackedRequestPath(pathname) {
|
|
851
|
+
const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
852
|
+
return normalized === "api" || normalized.startsWith("api/") || normalized === "graphql" || normalized.startsWith("graphql/");
|
|
853
|
+
}
|
|
854
|
+
function isAnalyticsPath(pathname) {
|
|
855
|
+
return pathname === "api/analytics" || pathname.startsWith("api/analytics/");
|
|
856
|
+
}
|
|
857
|
+
function parseAnalyticsDetailPath(pathname, prefix) {
|
|
858
|
+
if (!pathname.startsWith(prefix)) return null;
|
|
859
|
+
const rawId = pathname.slice(prefix.length);
|
|
860
|
+
if (!rawId || rawId.includes("/")) return null;
|
|
861
|
+
const id = Number(rawId);
|
|
862
|
+
return {
|
|
863
|
+
id: Number.isFinite(id) ? id : null,
|
|
864
|
+
rawId: decodeURIComponent(rawId)
|
|
865
|
+
};
|
|
866
|
+
}
|
|
593
867
|
const __analytics_dirname = dirname(fileURLToPath(import.meta.url));
|
|
594
868
|
let _dashboardCache;
|
|
595
869
|
function analyticsHTML() {
|
|
@@ -621,15 +895,8 @@ function checkAnalyticsAuth(request, auth) {
|
|
|
621
895
|
}
|
|
622
896
|
function sanitizeHeaders(headers) {
|
|
623
897
|
const result = {};
|
|
624
|
-
const sensitiveKeys = new Set([
|
|
625
|
-
"authorization",
|
|
626
|
-
"cookie",
|
|
627
|
-
"x-api-key",
|
|
628
|
-
"x-auth-token",
|
|
629
|
-
"proxy-authorization"
|
|
630
|
-
]);
|
|
631
898
|
headers.forEach((value, key) => {
|
|
632
|
-
result[key] =
|
|
899
|
+
result[key] = redactHeaderValue(key, value);
|
|
633
900
|
});
|
|
634
901
|
return result;
|
|
635
902
|
}
|
|
@@ -676,7 +943,7 @@ location.reload();
|
|
|
676
943
|
/** Return auth-failure response for analytics routes. */
|
|
677
944
|
function analyticsAuthResponse(pathname) {
|
|
678
945
|
const jsonHeaders = { "content-type": "application/json" };
|
|
679
|
-
if (pathname !== "api/analytics") return new Response(JSON.stringify({
|
|
946
|
+
if (pathname !== "api/analytics" && pathname !== "api/analytics/") return new Response(JSON.stringify({
|
|
680
947
|
code: "UNAUTHORIZED",
|
|
681
948
|
status: 401,
|
|
682
949
|
message: "Invalid token"
|
|
@@ -692,8 +959,22 @@ function analyticsAuthResponse(pathname) {
|
|
|
692
959
|
function jsonResponse(data, headers) {
|
|
693
960
|
return new Response(JSON.stringify(data), { headers });
|
|
694
961
|
}
|
|
962
|
+
function paginatedResponse(items, params, headers) {
|
|
963
|
+
const page = Math.max(1, Number(params.get("page")) || 1);
|
|
964
|
+
const limit = Math.min(200, Math.max(1, Number(params.get("limit")) || 50));
|
|
965
|
+
const total = items.length;
|
|
966
|
+
const totalPages = Math.ceil(total / limit);
|
|
967
|
+
const start = (page - 1) * limit;
|
|
968
|
+
return jsonResponse({
|
|
969
|
+
data: items.slice(start, start + limit),
|
|
970
|
+
page,
|
|
971
|
+
limit,
|
|
972
|
+
total,
|
|
973
|
+
totalPages
|
|
974
|
+
}, headers);
|
|
975
|
+
}
|
|
695
976
|
/** Serve analytics dashboard and API routes. */
|
|
696
|
-
async function serveAnalyticsRoute(pathname, collector, dashboardHtml) {
|
|
977
|
+
async function serveAnalyticsRoute(pathname, request, collector, dashboardHtml) {
|
|
697
978
|
const jsonCacheHeaders = {
|
|
698
979
|
"content-type": "application/json",
|
|
699
980
|
"cache-control": "no-cache"
|
|
@@ -702,32 +983,75 @@ async function serveAnalyticsRoute(pathname, collector, dashboardHtml) {
|
|
|
702
983
|
"content-type": "text/markdown; charset=utf-8",
|
|
703
984
|
"cache-control": "no-cache"
|
|
704
985
|
};
|
|
986
|
+
const url = new URL(request.url);
|
|
987
|
+
if (pathname === "api/analytics" || pathname === "api/analytics/") return new Response(dashboardHtml, { headers: { "content-type": "text/html" } });
|
|
705
988
|
if (pathname === "api/analytics/stats") return jsonResponse(collector.toJSON(), jsonCacheHeaders);
|
|
706
|
-
if (pathname === "api/analytics/
|
|
707
|
-
|
|
708
|
-
|
|
989
|
+
if (pathname === "api/analytics/hidden") {
|
|
990
|
+
if (request.method === "GET") return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
|
|
991
|
+
if (request.method === "POST") {
|
|
992
|
+
const body = await request.json();
|
|
993
|
+
if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
|
|
994
|
+
status: 400,
|
|
995
|
+
headers: jsonCacheHeaders
|
|
996
|
+
});
|
|
997
|
+
collector.addHiddenPath(body.path);
|
|
998
|
+
return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
|
|
999
|
+
}
|
|
1000
|
+
if (request.method === "DELETE") {
|
|
1001
|
+
const body = await request.json();
|
|
1002
|
+
if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
|
|
1003
|
+
status: 400,
|
|
1004
|
+
headers: jsonCacheHeaders
|
|
1005
|
+
});
|
|
1006
|
+
collector.removeHiddenPath(body.path);
|
|
1007
|
+
return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (pathname === "api/analytics/errors") return paginatedResponse((await collector.getErrors()).filter((e) => !collector.isHidden(e.procedure)), url.searchParams, jsonCacheHeaders);
|
|
1011
|
+
if (pathname === "api/analytics/requests") return paginatedResponse((await collector.getRequests()).filter((r) => !collector.isHidden(r.path)), url.searchParams, jsonCacheHeaders);
|
|
1012
|
+
if (pathname === "api/analytics/tasks") return paginatedResponse(await collector.getTaskExecutions(), url.searchParams, jsonCacheHeaders);
|
|
709
1013
|
if (pathname === "api/analytics/scheduled") {
|
|
710
1014
|
const { getScheduledTasks } = await import("../core/task.mjs");
|
|
711
1015
|
return jsonResponse(getScheduledTasks(), jsonCacheHeaders);
|
|
712
1016
|
}
|
|
713
1017
|
if (pathname.startsWith("api/analytics/requests/") && pathname.endsWith("/md")) {
|
|
714
|
-
const
|
|
715
|
-
const
|
|
1018
|
+
const rawId = pathname.slice(23, -3);
|
|
1019
|
+
const parsedId = Number(rawId);
|
|
1020
|
+
const requestId = decodeURIComponent(rawId);
|
|
1021
|
+
const entry = (await collector.getRequests()).find((r) => r.id === parsedId || r.requestId === requestId);
|
|
716
1022
|
if (entry) return new Response(requestToMarkdown(entry), { headers: mdHeaders });
|
|
717
1023
|
return new Response("not found", { status: 404 });
|
|
718
1024
|
}
|
|
1025
|
+
const requestDetail = parseAnalyticsDetailPath(pathname, "api/analytics/requests/");
|
|
1026
|
+
if (requestDetail) {
|
|
1027
|
+
const entry = (await collector.getRequests()).find((r) => r.id === requestDetail.id || r.requestId === requestDetail.rawId);
|
|
1028
|
+
return entry ? jsonResponse(entry, jsonCacheHeaders) : new Response("not found", { status: 404 });
|
|
1029
|
+
}
|
|
719
1030
|
if (pathname.startsWith("api/analytics/errors/") && pathname.endsWith("/md")) {
|
|
720
|
-
const
|
|
1031
|
+
const rawId = pathname.slice(21, -3);
|
|
1032
|
+
const id = Number(rawId);
|
|
721
1033
|
const entry = (await collector.getErrors()).find((e) => e.id === id);
|
|
722
1034
|
if (entry) return new Response(errorToMarkdown(entry), { headers: mdHeaders });
|
|
723
1035
|
return new Response("not found", { status: 404 });
|
|
724
1036
|
}
|
|
1037
|
+
const errorDetail = parseAnalyticsDetailPath(pathname, "api/analytics/errors/");
|
|
1038
|
+
if (errorDetail) {
|
|
1039
|
+
const entry = (await collector.getErrors()).find((e) => e.id === errorDetail.id);
|
|
1040
|
+
return entry ? jsonResponse(entry, jsonCacheHeaders) : new Response("not found", { status: 404 });
|
|
1041
|
+
}
|
|
725
1042
|
if (pathname === "api/analytics/errors/md") {
|
|
726
1043
|
const errors = await collector.getErrors();
|
|
727
1044
|
const md = errors.length === 0 ? "No errors.\n" : `# Errors (${errors.length})\n\n` + errors.map((e) => errorToMarkdown(e)).join("\n\n---\n\n");
|
|
728
1045
|
return new Response(md, { headers: mdHeaders });
|
|
729
1046
|
}
|
|
730
|
-
return new Response(
|
|
1047
|
+
return new Response(JSON.stringify({
|
|
1048
|
+
code: "NOT_FOUND",
|
|
1049
|
+
status: 404,
|
|
1050
|
+
message: "Analytics route not found"
|
|
1051
|
+
}), {
|
|
1052
|
+
status: 404,
|
|
1053
|
+
headers: jsonCacheHeaders
|
|
1054
|
+
});
|
|
731
1055
|
}
|
|
732
1056
|
/**
|
|
733
1057
|
* Wrap a fetch handler with analytics collection.
|
|
@@ -749,13 +1073,14 @@ function wrapWithAnalytics(handler, options = {}) {
|
|
|
749
1073
|
const qMark = url.indexOf("?", pathStart);
|
|
750
1074
|
const fullPath = qMark === -1 ? url.slice(pathStart) : url.slice(pathStart, qMark);
|
|
751
1075
|
const pathname = fullPath.length > 1 ? fullPath.slice(1) : "";
|
|
752
|
-
if (pathname
|
|
1076
|
+
if (isAnalyticsPath(pathname)) {
|
|
753
1077
|
if (auth) {
|
|
754
1078
|
const authResult = checkAnalyticsAuth(request, auth);
|
|
755
1079
|
if (!(authResult instanceof Promise ? await authResult : authResult)) return analyticsAuthResponse(pathname);
|
|
756
1080
|
}
|
|
757
|
-
return serveAnalyticsRoute(pathname, collector, dashboardHtml);
|
|
1081
|
+
return serveAnalyticsRoute(pathname, request, collector, dashboardHtml);
|
|
758
1082
|
}
|
|
1083
|
+
if (!isTrackedRequestPath(pathname) || collector.isIgnored(pathname)) return handler(request);
|
|
759
1084
|
const acc = new RequestAccumulator(request, collector);
|
|
760
1085
|
const reqTrace = new RequestTrace();
|
|
761
1086
|
const t0 = performance.now();
|
|
@@ -764,15 +1089,37 @@ function wrapWithAnalytics(handler, options = {}) {
|
|
|
764
1089
|
try {
|
|
765
1090
|
response = await handler(request);
|
|
766
1091
|
const durationMs = round(performance.now() - t0);
|
|
1092
|
+
const captured = await captureResponseBody(response);
|
|
1093
|
+
const procedureInput = reqTrace.procedureInput ?? null;
|
|
1094
|
+
const procedureOutput = reqTrace.procedureOutput ?? captured.output;
|
|
1095
|
+
const procedureSpans = reqTrace.spans ?? [];
|
|
767
1096
|
collector.record(pathname, durationMs);
|
|
768
1097
|
acc.addProcedure({
|
|
769
1098
|
procedure: pathname,
|
|
770
1099
|
durationMs,
|
|
771
1100
|
status: response.status,
|
|
772
|
-
input:
|
|
773
|
-
output:
|
|
774
|
-
spans:
|
|
1101
|
+
input: procedureInput,
|
|
1102
|
+
output: procedureOutput,
|
|
1103
|
+
spans: procedureSpans,
|
|
1104
|
+
error: captured.error
|
|
775
1105
|
});
|
|
1106
|
+
if (response.status >= 400) {
|
|
1107
|
+
const { code, message } = extractResponseError(procedureOutput, response.status, captured.error);
|
|
1108
|
+
collector.recordError(pathname, durationMs, message);
|
|
1109
|
+
collector.recordDetailedError({
|
|
1110
|
+
requestId: acc.requestId,
|
|
1111
|
+
timestamp: Date.now(),
|
|
1112
|
+
procedure: pathname,
|
|
1113
|
+
error: message,
|
|
1114
|
+
code,
|
|
1115
|
+
status: response.status,
|
|
1116
|
+
stack: "",
|
|
1117
|
+
input: procedureInput,
|
|
1118
|
+
headers: sanitizeHeaders(request.headers),
|
|
1119
|
+
durationMs,
|
|
1120
|
+
spans: procedureSpans
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
776
1123
|
} catch (error) {
|
|
777
1124
|
const durationMs = round(performance.now() - t0);
|
|
778
1125
|
const errorMsg = error instanceof Error ? error.message : String(error);
|