@mandujs/core 0.41.2 → 0.43.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/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,334 +1,334 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 17 — lightweight in-process metrics + heap snapshot.
|
|
3
|
-
*
|
|
4
|
-
* Two outputs are derived from the same underlying state:
|
|
5
|
-
*
|
|
6
|
-
* 1. JSON snapshot (`/_mandu/heap`) — human-oriented debug dump.
|
|
7
|
-
* 2. Prometheus text exposition (`/_mandu/metrics`) — scraper-friendly.
|
|
8
|
-
*
|
|
9
|
-
* Design rules:
|
|
10
|
-
* - Hand-rolled. No new runtime deps.
|
|
11
|
-
* - Zero allocation in the hot path — counters are plain numbers.
|
|
12
|
-
* - Cache sizes are provided through a registry so each cache lives
|
|
13
|
-
* in its own module (no circular imports). Call
|
|
14
|
-
* `registerCacheSize("patternCache", () => cache.size)` at module
|
|
15
|
-
* init; the metrics collector calls each reporter lazily on scrape.
|
|
16
|
-
* - Label cardinality is strictly bounded — HTTP request counts are
|
|
17
|
-
* keyed by `{method, statusClass}` where statusClass is `2xx`/`3xx`/
|
|
18
|
-
* `4xx`/`5xx`/`other`, not the raw status. Prevents runaway series.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
export type CacheName = "patternCache" | "fetchCache" | "perFileTimers" | string;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Registry of cache-size reporters. Each reporter is called at scrape
|
|
25
|
-
* time and must return the current cache entry count. Reporters that
|
|
26
|
-
* throw or return a non-finite number are treated as `0` (defence
|
|
27
|
-
* against a half-torn-down subsystem).
|
|
28
|
-
*/
|
|
29
|
-
const cacheSizeReporters = new Map<CacheName, () => number>();
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Register (or replace) a cache-size reporter. Callers typically wire
|
|
33
|
-
* this at module init:
|
|
34
|
-
*
|
|
35
|
-
* const cache = new LRUCache<string, Compiled>({ maxSize: 200 });
|
|
36
|
-
* registerCacheSize("patternCache", () => cache.size);
|
|
37
|
-
*/
|
|
38
|
-
export function registerCacheSize(name: CacheName, reporter: () => number): void {
|
|
39
|
-
cacheSizeReporters.set(name, reporter);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Unregister a reporter. Used by hot-reload paths that rebuild their
|
|
44
|
-
* cache under a fresh reference.
|
|
45
|
-
*/
|
|
46
|
-
export function unregisterCacheSize(name: CacheName): boolean {
|
|
47
|
-
return cacheSizeReporters.delete(name);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* For tests — drop every reporter. Production code should never need this.
|
|
52
|
-
*/
|
|
53
|
-
export function clearCacheSizeReporters(): void {
|
|
54
|
-
cacheSizeReporters.clear();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Collect current sizes. Missing reporters simply don't appear. A
|
|
59
|
-
* thrown / non-finite reporter contributes `0` and is silently logged
|
|
60
|
-
* on the event bus (best-effort — we never propagate).
|
|
61
|
-
*/
|
|
62
|
-
export function collectCacheSizes(): Record<string, number> {
|
|
63
|
-
const out: Record<string, number> = {};
|
|
64
|
-
for (const [name, reporter] of cacheSizeReporters) {
|
|
65
|
-
let size = 0;
|
|
66
|
-
try {
|
|
67
|
-
const v = reporter();
|
|
68
|
-
size = typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0;
|
|
69
|
-
} catch {
|
|
70
|
-
size = 0;
|
|
71
|
-
}
|
|
72
|
-
out[name] = size;
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --------------------------------------------------------------------
|
|
78
|
-
// HTTP request counter
|
|
79
|
-
// --------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* `Map` keyed by `"METHOD statusClass"` for bounded cardinality.
|
|
83
|
-
* E.g. `"GET 2xx"` → 41.
|
|
84
|
-
*/
|
|
85
|
-
const httpRequestCounter = new Map<string, number>();
|
|
86
|
-
|
|
87
|
-
const STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "other"] as const;
|
|
88
|
-
type StatusClass = (typeof STATUS_CLASSES)[number];
|
|
89
|
-
|
|
90
|
-
function classifyStatus(status: number): StatusClass {
|
|
91
|
-
if (status >= 200 && status < 300) return "2xx";
|
|
92
|
-
if (status >= 300 && status < 400) return "3xx";
|
|
93
|
-
if (status >= 400 && status < 500) return "4xx";
|
|
94
|
-
if (status >= 500 && status < 600) return "5xx";
|
|
95
|
-
return "other";
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function normalizeMethod(method: string | undefined): string {
|
|
99
|
-
if (!method) return "UNKNOWN";
|
|
100
|
-
const upper = method.toUpperCase();
|
|
101
|
-
// Whitelist standard methods so a rogue "evil\n" header can't break
|
|
102
|
-
// the Prometheus line format. Anything unknown bucket under OTHER.
|
|
103
|
-
const allowed = new Set([
|
|
104
|
-
"GET",
|
|
105
|
-
"HEAD",
|
|
106
|
-
"POST",
|
|
107
|
-
"PUT",
|
|
108
|
-
"DELETE",
|
|
109
|
-
"PATCH",
|
|
110
|
-
"OPTIONS",
|
|
111
|
-
"TRACE",
|
|
112
|
-
"CONNECT",
|
|
113
|
-
]);
|
|
114
|
-
return allowed.has(upper) ? upper : "OTHER";
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Bump the request counter. Safe to call on every request path — O(1).
|
|
119
|
-
* The method/status pair is normalised to a bounded cardinality set.
|
|
120
|
-
*/
|
|
121
|
-
export function recordHttpRequest(method: string | undefined, status: number): void {
|
|
122
|
-
const m = normalizeMethod(method);
|
|
123
|
-
const cls = classifyStatus(status);
|
|
124
|
-
const key = `${m} ${cls}`;
|
|
125
|
-
httpRequestCounter.set(key, (httpRequestCounter.get(key) ?? 0) + 1);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Reset counters. Used by tests; prod rarely needs this. */
|
|
129
|
-
export function resetHttpRequestCounter(): void {
|
|
130
|
-
httpRequestCounter.clear();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Snapshot current counts for programmatic inspection. */
|
|
134
|
-
export function getHttpRequestCounts(): Array<{ method: string; statusClass: StatusClass; value: number }> {
|
|
135
|
-
const out: Array<{ method: string; statusClass: StatusClass; value: number }> = [];
|
|
136
|
-
for (const [key, value] of httpRequestCounter) {
|
|
137
|
-
const [method, statusClass] = key.split(" ");
|
|
138
|
-
out.push({ method: method!, statusClass: statusClass as StatusClass, value });
|
|
139
|
-
}
|
|
140
|
-
// Stable order → deterministic Prometheus output (aids scraping + tests).
|
|
141
|
-
out.sort((a, b) => {
|
|
142
|
-
if (a.method !== b.method) return a.method < b.method ? -1 : 1;
|
|
143
|
-
return a.statusClass < b.statusClass ? -1 : 1;
|
|
144
|
-
});
|
|
145
|
-
return out;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// --------------------------------------------------------------------
|
|
149
|
-
// Heap snapshot
|
|
150
|
-
// --------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
export interface HeapSnapshot {
|
|
153
|
-
/** Unix epoch millis when snapshot was captured. */
|
|
154
|
-
timestamp: number;
|
|
155
|
-
/** Process uptime in seconds. */
|
|
156
|
-
uptime: number;
|
|
157
|
-
/** `process.memoryUsage()` — always available. */
|
|
158
|
-
process: {
|
|
159
|
-
rss: number;
|
|
160
|
-
heapTotal: number;
|
|
161
|
-
heapUsed: number;
|
|
162
|
-
external: number;
|
|
163
|
-
arrayBuffers: number;
|
|
164
|
-
};
|
|
165
|
-
/** `Bun.memoryUsage()` if running on Bun and the API is available. */
|
|
166
|
-
bun?: Record<string, number>;
|
|
167
|
-
/** Current cache entry counts, keyed by reporter name. */
|
|
168
|
-
caches: Record<string, number>;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Assemble a heap snapshot. This is the single source of truth for
|
|
173
|
-
* both the JSON endpoint and the Prometheus exporter.
|
|
174
|
-
*/
|
|
175
|
-
export function collectHeapSnapshot(): HeapSnapshot {
|
|
176
|
-
const mem = process.memoryUsage();
|
|
177
|
-
const snapshot: HeapSnapshot = {
|
|
178
|
-
timestamp: Date.now(),
|
|
179
|
-
uptime: process.uptime(),
|
|
180
|
-
process: {
|
|
181
|
-
rss: mem.rss,
|
|
182
|
-
heapTotal: mem.heapTotal,
|
|
183
|
-
heapUsed: mem.heapUsed,
|
|
184
|
-
external: mem.external,
|
|
185
|
-
arrayBuffers: mem.arrayBuffers ?? 0,
|
|
186
|
-
},
|
|
187
|
-
caches: collectCacheSizes(),
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
// Bun.memoryUsage() is currently a non-standard helper; we feature-
|
|
191
|
-
// detect to stay forward-compatible with other runtimes (Node tests,
|
|
192
|
-
// edge workers) where the global is absent.
|
|
193
|
-
const bunGlobal = (globalThis as { Bun?: { memoryUsage?: () => Record<string, number> } }).Bun;
|
|
194
|
-
if (bunGlobal?.memoryUsage) {
|
|
195
|
-
try {
|
|
196
|
-
const bunMem = bunGlobal.memoryUsage();
|
|
197
|
-
if (bunMem && typeof bunMem === "object") {
|
|
198
|
-
snapshot.bun = bunMem;
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
// Swallow — not every Bun version ships this.
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return snapshot;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// --------------------------------------------------------------------
|
|
209
|
-
// Prometheus text exposition
|
|
210
|
-
// --------------------------------------------------------------------
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Escape a label value per Prometheus text format §
|
|
214
|
-
*
|
|
215
|
-
* - backslash → `\\`
|
|
216
|
-
* - newline → `\n`
|
|
217
|
-
* - double-quote → `\"`
|
|
218
|
-
*
|
|
219
|
-
* We never inline tab/CR because `normalizeMethod` already clamps
|
|
220
|
-
* methods to uppercase ASCII and statusClass is a fixed enum.
|
|
221
|
-
*/
|
|
222
|
-
function escapeLabelValue(value: string): string {
|
|
223
|
-
return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Render the current metric state in Prometheus text format. Each
|
|
228
|
-
* metric starts with `# HELP` + `# TYPE` headers per the spec.
|
|
229
|
-
*
|
|
230
|
-
* The output is deterministic (sorted labels, stable order) so tests
|
|
231
|
-
* can snapshot against it and scrapers get a consistent diff.
|
|
232
|
-
*/
|
|
233
|
-
export function renderPrometheus(snapshot?: HeapSnapshot): string {
|
|
234
|
-
const snap = snapshot ?? collectHeapSnapshot();
|
|
235
|
-
const lines: string[] = [];
|
|
236
|
-
|
|
237
|
-
// Node/Bun heap gauges.
|
|
238
|
-
lines.push(
|
|
239
|
-
"# HELP nodejs_heap_used_bytes Process heap used in bytes (process.memoryUsage().heapUsed).",
|
|
240
|
-
"# TYPE nodejs_heap_used_bytes gauge",
|
|
241
|
-
`nodejs_heap_used_bytes ${snap.process.heapUsed}`,
|
|
242
|
-
"# HELP nodejs_heap_total_bytes Process heap total in bytes (process.memoryUsage().heapTotal).",
|
|
243
|
-
"# TYPE nodejs_heap_total_bytes gauge",
|
|
244
|
-
`nodejs_heap_total_bytes ${snap.process.heapTotal}`,
|
|
245
|
-
"# HELP nodejs_external_bytes Process external memory in bytes (process.memoryUsage().external).",
|
|
246
|
-
"# TYPE nodejs_external_bytes gauge",
|
|
247
|
-
`nodejs_external_bytes ${snap.process.external}`,
|
|
248
|
-
"# HELP nodejs_rss_bytes Process resident set size in bytes.",
|
|
249
|
-
"# TYPE nodejs_rss_bytes gauge",
|
|
250
|
-
`nodejs_rss_bytes ${snap.process.rss}`,
|
|
251
|
-
"# HELP nodejs_uptime_seconds Process uptime in seconds.",
|
|
252
|
-
"# TYPE nodejs_uptime_seconds gauge",
|
|
253
|
-
`nodejs_uptime_seconds ${snap.uptime.toFixed(3)}`,
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
// Cache entry counts — one line per registered reporter. Keys are
|
|
257
|
-
// emitted in sorted order so the output is stable across scrapes.
|
|
258
|
-
lines.push(
|
|
259
|
-
"# HELP mandu_cache_entries Current entry count for Mandu internal caches.",
|
|
260
|
-
"# TYPE mandu_cache_entries gauge",
|
|
261
|
-
);
|
|
262
|
-
const cacheNames = Object.keys(snap.caches).sort();
|
|
263
|
-
if (cacheNames.length === 0) {
|
|
264
|
-
// Prometheus requires at least one sample for the series to be
|
|
265
|
-
// useful; emit a zero-valued placeholder so scrapers don't drop
|
|
266
|
-
// the metric entirely on an empty registry.
|
|
267
|
-
lines.push(`mandu_cache_entries{cache="none"} 0`);
|
|
268
|
-
} else {
|
|
269
|
-
for (const name of cacheNames) {
|
|
270
|
-
lines.push(`mandu_cache_entries{cache="${escapeLabelValue(name)}"} ${snap.caches[name]}`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// HTTP request counter.
|
|
275
|
-
lines.push(
|
|
276
|
-
"# HELP mandu_http_requests_total Total HTTP requests served by the Mandu runtime.",
|
|
277
|
-
"# TYPE mandu_http_requests_total counter",
|
|
278
|
-
);
|
|
279
|
-
const counts = getHttpRequestCounts();
|
|
280
|
-
if (counts.length === 0) {
|
|
281
|
-
lines.push(`mandu_http_requests_total{method="GET",status="2xx"} 0`);
|
|
282
|
-
} else {
|
|
283
|
-
for (const { method, statusClass, value } of counts) {
|
|
284
|
-
lines.push(
|
|
285
|
-
`mandu_http_requests_total{method="${escapeLabelValue(method)}",status="${escapeLabelValue(statusClass)}"} ${value}`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Final newline — the Prometheus parser is tolerant but conventional.
|
|
291
|
-
return lines.join("\n") + "\n";
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// --------------------------------------------------------------------
|
|
295
|
-
// HTTP endpoint handlers
|
|
296
|
-
// --------------------------------------------------------------------
|
|
297
|
-
|
|
298
|
-
/** Endpoint paths used by the runtime dispatcher. */
|
|
299
|
-
export const HEAP_ENDPOINT = "/_mandu/heap";
|
|
300
|
-
export const METRICS_ENDPOINT = "/_mandu/metrics";
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Gate production access. In dev (`isDev=true`) we always allow.
|
|
304
|
-
* In prod the operator must set `MANDU_DEBUG_HEAP=1` (so scrapers
|
|
305
|
-
* cannot trivially probe) unless an explicit config flag opted in.
|
|
306
|
-
*/
|
|
307
|
-
export function isObservabilityExposed(isDev: boolean, configFlag: boolean | undefined): boolean {
|
|
308
|
-
if (isDev) return configFlag !== false;
|
|
309
|
-
if (configFlag === true) return true;
|
|
310
|
-
return process.env.MANDU_DEBUG_HEAP === "1";
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export function buildHeapResponse(): Response {
|
|
314
|
-
const snapshot = collectHeapSnapshot();
|
|
315
|
-
return new Response(JSON.stringify(snapshot, null, 2), {
|
|
316
|
-
status: 200,
|
|
317
|
-
headers: {
|
|
318
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
319
|
-
"Cache-Control": "no-store",
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function buildMetricsResponse(): Response {
|
|
325
|
-
const body = renderPrometheus();
|
|
326
|
-
return new Response(body, {
|
|
327
|
-
status: 200,
|
|
328
|
-
headers: {
|
|
329
|
-
// Prometheus text exposition format spec version 0.0.4.
|
|
330
|
-
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
331
|
-
"Cache-Control": "no-store",
|
|
332
|
-
},
|
|
333
|
-
});
|
|
334
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase 17 — lightweight in-process metrics + heap snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Two outputs are derived from the same underlying state:
|
|
5
|
+
*
|
|
6
|
+
* 1. JSON snapshot (`/_mandu/heap`) — human-oriented debug dump.
|
|
7
|
+
* 2. Prometheus text exposition (`/_mandu/metrics`) — scraper-friendly.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - Hand-rolled. No new runtime deps.
|
|
11
|
+
* - Zero allocation in the hot path — counters are plain numbers.
|
|
12
|
+
* - Cache sizes are provided through a registry so each cache lives
|
|
13
|
+
* in its own module (no circular imports). Call
|
|
14
|
+
* `registerCacheSize("patternCache", () => cache.size)` at module
|
|
15
|
+
* init; the metrics collector calls each reporter lazily on scrape.
|
|
16
|
+
* - Label cardinality is strictly bounded — HTTP request counts are
|
|
17
|
+
* keyed by `{method, statusClass}` where statusClass is `2xx`/`3xx`/
|
|
18
|
+
* `4xx`/`5xx`/`other`, not the raw status. Prevents runaway series.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type CacheName = "patternCache" | "fetchCache" | "perFileTimers" | string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registry of cache-size reporters. Each reporter is called at scrape
|
|
25
|
+
* time and must return the current cache entry count. Reporters that
|
|
26
|
+
* throw or return a non-finite number are treated as `0` (defence
|
|
27
|
+
* against a half-torn-down subsystem).
|
|
28
|
+
*/
|
|
29
|
+
const cacheSizeReporters = new Map<CacheName, () => number>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register (or replace) a cache-size reporter. Callers typically wire
|
|
33
|
+
* this at module init:
|
|
34
|
+
*
|
|
35
|
+
* const cache = new LRUCache<string, Compiled>({ maxSize: 200 });
|
|
36
|
+
* registerCacheSize("patternCache", () => cache.size);
|
|
37
|
+
*/
|
|
38
|
+
export function registerCacheSize(name: CacheName, reporter: () => number): void {
|
|
39
|
+
cacheSizeReporters.set(name, reporter);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Unregister a reporter. Used by hot-reload paths that rebuild their
|
|
44
|
+
* cache under a fresh reference.
|
|
45
|
+
*/
|
|
46
|
+
export function unregisterCacheSize(name: CacheName): boolean {
|
|
47
|
+
return cacheSizeReporters.delete(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* For tests — drop every reporter. Production code should never need this.
|
|
52
|
+
*/
|
|
53
|
+
export function clearCacheSizeReporters(): void {
|
|
54
|
+
cacheSizeReporters.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collect current sizes. Missing reporters simply don't appear. A
|
|
59
|
+
* thrown / non-finite reporter contributes `0` and is silently logged
|
|
60
|
+
* on the event bus (best-effort — we never propagate).
|
|
61
|
+
*/
|
|
62
|
+
export function collectCacheSizes(): Record<string, number> {
|
|
63
|
+
const out: Record<string, number> = {};
|
|
64
|
+
for (const [name, reporter] of cacheSizeReporters) {
|
|
65
|
+
let size = 0;
|
|
66
|
+
try {
|
|
67
|
+
const v = reporter();
|
|
68
|
+
size = typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0;
|
|
69
|
+
} catch {
|
|
70
|
+
size = 0;
|
|
71
|
+
}
|
|
72
|
+
out[name] = size;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --------------------------------------------------------------------
|
|
78
|
+
// HTTP request counter
|
|
79
|
+
// --------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `Map` keyed by `"METHOD statusClass"` for bounded cardinality.
|
|
83
|
+
* E.g. `"GET 2xx"` → 41.
|
|
84
|
+
*/
|
|
85
|
+
const httpRequestCounter = new Map<string, number>();
|
|
86
|
+
|
|
87
|
+
const STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "other"] as const;
|
|
88
|
+
type StatusClass = (typeof STATUS_CLASSES)[number];
|
|
89
|
+
|
|
90
|
+
function classifyStatus(status: number): StatusClass {
|
|
91
|
+
if (status >= 200 && status < 300) return "2xx";
|
|
92
|
+
if (status >= 300 && status < 400) return "3xx";
|
|
93
|
+
if (status >= 400 && status < 500) return "4xx";
|
|
94
|
+
if (status >= 500 && status < 600) return "5xx";
|
|
95
|
+
return "other";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeMethod(method: string | undefined): string {
|
|
99
|
+
if (!method) return "UNKNOWN";
|
|
100
|
+
const upper = method.toUpperCase();
|
|
101
|
+
// Whitelist standard methods so a rogue "evil\n" header can't break
|
|
102
|
+
// the Prometheus line format. Anything unknown bucket under OTHER.
|
|
103
|
+
const allowed = new Set([
|
|
104
|
+
"GET",
|
|
105
|
+
"HEAD",
|
|
106
|
+
"POST",
|
|
107
|
+
"PUT",
|
|
108
|
+
"DELETE",
|
|
109
|
+
"PATCH",
|
|
110
|
+
"OPTIONS",
|
|
111
|
+
"TRACE",
|
|
112
|
+
"CONNECT",
|
|
113
|
+
]);
|
|
114
|
+
return allowed.has(upper) ? upper : "OTHER";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bump the request counter. Safe to call on every request path — O(1).
|
|
119
|
+
* The method/status pair is normalised to a bounded cardinality set.
|
|
120
|
+
*/
|
|
121
|
+
export function recordHttpRequest(method: string | undefined, status: number): void {
|
|
122
|
+
const m = normalizeMethod(method);
|
|
123
|
+
const cls = classifyStatus(status);
|
|
124
|
+
const key = `${m} ${cls}`;
|
|
125
|
+
httpRequestCounter.set(key, (httpRequestCounter.get(key) ?? 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reset counters. Used by tests; prod rarely needs this. */
|
|
129
|
+
export function resetHttpRequestCounter(): void {
|
|
130
|
+
httpRequestCounter.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Snapshot current counts for programmatic inspection. */
|
|
134
|
+
export function getHttpRequestCounts(): Array<{ method: string; statusClass: StatusClass; value: number }> {
|
|
135
|
+
const out: Array<{ method: string; statusClass: StatusClass; value: number }> = [];
|
|
136
|
+
for (const [key, value] of httpRequestCounter) {
|
|
137
|
+
const [method, statusClass] = key.split(" ");
|
|
138
|
+
out.push({ method: method!, statusClass: statusClass as StatusClass, value });
|
|
139
|
+
}
|
|
140
|
+
// Stable order → deterministic Prometheus output (aids scraping + tests).
|
|
141
|
+
out.sort((a, b) => {
|
|
142
|
+
if (a.method !== b.method) return a.method < b.method ? -1 : 1;
|
|
143
|
+
return a.statusClass < b.statusClass ? -1 : 1;
|
|
144
|
+
});
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --------------------------------------------------------------------
|
|
149
|
+
// Heap snapshot
|
|
150
|
+
// --------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export interface HeapSnapshot {
|
|
153
|
+
/** Unix epoch millis when snapshot was captured. */
|
|
154
|
+
timestamp: number;
|
|
155
|
+
/** Process uptime in seconds. */
|
|
156
|
+
uptime: number;
|
|
157
|
+
/** `process.memoryUsage()` — always available. */
|
|
158
|
+
process: {
|
|
159
|
+
rss: number;
|
|
160
|
+
heapTotal: number;
|
|
161
|
+
heapUsed: number;
|
|
162
|
+
external: number;
|
|
163
|
+
arrayBuffers: number;
|
|
164
|
+
};
|
|
165
|
+
/** `Bun.memoryUsage()` if running on Bun and the API is available. */
|
|
166
|
+
bun?: Record<string, number>;
|
|
167
|
+
/** Current cache entry counts, keyed by reporter name. */
|
|
168
|
+
caches: Record<string, number>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Assemble a heap snapshot. This is the single source of truth for
|
|
173
|
+
* both the JSON endpoint and the Prometheus exporter.
|
|
174
|
+
*/
|
|
175
|
+
export function collectHeapSnapshot(): HeapSnapshot {
|
|
176
|
+
const mem = process.memoryUsage();
|
|
177
|
+
const snapshot: HeapSnapshot = {
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
uptime: process.uptime(),
|
|
180
|
+
process: {
|
|
181
|
+
rss: mem.rss,
|
|
182
|
+
heapTotal: mem.heapTotal,
|
|
183
|
+
heapUsed: mem.heapUsed,
|
|
184
|
+
external: mem.external,
|
|
185
|
+
arrayBuffers: mem.arrayBuffers ?? 0,
|
|
186
|
+
},
|
|
187
|
+
caches: collectCacheSizes(),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Bun.memoryUsage() is currently a non-standard helper; we feature-
|
|
191
|
+
// detect to stay forward-compatible with other runtimes (Node tests,
|
|
192
|
+
// edge workers) where the global is absent.
|
|
193
|
+
const bunGlobal = (globalThis as { Bun?: { memoryUsage?: () => Record<string, number> } }).Bun;
|
|
194
|
+
if (bunGlobal?.memoryUsage) {
|
|
195
|
+
try {
|
|
196
|
+
const bunMem = bunGlobal.memoryUsage();
|
|
197
|
+
if (bunMem && typeof bunMem === "object") {
|
|
198
|
+
snapshot.bun = bunMem;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Swallow — not every Bun version ships this.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return snapshot;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --------------------------------------------------------------------
|
|
209
|
+
// Prometheus text exposition
|
|
210
|
+
// --------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Escape a label value per Prometheus text format §
|
|
214
|
+
*
|
|
215
|
+
* - backslash → `\\`
|
|
216
|
+
* - newline → `\n`
|
|
217
|
+
* - double-quote → `\"`
|
|
218
|
+
*
|
|
219
|
+
* We never inline tab/CR because `normalizeMethod` already clamps
|
|
220
|
+
* methods to uppercase ASCII and statusClass is a fixed enum.
|
|
221
|
+
*/
|
|
222
|
+
function escapeLabelValue(value: string): string {
|
|
223
|
+
return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Render the current metric state in Prometheus text format. Each
|
|
228
|
+
* metric starts with `# HELP` + `# TYPE` headers per the spec.
|
|
229
|
+
*
|
|
230
|
+
* The output is deterministic (sorted labels, stable order) so tests
|
|
231
|
+
* can snapshot against it and scrapers get a consistent diff.
|
|
232
|
+
*/
|
|
233
|
+
export function renderPrometheus(snapshot?: HeapSnapshot): string {
|
|
234
|
+
const snap = snapshot ?? collectHeapSnapshot();
|
|
235
|
+
const lines: string[] = [];
|
|
236
|
+
|
|
237
|
+
// Node/Bun heap gauges.
|
|
238
|
+
lines.push(
|
|
239
|
+
"# HELP nodejs_heap_used_bytes Process heap used in bytes (process.memoryUsage().heapUsed).",
|
|
240
|
+
"# TYPE nodejs_heap_used_bytes gauge",
|
|
241
|
+
`nodejs_heap_used_bytes ${snap.process.heapUsed}`,
|
|
242
|
+
"# HELP nodejs_heap_total_bytes Process heap total in bytes (process.memoryUsage().heapTotal).",
|
|
243
|
+
"# TYPE nodejs_heap_total_bytes gauge",
|
|
244
|
+
`nodejs_heap_total_bytes ${snap.process.heapTotal}`,
|
|
245
|
+
"# HELP nodejs_external_bytes Process external memory in bytes (process.memoryUsage().external).",
|
|
246
|
+
"# TYPE nodejs_external_bytes gauge",
|
|
247
|
+
`nodejs_external_bytes ${snap.process.external}`,
|
|
248
|
+
"# HELP nodejs_rss_bytes Process resident set size in bytes.",
|
|
249
|
+
"# TYPE nodejs_rss_bytes gauge",
|
|
250
|
+
`nodejs_rss_bytes ${snap.process.rss}`,
|
|
251
|
+
"# HELP nodejs_uptime_seconds Process uptime in seconds.",
|
|
252
|
+
"# TYPE nodejs_uptime_seconds gauge",
|
|
253
|
+
`nodejs_uptime_seconds ${snap.uptime.toFixed(3)}`,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Cache entry counts — one line per registered reporter. Keys are
|
|
257
|
+
// emitted in sorted order so the output is stable across scrapes.
|
|
258
|
+
lines.push(
|
|
259
|
+
"# HELP mandu_cache_entries Current entry count for Mandu internal caches.",
|
|
260
|
+
"# TYPE mandu_cache_entries gauge",
|
|
261
|
+
);
|
|
262
|
+
const cacheNames = Object.keys(snap.caches).sort();
|
|
263
|
+
if (cacheNames.length === 0) {
|
|
264
|
+
// Prometheus requires at least one sample for the series to be
|
|
265
|
+
// useful; emit a zero-valued placeholder so scrapers don't drop
|
|
266
|
+
// the metric entirely on an empty registry.
|
|
267
|
+
lines.push(`mandu_cache_entries{cache="none"} 0`);
|
|
268
|
+
} else {
|
|
269
|
+
for (const name of cacheNames) {
|
|
270
|
+
lines.push(`mandu_cache_entries{cache="${escapeLabelValue(name)}"} ${snap.caches[name]}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// HTTP request counter.
|
|
275
|
+
lines.push(
|
|
276
|
+
"# HELP mandu_http_requests_total Total HTTP requests served by the Mandu runtime.",
|
|
277
|
+
"# TYPE mandu_http_requests_total counter",
|
|
278
|
+
);
|
|
279
|
+
const counts = getHttpRequestCounts();
|
|
280
|
+
if (counts.length === 0) {
|
|
281
|
+
lines.push(`mandu_http_requests_total{method="GET",status="2xx"} 0`);
|
|
282
|
+
} else {
|
|
283
|
+
for (const { method, statusClass, value } of counts) {
|
|
284
|
+
lines.push(
|
|
285
|
+
`mandu_http_requests_total{method="${escapeLabelValue(method)}",status="${escapeLabelValue(statusClass)}"} ${value}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Final newline — the Prometheus parser is tolerant but conventional.
|
|
291
|
+
return lines.join("\n") + "\n";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --------------------------------------------------------------------
|
|
295
|
+
// HTTP endpoint handlers
|
|
296
|
+
// --------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** Endpoint paths used by the runtime dispatcher. */
|
|
299
|
+
export const HEAP_ENDPOINT = "/_mandu/heap";
|
|
300
|
+
export const METRICS_ENDPOINT = "/_mandu/metrics";
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Gate production access. In dev (`isDev=true`) we always allow.
|
|
304
|
+
* In prod the operator must set `MANDU_DEBUG_HEAP=1` (so scrapers
|
|
305
|
+
* cannot trivially probe) unless an explicit config flag opted in.
|
|
306
|
+
*/
|
|
307
|
+
export function isObservabilityExposed(isDev: boolean, configFlag: boolean | undefined): boolean {
|
|
308
|
+
if (isDev) return configFlag !== false;
|
|
309
|
+
if (configFlag === true) return true;
|
|
310
|
+
return process.env.MANDU_DEBUG_HEAP === "1";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function buildHeapResponse(): Response {
|
|
314
|
+
const snapshot = collectHeapSnapshot();
|
|
315
|
+
return new Response(JSON.stringify(snapshot, null, 2), {
|
|
316
|
+
status: 200,
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
319
|
+
"Cache-Control": "no-store",
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function buildMetricsResponse(): Response {
|
|
325
|
+
const body = renderPrometheus();
|
|
326
|
+
return new Response(body, {
|
|
327
|
+
status: 200,
|
|
328
|
+
headers: {
|
|
329
|
+
// Prometheus text exposition format spec version 0.0.4.
|
|
330
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
331
|
+
"Cache-Control": "no-store",
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|