/** * Phase 18.ψ — user-facing performance marks API * * Layered on top of the Phase 0 `@mandujs/core/perf` gate (`MANDU_PERF=1`), * this module exposes a developer-friendly surface for measuring custom * operations in application code. The existing module-level * {@link import("./index").mark} + {@link import("./index").measure} remain * unchanged for framework-internal callers; this file adds new primitives * that close the ergonomic gap identified in user feedback: * * ```ts * import { time, timeAsync, createPerf } from "@mandujs/core/perf"; * * // 1. Basic scoped timing — returns a close-over end() function. * const end = time("db-query"); * const rows = await db.query("SELECT ..."); * end(); // emits measurement * * // 2. Async scope — auto-closed, resolved value forwarded, errors rethrown. * const data = await timeAsync("fetch-user", async () => { * return await fetch("/api/user/123").then((r) => r.json()); * }); * * // 3. Scoped instance (dependency-injection / opt-in per-module): * const perf = createPerf({ enabled: process.env.MANDU_PERF === "1" }); * perf.time("foo")(); // no-op when disabled, zero overhead * ``` * * Design contracts: * - **Zero overhead when gated off.** When {@link isPerfEnabled} is * `false`, every function is a single branch that returns a * stable no-op (see `NOOP_END`). No `Map` allocation, no histogram * entry, no span creation, no console output. * - **Gate is shared.** Framework-internal marks (HMR, SSR, bundler) * and user marks honor the same `MANDU_PERF=1` switch. Users do not * need a separate env var. * - **OTel integration is automatic.** When Phase 18.θ's request tracer * is enabled, every user mark becomes a child span under the active * request span. Attribute `mandu.perf.category = "user" | "framework" * | "bundler" | "ssr"` differentiates mark origin in the trace view. * Opt-out via `{ trace: false }`. * - **Bounded histogram buffer.** The last 1000 recorded measurements * are retained in a ring buffer for `/_mandu/heap` inspection. Older * entries are dropped in insertion order. Same cap as Phase 17's * event-bus recent buffer. * - **No new runtime deps.** Timing uses `Bun.nanoseconds()` when * available, `performance.now()` elsewhere. * * Rationale vs `mark()` + `measure()` (framework-internal): * - `mark(name)` records a start timestamp, then a later `measure(label, * name)` call computes the delta. That signature is great for * framework code where start/end happen in separate functions, but * forces users to invent unique `name` strings and wire them through * closures. The `time() => end()` shape is more natural for * application code where both ends live in the same lexical scope. * - Both APIs coexist; `time()` internally uses `Bun.nanoseconds()` * directly and does NOT share the framework's `marks: Map`. This avoids collisions between user-chosen names and * framework markers (e.g., if a user named a span `"hmr:rebuild-total"`). * * @module perf/user-marks */ import { isPerfEnabled } from "./index"; import { getTracer, runWithSpan, type Span } from "../observability/tracing"; // ─── Types ────────────────────────────────────────────────────────────── /** * Origin of a mark. Framework code sets this to the appropriate value * when bridging `mark()`/`measure()` calls into the histogram. User * `time()` / `timeAsync()` always records `"user"`. */ export type PerfCategory = "user" | "framework" | "bundler" | "ssr"; /** Options accepted by {@link time} / {@link timeAsync} and {@link Perf.time}. */ export interface PerfMarkOptions { /** * Span category for histogram attribution + OTel attribute. Default: * `"user"`. Framework code sets this explicitly. */ category?: PerfCategory; /** * When `true` (default) and a tracer is active, the mark creates a * child span on `end()` with attributes `mandu.perf.category` and * duration. When `false`, histogram still records but no span emits. */ trace?: boolean; /** * Extra attributes attached to the OTel child span (when * `trace !== false` and a tracer is active). Ignored otherwise. */ attributes?: Record; } /** Zero-arg function returned by {@link time}. Idempotent — safe to call twice. */ export type PerfEndFn = () => number; /** One entry in the bounded histogram buffer. */ export interface PerfMarkEntry { /** Mark label. */ name: string; /** Duration in milliseconds. */ durationMs: number; /** Category as set by caller. */ category: PerfCategory; /** Unix epoch millis when the mark was closed. */ timestamp: number; } /** Statistical summary per unique mark name. */ export interface PerfHistogramEntry { name: string; count: number; mean: number; /** 50th percentile. */ p50: number; /** 95th percentile. */ p95: number; /** 99th percentile. */ p99: number; /** Minimum observed duration (ms). */ min: number; /** Maximum observed duration (ms). */ max: number; } /** Shape of the `perf` block appended to `/_mandu/heap` JSON. */ export interface PerfDashboardSnapshot { enabled: boolean; /** Total closed marks since process start (NOT the buffer size). */ totalCount: number; /** Current number of entries retained in the ring buffer. */ bufferedCount: number; /** Max ring-buffer capacity. */ bufferLimit: number; /** Per-name histogram, sorted by descending `count`. */ histogram: PerfHistogramEntry[]; /** The most-recent N marks (up to 50), newest last. */ recent: PerfMarkEntry[]; } // ─── Timing primitive ─────────────────────────────────────────────────── /** * High-resolution timestamp in milliseconds as a float. Prefers * `Bun.nanoseconds()` (monotonic, integer-nanos precision) and falls * back to `performance.now()` for non-Bun runtimes (tests that spawn * Node, edge worker shims). */ function nowMs(): number { const bun = (globalThis as { Bun?: { nanoseconds?: () => number } }).Bun; if (bun && typeof bun.nanoseconds === "function") { return bun.nanoseconds() / 1_000_000; } if (typeof performance !== "undefined" && typeof performance.now === "function") { return performance.now(); } return Date.now(); } // ─── Ring buffer + running stats ───────────────────────────────────────── /** * Max entries retained in the recent-marks ring buffer. Matches the * Phase 17 event-bus recent buffer so operators get a consistent * "last N" window across `/_mandu/heap` and `/_mandu/events/recent`. */ export const PERF_BUFFER_LIMIT = 1000; /** * Per-name running state. We store the raw observations for percentile * computation (bounded per-name at `PER_NAME_OBS_LIMIT` to cap memory * across a long dev session). For O(1) scrape, we also maintain count / * min / max / sum — percentiles are the only O(n log n) piece. */ const PER_NAME_OBS_LIMIT = 500; interface PerNameStats { count: number; sum: number; min: number; max: number; /** Bounded window of observations (FIFO) for percentile calc. */ observations: number[]; /** Next write index into the ring. */ obsHead: number; obsFull: boolean; } /** Ring buffer of most-recent `PerfMarkEntry` values (capacity `PERF_BUFFER_LIMIT`). */ const ringBuffer: (PerfMarkEntry | undefined)[] = new Array(PERF_BUFFER_LIMIT); let ringHead = 0; let ringFull = false; /** Monotonic counter of closed marks since process start. */ let totalClosedCount = 0; /** Per-name stats map. Only populated when perf is enabled. */ const perNameStats = new Map(); function getOrCreateStats(name: string): PerNameStats { let s = perNameStats.get(name); if (!s) { s = { count: 0, sum: 0, min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, observations: new Array(PER_NAME_OBS_LIMIT), obsHead: 0, obsFull: false, }; perNameStats.set(name, s); } return s; } /** Append a closed mark to the ring buffer + update per-name stats. */ function recordEntry(entry: PerfMarkEntry): void { // Ring buffer append. ringBuffer[ringHead] = entry; ringHead = (ringHead + 1) % PERF_BUFFER_LIMIT; if (ringHead === 0) ringFull = true; totalClosedCount++; // Per-name stats update. const s = getOrCreateStats(entry.name); s.count++; s.sum += entry.durationMs; if (entry.durationMs < s.min) s.min = entry.durationMs; if (entry.durationMs > s.max) s.max = entry.durationMs; s.observations[s.obsHead] = entry.durationMs; s.obsHead = (s.obsHead + 1) % PER_NAME_OBS_LIMIT; if (s.obsHead === 0) s.obsFull = true; } /** Compute the p-th percentile (0..100) of a sorted array. */ function percentile(sorted: number[], p: number): number { if (sorted.length === 0) return 0; // Nearest-rank method — simple, deterministic, no interpolation // artifacts on small samples. const rank = Math.ceil((p / 100) * sorted.length); const idx = Math.max(0, Math.min(sorted.length - 1, rank - 1)); return sorted[idx] ?? 0; } function buildHistogramEntry(name: string, s: PerNameStats): PerfHistogramEntry { const obs = s.obsFull ? s.observations.slice(0, PER_NAME_OBS_LIMIT) : s.observations.slice(0, s.obsHead); const sorted = [...obs].sort((a, b) => a - b); return { name, count: s.count, mean: s.count > 0 ? s.sum / s.count : 0, p50: percentile(sorted, 50), p95: percentile(sorted, 95), p99: percentile(sorted, 99), min: s.min === Number.POSITIVE_INFINITY ? 0 : s.min, max: s.max === Number.NEGATIVE_INFINITY ? 0 : s.max, }; } // ─── No-op sentinel ───────────────────────────────────────────────────── const NOOP_END: PerfEndFn = () => 0; // ─── Public functions ─────────────────────────────────────────────────── /** * Open a performance measurement. Returns an `end()` function that, * when called, closes the measurement and records it in the histogram * + OTel span (if tracing is active). * * When {@link isPerfEnabled} is `false`, returns a shared no-op end * function. Zero allocations on the disabled path. * * Calling `end()` more than once is a no-op on the second call; the * duration recorded is always the delta from the first `time()` call * to the first `end()` call. */ export function time(name: string, options: PerfMarkOptions = {}): PerfEndFn { if (!isPerfEnabled()) return NOOP_END; const category: PerfCategory = options.category ?? "user"; const traceEnabled = options.trace !== false; const startMs = nowMs(); // Open an OTel span lazily: only if tracing is enabled AND the active // tracer is recording. We create the span now (so attributes can be // set on it pre-end), but only end it when the user calls end(). let span: Span | undefined; if (traceEnabled) { const tracer = getTracer(); if (tracer.enabled) { span = tracer.startSpan(name, { kind: "internal", attributes: { "mandu.perf.category": category, ...(options.attributes ?? {}), }, }); } } let closed = false; return function endMark(): number { if (closed) return 0; closed = true; const durationMs = nowMs() - startMs; const entry: PerfMarkEntry = { name, durationMs, category, timestamp: Date.now(), }; recordEntry(entry); if (span) { // Attach the measured duration so downstream consumers can read it // from trace attributes without having to compute `endTime - startTime`. span.setAttribute("mandu.perf.duration_ms", durationMs); span.setStatus("ok"); span.end(); } return durationMs; }; } /** * Measure the wall-clock duration of `fn`. Propagates the resolved * value and rethrows errors (closing the mark with `status=error` on * the OTel span before rethrow). * * Works for both sync and async `fn`: the return type is always * `Promise` because the common case is `await`ing an async body. * For a sync-only variant that preserves the sync return type, use * {@link time} directly. * * When perf is disabled, `fn` is invoked directly and the result is * returned unchanged — a single branch adds near-zero overhead. */ export async function timeAsync( name: string, fn: () => T | Promise, options: PerfMarkOptions = {}, ): Promise { if (!isPerfEnabled()) return await fn(); // When tracing is active, put the child span in the ALS scope so any // further `time()` / `timeAsync()` calls inside `fn` nest correctly. const category: PerfCategory = options.category ?? "user"; const traceEnabled = options.trace !== false; const tracer = getTracer(); const useSpanScope = traceEnabled && tracer.enabled; const startMs = nowMs(); let span: Span | undefined; if (useSpanScope) { span = tracer.startSpan(name, { kind: "internal", attributes: { "mandu.perf.category": category, ...(options.attributes ?? {}), }, }); } const finalize = (status: "ok" | "error", errorMsg?: string): number => { const durationMs = nowMs() - startMs; recordEntry({ name, durationMs, category, timestamp: Date.now(), }); if (span) { span.setAttribute("mandu.perf.duration_ms", durationMs); span.setStatus(status, errorMsg); span.end(); } return durationMs; }; try { const result = span ? await runWithSpan(span, () => Promise.resolve(fn())) : await fn(); finalize("ok"); return result; } catch (err) { const msg = err instanceof Error ? err.message : String(err); finalize("error", msg); throw err; } } // ─── Scoped instance (createPerf) ──────────────────────────────────────── /** Subset of the top-level API, bound to a specific enabled flag. */ export interface Perf { readonly enabled: boolean; time(name: string, options?: PerfMarkOptions): PerfEndFn; timeAsync( name: string, fn: () => T | Promise, options?: PerfMarkOptions, ): Promise; } export interface CreatePerfOptions { /** * Override the gate. When `false`, all operations are no-ops * regardless of `MANDU_PERF`. Useful to wire perf through a DI * container that already resolves the env var. */ enabled?: boolean; } /** * Create a scoped perf instance. Equivalent to the module-level * {@link time} / {@link timeAsync} when `opts.enabled` is unset (defaults * to the global gate), but allows libraries / tests to force-disable * without mutating env. * * The scoped instance shares the global histogram — all marks flow * into the same `/_mandu/heap` buffer regardless of which factory * created them. */ export function createPerf(opts: CreatePerfOptions = {}): Perf { const enabled = opts.enabled ?? isPerfEnabled(); if (!enabled) { return { enabled: false, time: () => NOOP_END, timeAsync: async (_n: string, fn: () => T | Promise): Promise => await fn(), }; } return { enabled: true, time, timeAsync, }; } // ─── Dashboard snapshot ────────────────────────────────────────────────── /** * Number of recent marks to include verbatim in {@link collectPerfSnapshot}. * Kept small so the `/_mandu/heap` payload stays tight. */ const RECENT_SNAPSHOT_SIZE = 50; /** * Extract entries from the ring buffer in chronological order (oldest * first). Returns up to `limit` most-recent entries. */ function readRing(limit: number): PerfMarkEntry[] { const out: PerfMarkEntry[] = []; if (!ringFull && ringHead === 0) return out; const total = ringFull ? PERF_BUFFER_LIMIT : ringHead; const start = ringFull ? ringHead : 0; for (let i = 0; i < total; i++) { const idx = (start + i) % PERF_BUFFER_LIMIT; const entry = ringBuffer[idx]; if (entry) out.push(entry); } if (limit > 0 && out.length > limit) return out.slice(out.length - limit); return out; } /** * Build the perf dashboard snapshot for `/_mandu/heap`. Safe to call * even when perf is disabled — returns `{ enabled: false, ... }` with * empty arrays. */ export function collectPerfSnapshot(): PerfDashboardSnapshot { const histogram: PerfHistogramEntry[] = []; for (const [name, stats] of perNameStats) { histogram.push(buildHistogramEntry(name, stats)); } histogram.sort((a, b) => b.count - a.count); const bufferedCount = ringFull ? PERF_BUFFER_LIMIT : ringHead; return { enabled: isPerfEnabled(), totalCount: totalClosedCount, bufferedCount, bufferLimit: PERF_BUFFER_LIMIT, histogram, recent: readRing(RECENT_SNAPSHOT_SIZE), }; } // ─── Framework bridge ──────────────────────────────────────────────────── /** * Bridge hook — framework modules (`bundler/*`, `runtime/streaming-ssr`, * `cli/commands/dev`, etc.) can optionally record their own measurements * into the same histogram so the `/_mandu/heap` view shows a unified * picture. Callers should only invoke when `isPerfEnabled()` is already * known to be `true` (the check is duplicated here for safety). * * Framework code remains free to keep using `mark()`/`measure()` for * console logging — this bridge is additive. * * @internal — public only so bundler / runtime packages can reach it; * not part of the documented app-facing API. */ export function recordFrameworkMeasurement( name: string, durationMs: number, category: Exclude = "framework", ): void { if (!isPerfEnabled()) return; if (!Number.isFinite(durationMs) || durationMs < 0) return; recordEntry({ name, durationMs, category, timestamp: Date.now(), }); } // ─── Test helpers ─────────────────────────────────────────────────────── /** * Test-only: clear all buffered marks and per-name stats. Does NOT * touch the `MANDU_PERF` gate — pair with * {@link import("./index")._resetCacheForTesting} when you need both. * * @internal */ export function _resetUserMarksForTesting(): void { for (let i = 0; i < PERF_BUFFER_LIMIT; i++) ringBuffer[i] = undefined; ringHead = 0; ringFull = false; totalClosedCount = 0; perNameStats.clear(); } /** * Test-only: peek at the last closed mark (or undefined if the buffer * is empty). Useful for end-to-end assertions without exposing the * full ring buffer internals. * * @internal */ export function _peekLastMarkForTesting(): PerfMarkEntry | undefined { if (!ringFull && ringHead === 0) return undefined; const lastIdx = (ringHead - 1 + PERF_BUFFER_LIMIT) % PERF_BUFFER_LIMIT; return ringBuffer[lastIdx]; }