@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
package/src/perf/user-marks.ts
CHANGED
|
@@ -1,553 +1,553 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 18.ψ — user-facing performance marks API
|
|
3
|
-
*
|
|
4
|
-
* Layered on top of the Phase 0 `@mandujs/core/perf` gate (`MANDU_PERF=1`),
|
|
5
|
-
* this module exposes a developer-friendly surface for measuring custom
|
|
6
|
-
* operations in application code. The existing module-level
|
|
7
|
-
* {@link import("./index").mark} + {@link import("./index").measure} remain
|
|
8
|
-
* unchanged for framework-internal callers; this file adds new primitives
|
|
9
|
-
* that close the ergonomic gap identified in user feedback:
|
|
10
|
-
*
|
|
11
|
-
* ```ts
|
|
12
|
-
* import { time, timeAsync, createPerf } from "@mandujs/core/perf";
|
|
13
|
-
*
|
|
14
|
-
* // 1. Basic scoped timing — returns a close-over end() function.
|
|
15
|
-
* const end = time("db-query");
|
|
16
|
-
* const rows = await db.query("SELECT ...");
|
|
17
|
-
* end(); // emits measurement
|
|
18
|
-
*
|
|
19
|
-
* // 2. Async scope — auto-closed, resolved value forwarded, errors rethrown.
|
|
20
|
-
* const data = await timeAsync("fetch-user", async () => {
|
|
21
|
-
* return await fetch("/api/user/123").then((r) => r.json());
|
|
22
|
-
* });
|
|
23
|
-
*
|
|
24
|
-
* // 3. Scoped instance (dependency-injection / opt-in per-module):
|
|
25
|
-
* const perf = createPerf({ enabled: process.env.MANDU_PERF === "1" });
|
|
26
|
-
* perf.time("foo")(); // no-op when disabled, zero overhead
|
|
27
|
-
* ```
|
|
28
|
-
*
|
|
29
|
-
* Design contracts:
|
|
30
|
-
* - **Zero overhead when gated off.** When {@link isPerfEnabled} is
|
|
31
|
-
* `false`, every function is a single branch that returns a
|
|
32
|
-
* stable no-op (see `NOOP_END`). No `Map` allocation, no histogram
|
|
33
|
-
* entry, no span creation, no console output.
|
|
34
|
-
* - **Gate is shared.** Framework-internal marks (HMR, SSR, bundler)
|
|
35
|
-
* and user marks honor the same `MANDU_PERF=1` switch. Users do not
|
|
36
|
-
* need a separate env var.
|
|
37
|
-
* - **OTel integration is automatic.** When Phase 18.θ's request tracer
|
|
38
|
-
* is enabled, every user mark becomes a child span under the active
|
|
39
|
-
* request span. Attribute `mandu.perf.category = "user" | "framework"
|
|
40
|
-
* | "bundler" | "ssr"` differentiates mark origin in the trace view.
|
|
41
|
-
* Opt-out via `{ trace: false }`.
|
|
42
|
-
* - **Bounded histogram buffer.** The last 1000 recorded measurements
|
|
43
|
-
* are retained in a ring buffer for `/_mandu/heap` inspection. Older
|
|
44
|
-
* entries are dropped in insertion order. Same cap as Phase 17's
|
|
45
|
-
* event-bus recent buffer.
|
|
46
|
-
* - **No new runtime deps.** Timing uses `Bun.nanoseconds()` when
|
|
47
|
-
* available, `performance.now()` elsewhere.
|
|
48
|
-
*
|
|
49
|
-
* Rationale vs `mark()` + `measure()` (framework-internal):
|
|
50
|
-
* - `mark(name)` records a start timestamp, then a later `measure(label,
|
|
51
|
-
* name)` call computes the delta. That signature is great for
|
|
52
|
-
* framework code where start/end happen in separate functions, but
|
|
53
|
-
* forces users to invent unique `name` strings and wire them through
|
|
54
|
-
* closures. The `time() => end()` shape is more natural for
|
|
55
|
-
* application code where both ends live in the same lexical scope.
|
|
56
|
-
* - Both APIs coexist; `time()` internally uses `Bun.nanoseconds()`
|
|
57
|
-
* directly and does NOT share the framework's `marks: Map<string,
|
|
58
|
-
* number>`. This avoids collisions between user-chosen names and
|
|
59
|
-
* framework markers (e.g., if a user named a span `"hmr:rebuild-total"`).
|
|
60
|
-
*
|
|
61
|
-
* @module perf/user-marks
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
import { isPerfEnabled } from "./index";
|
|
65
|
-
import { getTracer, getActiveSpan, runWithSpan, type Span } from "../observability/tracing";
|
|
66
|
-
|
|
67
|
-
// ─── Types ──────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Origin of a mark. Framework code sets this to the appropriate value
|
|
71
|
-
* when bridging `mark()`/`measure()` calls into the histogram. User
|
|
72
|
-
* `time()` / `timeAsync()` always records `"user"`.
|
|
73
|
-
*/
|
|
74
|
-
export type PerfCategory = "user" | "framework" | "bundler" | "ssr";
|
|
75
|
-
|
|
76
|
-
/** Options accepted by {@link time} / {@link timeAsync} and {@link Perf.time}. */
|
|
77
|
-
export interface PerfMarkOptions {
|
|
78
|
-
/**
|
|
79
|
-
* Span category for histogram attribution + OTel attribute. Default:
|
|
80
|
-
* `"user"`. Framework code sets this explicitly.
|
|
81
|
-
*/
|
|
82
|
-
category?: PerfCategory;
|
|
83
|
-
/**
|
|
84
|
-
* When `true` (default) and a tracer is active, the mark creates a
|
|
85
|
-
* child span on `end()` with attributes `mandu.perf.category` and
|
|
86
|
-
* duration. When `false`, histogram still records but no span emits.
|
|
87
|
-
*/
|
|
88
|
-
trace?: boolean;
|
|
89
|
-
/**
|
|
90
|
-
* Extra attributes attached to the OTel child span (when
|
|
91
|
-
* `trace !== false` and a tracer is active). Ignored otherwise.
|
|
92
|
-
*/
|
|
93
|
-
attributes?: Record<string, string | number | boolean>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Zero-arg function returned by {@link time}. Idempotent — safe to call twice. */
|
|
97
|
-
export type PerfEndFn = () => number;
|
|
98
|
-
|
|
99
|
-
/** One entry in the bounded histogram buffer. */
|
|
100
|
-
export interface PerfMarkEntry {
|
|
101
|
-
/** Mark label. */
|
|
102
|
-
name: string;
|
|
103
|
-
/** Duration in milliseconds. */
|
|
104
|
-
durationMs: number;
|
|
105
|
-
/** Category as set by caller. */
|
|
106
|
-
category: PerfCategory;
|
|
107
|
-
/** Unix epoch millis when the mark was closed. */
|
|
108
|
-
timestamp: number;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Statistical summary per unique mark name. */
|
|
112
|
-
export interface PerfHistogramEntry {
|
|
113
|
-
name: string;
|
|
114
|
-
count: number;
|
|
115
|
-
mean: number;
|
|
116
|
-
/** 50th percentile. */
|
|
117
|
-
p50: number;
|
|
118
|
-
/** 95th percentile. */
|
|
119
|
-
p95: number;
|
|
120
|
-
/** 99th percentile. */
|
|
121
|
-
p99: number;
|
|
122
|
-
/** Minimum observed duration (ms). */
|
|
123
|
-
min: number;
|
|
124
|
-
/** Maximum observed duration (ms). */
|
|
125
|
-
max: number;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Shape of the `perf` block appended to `/_mandu/heap` JSON. */
|
|
129
|
-
export interface PerfDashboardSnapshot {
|
|
130
|
-
enabled: boolean;
|
|
131
|
-
/** Total closed marks since process start (NOT the buffer size). */
|
|
132
|
-
totalCount: number;
|
|
133
|
-
/** Current number of entries retained in the ring buffer. */
|
|
134
|
-
bufferedCount: number;
|
|
135
|
-
/** Max ring-buffer capacity. */
|
|
136
|
-
bufferLimit: number;
|
|
137
|
-
/** Per-name histogram, sorted by descending `count`. */
|
|
138
|
-
histogram: PerfHistogramEntry[];
|
|
139
|
-
/** The most-recent N marks (up to 50), newest last. */
|
|
140
|
-
recent: PerfMarkEntry[];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ─── Timing primitive ───────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* High-resolution timestamp in milliseconds as a float. Prefers
|
|
147
|
-
* `Bun.nanoseconds()` (monotonic, integer-nanos precision) and falls
|
|
148
|
-
* back to `performance.now()` for non-Bun runtimes (tests that spawn
|
|
149
|
-
* Node, edge worker shims).
|
|
150
|
-
*/
|
|
151
|
-
function nowMs(): number {
|
|
152
|
-
const bun = (globalThis as { Bun?: { nanoseconds?: () => number } }).Bun;
|
|
153
|
-
if (bun && typeof bun.nanoseconds === "function") {
|
|
154
|
-
return bun.nanoseconds() / 1_000_000;
|
|
155
|
-
}
|
|
156
|
-
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
157
|
-
return performance.now();
|
|
158
|
-
}
|
|
159
|
-
return Date.now();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ─── Ring buffer + running stats ─────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Max entries retained in the recent-marks ring buffer. Matches the
|
|
166
|
-
* Phase 17 event-bus recent buffer so operators get a consistent
|
|
167
|
-
* "last N" window across `/_mandu/heap` and `/_mandu/events/recent`.
|
|
168
|
-
*/
|
|
169
|
-
export const PERF_BUFFER_LIMIT = 1000;
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Per-name running state. We store the raw observations for percentile
|
|
173
|
-
* computation (bounded per-name at `PER_NAME_OBS_LIMIT` to cap memory
|
|
174
|
-
* across a long dev session). For O(1) scrape, we also maintain count /
|
|
175
|
-
* min / max / sum — percentiles are the only O(n log n) piece.
|
|
176
|
-
*/
|
|
177
|
-
const PER_NAME_OBS_LIMIT = 500;
|
|
178
|
-
|
|
179
|
-
interface PerNameStats {
|
|
180
|
-
count: number;
|
|
181
|
-
sum: number;
|
|
182
|
-
min: number;
|
|
183
|
-
max: number;
|
|
184
|
-
/** Bounded window of observations (FIFO) for percentile calc. */
|
|
185
|
-
observations: number[];
|
|
186
|
-
/** Next write index into the ring. */
|
|
187
|
-
obsHead: number;
|
|
188
|
-
obsFull: boolean;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Ring buffer of most-recent `PerfMarkEntry` values (capacity `PERF_BUFFER_LIMIT`). */
|
|
192
|
-
const ringBuffer: (PerfMarkEntry | undefined)[] = new Array(PERF_BUFFER_LIMIT);
|
|
193
|
-
let ringHead = 0;
|
|
194
|
-
let ringFull = false;
|
|
195
|
-
|
|
196
|
-
/** Monotonic counter of closed marks since process start. */
|
|
197
|
-
let totalClosedCount = 0;
|
|
198
|
-
|
|
199
|
-
/** Per-name stats map. Only populated when perf is enabled. */
|
|
200
|
-
const perNameStats = new Map<string, PerNameStats>();
|
|
201
|
-
|
|
202
|
-
function getOrCreateStats(name: string): PerNameStats {
|
|
203
|
-
let s = perNameStats.get(name);
|
|
204
|
-
if (!s) {
|
|
205
|
-
s = {
|
|
206
|
-
count: 0,
|
|
207
|
-
sum: 0,
|
|
208
|
-
min: Number.POSITIVE_INFINITY,
|
|
209
|
-
max: Number.NEGATIVE_INFINITY,
|
|
210
|
-
observations: new Array(PER_NAME_OBS_LIMIT),
|
|
211
|
-
obsHead: 0,
|
|
212
|
-
obsFull: false,
|
|
213
|
-
};
|
|
214
|
-
perNameStats.set(name, s);
|
|
215
|
-
}
|
|
216
|
-
return s;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Append a closed mark to the ring buffer + update per-name stats. */
|
|
220
|
-
function recordEntry(entry: PerfMarkEntry): void {
|
|
221
|
-
// Ring buffer append.
|
|
222
|
-
ringBuffer[ringHead] = entry;
|
|
223
|
-
ringHead = (ringHead + 1) % PERF_BUFFER_LIMIT;
|
|
224
|
-
if (ringHead === 0) ringFull = true;
|
|
225
|
-
totalClosedCount++;
|
|
226
|
-
|
|
227
|
-
// Per-name stats update.
|
|
228
|
-
const s = getOrCreateStats(entry.name);
|
|
229
|
-
s.count++;
|
|
230
|
-
s.sum += entry.durationMs;
|
|
231
|
-
if (entry.durationMs < s.min) s.min = entry.durationMs;
|
|
232
|
-
if (entry.durationMs > s.max) s.max = entry.durationMs;
|
|
233
|
-
s.observations[s.obsHead] = entry.durationMs;
|
|
234
|
-
s.obsHead = (s.obsHead + 1) % PER_NAME_OBS_LIMIT;
|
|
235
|
-
if (s.obsHead === 0) s.obsFull = true;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Compute the p-th percentile (0..100) of a sorted array. */
|
|
239
|
-
function percentile(sorted: number[], p: number): number {
|
|
240
|
-
if (sorted.length === 0) return 0;
|
|
241
|
-
// Nearest-rank method — simple, deterministic, no interpolation
|
|
242
|
-
// artifacts on small samples.
|
|
243
|
-
const rank = Math.ceil((p / 100) * sorted.length);
|
|
244
|
-
const idx = Math.max(0, Math.min(sorted.length - 1, rank - 1));
|
|
245
|
-
return sorted[idx] ?? 0;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function buildHistogramEntry(name: string, s: PerNameStats): PerfHistogramEntry {
|
|
249
|
-
const obs = s.obsFull
|
|
250
|
-
? s.observations.slice(0, PER_NAME_OBS_LIMIT)
|
|
251
|
-
: s.observations.slice(0, s.obsHead);
|
|
252
|
-
const sorted = [...obs].sort((a, b) => a - b);
|
|
253
|
-
return {
|
|
254
|
-
name,
|
|
255
|
-
count: s.count,
|
|
256
|
-
mean: s.count > 0 ? s.sum / s.count : 0,
|
|
257
|
-
p50: percentile(sorted, 50),
|
|
258
|
-
p95: percentile(sorted, 95),
|
|
259
|
-
p99: percentile(sorted, 99),
|
|
260
|
-
min: s.min === Number.POSITIVE_INFINITY ? 0 : s.min,
|
|
261
|
-
max: s.max === Number.NEGATIVE_INFINITY ? 0 : s.max,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// ─── No-op sentinel ─────────────────────────────────────────────────────
|
|
266
|
-
|
|
267
|
-
const NOOP_END: PerfEndFn = () => 0;
|
|
268
|
-
|
|
269
|
-
// ─── Public functions ───────────────────────────────────────────────────
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Open a performance measurement. Returns an `end()` function that,
|
|
273
|
-
* when called, closes the measurement and records it in the histogram
|
|
274
|
-
* + OTel span (if tracing is active).
|
|
275
|
-
*
|
|
276
|
-
* When {@link isPerfEnabled} is `false`, returns a shared no-op end
|
|
277
|
-
* function. Zero allocations on the disabled path.
|
|
278
|
-
*
|
|
279
|
-
* Calling `end()` more than once is a no-op on the second call; the
|
|
280
|
-
* duration recorded is always the delta from the first `time()` call
|
|
281
|
-
* to the first `end()` call.
|
|
282
|
-
*/
|
|
283
|
-
export function time(name: string, options: PerfMarkOptions = {}): PerfEndFn {
|
|
284
|
-
if (!isPerfEnabled()) return NOOP_END;
|
|
285
|
-
const category: PerfCategory = options.category ?? "user";
|
|
286
|
-
const traceEnabled = options.trace !== false;
|
|
287
|
-
const startMs = nowMs();
|
|
288
|
-
|
|
289
|
-
// Open an OTel span lazily: only if tracing is enabled AND the active
|
|
290
|
-
// tracer is recording. We create the span now (so attributes can be
|
|
291
|
-
// set on it pre-end), but only end it when the user calls end().
|
|
292
|
-
let span: Span | undefined;
|
|
293
|
-
if (traceEnabled) {
|
|
294
|
-
const tracer = getTracer();
|
|
295
|
-
if (tracer.enabled) {
|
|
296
|
-
span = tracer.startSpan(name, {
|
|
297
|
-
kind: "internal",
|
|
298
|
-
attributes: {
|
|
299
|
-
"mandu.perf.category": category,
|
|
300
|
-
...(options.attributes ?? {}),
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
let closed = false;
|
|
307
|
-
return function endMark(): number {
|
|
308
|
-
if (closed) return 0;
|
|
309
|
-
closed = true;
|
|
310
|
-
const durationMs = nowMs() - startMs;
|
|
311
|
-
const entry: PerfMarkEntry = {
|
|
312
|
-
name,
|
|
313
|
-
durationMs,
|
|
314
|
-
category,
|
|
315
|
-
timestamp: Date.now(),
|
|
316
|
-
};
|
|
317
|
-
recordEntry(entry);
|
|
318
|
-
if (span) {
|
|
319
|
-
// Attach the measured duration so downstream consumers can read it
|
|
320
|
-
// from trace attributes without having to compute `endTime - startTime`.
|
|
321
|
-
span.setAttribute("mandu.perf.duration_ms", durationMs);
|
|
322
|
-
span.setStatus("ok");
|
|
323
|
-
span.end();
|
|
324
|
-
}
|
|
325
|
-
return durationMs;
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Measure the wall-clock duration of `fn`. Propagates the resolved
|
|
331
|
-
* value and rethrows errors (closing the mark with `status=error` on
|
|
332
|
-
* the OTel span before rethrow).
|
|
333
|
-
*
|
|
334
|
-
* Works for both sync and async `fn`: the return type is always
|
|
335
|
-
* `Promise<T>` because the common case is `await`ing an async body.
|
|
336
|
-
* For a sync-only variant that preserves the sync return type, use
|
|
337
|
-
* {@link time} directly.
|
|
338
|
-
*
|
|
339
|
-
* When perf is disabled, `fn` is invoked directly and the result is
|
|
340
|
-
* returned unchanged — a single branch adds near-zero overhead.
|
|
341
|
-
*/
|
|
342
|
-
export async function timeAsync<T>(
|
|
343
|
-
name: string,
|
|
344
|
-
fn: () => T | Promise<T>,
|
|
345
|
-
options: PerfMarkOptions = {},
|
|
346
|
-
): Promise<T> {
|
|
347
|
-
if (!isPerfEnabled()) return await fn();
|
|
348
|
-
|
|
349
|
-
// When tracing is active, put the child span in the ALS scope so any
|
|
350
|
-
// further `time()` / `timeAsync()` calls inside `fn` nest correctly.
|
|
351
|
-
const category: PerfCategory = options.category ?? "user";
|
|
352
|
-
const traceEnabled = options.trace !== false;
|
|
353
|
-
const tracer = getTracer();
|
|
354
|
-
const useSpanScope = traceEnabled && tracer.enabled;
|
|
355
|
-
|
|
356
|
-
const startMs = nowMs();
|
|
357
|
-
let span: Span | undefined;
|
|
358
|
-
if (useSpanScope) {
|
|
359
|
-
span = tracer.startSpan(name, {
|
|
360
|
-
kind: "internal",
|
|
361
|
-
attributes: {
|
|
362
|
-
"mandu.perf.category": category,
|
|
363
|
-
...(options.attributes ?? {}),
|
|
364
|
-
},
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const finalize = (status: "ok" | "error", errorMsg?: string): number => {
|
|
369
|
-
const durationMs = nowMs() - startMs;
|
|
370
|
-
recordEntry({
|
|
371
|
-
name,
|
|
372
|
-
durationMs,
|
|
373
|
-
category,
|
|
374
|
-
timestamp: Date.now(),
|
|
375
|
-
});
|
|
376
|
-
if (span) {
|
|
377
|
-
span.setAttribute("mandu.perf.duration_ms", durationMs);
|
|
378
|
-
span.setStatus(status, errorMsg);
|
|
379
|
-
span.end();
|
|
380
|
-
}
|
|
381
|
-
return durationMs;
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
try {
|
|
385
|
-
const result = span
|
|
386
|
-
? await runWithSpan(span, () => Promise.resolve(fn()))
|
|
387
|
-
: await fn();
|
|
388
|
-
finalize("ok");
|
|
389
|
-
return result;
|
|
390
|
-
} catch (err) {
|
|
391
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
392
|
-
finalize("error", msg);
|
|
393
|
-
throw err;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ─── Scoped instance (createPerf) ────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
/** Subset of the top-level API, bound to a specific enabled flag. */
|
|
400
|
-
export interface Perf {
|
|
401
|
-
readonly enabled: boolean;
|
|
402
|
-
time(name: string, options?: PerfMarkOptions): PerfEndFn;
|
|
403
|
-
timeAsync<T>(
|
|
404
|
-
name: string,
|
|
405
|
-
fn: () => T | Promise<T>,
|
|
406
|
-
options?: PerfMarkOptions,
|
|
407
|
-
): Promise<T>;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
export interface CreatePerfOptions {
|
|
411
|
-
/**
|
|
412
|
-
* Override the gate. When `false`, all operations are no-ops
|
|
413
|
-
* regardless of `MANDU_PERF`. Useful to wire perf through a DI
|
|
414
|
-
* container that already resolves the env var.
|
|
415
|
-
*/
|
|
416
|
-
enabled?: boolean;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Create a scoped perf instance. Equivalent to the module-level
|
|
421
|
-
* {@link time} / {@link timeAsync} when `opts.enabled` is unset (defaults
|
|
422
|
-
* to the global gate), but allows libraries / tests to force-disable
|
|
423
|
-
* without mutating env.
|
|
424
|
-
*
|
|
425
|
-
* The scoped instance shares the global histogram — all marks flow
|
|
426
|
-
* into the same `/_mandu/heap` buffer regardless of which factory
|
|
427
|
-
* created them.
|
|
428
|
-
*/
|
|
429
|
-
export function createPerf(opts: CreatePerfOptions = {}): Perf {
|
|
430
|
-
const enabled = opts.enabled ?? isPerfEnabled();
|
|
431
|
-
if (!enabled) {
|
|
432
|
-
return {
|
|
433
|
-
enabled: false,
|
|
434
|
-
time: () => NOOP_END,
|
|
435
|
-
timeAsync: async <T>(_n: string, fn: () => T | Promise<T>): Promise<T> => await fn(),
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
return {
|
|
439
|
-
enabled: true,
|
|
440
|
-
time,
|
|
441
|
-
timeAsync,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ─── Dashboard snapshot ──────────────────────────────────────────────────
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Number of recent marks to include verbatim in {@link collectPerfSnapshot}.
|
|
449
|
-
* Kept small so the `/_mandu/heap` payload stays tight.
|
|
450
|
-
*/
|
|
451
|
-
const RECENT_SNAPSHOT_SIZE = 50;
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Extract entries from the ring buffer in chronological order (oldest
|
|
455
|
-
* first). Returns up to `limit` most-recent entries.
|
|
456
|
-
*/
|
|
457
|
-
function readRing(limit: number): PerfMarkEntry[] {
|
|
458
|
-
const out: PerfMarkEntry[] = [];
|
|
459
|
-
if (!ringFull && ringHead === 0) return out;
|
|
460
|
-
const total = ringFull ? PERF_BUFFER_LIMIT : ringHead;
|
|
461
|
-
const start = ringFull ? ringHead : 0;
|
|
462
|
-
for (let i = 0; i < total; i++) {
|
|
463
|
-
const idx = (start + i) % PERF_BUFFER_LIMIT;
|
|
464
|
-
const entry = ringBuffer[idx];
|
|
465
|
-
if (entry) out.push(entry);
|
|
466
|
-
}
|
|
467
|
-
if (limit > 0 && out.length > limit) return out.slice(out.length - limit);
|
|
468
|
-
return out;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Build the perf dashboard snapshot for `/_mandu/heap`. Safe to call
|
|
473
|
-
* even when perf is disabled — returns `{ enabled: false, ... }` with
|
|
474
|
-
* empty arrays.
|
|
475
|
-
*/
|
|
476
|
-
export function collectPerfSnapshot(): PerfDashboardSnapshot {
|
|
477
|
-
const histogram: PerfHistogramEntry[] = [];
|
|
478
|
-
for (const [name, stats] of perNameStats) {
|
|
479
|
-
histogram.push(buildHistogramEntry(name, stats));
|
|
480
|
-
}
|
|
481
|
-
histogram.sort((a, b) => b.count - a.count);
|
|
482
|
-
|
|
483
|
-
const bufferedCount = ringFull ? PERF_BUFFER_LIMIT : ringHead;
|
|
484
|
-
|
|
485
|
-
return {
|
|
486
|
-
enabled: isPerfEnabled(),
|
|
487
|
-
totalCount: totalClosedCount,
|
|
488
|
-
bufferedCount,
|
|
489
|
-
bufferLimit: PERF_BUFFER_LIMIT,
|
|
490
|
-
histogram,
|
|
491
|
-
recent: readRing(RECENT_SNAPSHOT_SIZE),
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ─── Framework bridge ────────────────────────────────────────────────────
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Bridge hook — framework modules (`bundler/*`, `runtime/streaming-ssr`,
|
|
499
|
-
* `cli/commands/dev`, etc.) can optionally record their own measurements
|
|
500
|
-
* into the same histogram so the `/_mandu/heap` view shows a unified
|
|
501
|
-
* picture. Callers should only invoke when `isPerfEnabled()` is already
|
|
502
|
-
* known to be `true` (the check is duplicated here for safety).
|
|
503
|
-
*
|
|
504
|
-
* Framework code remains free to keep using `mark()`/`measure()` for
|
|
505
|
-
* console logging — this bridge is additive.
|
|
506
|
-
*
|
|
507
|
-
* @internal — public only so bundler / runtime packages can reach it;
|
|
508
|
-
* not part of the documented app-facing API.
|
|
509
|
-
*/
|
|
510
|
-
export function recordFrameworkMeasurement(
|
|
511
|
-
name: string,
|
|
512
|
-
durationMs: number,
|
|
513
|
-
category: Exclude<PerfCategory, "user"> = "framework",
|
|
514
|
-
): void {
|
|
515
|
-
if (!isPerfEnabled()) return;
|
|
516
|
-
if (!Number.isFinite(durationMs) || durationMs < 0) return;
|
|
517
|
-
recordEntry({
|
|
518
|
-
name,
|
|
519
|
-
durationMs,
|
|
520
|
-
category,
|
|
521
|
-
timestamp: Date.now(),
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// ─── Test helpers ───────────────────────────────────────────────────────
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Test-only: clear all buffered marks and per-name stats. Does NOT
|
|
529
|
-
* touch the `MANDU_PERF` gate — pair with
|
|
530
|
-
* {@link import("./index")._resetCacheForTesting} when you need both.
|
|
531
|
-
*
|
|
532
|
-
* @internal
|
|
533
|
-
*/
|
|
534
|
-
export function _resetUserMarksForTesting(): void {
|
|
535
|
-
for (let i = 0; i < PERF_BUFFER_LIMIT; i++) ringBuffer[i] = undefined;
|
|
536
|
-
ringHead = 0;
|
|
537
|
-
ringFull = false;
|
|
538
|
-
totalClosedCount = 0;
|
|
539
|
-
perNameStats.clear();
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Test-only: peek at the last closed mark (or undefined if the buffer
|
|
544
|
-
* is empty). Useful for end-to-end assertions without exposing the
|
|
545
|
-
* full ring buffer internals.
|
|
546
|
-
*
|
|
547
|
-
* @internal
|
|
548
|
-
*/
|
|
549
|
-
export function _peekLastMarkForTesting(): PerfMarkEntry | undefined {
|
|
550
|
-
if (!ringFull && ringHead === 0) return undefined;
|
|
551
|
-
const lastIdx = (ringHead - 1 + PERF_BUFFER_LIMIT) % PERF_BUFFER_LIMIT;
|
|
552
|
-
return ringBuffer[lastIdx];
|
|
553
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.ψ — user-facing performance marks API
|
|
3
|
+
*
|
|
4
|
+
* Layered on top of the Phase 0 `@mandujs/core/perf` gate (`MANDU_PERF=1`),
|
|
5
|
+
* this module exposes a developer-friendly surface for measuring custom
|
|
6
|
+
* operations in application code. The existing module-level
|
|
7
|
+
* {@link import("./index").mark} + {@link import("./index").measure} remain
|
|
8
|
+
* unchanged for framework-internal callers; this file adds new primitives
|
|
9
|
+
* that close the ergonomic gap identified in user feedback:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { time, timeAsync, createPerf } from "@mandujs/core/perf";
|
|
13
|
+
*
|
|
14
|
+
* // 1. Basic scoped timing — returns a close-over end() function.
|
|
15
|
+
* const end = time("db-query");
|
|
16
|
+
* const rows = await db.query("SELECT ...");
|
|
17
|
+
* end(); // emits measurement
|
|
18
|
+
*
|
|
19
|
+
* // 2. Async scope — auto-closed, resolved value forwarded, errors rethrown.
|
|
20
|
+
* const data = await timeAsync("fetch-user", async () => {
|
|
21
|
+
* return await fetch("/api/user/123").then((r) => r.json());
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // 3. Scoped instance (dependency-injection / opt-in per-module):
|
|
25
|
+
* const perf = createPerf({ enabled: process.env.MANDU_PERF === "1" });
|
|
26
|
+
* perf.time("foo")(); // no-op when disabled, zero overhead
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Design contracts:
|
|
30
|
+
* - **Zero overhead when gated off.** When {@link isPerfEnabled} is
|
|
31
|
+
* `false`, every function is a single branch that returns a
|
|
32
|
+
* stable no-op (see `NOOP_END`). No `Map` allocation, no histogram
|
|
33
|
+
* entry, no span creation, no console output.
|
|
34
|
+
* - **Gate is shared.** Framework-internal marks (HMR, SSR, bundler)
|
|
35
|
+
* and user marks honor the same `MANDU_PERF=1` switch. Users do not
|
|
36
|
+
* need a separate env var.
|
|
37
|
+
* - **OTel integration is automatic.** When Phase 18.θ's request tracer
|
|
38
|
+
* is enabled, every user mark becomes a child span under the active
|
|
39
|
+
* request span. Attribute `mandu.perf.category = "user" | "framework"
|
|
40
|
+
* | "bundler" | "ssr"` differentiates mark origin in the trace view.
|
|
41
|
+
* Opt-out via `{ trace: false }`.
|
|
42
|
+
* - **Bounded histogram buffer.** The last 1000 recorded measurements
|
|
43
|
+
* are retained in a ring buffer for `/_mandu/heap` inspection. Older
|
|
44
|
+
* entries are dropped in insertion order. Same cap as Phase 17's
|
|
45
|
+
* event-bus recent buffer.
|
|
46
|
+
* - **No new runtime deps.** Timing uses `Bun.nanoseconds()` when
|
|
47
|
+
* available, `performance.now()` elsewhere.
|
|
48
|
+
*
|
|
49
|
+
* Rationale vs `mark()` + `measure()` (framework-internal):
|
|
50
|
+
* - `mark(name)` records a start timestamp, then a later `measure(label,
|
|
51
|
+
* name)` call computes the delta. That signature is great for
|
|
52
|
+
* framework code where start/end happen in separate functions, but
|
|
53
|
+
* forces users to invent unique `name` strings and wire them through
|
|
54
|
+
* closures. The `time() => end()` shape is more natural for
|
|
55
|
+
* application code where both ends live in the same lexical scope.
|
|
56
|
+
* - Both APIs coexist; `time()` internally uses `Bun.nanoseconds()`
|
|
57
|
+
* directly and does NOT share the framework's `marks: Map<string,
|
|
58
|
+
* number>`. This avoids collisions between user-chosen names and
|
|
59
|
+
* framework markers (e.g., if a user named a span `"hmr:rebuild-total"`).
|
|
60
|
+
*
|
|
61
|
+
* @module perf/user-marks
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { isPerfEnabled } from "./index";
|
|
65
|
+
import { getTracer, getActiveSpan, runWithSpan, type Span } from "../observability/tracing";
|
|
66
|
+
|
|
67
|
+
// ─── Types ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Origin of a mark. Framework code sets this to the appropriate value
|
|
71
|
+
* when bridging `mark()`/`measure()` calls into the histogram. User
|
|
72
|
+
* `time()` / `timeAsync()` always records `"user"`.
|
|
73
|
+
*/
|
|
74
|
+
export type PerfCategory = "user" | "framework" | "bundler" | "ssr";
|
|
75
|
+
|
|
76
|
+
/** Options accepted by {@link time} / {@link timeAsync} and {@link Perf.time}. */
|
|
77
|
+
export interface PerfMarkOptions {
|
|
78
|
+
/**
|
|
79
|
+
* Span category for histogram attribution + OTel attribute. Default:
|
|
80
|
+
* `"user"`. Framework code sets this explicitly.
|
|
81
|
+
*/
|
|
82
|
+
category?: PerfCategory;
|
|
83
|
+
/**
|
|
84
|
+
* When `true` (default) and a tracer is active, the mark creates a
|
|
85
|
+
* child span on `end()` with attributes `mandu.perf.category` and
|
|
86
|
+
* duration. When `false`, histogram still records but no span emits.
|
|
87
|
+
*/
|
|
88
|
+
trace?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Extra attributes attached to the OTel child span (when
|
|
91
|
+
* `trace !== false` and a tracer is active). Ignored otherwise.
|
|
92
|
+
*/
|
|
93
|
+
attributes?: Record<string, string | number | boolean>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Zero-arg function returned by {@link time}. Idempotent — safe to call twice. */
|
|
97
|
+
export type PerfEndFn = () => number;
|
|
98
|
+
|
|
99
|
+
/** One entry in the bounded histogram buffer. */
|
|
100
|
+
export interface PerfMarkEntry {
|
|
101
|
+
/** Mark label. */
|
|
102
|
+
name: string;
|
|
103
|
+
/** Duration in milliseconds. */
|
|
104
|
+
durationMs: number;
|
|
105
|
+
/** Category as set by caller. */
|
|
106
|
+
category: PerfCategory;
|
|
107
|
+
/** Unix epoch millis when the mark was closed. */
|
|
108
|
+
timestamp: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Statistical summary per unique mark name. */
|
|
112
|
+
export interface PerfHistogramEntry {
|
|
113
|
+
name: string;
|
|
114
|
+
count: number;
|
|
115
|
+
mean: number;
|
|
116
|
+
/** 50th percentile. */
|
|
117
|
+
p50: number;
|
|
118
|
+
/** 95th percentile. */
|
|
119
|
+
p95: number;
|
|
120
|
+
/** 99th percentile. */
|
|
121
|
+
p99: number;
|
|
122
|
+
/** Minimum observed duration (ms). */
|
|
123
|
+
min: number;
|
|
124
|
+
/** Maximum observed duration (ms). */
|
|
125
|
+
max: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Shape of the `perf` block appended to `/_mandu/heap` JSON. */
|
|
129
|
+
export interface PerfDashboardSnapshot {
|
|
130
|
+
enabled: boolean;
|
|
131
|
+
/** Total closed marks since process start (NOT the buffer size). */
|
|
132
|
+
totalCount: number;
|
|
133
|
+
/** Current number of entries retained in the ring buffer. */
|
|
134
|
+
bufferedCount: number;
|
|
135
|
+
/** Max ring-buffer capacity. */
|
|
136
|
+
bufferLimit: number;
|
|
137
|
+
/** Per-name histogram, sorted by descending `count`. */
|
|
138
|
+
histogram: PerfHistogramEntry[];
|
|
139
|
+
/** The most-recent N marks (up to 50), newest last. */
|
|
140
|
+
recent: PerfMarkEntry[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Timing primitive ───────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* High-resolution timestamp in milliseconds as a float. Prefers
|
|
147
|
+
* `Bun.nanoseconds()` (monotonic, integer-nanos precision) and falls
|
|
148
|
+
* back to `performance.now()` for non-Bun runtimes (tests that spawn
|
|
149
|
+
* Node, edge worker shims).
|
|
150
|
+
*/
|
|
151
|
+
function nowMs(): number {
|
|
152
|
+
const bun = (globalThis as { Bun?: { nanoseconds?: () => number } }).Bun;
|
|
153
|
+
if (bun && typeof bun.nanoseconds === "function") {
|
|
154
|
+
return bun.nanoseconds() / 1_000_000;
|
|
155
|
+
}
|
|
156
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
157
|
+
return performance.now();
|
|
158
|
+
}
|
|
159
|
+
return Date.now();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Ring buffer + running stats ─────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Max entries retained in the recent-marks ring buffer. Matches the
|
|
166
|
+
* Phase 17 event-bus recent buffer so operators get a consistent
|
|
167
|
+
* "last N" window across `/_mandu/heap` and `/_mandu/events/recent`.
|
|
168
|
+
*/
|
|
169
|
+
export const PERF_BUFFER_LIMIT = 1000;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Per-name running state. We store the raw observations for percentile
|
|
173
|
+
* computation (bounded per-name at `PER_NAME_OBS_LIMIT` to cap memory
|
|
174
|
+
* across a long dev session). For O(1) scrape, we also maintain count /
|
|
175
|
+
* min / max / sum — percentiles are the only O(n log n) piece.
|
|
176
|
+
*/
|
|
177
|
+
const PER_NAME_OBS_LIMIT = 500;
|
|
178
|
+
|
|
179
|
+
interface PerNameStats {
|
|
180
|
+
count: number;
|
|
181
|
+
sum: number;
|
|
182
|
+
min: number;
|
|
183
|
+
max: number;
|
|
184
|
+
/** Bounded window of observations (FIFO) for percentile calc. */
|
|
185
|
+
observations: number[];
|
|
186
|
+
/** Next write index into the ring. */
|
|
187
|
+
obsHead: number;
|
|
188
|
+
obsFull: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Ring buffer of most-recent `PerfMarkEntry` values (capacity `PERF_BUFFER_LIMIT`). */
|
|
192
|
+
const ringBuffer: (PerfMarkEntry | undefined)[] = new Array(PERF_BUFFER_LIMIT);
|
|
193
|
+
let ringHead = 0;
|
|
194
|
+
let ringFull = false;
|
|
195
|
+
|
|
196
|
+
/** Monotonic counter of closed marks since process start. */
|
|
197
|
+
let totalClosedCount = 0;
|
|
198
|
+
|
|
199
|
+
/** Per-name stats map. Only populated when perf is enabled. */
|
|
200
|
+
const perNameStats = new Map<string, PerNameStats>();
|
|
201
|
+
|
|
202
|
+
function getOrCreateStats(name: string): PerNameStats {
|
|
203
|
+
let s = perNameStats.get(name);
|
|
204
|
+
if (!s) {
|
|
205
|
+
s = {
|
|
206
|
+
count: 0,
|
|
207
|
+
sum: 0,
|
|
208
|
+
min: Number.POSITIVE_INFINITY,
|
|
209
|
+
max: Number.NEGATIVE_INFINITY,
|
|
210
|
+
observations: new Array(PER_NAME_OBS_LIMIT),
|
|
211
|
+
obsHead: 0,
|
|
212
|
+
obsFull: false,
|
|
213
|
+
};
|
|
214
|
+
perNameStats.set(name, s);
|
|
215
|
+
}
|
|
216
|
+
return s;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Append a closed mark to the ring buffer + update per-name stats. */
|
|
220
|
+
function recordEntry(entry: PerfMarkEntry): void {
|
|
221
|
+
// Ring buffer append.
|
|
222
|
+
ringBuffer[ringHead] = entry;
|
|
223
|
+
ringHead = (ringHead + 1) % PERF_BUFFER_LIMIT;
|
|
224
|
+
if (ringHead === 0) ringFull = true;
|
|
225
|
+
totalClosedCount++;
|
|
226
|
+
|
|
227
|
+
// Per-name stats update.
|
|
228
|
+
const s = getOrCreateStats(entry.name);
|
|
229
|
+
s.count++;
|
|
230
|
+
s.sum += entry.durationMs;
|
|
231
|
+
if (entry.durationMs < s.min) s.min = entry.durationMs;
|
|
232
|
+
if (entry.durationMs > s.max) s.max = entry.durationMs;
|
|
233
|
+
s.observations[s.obsHead] = entry.durationMs;
|
|
234
|
+
s.obsHead = (s.obsHead + 1) % PER_NAME_OBS_LIMIT;
|
|
235
|
+
if (s.obsHead === 0) s.obsFull = true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Compute the p-th percentile (0..100) of a sorted array. */
|
|
239
|
+
function percentile(sorted: number[], p: number): number {
|
|
240
|
+
if (sorted.length === 0) return 0;
|
|
241
|
+
// Nearest-rank method — simple, deterministic, no interpolation
|
|
242
|
+
// artifacts on small samples.
|
|
243
|
+
const rank = Math.ceil((p / 100) * sorted.length);
|
|
244
|
+
const idx = Math.max(0, Math.min(sorted.length - 1, rank - 1));
|
|
245
|
+
return sorted[idx] ?? 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildHistogramEntry(name: string, s: PerNameStats): PerfHistogramEntry {
|
|
249
|
+
const obs = s.obsFull
|
|
250
|
+
? s.observations.slice(0, PER_NAME_OBS_LIMIT)
|
|
251
|
+
: s.observations.slice(0, s.obsHead);
|
|
252
|
+
const sorted = [...obs].sort((a, b) => a - b);
|
|
253
|
+
return {
|
|
254
|
+
name,
|
|
255
|
+
count: s.count,
|
|
256
|
+
mean: s.count > 0 ? s.sum / s.count : 0,
|
|
257
|
+
p50: percentile(sorted, 50),
|
|
258
|
+
p95: percentile(sorted, 95),
|
|
259
|
+
p99: percentile(sorted, 99),
|
|
260
|
+
min: s.min === Number.POSITIVE_INFINITY ? 0 : s.min,
|
|
261
|
+
max: s.max === Number.NEGATIVE_INFINITY ? 0 : s.max,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── No-op sentinel ─────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const NOOP_END: PerfEndFn = () => 0;
|
|
268
|
+
|
|
269
|
+
// ─── Public functions ───────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Open a performance measurement. Returns an `end()` function that,
|
|
273
|
+
* when called, closes the measurement and records it in the histogram
|
|
274
|
+
* + OTel span (if tracing is active).
|
|
275
|
+
*
|
|
276
|
+
* When {@link isPerfEnabled} is `false`, returns a shared no-op end
|
|
277
|
+
* function. Zero allocations on the disabled path.
|
|
278
|
+
*
|
|
279
|
+
* Calling `end()` more than once is a no-op on the second call; the
|
|
280
|
+
* duration recorded is always the delta from the first `time()` call
|
|
281
|
+
* to the first `end()` call.
|
|
282
|
+
*/
|
|
283
|
+
export function time(name: string, options: PerfMarkOptions = {}): PerfEndFn {
|
|
284
|
+
if (!isPerfEnabled()) return NOOP_END;
|
|
285
|
+
const category: PerfCategory = options.category ?? "user";
|
|
286
|
+
const traceEnabled = options.trace !== false;
|
|
287
|
+
const startMs = nowMs();
|
|
288
|
+
|
|
289
|
+
// Open an OTel span lazily: only if tracing is enabled AND the active
|
|
290
|
+
// tracer is recording. We create the span now (so attributes can be
|
|
291
|
+
// set on it pre-end), but only end it when the user calls end().
|
|
292
|
+
let span: Span | undefined;
|
|
293
|
+
if (traceEnabled) {
|
|
294
|
+
const tracer = getTracer();
|
|
295
|
+
if (tracer.enabled) {
|
|
296
|
+
span = tracer.startSpan(name, {
|
|
297
|
+
kind: "internal",
|
|
298
|
+
attributes: {
|
|
299
|
+
"mandu.perf.category": category,
|
|
300
|
+
...(options.attributes ?? {}),
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let closed = false;
|
|
307
|
+
return function endMark(): number {
|
|
308
|
+
if (closed) return 0;
|
|
309
|
+
closed = true;
|
|
310
|
+
const durationMs = nowMs() - startMs;
|
|
311
|
+
const entry: PerfMarkEntry = {
|
|
312
|
+
name,
|
|
313
|
+
durationMs,
|
|
314
|
+
category,
|
|
315
|
+
timestamp: Date.now(),
|
|
316
|
+
};
|
|
317
|
+
recordEntry(entry);
|
|
318
|
+
if (span) {
|
|
319
|
+
// Attach the measured duration so downstream consumers can read it
|
|
320
|
+
// from trace attributes without having to compute `endTime - startTime`.
|
|
321
|
+
span.setAttribute("mandu.perf.duration_ms", durationMs);
|
|
322
|
+
span.setStatus("ok");
|
|
323
|
+
span.end();
|
|
324
|
+
}
|
|
325
|
+
return durationMs;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Measure the wall-clock duration of `fn`. Propagates the resolved
|
|
331
|
+
* value and rethrows errors (closing the mark with `status=error` on
|
|
332
|
+
* the OTel span before rethrow).
|
|
333
|
+
*
|
|
334
|
+
* Works for both sync and async `fn`: the return type is always
|
|
335
|
+
* `Promise<T>` because the common case is `await`ing an async body.
|
|
336
|
+
* For a sync-only variant that preserves the sync return type, use
|
|
337
|
+
* {@link time} directly.
|
|
338
|
+
*
|
|
339
|
+
* When perf is disabled, `fn` is invoked directly and the result is
|
|
340
|
+
* returned unchanged — a single branch adds near-zero overhead.
|
|
341
|
+
*/
|
|
342
|
+
export async function timeAsync<T>(
|
|
343
|
+
name: string,
|
|
344
|
+
fn: () => T | Promise<T>,
|
|
345
|
+
options: PerfMarkOptions = {},
|
|
346
|
+
): Promise<T> {
|
|
347
|
+
if (!isPerfEnabled()) return await fn();
|
|
348
|
+
|
|
349
|
+
// When tracing is active, put the child span in the ALS scope so any
|
|
350
|
+
// further `time()` / `timeAsync()` calls inside `fn` nest correctly.
|
|
351
|
+
const category: PerfCategory = options.category ?? "user";
|
|
352
|
+
const traceEnabled = options.trace !== false;
|
|
353
|
+
const tracer = getTracer();
|
|
354
|
+
const useSpanScope = traceEnabled && tracer.enabled;
|
|
355
|
+
|
|
356
|
+
const startMs = nowMs();
|
|
357
|
+
let span: Span | undefined;
|
|
358
|
+
if (useSpanScope) {
|
|
359
|
+
span = tracer.startSpan(name, {
|
|
360
|
+
kind: "internal",
|
|
361
|
+
attributes: {
|
|
362
|
+
"mandu.perf.category": category,
|
|
363
|
+
...(options.attributes ?? {}),
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const finalize = (status: "ok" | "error", errorMsg?: string): number => {
|
|
369
|
+
const durationMs = nowMs() - startMs;
|
|
370
|
+
recordEntry({
|
|
371
|
+
name,
|
|
372
|
+
durationMs,
|
|
373
|
+
category,
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
});
|
|
376
|
+
if (span) {
|
|
377
|
+
span.setAttribute("mandu.perf.duration_ms", durationMs);
|
|
378
|
+
span.setStatus(status, errorMsg);
|
|
379
|
+
span.end();
|
|
380
|
+
}
|
|
381
|
+
return durationMs;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const result = span
|
|
386
|
+
? await runWithSpan(span, () => Promise.resolve(fn()))
|
|
387
|
+
: await fn();
|
|
388
|
+
finalize("ok");
|
|
389
|
+
return result;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
392
|
+
finalize("error", msg);
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── Scoped instance (createPerf) ────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** Subset of the top-level API, bound to a specific enabled flag. */
|
|
400
|
+
export interface Perf {
|
|
401
|
+
readonly enabled: boolean;
|
|
402
|
+
time(name: string, options?: PerfMarkOptions): PerfEndFn;
|
|
403
|
+
timeAsync<T>(
|
|
404
|
+
name: string,
|
|
405
|
+
fn: () => T | Promise<T>,
|
|
406
|
+
options?: PerfMarkOptions,
|
|
407
|
+
): Promise<T>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface CreatePerfOptions {
|
|
411
|
+
/**
|
|
412
|
+
* Override the gate. When `false`, all operations are no-ops
|
|
413
|
+
* regardless of `MANDU_PERF`. Useful to wire perf through a DI
|
|
414
|
+
* container that already resolves the env var.
|
|
415
|
+
*/
|
|
416
|
+
enabled?: boolean;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create a scoped perf instance. Equivalent to the module-level
|
|
421
|
+
* {@link time} / {@link timeAsync} when `opts.enabled` is unset (defaults
|
|
422
|
+
* to the global gate), but allows libraries / tests to force-disable
|
|
423
|
+
* without mutating env.
|
|
424
|
+
*
|
|
425
|
+
* The scoped instance shares the global histogram — all marks flow
|
|
426
|
+
* into the same `/_mandu/heap` buffer regardless of which factory
|
|
427
|
+
* created them.
|
|
428
|
+
*/
|
|
429
|
+
export function createPerf(opts: CreatePerfOptions = {}): Perf {
|
|
430
|
+
const enabled = opts.enabled ?? isPerfEnabled();
|
|
431
|
+
if (!enabled) {
|
|
432
|
+
return {
|
|
433
|
+
enabled: false,
|
|
434
|
+
time: () => NOOP_END,
|
|
435
|
+
timeAsync: async <T>(_n: string, fn: () => T | Promise<T>): Promise<T> => await fn(),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
enabled: true,
|
|
440
|
+
time,
|
|
441
|
+
timeAsync,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Dashboard snapshot ──────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Number of recent marks to include verbatim in {@link collectPerfSnapshot}.
|
|
449
|
+
* Kept small so the `/_mandu/heap` payload stays tight.
|
|
450
|
+
*/
|
|
451
|
+
const RECENT_SNAPSHOT_SIZE = 50;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Extract entries from the ring buffer in chronological order (oldest
|
|
455
|
+
* first). Returns up to `limit` most-recent entries.
|
|
456
|
+
*/
|
|
457
|
+
function readRing(limit: number): PerfMarkEntry[] {
|
|
458
|
+
const out: PerfMarkEntry[] = [];
|
|
459
|
+
if (!ringFull && ringHead === 0) return out;
|
|
460
|
+
const total = ringFull ? PERF_BUFFER_LIMIT : ringHead;
|
|
461
|
+
const start = ringFull ? ringHead : 0;
|
|
462
|
+
for (let i = 0; i < total; i++) {
|
|
463
|
+
const idx = (start + i) % PERF_BUFFER_LIMIT;
|
|
464
|
+
const entry = ringBuffer[idx];
|
|
465
|
+
if (entry) out.push(entry);
|
|
466
|
+
}
|
|
467
|
+
if (limit > 0 && out.length > limit) return out.slice(out.length - limit);
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Build the perf dashboard snapshot for `/_mandu/heap`. Safe to call
|
|
473
|
+
* even when perf is disabled — returns `{ enabled: false, ... }` with
|
|
474
|
+
* empty arrays.
|
|
475
|
+
*/
|
|
476
|
+
export function collectPerfSnapshot(): PerfDashboardSnapshot {
|
|
477
|
+
const histogram: PerfHistogramEntry[] = [];
|
|
478
|
+
for (const [name, stats] of perNameStats) {
|
|
479
|
+
histogram.push(buildHistogramEntry(name, stats));
|
|
480
|
+
}
|
|
481
|
+
histogram.sort((a, b) => b.count - a.count);
|
|
482
|
+
|
|
483
|
+
const bufferedCount = ringFull ? PERF_BUFFER_LIMIT : ringHead;
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
enabled: isPerfEnabled(),
|
|
487
|
+
totalCount: totalClosedCount,
|
|
488
|
+
bufferedCount,
|
|
489
|
+
bufferLimit: PERF_BUFFER_LIMIT,
|
|
490
|
+
histogram,
|
|
491
|
+
recent: readRing(RECENT_SNAPSHOT_SIZE),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Framework bridge ────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Bridge hook — framework modules (`bundler/*`, `runtime/streaming-ssr`,
|
|
499
|
+
* `cli/commands/dev`, etc.) can optionally record their own measurements
|
|
500
|
+
* into the same histogram so the `/_mandu/heap` view shows a unified
|
|
501
|
+
* picture. Callers should only invoke when `isPerfEnabled()` is already
|
|
502
|
+
* known to be `true` (the check is duplicated here for safety).
|
|
503
|
+
*
|
|
504
|
+
* Framework code remains free to keep using `mark()`/`measure()` for
|
|
505
|
+
* console logging — this bridge is additive.
|
|
506
|
+
*
|
|
507
|
+
* @internal — public only so bundler / runtime packages can reach it;
|
|
508
|
+
* not part of the documented app-facing API.
|
|
509
|
+
*/
|
|
510
|
+
export function recordFrameworkMeasurement(
|
|
511
|
+
name: string,
|
|
512
|
+
durationMs: number,
|
|
513
|
+
category: Exclude<PerfCategory, "user"> = "framework",
|
|
514
|
+
): void {
|
|
515
|
+
if (!isPerfEnabled()) return;
|
|
516
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) return;
|
|
517
|
+
recordEntry({
|
|
518
|
+
name,
|
|
519
|
+
durationMs,
|
|
520
|
+
category,
|
|
521
|
+
timestamp: Date.now(),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ─── Test helpers ───────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Test-only: clear all buffered marks and per-name stats. Does NOT
|
|
529
|
+
* touch the `MANDU_PERF` gate — pair with
|
|
530
|
+
* {@link import("./index")._resetCacheForTesting} when you need both.
|
|
531
|
+
*
|
|
532
|
+
* @internal
|
|
533
|
+
*/
|
|
534
|
+
export function _resetUserMarksForTesting(): void {
|
|
535
|
+
for (let i = 0; i < PERF_BUFFER_LIMIT; i++) ringBuffer[i] = undefined;
|
|
536
|
+
ringHead = 0;
|
|
537
|
+
ringFull = false;
|
|
538
|
+
totalClosedCount = 0;
|
|
539
|
+
perNameStats.clear();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Test-only: peek at the last closed mark (or undefined if the buffer
|
|
544
|
+
* is empty). Useful for end-to-end assertions without exposing the
|
|
545
|
+
* full ring buffer internals.
|
|
546
|
+
*
|
|
547
|
+
* @internal
|
|
548
|
+
*/
|
|
549
|
+
export function _peekLastMarkForTesting(): PerfMarkEntry | undefined {
|
|
550
|
+
if (!ringFull && ringHead === 0) return undefined;
|
|
551
|
+
const lastIdx = (ringHead - 1 + PERF_BUFFER_LIMIT) % PERF_BUFFER_LIMIT;
|
|
552
|
+
return ringBuffer[lastIdx];
|
|
553
|
+
}
|