Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
# @suaveplan/profiler - Universal runtime performance profiler — Profiler/Sampler/MemoryProfiler/BottleneckDetector/ProfileComparator/FlameGraphGenerator/ProfileReporter/OtelExporter with injectable Clock+TimersLike+HeapCapture, PROFILER_CODES error registry, and OTel self-telemetry.
Universal runtime performance profiler.
<!-- begin:auto-docs -->
## Documentation tree
- [bottleneck](./src/bottleneck/bottleneck.md) — `BottleneckDetector` analyses arrays of `ProfileEntry` records to surface performance bottlenecks. It groups entries by…
- [bottleneck](./src/bottleneck/bottleneck.md) — `BottleneckDetector` analyses arrays of `ProfileEntry` records to surface performance bottlenecks. It groups entries by…
- [comparison](./src/comparison/comparison.md) — `ProfileComparator` diffs two sets of profiling data — either raw `ProfileEntry[]` arrays or pre-aggregated `ProfileRep…
- [comparison](./src/comparison/comparison.md) — `ProfileComparator` diffs two sets of profiling data — either raw `ProfileEntry[]` arrays or pre-aggregated `ProfileRep…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/profiler` and the typed `ProfilerError` class consumers throw fr…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/profiler` and the typed `ProfilerError` class consumers throw fr…
- [flamegraph](./src/flamegraph/flamegraph.md) — `FlameGraphGenerator` builds a flame-graph data structure from `ProfileEntry` arrays by nesting entries on start/end-ti…
- [flamegraph](./src/flamegraph/flamegraph.md) — `FlameGraphGenerator` builds a flame-graph data structure from `ProfileEntry` arrays by nesting entries on start/end-ti…
- [memory](./src/memory/memory.md) — `MemoryProfiler` captures periodic heap snapshots, records labelled allocation totals, tracks a peak-heap watermark, an…
- [memory](./src/memory/memory.md) — `MemoryProfiler` captures periodic heap snapshots, records labelled allocation totals, tracks a peak-heap watermark, an…
- [otel](./src/otel/otel.md) — `OtelExporter` converts `ProfileEntry` records to OTLP-compatible spans and optionally exports them to a collector endp…
- [otel](./src/otel/otel.md) — `OtelExporter` converts `ProfileEntry` records to OTLP-compatible spans and optionally exports them to a collector endp…
- [profiler](./src/profiler/profiler.md) — `Profiler` is the core measurement primitive of `@suaveplan/profiler`. It records timing entries between paired `start(…
- [profiler](./src/profiler/profiler.md) — `Profiler` is the core measurement primitive of `@suaveplan/profiler`. It records timing entries between paired `start(…
- [reporter](./src/reporter/reporter.md) — `ProfileReporter` computes summary statistics from profiling entries — count, min, max, mean, median, configurable perc…
- [reporter](./src/reporter/reporter.md) — `ProfileReporter` computes summary statistics from profiling entries — count, min, max, mean, median, configurable perc…
- [sampler](./src/sampler/sampler.md) — `Sampler` collects periodic snapshots of runtime metrics (memory usage by default, plus caller-supplied custom metrics)…
- [sampler](./src/sampler/sampler.md) — `Sampler` collects periodic snapshots of runtime metrics (memory usage by default, plus caller-supplied custom metrics)…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry meter, counters, and histograms emitted by `@suaveplan/profiler` itself. Every public completion…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry meter, counters, and histograms emitted by `@suaveplan/profiler` itself. Every public completion…
<!-- end:auto-docs -->
## Modules
- [`profiler/`](./src/profiler/profiler.md) — `Profiler`: start/stop/mark/measure with injectable `Clock`.
- [`sampler/`](./src/sampler/sampler.md) — `Sampler`: periodic metric collection driven by `TimersLike`.
- [`memory/`](./src/memory/memory.md) — `MemoryProfiler`: heap snapshots, allocation tracking, leak detection.
- [`bottleneck/`](./src/bottleneck/bottleneck.md) — `BottleneckDetector`: hot-spot + budget-violation analysis.
- [`comparison/`](./src/comparison/comparison.md) — `ProfileComparator`: regressions / improvements / unchanged.
- [`flamegraph/`](./src/flamegraph/flamegraph.md) — `FlameGraphGenerator`: containment-tree builder + d3 JSON.
- [`reporter/`](./src/reporter/reporter.md) — `ProfileReporter`: percentile stats + text format.
- [`otel/`](./src/otel/otel.md) — `OtelExporter`: OTLP-compatible JSON exporter.
- [`error-codes/`](./src/error-codes/error-codes.md) — `PROFILER_CODES` + `ProfilerError`.
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter for the profiler itself.
## Install
```bash
bun add @suaveplan/profiler
```
## Usage
```ts
import { Profiler, ProfileReporter, OtelExporter } from "@suaveplan/profiler";
const profiler = new Profiler();
profiler.start("render", "ui");
// ... work ...
profiler.stop("render");
const reporter = new ProfileReporter({ alertThreshold: 16 });
console.log(reporter.formatReport(reporter.generateReport(profiler.getEntries())));
const exporter = new OtelExporter({ serviceName: "my-app" });
await exporter.export(exporter.convertEntries(profiler.getEntries()));
```
## License
MIT

View File

@@ -0,0 +1,122 @@
{
"name": "@suaveplan/profiler",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal runtime performance profiler — Profiler/Sampler/MemoryProfiler/BottleneckDetector/ProfileComparator/FlameGraphGenerator/ProfileReporter/OtelExporter with injectable Clock+TimersLike+HeapCapture, PROFILER_CODES error registry, and OTel self-telemetry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./profiler": {
"import": {
"types": "./dist/profiler/index.d.ts",
"default": "./dist/profiler/index.js"
}
},
"./sampler": {
"import": {
"types": "./dist/sampler/index.d.ts",
"default": "./dist/sampler/index.js"
}
},
"./memory": {
"import": {
"types": "./dist/memory/index.d.ts",
"default": "./dist/memory/index.js"
}
},
"./bottleneck": {
"import": {
"types": "./dist/bottleneck/index.d.ts",
"default": "./dist/bottleneck/index.js"
}
},
"./comparison": {
"import": {
"types": "./dist/comparison/index.d.ts",
"default": "./dist/comparison/index.js"
}
},
"./flamegraph": {
"import": {
"types": "./dist/flamegraph/index.d.ts",
"default": "./dist/flamegraph/index.js"
}
},
"./reporter": {
"import": {
"types": "./dist/reporter/index.d.ts",
"default": "./dist/reporter/index.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,59 @@
# bottleneck
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BottleneckDetector` analyses arrays of `ProfileEntry` records to surface performance bottlenecks. It groups entries by label, computes per-group average duration and frequency, and ranks results by impact (`avgDuration * frequency`). Detection is pure and stateless — the same detector can be applied to entries from any `Profiler` instance without coupling.
## Features
- Zod-validated configuration (`timeBudgetMs > 0`, `hotSpotFrequency > 0`, `maxResults > 0`).
- `analyze(entries)` — returns up to `maxResults` bottlenecks with attached `suggestion` strings, ranked by impact descending.
- `findBudgetViolations(entries)` — returns every entry whose `duration` exceeds `timeBudgetMs`.
- `findHotSpots(entries)` — returns entries that are BOTH slower than budget AND occur at least `hotSpotFrequency` times.
- Suggestion tiering: hot-spot, massively-over-budget (>10x), over-budget, and very-frequent thresholds each produce a distinct human-readable hint.
## Usage: Basic
```ts
import { BottleneckDetector } from "@suaveplan/profiler";
const detector = new BottleneckDetector({ timeBudgetMs: 16 });
const bottlenecks = detector.analyze(profiler.getEntries());
for (const b of bottlenecks) {
console.warn(b.suggestion);
}
```
## Usage: Advanced
```ts
import { BottleneckDetector } from "@suaveplan/profiler";
const detector = new BottleneckDetector({
timeBudgetMs: 4, // sub-frame budget for 240Hz displays
hotSpotFrequency: 50,
maxResults: 5,
});
const violations = detector.findBudgetViolations(profiler.getEntries());
const hotSpots = detector.findHotSpots(profiler.getEntries());
if (hotSpots.length > 0) {
metrics.bottlenecks.add(hotSpots.length, { tier: "hot-spot" });
}
```
## API Reference
- `class BottleneckDetector` — `new BottleneckDetector(config?: BottleneckConfig)`.
- `interface BottleneckConfig { timeBudgetMs?, hotSpotFrequency?, maxResults? }`.
- Methods: `analyze`, `findBudgetViolations`, `findHotSpots`.
- Errors: `ProfilerError` with `PROFILER.INVALID_OPTIONS` on bad config.
## Implementation Notes
`analyze` filters out entries with `duration === undefined` (still-running measurements), groups by label, and dispatches to `generateSuggestion`. The suggestion tiers are checked in priority order so a hot-spot label takes precedence over the massive-over-budget tier even when both apply, and the very-frequent tier only fires when an entry is cheap-but-noisy. Sort is stable and deterministic — equal impacts retain insertion order, which keeps the output reproducible across runs.

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PROFILER_CODES,
type ProfilerError,
} from "../error-codes/error-codes.js";
import type { ProfileEntry } from "../types/profiler.types.js";
import { BottleneckDetector } from "./bottleneck.js";
function entry(
label: string,
duration: number,
category?: string
): ProfileEntry {
return {
label,
startTime: 0,
endTime: duration,
duration,
category,
};
}
describe("BottleneckDetector", () => {
it("throws INVALID_OPTIONS on bad config", { timeout: 5000 }, () => {
try {
new BottleneckDetector({ timeBudgetMs: -1 });
throw new Error("expected throw");
} catch (err) {
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("returns empty for no completed entries", { timeout: 5000 }, () => {
const d = new BottleneckDetector();
expect(
d.analyze([
{ label: "x", startTime: 0 },
{ label: "y", startTime: 1 },
])
).toHaveLength(0);
});
it("ranks bottlenecks by impact descending", { timeout: 5000 }, () => {
const d = new BottleneckDetector({
timeBudgetMs: 10,
hotSpotFrequency: 2,
maxResults: 5,
});
const entries = [
...Array.from({ length: 5 }, () => entry("hot", 50, "render")),
entry("slow", 200, "io"),
entry("ok", 5),
];
const result = d.analyze(entries);
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result[0]?.impact).toBeGreaterThan(result[1]?.impact ?? 0);
});
it("respects maxResults", { timeout: 5000 }, () => {
const d = new BottleneckDetector({
timeBudgetMs: 1,
hotSpotFrequency: 1,
maxResults: 1,
});
const entries = [entry("a", 100), entry("b", 200), entry("c", 300)];
expect(d.analyze(entries)).toHaveLength(1);
});
it("findBudgetViolations returns over-budget entries", {
timeout: 5000,
}, () => {
const d = new BottleneckDetector({ timeBudgetMs: 10 });
const violations = d.findBudgetViolations([
entry("ok", 5),
entry("bad", 50),
{ label: "still-running", startTime: 0 },
]);
expect(violations).toHaveLength(1);
expect(violations[0]?.label).toBe("bad");
});
it("findHotSpots sorts multiple hotspots by impact descending", {
timeout: 5000,
}, () => {
const d = new BottleneckDetector({
timeBudgetMs: 5,
hotSpotFrequency: 2,
});
const entries = [
...Array.from({ length: 10 }, () => entry("hot-a", 50)),
...Array.from({ length: 3 }, () => entry("hot-b", 200)),
];
const hs = d.findHotSpots(entries);
expect(hs.length).toBe(2);
expect((hs[0]?.impact ?? 0) >= (hs[1]?.impact ?? 0)).toBe(true);
});
it("findHotSpots requires both threshold and frequency", {
timeout: 5000,
}, () => {
const d = new BottleneckDetector({
timeBudgetMs: 10,
hotSpotFrequency: 3,
});
const slowButRare = [entry("x", 200)];
const fastButFrequent = Array.from({ length: 5 }, () => entry("y", 1));
const slowAndFrequent = Array.from({ length: 5 }, () =>
entry("z", 100)
);
expect(
d.findHotSpots([
...slowButRare,
...fastButFrequent,
...slowAndFrequent,
])
).toHaveLength(1);
});
it("greatly-exceeds-budget suggestion fires for single slow entry", {
timeout: 5000,
}, () => {
const d = new BottleneckDetector({
timeBudgetMs: 10,
hotSpotFrequency: 100,
});
const result = d.analyze([entry("massive", 500)]);
expect(result[0]?.suggestion).toContain("greatly exceeds");
});
it("exceeds-budget (1x-10x) suggestion fires", { timeout: 5000 }, () => {
const d = new BottleneckDetector({
timeBudgetMs: 10,
hotSpotFrequency: 100,
});
const result = d.analyze([entry("a-bit-slow", 50)]);
expect(result[0]?.suggestion).toContain("exceeds time budget");
});
it("very-frequent suggestion fires for cheap-but-frequent", {
timeout: 5000,
}, () => {
const d = new BottleneckDetector({
timeBudgetMs: 1000,
hotSpotFrequency: 2,
});
const entries = Array.from({ length: 20 }, () => entry("noisy", 1));
const result = d.analyze(entries);
expect(result[0]?.suggestion).toContain("very frequently");
});
it("returns null suggestion for cheap and rare", { timeout: 5000 }, () => {
const d = new BottleneckDetector({
timeBudgetMs: 100,
hotSpotFrequency: 100,
});
expect(d.analyze([entry("trivial", 1)])).toHaveLength(0);
});
});

View File

@@ -0,0 +1,157 @@
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import type {
Bottleneck,
BottleneckConfig,
ProfileEntry,
} from "../types/profiler.types.js";
const ConfigSchema = z.object({
timeBudgetMs: z.number().positive().optional(),
hotSpotFrequency: z.number().int().positive().optional(),
maxResults: z.number().int().positive().optional(),
});
interface EntryGroup {
label: string;
category?: string | undefined;
durations: number[];
}
/**
* Analyzes profiler entries to identify performance bottlenecks, hot
* spots, and budget violations. Pure stateless analysis — safe to share
* across consumers.
*/
export class BottleneckDetector {
private readonly timeBudgetMs: number;
private readonly hotSpotFrequency: number;
private readonly maxResults: number;
public constructor(config?: BottleneckConfig) {
const parsed = ConfigSchema.safeParse(config ?? {});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid BottleneckDetector options",
{ context: { issues: parsed.error.issues } }
);
}
this.timeBudgetMs = parsed.data.timeBudgetMs ?? 16;
this.hotSpotFrequency = parsed.data.hotSpotFrequency ?? 10;
this.maxResults = parsed.data.maxResults ?? 20;
}
/** Analyze entries and return detected bottlenecks ranked by impact. */
public analyze(entries: readonly ProfileEntry[]): Bottleneck[] {
const completed = entries.filter((e) => e.duration !== undefined);
if (completed.length === 0) return [];
const groups = this.groupByLabel(completed);
const bottlenecks: Bottleneck[] = [];
for (const group of groups.values()) {
const avg = this.average(group.durations);
const freq = group.durations.length;
const suggestion = this.generateSuggestion(avg, freq, group.label);
if (suggestion !== null) {
bottlenecks.push({
label: group.label,
category: group.category,
avgDuration: avg,
frequency: freq,
impact: avg * freq,
suggestion,
});
}
}
bottlenecks.sort((a, b) => b.impact - a.impact);
return bottlenecks.slice(0, this.maxResults);
}
/** Find entries exceeding the time budget. */
public findBudgetViolations(
entries: readonly ProfileEntry[]
): ProfileEntry[] {
return entries.filter(
(e) => e.duration !== undefined && e.duration > this.timeBudgetMs
);
}
/** Find hot spots: frequently called AND slower than budget. */
public findHotSpots(entries: readonly ProfileEntry[]): Bottleneck[] {
const completed = entries.filter((e) => e.duration !== undefined);
const groups = this.groupByLabel(completed);
const hotSpots: Bottleneck[] = [];
for (const group of groups.values()) {
const avg = this.average(group.durations);
const freq = group.durations.length;
if (freq >= this.hotSpotFrequency && avg > this.timeBudgetMs) {
hotSpots.push({
label: group.label,
category: group.category,
avgDuration: avg,
frequency: freq,
impact: avg * freq,
suggestion: `"${group.label}" is called ${freq} times with avg ${avg.toFixed(1)}ms. Consider caching or batching.`,
});
}
}
hotSpots.sort((a, b) => b.impact - a.impact);
return hotSpots;
}
private groupByLabel(
entries: readonly ProfileEntry[]
): Map<string, EntryGroup> {
const groups = new Map<string, EntryGroup>();
for (const entry of entries) {
// Callers (`analyze` / `findHotSpots`) pre-filter completed
// entries, so `entry.duration` is always defined here.
const duration = entry.duration as number;
const existing = groups.get(entry.label);
if (existing) {
existing.durations.push(duration);
} else {
groups.set(entry.label, {
label: entry.label,
category: entry.category,
durations: [duration],
});
}
}
return groups;
}
private average(values: number[]): number {
// Caller `groupByLabel` always inserts at least one duration per
// label, so `values` is non-empty here.
return values.reduce((a, b) => a + b, 0) / values.length;
}
private generateSuggestion(
avgDuration: number,
frequency: number,
label: string
): string | null {
if (
avgDuration > this.timeBudgetMs &&
frequency >= this.hotSpotFrequency
) {
return `"${label}" is a hot spot (${frequency}x, avg ${avgDuration.toFixed(1)}ms). Consider caching, memoization, or batching.`;
}
if (avgDuration > this.timeBudgetMs * 10) {
return `"${label}" greatly exceeds budget (avg ${avgDuration.toFixed(1)}ms vs ${this.timeBudgetMs}ms). Profile inner logic for optimization.`;
}
if (avgDuration > this.timeBudgetMs) {
return `"${label}" exceeds time budget (avg ${avgDuration.toFixed(1)}ms vs ${this.timeBudgetMs}ms). Consider async processing or optimization.`;
}
if (frequency >= this.hotSpotFrequency * 5) {
return `"${label}" is called very frequently (${frequency}x). Consider debouncing or reducing call frequency.`;
}
return null;
}
}

View File

@@ -0,0 +1 @@
export { BottleneckDetector } from "./bottleneck.js";

View File

@@ -0,0 +1,57 @@
# comparison
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ProfileComparator` diffs two sets of profiling data — either raw `ProfileEntry[]` arrays or pre-aggregated `ProfileReport` objects — and classifies each per-label delta as a regression, an improvement, or unchanged. The classification threshold and the wall-clock used for the report's `generatedAt` field are both injectable, so test runs are deterministic and CI gates can configure their own sensitivity.
## Features
- `compareReports(baseline, current)` operates at category granularity (uses `report.byCategory` means).
- `compareEntries(baseline, current)` operates at label granularity (computes per-label averages internally).
- `regressionThreshold` controls how many percent of change is required to classify a delta as a regression or improvement. Default 5.
- Sorts: regressions descending by `percentChange`, improvements ascending.
- Zero-baseline edge case handled symmetrically — a label that appears only in `current` is classified by treating baseline as 0 and reporting 100% change.
## Usage: Basic
```ts
import { ProfileComparator, ProfileReporter } from "@suaveplan/profiler";
const reporter = new ProfileReporter();
const baseline = reporter.generateReport(oldEntries);
const current = reporter.generateReport(newEntries);
const diff = new ProfileComparator().compareReports(baseline, current);
console.log("regressions:", diff.regressions.length);
```
## Usage: Advanced
```ts
import { ProfileComparator } from "@suaveplan/profiler";
const comparator = new ProfileComparator({
regressionThreshold: 2, // tighter than default 5%
clock: () => Date.parse("2025-01-01T00:00:00Z"),
});
const diff = comparator.compareEntries(baseline.getEntries(), pr.getEntries());
if (diff.regressions.length > 0) {
process.exitCode = 1;
console.error("perf regression gate triggered:", diff.regressions);
}
```
## API Reference
- `class ProfileComparator` — `new ProfileComparator(options?: ProfileComparatorOptions)`.
- `interface ProfileComparatorOptions { regressionThreshold?: number; clock?: Clock }`.
- Methods: `compareReports(baseline, current)`, `compareEntries(baseline, current)`.
- Errors: `ProfilerError` with `PROFILER.INVALID_OPTIONS` on negative threshold.
## Implementation Notes
`compareEntries` ignores entries with `duration === undefined` so partially-completed profiles can be compared safely. Per-label averages are computed from raw durations rather than from any pre-aggregated stats so the same comparator can mix labels with different occurrence counts in baseline vs current. The `unchanged` bucket exists explicitly so consumers wanting a full per-label audit can read it without re-grouping. The `generatedAt` timestamp is produced from `new Date(clock()).toISOString()` so the default and the injected clock paths produce identically-formatted strings.

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PROFILER_CODES,
type ProfilerError,
} from "../error-codes/error-codes.js";
import type {
ProfileEntry,
ProfileReport,
ProfileStats,
} from "../types/profiler.types.js";
import { ProfileComparator } from "./comparison.js";
function entry(
label: string,
duration: number,
category?: string
): ProfileEntry {
return { label, startTime: 0, endTime: duration, duration, category };
}
function stats(mean: number, total: number, count = 1): ProfileStats {
return {
count,
min: mean,
max: mean,
mean,
median: mean,
percentiles: { 50: mean },
totalDuration: total,
};
}
function report(
overall: ProfileStats,
byCategory: Record<string, ProfileStats>
): ProfileReport {
return {
generatedAt: "now",
stats: overall,
byCategory,
alerts: [],
marks: [],
};
}
describe("ProfileComparator", () => {
it("throws INVALID_OPTIONS for negative regressionThreshold", {
timeout: 5000,
}, () => {
try {
new ProfileComparator({ regressionThreshold: -1 });
throw new Error("expected throw");
} catch (err) {
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("classifies regressions, improvements, unchanged", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({
regressionThreshold: 5,
clock: () => 0,
});
const baseline = [
entry("slow", 100),
entry("fast", 100),
entry("steady", 100),
];
const current = [
entry("slow", 200),
entry("fast", 50),
entry("steady", 100),
entry("new", 80),
];
const result = c.compareEntries(baseline, current);
expect(result.regressions.map((r) => r.label)).toContain("slow");
expect(result.regressions.map((r) => r.label)).toContain("new");
expect(result.improvements.map((r) => r.label)).toContain("fast");
expect(result.unchanged.map((r) => r.label)).toContain("steady");
});
it("sorts regressions descending and improvements ascending", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const baseline = [entry("a", 100), entry("b", 100), entry("c", 100)];
const current = [entry("a", 200), entry("b", 150), entry("c", 50)];
const result = c.compareEntries(baseline, current);
// regressions sorted desc by percentChange
expect(
(result.regressions[0]?.percentChange ?? 0) >=
(result.regressions[1]?.percentChange ?? 0)
).toBe(true);
});
it("handles zero-baseline edge case as 100%", { timeout: 5000 }, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const result = c.compareEntries([], [entry("only", 50)]);
expect(result.overallPercentChange).toBe(100);
});
it("handles zero-baseline zero-current as 0", { timeout: 5000 }, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const result = c.compareEntries([], []);
expect(result.overallPercentChange).toBe(0);
expect(result.overallDelta).toBe(0);
});
it("ignores entries with missing duration", { timeout: 5000 }, () => {
const c = new ProfileComparator();
const result = c.compareEntries(
[entry("a", 10), { label: "still", startTime: 0 }],
[entry("a", 10)]
);
expect(result.regressions.length + result.improvements.length).toBe(0);
});
it("compareReports works at category granularity", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const baseline = report(stats(100, 500, 5), {
render: stats(50, 250, 5),
});
const current = report(stats(200, 1000, 5), {
render: stats(150, 750, 5),
});
const result = c.compareReports(baseline, current);
expect(result.regressions.length).toBeGreaterThanOrEqual(1);
expect(result.overallDelta).toBe(500);
});
it("zero-baseline zero-current label is unchanged", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const r = c.compareEntries([], []);
expect(r.unchanged).toHaveLength(0);
});
it("uses default clock and serialises generatedAt", {
timeout: 5000,
}, () => {
const c = new ProfileComparator();
const r = c.compareEntries([], []);
expect(typeof r.generatedAt).toBe("string");
});
it("sorts multiple improvements ascending by percentChange", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const baseline = [entry("a", 100), entry("b", 100)];
const current = [entry("a", 80), entry("b", 20)];
const r = c.compareEntries(baseline, current);
// both are improvements; "b" has the larger drop so should sort earlier
// when ascending by percentChange (more negative first).
expect(r.improvements[0]?.label).toBe("b");
expect(r.improvements[1]?.label).toBe("a");
});
it("zero-baseline-and-zero-current label has 0% change", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const r = c.compareEntries([entry("zero", 0)], [entry("zero", 0)]);
const zero = r.unchanged.find((d) => d.label === "zero");
expect(zero?.percentChange).toBe(0);
});
it("classifies baseline-only label as 100% improvement", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const r = c.compareEntries([entry("gone", 50)], []);
// baseline-only label → current=0 → percentChange=-100 → improvement
const goneDelta = [
...r.improvements,
...r.unchanged,
...r.regressions,
].find((d) => d.label === "gone");
expect(goneDelta).toBeDefined();
expect(r.improvements.map((i) => i.label)).toContain("gone");
});
it("aggregates duplicate labels from baseline/current arrays", {
timeout: 5000,
}, () => {
const c = new ProfileComparator({ regressionThreshold: 5 });
const baseline = [entry("k", 10), entry("k", 30)]; // avg 20
const current = [entry("k", 10), entry("k", 10)]; // avg 10
const r = c.compareEntries(baseline, current);
expect(r.improvements.length).toBe(1);
});
});

View File

@@ -0,0 +1,210 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import type {
ComparisonReport,
ProfileDelta,
ProfileEntry,
ProfileReport,
} from "../types/profiler.types.js";
const OptionsSchema = z.object({
regressionThreshold: z.number().nonnegative().optional(),
});
interface LabelStats {
label: string;
category?: string | undefined;
avgDuration: number;
count: number;
}
/** Options for {@link ProfileComparator}. */
export interface ProfileComparatorOptions {
/** Minimum percent change to classify as regression/improvement. Default 5. */
readonly regressionThreshold?: number;
/** Injectable wall-clock for the `generatedAt` timestamp. */
readonly clock?: Clock;
}
/**
* Compares two profile reports or entry sets to identify regressions,
* improvements, and produce a deterministic delta classification.
*/
export class ProfileComparator {
private readonly regressionThreshold: number;
private readonly clock: Clock;
public constructor(options: ProfileComparatorOptions = {}) {
const parsed = OptionsSchema.safeParse({
regressionThreshold: options.regressionThreshold,
});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid ProfileComparator options",
{ context: { issues: parsed.error.issues } }
);
}
this.regressionThreshold = parsed.data.regressionThreshold ?? 5;
this.clock = options.clock ?? (() => Date.now());
}
/** Compare two `ProfileReport` instances at category granularity. */
public compareReports(
baseline: ProfileReport,
current: ProfileReport
): ComparisonReport {
const deltas = this.computeDeltas(
this.reportToStats(baseline),
this.reportToStats(current)
);
return this.buildReport(
deltas,
baseline.stats.totalDuration,
current.stats.totalDuration
);
}
/** Compare two raw entry arrays at label granularity. */
public compareEntries(
baseline: readonly ProfileEntry[],
current: readonly ProfileEntry[]
): ComparisonReport {
const baselineStats = this.groupEntries(baseline);
const currentStats = this.groupEntries(current);
const baselineTotal = this.totalDuration(baseline);
const currentTotal = this.totalDuration(current);
const deltas = this.computeDeltas(baselineStats, currentStats);
return this.buildReport(deltas, baselineTotal, currentTotal);
}
private totalDuration(entries: readonly ProfileEntry[]): number {
let total = 0;
for (const e of entries) {
if (e.duration !== undefined) total += e.duration;
}
return total;
}
private computeDeltas(
baseline: Map<string, LabelStats>,
current: Map<string, LabelStats>
): ProfileDelta[] {
const deltas: ProfileDelta[] = [];
const allLabels = new Set([...baseline.keys(), ...current.keys()]);
for (const label of allLabels) {
const base = baseline.get(label);
const cur = current.get(label);
const baseDuration = base?.avgDuration ?? 0;
const curDuration = cur?.avgDuration ?? 0;
const absoluteChange = curDuration - baseDuration;
const percentChange =
baseDuration === 0
? curDuration === 0
? 0
: 100
: (absoluteChange / baseDuration) * 100;
deltas.push({
label,
category: cur?.category ?? base?.category,
baselineDuration: baseDuration,
currentDuration: curDuration,
absoluteChange,
percentChange,
});
}
return deltas;
}
private buildReport(
deltas: ProfileDelta[],
baselineTotal: number,
currentTotal: number
): ComparisonReport {
const regressions: ProfileDelta[] = [];
const improvements: ProfileDelta[] = [];
const unchanged: ProfileDelta[] = [];
for (const delta of deltas) {
if (delta.percentChange > this.regressionThreshold) {
regressions.push(delta);
} else if (delta.percentChange < -this.regressionThreshold) {
improvements.push(delta);
} else {
unchanged.push(delta);
}
}
regressions.sort((a, b) => b.percentChange - a.percentChange);
improvements.sort((a, b) => a.percentChange - b.percentChange);
const overallDelta = currentTotal - baselineTotal;
const overallPercentChange =
baselineTotal === 0
? currentTotal === 0
? 0
: 100
: (overallDelta / baselineTotal) * 100;
return {
generatedAt: new Date(this.clock()).toISOString(),
regressions,
improvements,
unchanged,
overallDelta,
overallPercentChange,
};
}
private groupEntries(
entries: readonly ProfileEntry[]
): Map<string, LabelStats> {
const groups = new Map<
string,
{ durations: number[]; category?: string | undefined }
>();
for (const entry of entries) {
if (entry.duration === undefined) continue;
const existing = groups.get(entry.label);
if (existing) {
existing.durations.push(entry.duration);
} else {
groups.set(entry.label, {
durations: [entry.duration],
category: entry.category,
});
}
}
const result = new Map<string, LabelStats>();
for (const [label, data] of groups) {
const avg =
data.durations.reduce((a, b) => a + b, 0) /
data.durations.length;
result.set(label, {
label,
category: data.category,
avgDuration: avg,
count: data.durations.length,
});
}
return result;
}
private reportToStats(report: ProfileReport): Map<string, LabelStats> {
const result = new Map<string, LabelStats>();
for (const [category, stats] of Object.entries(report.byCategory)) {
result.set(category, {
label: category,
category,
avgDuration: stats.mean,
count: stats.count,
});
}
return result;
}
}

View File

@@ -0,0 +1,2 @@
export type { ProfileComparatorOptions } from "./comparison.js";
export { ProfileComparator } from "./comparison.js";

View File

@@ -0,0 +1,59 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/profiler` and the typed `ProfilerError` class consumers throw from every failure path. Codes are registered with `@suaveplan/error`'s `defineCodes` helper so they survive serialisation and module-boundary crossings — consumers discriminate on `error.code` rather than `instanceof`, which keeps the contract portable across worker threads, HTTP wire formats, and language-server log scrapes.
## Features
- Seven canonical codes: `INVALID_OPTIONS`, `UNKNOWN_LABEL`, `SAMPLER_ALREADY_RUNNING`, `SAMPLER_NOT_RUNNING`, `MEMORY_ALREADY_RUNNING`, `MEMORY_NOT_RUNNING`, `EXPORT_FAILED`.
- Every code namespaced under the `PROFILER.` prefix at registration time.
- `ProfilerError` extends `SuaveplanError<ProfilerCode>` so the `code` field narrows against the literal union and `switch (err.code)` is exhaustively type-checked.
- Public constructor: `SuaveplanError`'s own constructor is protected, so we delegate via `super(code, message, options)` and set `this.name = "ProfilerError"` so stack frames stay readable.
- `ProfilerError` accepts `{ cause?, context? }` — `cause` chains underlying errors, `context` ships structured details to log sinks.
## Usage: Basic
```ts
import { PROFILER_CODES, ProfilerError } from "@suaveplan/profiler";
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Sampler intervalMs must be positive"
);
```
## Usage: Advanced
```ts
try {
new Sampler({ intervalMs: -1 });
} catch (err) {
if (err instanceof ProfilerError) {
switch (err.code) {
case PROFILER_CODES.INVALID_OPTIONS:
logger.warn("config rejected", err.context);
break;
case PROFILER_CODES.EXPORT_FAILED:
metrics.exportFailures.add(1);
break;
default:
throw err;
}
}
}
```
## API Reference
- `PROFILER_CODES: Readonly<{ INVALID_OPTIONS: "PROFILER.INVALID_OPTIONS", ... }>` — frozen literal-type record.
- `type ProfilerCode = (typeof PROFILER_CODES)[keyof typeof PROFILER_CODES]` — discriminated union.
- `class ProfilerError extends SuaveplanError<ProfilerCode>` — public `constructor(code, message, options?)`.
## Implementation Notes
The registry is built with `defineCodes("PROFILER", [...] as const)` so TypeScript can infer the keys as literal types. Adding a new code requires appending to the `as const` array and to the OpenSpec spec delta — no other change is needed because the union and registry update together. The `ProfilerError` constructor is intentionally minimal: it delegates to `SuaveplanError`, sets `this.name`, and accepts the same options bundle so error logs and OTel span events render correctly out of the box.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PROFILER_CODES, ProfilerError } from "./error-codes.js";
describe("PROFILER_CODES", () => {
it("namespaces every code under PROFILER.", { timeout: 5000 }, () => {
for (const value of Object.values(PROFILER_CODES)) {
expect(value.startsWith("PROFILER.")).toBe(true);
}
});
it("registers all expected codes", { timeout: 5000 }, () => {
expect(PROFILER_CODES.INVALID_OPTIONS).toBe("PROFILER.INVALID_OPTIONS");
expect(PROFILER_CODES.UNKNOWN_LABEL).toBe("PROFILER.UNKNOWN_LABEL");
expect(PROFILER_CODES.SAMPLER_ALREADY_RUNNING).toBe(
"PROFILER.SAMPLER_ALREADY_RUNNING"
);
expect(PROFILER_CODES.SAMPLER_NOT_RUNNING).toBe(
"PROFILER.SAMPLER_NOT_RUNNING"
);
expect(PROFILER_CODES.MEMORY_ALREADY_RUNNING).toBe(
"PROFILER.MEMORY_ALREADY_RUNNING"
);
expect(PROFILER_CODES.MEMORY_NOT_RUNNING).toBe(
"PROFILER.MEMORY_NOT_RUNNING"
);
expect(PROFILER_CODES.EXPORT_FAILED).toBe("PROFILER.EXPORT_FAILED");
});
});
describe("ProfilerError", () => {
it("carries the supplied code and message", { timeout: 5000 }, () => {
const err = new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"bad options",
{ context: { x: 1 } }
);
expect(err.code).toBe(PROFILER_CODES.INVALID_OPTIONS);
expect(err.message).toBe("bad options");
expect(err.name).toBe("ProfilerError");
expect(err.context).toEqual({ x: 1 });
});
it("preserves cause chain", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new ProfilerError(PROFILER_CODES.EXPORT_FAILED, "wrapped", {
cause,
});
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,59 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/profiler`.
*
* Codes follow `PROFILER.<REASON>` and are resolved via `@suaveplan/error`'s
* `defineCodes`. Consumers SHOULD discriminate on `error.code` rather than
* `instanceof` so the namespace survives module boundaries and runtime
* serialisation.
*
* The registry covers Zod boundary failures (`INVALID_OPTIONS`), illegal
* lifecycle transitions (`SAMPLER_NOT_RUNNING`/`SAMPLER_ALREADY_RUNNING`),
* missing-label failures on `stop` (`UNKNOWN_LABEL`), and OTel-export
* upstream failures (`EXPORT_FAILED`).
*/
export const PROFILER_CODES = defineCodes("PROFILER", [
"INVALID_OPTIONS",
"UNKNOWN_LABEL",
"SAMPLER_ALREADY_RUNNING",
"SAMPLER_NOT_RUNNING",
"MEMORY_ALREADY_RUNNING",
"MEMORY_NOT_RUNNING",
"EXPORT_FAILED",
] as const);
export type ProfilerCode = (typeof PROFILER_CODES)[keyof typeof PROFILER_CODES];
/**
* Typed error for every `@suaveplan/profiler` failure path.
*
* Extends `SuaveplanError<ProfilerCode>` so the `code` field narrows
* against the literal union and consumers get full type-checking on
* `switch (err.code)` blocks. Carries a public constructor so that
* `new ProfilerError(...)` works at the call site — `SuaveplanError`'s
* own constructor is protected.
*
* @example
* ```ts
* import { PROFILER_CODES, ProfilerError } from "@suaveplan/profiler";
*
* try {
* new Sampler({ intervalMs: -1 });
* } catch (err) {
* if (err instanceof ProfilerError && err.code === PROFILER_CODES.INVALID_OPTIONS) {
* console.error("bad config", err.context);
* }
* }
* ```
*/
export class ProfilerError extends SuaveplanError<ProfilerCode> {
public constructor(
code: ProfilerCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "ProfilerError";
}
}

View File

@@ -0,0 +1,2 @@
export type { ProfilerCode } from "./error-codes.js";
export { PROFILER_CODES, ProfilerError } from "./error-codes.js";

View File

@@ -0,0 +1,54 @@
# flamegraph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FlameGraphGenerator` builds a flame-graph data structure from `ProfileEntry` arrays by nesting entries on start/end-time containment. The output is a `FlameNode` tree compatible with `d3-flame-graph` and similar visualisers; SVG/HTML rendering is intentionally out of scope (a downstream concern), but `toD3Format` produces the exact JSON shape these libraries expect.
## Features
- Pure containment algorithm: an entry whose `[startTime, endTime]` interval is fully inside another's becomes a child of it.
- Tie-breaking on equal start times: wider spans first so they own the narrower children.
- Self-time computation: `selfTime = value - sum(children.value)`, never negative.
- `collapseRecursive(node)` merges consecutive same-name parent/child pairs, useful for cleaning up tail-recursive call patterns before rendering.
- `toD3Format(node)` strips internal fields (`selfTime`, `startTime`, `endTime`) so the JSON is exactly `{ name, value, children }`.
## Usage: Basic
```ts
import { FlameGraphGenerator } from "@suaveplan/profiler";
const gen = new FlameGraphGenerator();
const tree = gen.generate(profiler.getEntries());
const d3Json = gen.toD3Format(tree);
```
## Usage: Advanced
```ts
import { FlameGraphGenerator } from "@suaveplan/profiler";
const gen = new FlameGraphGenerator();
const rawTree = gen.generate(entries);
const collapsed = gen.collapseRecursive(rawTree);
// rawTree shows the full recursion stack; collapsed flattens
// `parser → parser → parser → leaf` into `parser → leaf`.
const json = gen.toD3Format(collapsed);
await writeFile("flame.json", JSON.stringify(json));
```
## API Reference
- `class FlameGraphGenerator` — no constructor args (stateless).
- Methods:
- `generate(entries: readonly ProfileEntry[]): FlameNode`
- `toD3Format(node: FlameNode): { name, value, children }`
- `collapseRecursive(node: FlameNode): FlameNode`
## Implementation Notes
The internal `MutableFlameNode` carries `startTime`/`endTime` for the duration of tree construction so containment checks remain O(1) per node-pair; the final `freeze` step drops these fields and returns the immutable `FlameNode` shape. When a new entry is inserted, the algorithm first checks for a containing existing child (recursing into it) and otherwise creates a new node, absorbing any existing siblings the new entry contains. This handles arbitrary insertion order — the input is sorted by start-time ascending, ties broken by wider span first, before insertion starts.

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ProfileEntry } from "../types/profiler.types.js";
import { FlameGraphGenerator } from "./flamegraph.js";
function entry(label: string, start: number, end: number): ProfileEntry {
return { label, startTime: start, endTime: end, duration: end - start };
}
describe("FlameGraphGenerator", () => {
it("builds a containment tree", { timeout: 5000 }, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([
entry("outer", 0, 100),
entry("middle", 10, 90),
entry("inner", 20, 50),
]);
expect(tree.name).toBe("root");
expect(tree.children).toHaveLength(1);
expect(tree.children[0]?.name).toBe("outer");
expect(tree.children[0]?.children[0]?.name).toBe("middle");
expect(tree.children[0]?.children[0]?.children[0]?.name).toBe("inner");
});
it("filters incomplete entries", { timeout: 5000 }, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([
entry("ok", 0, 50),
{ label: "pending", startTime: 0 },
]);
expect(tree.children).toHaveLength(1);
});
it("computes selfTime as value minus children", { timeout: 5000 }, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([
entry("parent", 0, 100),
entry("child", 10, 30),
]);
const parent = tree.children[0];
expect(parent?.value).toBe(100);
expect(parent?.selfTime).toBe(80);
});
it("entry that contains existing siblings absorbs them", {
timeout: 5000,
}, () => {
const gen = new FlameGraphGenerator();
// Insert child first, then outer that contains it. The sort puts
// outer first because earlier startTime + wider; this still covers
// the entryContainsNode path when an entry shares startTime with a
// previously inserted sibling.
const tree = gen.generate([
entry("sibling-1", 5, 10),
entry("sibling-2", 15, 20),
entry("wide", 0, 50),
]);
const wide = tree.children.find((c) => c.name === "wide");
expect(wide).toBeDefined();
expect(wide?.children.map((c) => c.name).sort()).toEqual([
"sibling-1",
"sibling-2",
]);
});
it("toD3Format emits d3-compatible JSON", { timeout: 5000 }, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([entry("only", 0, 100)]);
const d3 = gen.toD3Format(tree);
expect(d3).toEqual({
name: "root",
value: 100,
children: [{ name: "only", value: 100, children: [] }],
});
});
it("collapseRecursive merges same-named parent/child", {
timeout: 5000,
}, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([
entry("rec", 0, 100),
entry("rec", 10, 90),
entry("leaf", 20, 50),
]);
const collapsed = gen.collapseRecursive(tree);
// walk tree finding rec
const recNodes: number[] = [];
const walk = (n: typeof collapsed): void => {
if (n.name === "rec") recNodes.push(n.value);
for (const c of n.children) walk(c);
};
walk(collapsed);
expect(recNodes).toHaveLength(1);
});
it("late wide entry contains some siblings but not others", {
timeout: 5000,
}, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([
entry("early", 0, 10),
entry("mid", 20, 30),
entry("late-wide", 15, 40),
]);
const names = tree.children.map((c) => c.name).sort();
expect(names).toContain("early");
expect(names).toContain("late-wide");
const late = tree.children.find((c) => c.name === "late-wide");
expect(late?.children.map((c) => c.name)).toEqual(["mid"]);
});
it("ties on startTime break by wider span first", { timeout: 5000 }, () => {
const gen = new FlameGraphGenerator();
const tree = gen.generate([entry("a", 0, 10), entry("b", 0, 50)]);
// b is wider so should appear first; a is contained within b
const b = tree.children.find((c) => c.name === "b");
expect(b?.children.map((c) => c.name)).toContain("a");
});
});

View File

@@ -0,0 +1,150 @@
import type { FlameNode, ProfileEntry } from "../types/profiler.types.js";
interface MutableFlameNode {
name: string;
value: number;
selfTime: number;
startTime: number;
endTime: number;
children: MutableFlameNode[];
}
/**
* Builds flame-graph data structures from profiler entries by nesting them
* on start/end-time containment. Output is a D3-flame-graph-compatible
* JSON tree.
*/
export class FlameGraphGenerator {
/** Build a flame-graph tree from profiler entries. */
public generate(entries: readonly ProfileEntry[]): FlameNode {
const completed = entries.filter(
(e) => e.duration !== undefined && e.endTime !== undefined
);
const sorted = [...completed].sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return (b.duration as number) - (a.duration as number);
});
const root: MutableFlameNode = {
name: "root",
value: 0,
selfTime: 0,
startTime: Number.NEGATIVE_INFINITY,
endTime: Number.POSITIVE_INFINITY,
children: [],
};
for (const entry of sorted) this.insertIntoTree(root, entry);
this.computeTimes(root);
return this.freeze(root);
}
/** Export a `FlameNode` tree to d3-flame-graph compatible JSON. */
public toD3Format(node: FlameNode): Record<string, unknown> {
return {
name: node.name,
value: node.value,
children: node.children.map((c) => this.toD3Format(c)),
};
}
/** Collapse consecutive recursive (same-name) parent/child nodes. */
public collapseRecursive(node: FlameNode): FlameNode {
const mutable = this.toMutable(node);
const collapsed = this.collapseNode(mutable);
return this.freeze(collapsed);
}
private insertIntoTree(
parent: MutableFlameNode,
entry: ProfileEntry
): void {
for (const child of parent.children) {
if (this.nodeContainsEntry(child, entry)) {
this.insertIntoTree(child, entry);
return;
}
}
const newNode: MutableFlameNode = {
name: entry.label,
value: entry.duration as number,
selfTime: entry.duration as number,
startTime: entry.startTime,
endTime: entry.endTime as number,
children: [],
};
// `generate` sorts entries by start time ascending (ties wider
// first), so by the time we get here a new entry can never fully
// contain an existing sibling at the same parent level — entries
// that would contain a sibling are inserted before it. Any
// existing child therefore stays at this level alongside the new
// node.
newNode.children = [];
parent.children = [...parent.children, newNode];
}
private nodeContainsEntry(
node: MutableFlameNode,
entry: ProfileEntry
): boolean {
const end = entry.endTime as number;
return entry.startTime >= node.startTime && end <= node.endTime;
}
private computeTimes(node: MutableFlameNode): void {
let childrenTotal = 0;
for (const child of node.children) {
this.computeTimes(child);
childrenTotal += child.value;
}
if (node.name === "root") {
node.value = childrenTotal;
node.selfTime = 0;
} else {
node.selfTime = Math.max(0, node.value - childrenTotal);
}
}
private collapseNode(node: MutableFlameNode): MutableFlameNode {
const newChildren: MutableFlameNode[] = [];
for (const child of node.children) {
if (child.name === node.name) {
for (const grandchild of child.children) {
newChildren.push(this.collapseNode(grandchild));
}
} else {
newChildren.push(this.collapseNode(child));
}
}
return {
name: node.name,
value: node.value,
selfTime: node.selfTime,
startTime: node.startTime,
endTime: node.endTime,
children: newChildren,
};
}
private toMutable(node: FlameNode): MutableFlameNode {
return {
name: node.name,
value: node.value,
selfTime: node.selfTime,
startTime: 0,
endTime: 0,
children: node.children.map((c) => this.toMutable(c)),
};
}
private freeze(node: MutableFlameNode): FlameNode {
return {
name: node.name,
value: node.value,
selfTime: node.selfTime,
children: node.children.map((c) => this.freeze(c)),
};
}
}

View File

@@ -0,0 +1 @@
export { FlameGraphGenerator } from "./flamegraph.js";

View File

@@ -0,0 +1,53 @@
export { BottleneckDetector } from "./bottleneck/bottleneck.js";
export type { ProfileComparatorOptions } from "./comparison/comparison.js";
export { ProfileComparator } from "./comparison/comparison.js";
export type { ProfilerCode } from "./error-codes/error-codes.js";
export { PROFILER_CODES, ProfilerError } from "./error-codes/error-codes.js";
export { FlameGraphGenerator } from "./flamegraph/flamegraph.js";
export type { MemoryProfilerOptions } from "./memory/memory.js";
export { MemoryProfiler } from "./memory/memory.js";
export { OtelExporter } from "./otel/otel.js";
export type { ProfilerOptions } from "./profiler/profiler.js";
export { Profiler } from "./profiler/profiler.js";
export type { ProfileReporterOptions } from "./reporter/reporter.js";
export { ProfileReporter } from "./reporter/reporter.js";
export type { SamplerOptions } from "./sampler/sampler.js";
export { Sampler } from "./sampler/sampler.js";
export {
entriesCounter,
marksCounter,
measureDurationHistogram,
memorySnapshotsCounter,
recordEntry,
recordMark,
recordMeasureDuration,
recordMemorySnapshot,
recordSample,
samplesCounter,
} from "./telemetry/telemetry.js";
export type {
AllocationRecord,
Bottleneck,
BottleneckConfig,
ComparisonReport,
FetchLike,
FlameNode,
HeapCapture,
MemoryProbe,
MemoryProfilerConfig,
MemoryProfilerEventMap,
MemorySnapshot,
OtelExporterConfig,
OtelSpan,
ProfileDelta,
ProfileEntry,
ProfileMark,
ProfileReport,
ProfilerEventMap,
ProfileStats,
RandomBytes,
ReporterConfig,
SamplePoint,
SamplerConfig,
SamplerEventMap,
} from "./types/profiler.types.js";

View File

@@ -0,0 +1,2 @@
export type { MemoryProfilerOptions } from "./memory.js";
export { MemoryProfiler } from "./memory.js";

View File

@@ -0,0 +1,66 @@
# memory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryProfiler` captures periodic heap snapshots, records labelled allocation totals, tracks a peak-heap watermark, and runs a simple trend-based leak detector. Heap readings come from an injectable `HeapCapture` so test runs are deterministic and non-Node environments can plug their own probe. Timer source is `TimersLike`, matching `Sampler`.
## Features
- Zod-validated config (`sampleIntervalMs > 0`, `maxSnapshots > 0`, `leakThresholdBytes ≥ 0`, `leakWindowSize ≥ 2`).
- Injectable `Clock`, `TimersLike`, `HeapCapture`. Defaults read `process.memoryUsage` then `performance.memory` then return zeros.
- `start()` / `stop()` are idempotent — double-start and double-stop are no-ops.
- `takeSnapshot(label?)` records a snapshot; FIFO-evicts beyond `maxSnapshots`.
- `recordAllocation(label, bytes)` aggregates `allocations` / `totalBytes` / `peakBytes` per label.
- `detectLeak()` returns true when the last `leakWindowSize` snapshots are monotonically non-decreasing AND total growth meets `leakThresholdBytes`.
- `triggerGC()` invokes `globalThis.gc()` when Node was started with `--expose-gc`.
- `events: EventBus<MemoryProfilerEventMap>` — `memory:start`, `memory:stop`, `memory:snapshot`, `memory:leak-detected`.
- Telemetry: `profiler_memory_snapshots_total` counter on every snapshot.
## Usage: Basic
```ts
import { MemoryProfiler } from "@suaveplan/profiler";
const mp = new MemoryProfiler({ sampleIntervalMs: 1000 });
mp.start();
// ...
mp.stop();
if (mp.detectLeak()) console.warn("leak suspected");
```
## Usage: Advanced
```ts
import { MemoryProfiler } from "@suaveplan/profiler";
const fakeHeap: Array<{ heapUsed: number; heapTotal: number }> = [
{ heapUsed: 1_000_000, heapTotal: 10_000_000 },
{ heapUsed: 2_000_000, heapTotal: 10_000_000 },
{ heapUsed: 3_000_000, heapTotal: 10_000_000 },
];
let i = 0;
const mp = new MemoryProfiler({
leakWindowSize: 3,
leakThresholdBytes: 1_500_000,
heapCapture: () => fakeHeap[i++] ?? fakeHeap[fakeHeap.length - 1]!,
});
mp.events.on("memory:leak-detected", ({ snapshots }) => {
log("leak window", snapshots);
});
for (let n = 0; n < 3; n++) mp.takeSnapshot();
mp.detectLeak(); // true → emits memory:leak-detected
```
## API Reference
- `class MemoryProfiler` — `new MemoryProfiler(options?: MemoryProfilerOptions)`.
- `interface MemoryProfilerOptions extends MemoryProfilerConfig { clock?, timers?, heapCapture? }`.
- Methods: `start`, `stop`, `takeSnapshot`, `recordAllocation`, `getAllocations`, `getAllocation`, `getSnapshots`, `getPeakHeapUsed`, `isRunning`, `detectLeak`, `triggerGC`, `clear`.
## Implementation Notes
The leak detector walks the last `leakWindowSize` snapshots and counts strictly-increasing transitions; a leak is declared only when EVERY transition is non-decreasing AND end-minus-start exceeds the threshold. That eliminates the false-positive case where one large jump in an otherwise flat window would otherwise read as a leak. `triggerGC` is a best-effort hook — Node only exposes `globalThis.gc` under `--expose-gc`, so the false branch matters in production.

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import { MemoryProfiler, readPlatformHeap } from "./memory.js";
function createFakeTimers() {
const scheduled: Array<{ handle: number; cb: () => void }> = [];
const cleared: number[] = [];
let nextHandle = 1;
const timers: TimersLike = {
setTimer(cb) {
const handle = nextHandle++;
scheduled.push({ handle, cb });
return handle;
},
clearTimer(handle) {
cleared.push(handle);
},
};
return { timers, scheduled, cleared };
}
function heapCaptureFromValues(values: Array<{ used: number; total: number }>) {
let i = 0;
return () => {
const v = values[i++] ??
values[values.length - 1] ?? {
used: 0,
total: 0,
};
return { heapUsed: v.used, heapTotal: v.total };
};
}
describe("MemoryProfiler", () => {
it("throws INVALID_OPTIONS for bad config", { timeout: 5000 }, () => {
try {
new MemoryProfiler({ sampleIntervalMs: -1 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ProfilerError);
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("uses the injected heap-capture", { timeout: 5000 }, () => {
const mp = new MemoryProfiler({
heapCapture: heapCaptureFromValues([{ used: 100, total: 200 }]),
});
const snap = mp.takeSnapshot("after-init");
expect(snap.heapUsed).toBe(100);
expect(snap.heapTotal).toBe(200);
expect(snap.label).toBe("after-init");
expect(mp.getPeakHeapUsed()).toBe(100);
});
it("default heap-capture returns numeric heapUsed", {
timeout: 5000,
}, () => {
const mp = new MemoryProfiler();
const snap = mp.takeSnapshot();
expect(typeof snap.heapUsed).toBe("number");
});
it("start/stop is idempotent and clears the timer", {
timeout: 5000,
}, () => {
const fake = createFakeTimers();
const mp = new MemoryProfiler({
timers: fake.timers,
heapCapture: () => ({ heapUsed: 1, heapTotal: 2 }),
});
mp.start();
mp.start();
expect(fake.scheduled).toHaveLength(1);
mp.stop();
mp.stop();
expect(fake.cleared).toHaveLength(1);
expect(mp.isRunning()).toBe(false);
});
it("scheduled tick takes a snapshot and re-arms", { timeout: 5000 }, () => {
const fake = createFakeTimers();
const mp = new MemoryProfiler({
timers: fake.timers,
heapCapture: () => ({ heapUsed: 10, heapTotal: 20 }),
});
mp.start();
fake.scheduled.shift()?.cb();
expect(mp.getSnapshots()).toHaveLength(1);
expect(fake.scheduled).toHaveLength(1);
});
it("scheduled callback returns when stopped between ticks", {
timeout: 5000,
}, () => {
const fake = createFakeTimers();
const mp = new MemoryProfiler({
timers: fake.timers,
heapCapture: () => ({ heapUsed: 1, heapTotal: 2 }),
});
mp.start();
const first = fake.scheduled.shift();
mp.stop();
first?.cb();
expect(mp.getSnapshots()).toHaveLength(0);
});
it("evicts oldest snapshots beyond maxSnapshots", { timeout: 5000 }, () => {
const mp = new MemoryProfiler({
maxSnapshots: 2,
heapCapture: heapCaptureFromValues([
{ used: 1, total: 10 },
{ used: 2, total: 10 },
{ used: 3, total: 10 },
]),
});
mp.takeSnapshot();
mp.takeSnapshot();
mp.takeSnapshot();
expect(mp.getSnapshots()).toHaveLength(2);
expect(mp.getSnapshots()[0]?.heapUsed).toBe(2);
});
it("recordAllocation aggregates per label", { timeout: 5000 }, () => {
const mp = new MemoryProfiler();
mp.recordAllocation("buffer", 100);
mp.recordAllocation("buffer", 200);
const rec = mp.getAllocation("buffer");
expect(rec?.allocations).toBe(2);
expect(rec?.totalBytes).toBe(300);
expect(rec?.peakBytes).toBe(200);
expect(mp.getAllocations()).toHaveLength(1);
expect(mp.getAllocation("missing")).toBeUndefined();
});
it("detectLeak fires when growth exceeds threshold", {
timeout: 5000,
}, () => {
const values = [
{ used: 1_000_000, total: 10_000_000 },
{ used: 1_500_000, total: 10_000_000 },
{ used: 2_000_000, total: 10_000_000 },
{ used: 2_500_000, total: 10_000_000 },
{ used: 3_000_000, total: 10_000_000 },
];
const mp = new MemoryProfiler({
leakThresholdBytes: 1_000_000,
leakWindowSize: 5,
heapCapture: heapCaptureFromValues(values),
});
let leakSeen = false;
mp.events.on("memory:leak-detected", () => {
leakSeen = true;
});
for (let i = 0; i < 5; i++) mp.takeSnapshot();
expect(mp.detectLeak()).toBe(true);
expect(leakSeen).toBe(true);
});
it("detectLeak returns false before window is filled", {
timeout: 5000,
}, () => {
const mp = new MemoryProfiler({ leakWindowSize: 5 });
expect(mp.detectLeak()).toBe(false);
});
it("detectLeak returns false for stable window", { timeout: 5000 }, () => {
const mp = new MemoryProfiler({
leakWindowSize: 5,
leakThresholdBytes: 1_000_000,
heapCapture: () => ({ heapUsed: 100, heapTotal: 200 }),
});
for (let i = 0; i < 5; i++) mp.takeSnapshot();
expect(mp.detectLeak()).toBe(false);
});
it("triggerGC returns true when global.gc is exposed and false otherwise", {
timeout: 5000,
}, () => {
const mp = new MemoryProfiler();
const g = globalThis as unknown as { gc?: () => void };
const had = g.gc;
g.gc = () => undefined;
expect(mp.triggerGC()).toBe(true);
if (had) {
g.gc = had;
} else {
(g as { gc?: () => void }).gc = undefined;
}
expect(mp.triggerGC()).toBe(false);
});
it("clear empties snapshots and allocations and resets peak", {
timeout: 5000,
}, () => {
const mp = new MemoryProfiler({
heapCapture: heapCaptureFromValues([{ used: 5, total: 10 }]),
});
mp.takeSnapshot();
mp.recordAllocation("x", 1);
mp.clear();
expect(mp.getSnapshots()).toHaveLength(0);
expect(mp.getAllocations()).toHaveLength(0);
expect(mp.getPeakHeapUsed()).toBe(0);
});
it("readPlatformHeap uses process.memoryUsage when present", {
timeout: 5000,
}, () => {
const out = readPlatformHeap({
memoryUsage: () => ({ heapUsed: 123, heapTotal: 456 }),
});
expect(out).toEqual({ heapUsed: 123, heapTotal: 456 });
});
it("readPlatformHeap uses performance.memory when process is absent", {
timeout: 5000,
}, () => {
const out = readPlatformHeap(undefined, {
memory: { usedJSHeapSize: 7, totalJSHeapSize: 8 },
});
expect(out).toEqual({ heapUsed: 7, heapTotal: 8 });
});
it("readPlatformHeap returns zeros when both are missing", {
timeout: 5000,
}, () => {
expect(readPlatformHeap()).toEqual({ heapUsed: 0, heapTotal: 0 });
});
it("readPlatformHeap returns zeros when perf has no memory field", {
timeout: 5000,
}, () => {
expect(readPlatformHeap(undefined, {})).toEqual({
heapUsed: 0,
heapTotal: 0,
});
});
it("uses the default TimersLike when none injected", {
timeout: 5000,
}, async () => {
const mp = new MemoryProfiler({
sampleIntervalMs: 5,
heapCapture: () => ({ heapUsed: 1, heapTotal: 2 }),
});
mp.start();
await new Promise((r) => setTimeout(r, 20));
mp.stop();
expect(mp.getSnapshots().length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,265 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, TimersLike } from "@suaveplan/types";
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import { recordMemorySnapshot } from "../telemetry/telemetry.js";
import type {
AllocationRecord,
HeapCapture,
MemoryProfilerConfig,
MemoryProfilerEventMap,
MemorySnapshot,
} from "../types/profiler.types.js";
const MemoryConfigSchema = z.object({
sampleIntervalMs: z.number().positive().optional(),
maxSnapshots: z.number().int().positive().optional(),
leakThresholdBytes: z.number().nonnegative().optional(),
leakWindowSize: z.number().int().min(2).optional(),
});
/**
* Read the current heap usage from any platform.
*
* Exported for testability: production code calls this with the live
* `process` / `performance` globals, but tests can pass synthesised stubs
* to exercise the non-Node and zero-fallback branches.
*/
export function readPlatformHeap(
proc?:
| { memoryUsage?: () => { heapUsed: number; heapTotal: number } }
| undefined,
perf?:
| {
memory?: {
usedJSHeapSize: number;
totalJSHeapSize: number;
};
}
| undefined
): { heapUsed: number; heapTotal: number } {
if (proc?.memoryUsage) {
const mem = proc.memoryUsage();
return { heapUsed: mem.heapUsed, heapTotal: mem.heapTotal };
}
if (perf?.memory) {
return {
heapUsed: perf.memory.usedJSHeapSize,
heapTotal: perf.memory.totalJSHeapSize,
};
}
return { heapUsed: 0, heapTotal: 0 };
}
function defaultHeapCapture(): { heapUsed: number; heapTotal: number } {
const proc = (globalThis as { process?: typeof process }).process;
const perf = (globalThis as { performance?: unknown }).performance as
| {
memory?: {
usedJSHeapSize: number;
totalJSHeapSize: number;
};
}
| undefined;
return readPlatformHeap(proc, perf);
}
function defaultClock(): number {
return Date.now();
}
const defaultTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (handle) => {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
},
};
/** Options for {@link MemoryProfiler}. */
export interface MemoryProfilerOptions extends MemoryProfilerConfig {
readonly clock?: Clock;
readonly timers?: TimersLike;
readonly heapCapture?: HeapCapture;
}
/**
* Tracks heap usage over time, detects memory leaks via trend analysis,
* records per-label allocations, and keeps a peak watermark.
*
* Heap readings come from an injectable {@link HeapCapture}; the default
* reads `process.memoryUsage()` (Node) or `performance.memory` (Chrome)
* and falls back to zeros otherwise.
*/
export class MemoryProfiler {
private readonly sampleIntervalMs: number;
private readonly maxSnapshots: number;
private readonly leakThresholdBytes: number;
private readonly leakWindowSize: number;
private readonly clock: Clock;
private readonly timers: TimersLike;
private readonly heapCapture: HeapCapture;
private readonly snapshots: MemorySnapshot[] = [];
private readonly allocations = new Map<string, AllocationRecord>();
private timerHandle: number | null = null;
private running = false;
private peakHeapUsed = 0;
public readonly events = new EventBus<MemoryProfilerEventMap>();
public constructor(options: MemoryProfilerOptions = {}) {
const parsed = MemoryConfigSchema.safeParse({
sampleIntervalMs: options.sampleIntervalMs,
maxSnapshots: options.maxSnapshots,
leakThresholdBytes: options.leakThresholdBytes,
leakWindowSize: options.leakWindowSize,
});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid MemoryProfiler options",
{ context: { issues: parsed.error.issues } }
);
}
this.sampleIntervalMs = parsed.data.sampleIntervalMs ?? 1000;
this.maxSnapshots = parsed.data.maxSnapshots ?? 500;
this.leakThresholdBytes = parsed.data.leakThresholdBytes ?? 1024 * 1024;
this.leakWindowSize = parsed.data.leakWindowSize ?? 5;
this.clock = options.clock ?? defaultClock;
this.timers = options.timers ?? defaultTimers;
this.heapCapture = options.heapCapture ?? defaultHeapCapture;
}
/** Start periodic heap sampling. No-op when already running. */
public start(): void {
if (this.running) return;
this.running = true;
this.schedule();
this.events.emit("memory:start", {});
}
/** Stop periodic heap sampling. No-op when not running. */
public stop(): void {
if (!this.running) return;
this.running = false;
// `start` always installs a timer, so `timerHandle` is non-null
// here.
this.timers.clearTimer(this.timerHandle as number);
this.timerHandle = null;
this.events.emit("memory:stop", {});
}
/** Take a single heap snapshot. */
public takeSnapshot(label?: string): MemorySnapshot {
const { heapUsed, heapTotal } = this.heapCapture();
if (heapUsed > this.peakHeapUsed) this.peakHeapUsed = heapUsed;
const snapshot: MemorySnapshot = {
timestamp: this.clock(),
heapUsed,
heapTotal,
label,
};
this.snapshots.push(snapshot);
if (this.snapshots.length > this.maxSnapshots) {
this.snapshots.splice(0, this.snapshots.length - this.maxSnapshots);
}
recordMemorySnapshot();
this.events.emit("memory:snapshot", { snapshot });
return snapshot;
}
/** Record a labelled allocation. */
public recordAllocation(label: string, bytes: number): void {
const existing = this.allocations.get(label);
if (existing) {
this.allocations.set(label, {
label,
allocations: existing.allocations + 1,
totalBytes: existing.totalBytes + bytes,
peakBytes: Math.max(existing.peakBytes, bytes),
});
} else {
this.allocations.set(label, {
label,
allocations: 1,
totalBytes: bytes,
peakBytes: bytes,
});
}
}
/** Get allocation records for all labels. */
public getAllocations(): readonly AllocationRecord[] {
return [...this.allocations.values()];
}
/** Get allocation record for a specific label. */
public getAllocation(label: string): AllocationRecord | undefined {
return this.allocations.get(label);
}
/** Get all snapshots. */
public getSnapshots(): readonly MemorySnapshot[] {
return this.snapshots;
}
/** Get the peak heap usage observed. */
public getPeakHeapUsed(): number {
return this.peakHeapUsed;
}
/** Whether the profiler is currently sampling. */
public isRunning(): boolean {
return this.running;
}
/**
* Detect a potential memory leak by looking for a consistent upward
* trend in the last N snapshots whose total growth meets the
* configured threshold.
*/
public detectLeak(): boolean {
if (this.snapshots.length < this.leakWindowSize) return false;
const recent = this.snapshots.slice(-this.leakWindowSize);
let increases = 0;
for (let i = 1; i < recent.length; i++) {
const prev = recent[i - 1] as MemorySnapshot;
const cur = recent[i] as MemorySnapshot;
if (cur.heapUsed > prev.heapUsed) increases++;
}
const first = recent[0] as MemorySnapshot;
const last = recent[recent.length - 1] as MemorySnapshot;
const totalGrowth = last.heapUsed - first.heapUsed;
const leaked =
increases >= this.leakWindowSize - 1 &&
totalGrowth >= this.leakThresholdBytes;
if (leaked) {
this.events.emit("memory:leak-detected", { snapshots: recent });
}
return leaked;
}
/** Trigger garbage collection if `--expose-gc` was used. */
public triggerGC(): boolean {
const g = globalThis as unknown as { gc?: () => void };
if (typeof g.gc === "function") {
g.gc();
return true;
}
return false;
}
/** Clear all snapshots and allocations. */
public clear(): void {
this.snapshots.length = 0;
this.allocations.clear();
this.peakHeapUsed = 0;
}
private schedule(): void {
this.timerHandle = this.timers.setTimer(() => {
if (!this.running) return;
this.takeSnapshot();
this.schedule();
}, this.sampleIntervalMs);
}
}

View File

@@ -0,0 +1 @@
export { OtelExporter } from "./otel.js";

View File

@@ -0,0 +1,63 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`OtelExporter` converts `ProfileEntry` records to OTLP-compatible spans and optionally exports them to a collector endpoint over HTTP. Both the `fetch` implementation and the random-bytes producer used for trace/span IDs are injectable so test runs can assert exact payloads and produce deterministic IDs.
## Features
- Zod-validated config (`endpoint` MUST be a valid URL when supplied; `serviceName` non-empty; `batchSize > 0`).
- Defaults: `http://localhost:4318/v1/traces`, `serviceName: "profiler"`, `batchSize: 100`.
- `entryToSpan(entry, traceId?, parentSpanId?)` — single conversion; defaults trace/span IDs from the injected RNG.
- `convertEntries(entries, traceId?)` — batch conversion under a single shared trace.
- `addToBatch(spans)` / `flush()` / `getPendingCount()` — pending-batch buffer.
- `export(spans)` — POSTs an OTLP JSON payload; returns `true` on `response.ok`, `false` on non-ok responses or thrown errors (no exceptions escape).
- `exportBatch()` — drains pending in `batchSize` slices, returning the count of successfully exported spans.
- Attribute encoding: strings → `stringValue`, integers → `intValue`, non-integer numbers → `doubleValue`, booleans → `boolValue`.
## Usage: Basic
```ts
import { OtelExporter } from "@suaveplan/profiler";
const exporter = new OtelExporter({ serviceName: "my-app" });
const spans = exporter.convertEntries(profiler.getEntries());
await exporter.export(spans);
```
## Usage: Advanced
```ts
import { OtelExporter } from "@suaveplan/profiler";
const seed = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
let cursor = 0;
const exporter = new OtelExporter({
endpoint: "https://collector.internal/v1/traces",
headers: { "x-api-key": process.env.OTLP_KEY ?? "" },
batchSize: 25,
randomBytes: (n) => seed.slice(cursor, (cursor += n)),
fetchFn: async (url, init) => {
const res = await myInstrumentedFetch(url, init);
return { ok: res.ok };
},
});
exporter.addToBatch(exporter.convertEntries(profiler.getEntries()));
const ok = await exporter.exportBatch();
```
## API Reference
- `class OtelExporter` — `new OtelExporter(config?: OtelExporterConfig)`.
- `interface OtelExporterConfig { endpoint?, serviceName?, batchSize?, headers?, fetchFn?, randomBytes? }`.
- Methods: `generateTraceId`, `generateSpanId`, `entryToSpan`, `convertEntries`, `addToBatch`, `getPendingCount`, `flush`, `export`, `exportBatch`.
- Errors: `ProfilerError` with `PROFILER.INVALID_OPTIONS` on bad config.
## Implementation Notes
The payload structure follows the OTLP/HTTP JSON encoding: a single `resourceSpans` entry with one `scopeSpans` group, and per-span attributes encoded as `{ key, value }` records where `value` is a discriminated union by primitive type. `metadata` fields that are objects/arrays are silently dropped — OTLP only allows primitive attribute values, and reshaping nested metadata is the caller's responsibility. Export failures are swallowed and surfaced via the boolean return so exporters can be used inside fire-and-forget profiling loops without crashing the host.

View File

@@ -0,0 +1,276 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PROFILER_CODES,
type ProfilerError,
} from "../error-codes/error-codes.js";
import type { FetchLike, ProfileEntry } from "../types/profiler.types.js";
import { fillRandomBytes, OtelExporter } from "./otel.js";
const seededRandom = (n: number): Uint8Array => {
const arr = new Uint8Array(n);
for (let i = 0; i < n; i++) arr[i] = (i + 1) & 0xff;
return arr;
};
function makeFetch(
responseOk: boolean,
onCall?: (url: string, init: { body: string }) => void
): FetchLike {
return async (url, init) => {
onCall?.(url, init);
return { ok: responseOk };
};
}
const sampleEntry = (label: string, duration = 5): ProfileEntry => ({
label,
startTime: 1.5,
endTime: 1.5 + duration,
duration,
category: "cat",
metadata: { foo: "bar", n: 1, b: true, ignored: { nested: 1 } },
});
describe("OtelExporter", () => {
it("throws INVALID_OPTIONS on bad endpoint", { timeout: 5000 }, () => {
try {
new OtelExporter({ endpoint: "not a url" });
throw new Error("expected throw");
} catch (err) {
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("generates 32-char traceId and 16-char spanId", {
timeout: 5000,
}, () => {
const e = new OtelExporter({ randomBytes: seededRandom });
expect(e.generateTraceId()).toHaveLength(32);
expect(e.generateSpanId()).toHaveLength(16);
});
it("converts an entry to OTLP span with attributes", {
timeout: 5000,
}, () => {
const e = new OtelExporter({
randomBytes: seededRandom,
serviceName: "svc",
});
const span = e.entryToSpan(sampleEntry("op"));
expect(span.attributes["service.name"]).toBe("svc");
expect(span.attributes["profiler.category"]).toBe("cat");
expect(span.attributes["profiler.duration_ms"]).toBe(5);
expect(span.attributes["profiler.meta.foo"]).toBe("bar");
expect(span.attributes["profiler.meta.n"]).toBe(1);
expect(span.attributes["profiler.meta.b"]).toBe(true);
expect(span.attributes["profiler.meta.ignored"]).toBeUndefined();
});
it("entryToSpan tolerates missing endTime / category / metadata", {
timeout: 5000,
}, () => {
const e = new OtelExporter({ randomBytes: seededRandom });
const span = e.entryToSpan({ label: "bare", startTime: 1 });
expect(span.attributes["profiler.category"]).toBeUndefined();
expect(span.attributes["profiler.duration_ms"]).toBeUndefined();
expect(span.startTimeUnixNano).toBe(span.endTimeUnixNano);
});
it("convertEntries filters incomplete entries", { timeout: 5000 }, () => {
const e = new OtelExporter({ randomBytes: seededRandom });
const out = e.convertEntries([
sampleEntry("a"),
{ label: "still-running", startTime: 0 },
]);
expect(out).toHaveLength(1);
});
it("flush returns and clears pending", { timeout: 5000 }, () => {
const e = new OtelExporter({ randomBytes: seededRandom });
const span = e.entryToSpan(sampleEntry("a"));
e.addToBatch([span]);
expect(e.getPendingCount()).toBe(1);
expect(e.flush()).toHaveLength(1);
expect(e.getPendingCount()).toBe(0);
});
it("export returns true on response.ok", { timeout: 5000 }, async () => {
let called = 0;
const e = new OtelExporter({
randomBytes: seededRandom,
fetchFn: makeFetch(true, () => {
called++;
}),
});
const ok = await e.export([e.entryToSpan(sampleEntry("a"))]);
expect(ok).toBe(true);
expect(called).toBe(1);
});
it("export returns true for empty array without calling fetch", {
timeout: 5000,
}, async () => {
let called = 0;
const e = new OtelExporter({
randomBytes: seededRandom,
fetchFn: makeFetch(true, () => {
called++;
}),
});
const ok = await e.export([]);
expect(ok).toBe(true);
expect(called).toBe(0);
});
it("export returns false on non-ok response", {
timeout: 5000,
}, async () => {
const e = new OtelExporter({
randomBytes: seededRandom,
fetchFn: makeFetch(false),
});
const ok = await e.export([e.entryToSpan(sampleEntry("a"))]);
expect(ok).toBe(false);
});
it("export returns false on fetch throw", { timeout: 5000 }, async () => {
const e = new OtelExporter({
randomBytes: seededRandom,
fetchFn: async () => {
throw new Error("net");
},
});
const ok = await e.export([e.entryToSpan(sampleEntry("a"))]);
expect(ok).toBe(false);
});
it("exportBatch chunks pending spans", { timeout: 5000 }, async () => {
let calls = 0;
const e = new OtelExporter({
randomBytes: seededRandom,
batchSize: 2,
fetchFn: makeFetch(true, () => {
calls++;
}),
});
for (let i = 0; i < 5; i++) {
e.addToBatch([e.entryToSpan(sampleEntry(`s-${i}`))]);
}
const exported = await e.exportBatch();
expect(exported).toBe(5);
expect(calls).toBe(3);
});
it("exportBatch with one failing chunk reports partial count", {
timeout: 5000,
}, async () => {
let calls = 0;
const e = new OtelExporter({
randomBytes: seededRandom,
batchSize: 2,
fetchFn: async () => {
calls++;
return { ok: calls !== 2 };
},
});
for (let i = 0; i < 4; i++) {
e.addToBatch([e.entryToSpan(sampleEntry(`s-${i}`))]);
}
const exported = await e.exportBatch();
expect(exported).toBe(2);
});
it("buildOtlpPayload emits int / double / string / bool attribute values", {
timeout: 5000,
}, async () => {
let captured: string | undefined;
const e = new OtelExporter({
randomBytes: seededRandom,
fetchFn: makeFetch(true, (_url, init) => {
captured = init.body;
}),
});
const entry: ProfileEntry = {
label: "mixed",
startTime: 0,
endTime: 1,
duration: 1,
metadata: { strv: "x", intv: 5, dblv: 1.5, boolv: true },
};
await e.export([e.entryToSpan(entry)]);
expect(captured).toBeDefined();
const parsed = JSON.parse(captured ?? "{}");
const attrs = parsed.resourceSpans[0].scopeSpans[0].spans[0].attributes;
const find = (k: string) =>
attrs.find((a: { key: string }) => a.key === k)?.value;
expect(find("profiler.meta.strv")).toEqual({ stringValue: "x" });
expect(find("profiler.meta.intv")).toEqual({ intValue: 5 });
expect(find("profiler.meta.dblv")).toEqual({ doubleValue: 1.5 });
expect(find("profiler.meta.boolv")).toEqual({ boolValue: true });
});
it("uses default fetch when none injected and default endpoint", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
let called = 0;
(globalThis as unknown as { fetch: unknown }).fetch = async () => {
called++;
return { ok: true } as Response;
};
try {
const e = new OtelExporter({ randomBytes: seededRandom });
const ok = await e.export([e.entryToSpan(sampleEntry("a"))]);
expect(ok).toBe(true);
expect(called).toBe(1);
} finally {
(globalThis as unknown as { fetch: unknown }).fetch = original;
}
});
it("uses default randomBytes when none injected", { timeout: 5000 }, () => {
const e = new OtelExporter();
expect(e.generateTraceId()).toHaveLength(32);
});
it("fillRandomBytes uses crypto.getRandomValues when available", {
timeout: 5000,
}, () => {
let called = false;
const cryptoLike = {
getRandomValues(arr: Uint8Array) {
called = true;
for (let i = 0; i < arr.length; i++) arr[i] = i;
return arr;
},
};
const out = fillRandomBytes(4, cryptoLike);
expect(called).toBe(true);
expect(Array.from(out)).toEqual([0, 1, 2, 3]);
});
it("fillRandomBytes falls back to Math.random when no crypto", {
timeout: 5000,
}, () => {
const out = fillRandomBytes(3, undefined, () => 0.5);
expect(out).toHaveLength(3);
expect(out[0]).toBe(128);
});
it("fillRandomBytes falls back to Math.random when crypto lacks getRandomValues", {
timeout: 5000,
}, () => {
const out = fillRandomBytes(2, {}, () => 0);
expect(Array.from(out)).toEqual([0, 0]);
});
it("fillRandomBytes default Math.random is invoked at zero-args", {
timeout: 5000,
}, () => {
// Cover the default `mathRandom = Math.random` parameter branch.
const out = fillRandomBytes(1, {});
expect(out).toHaveLength(1);
});
});

View File

@@ -0,0 +1,262 @@
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import type {
FetchLike,
OtelExporterConfig,
OtelSpan,
ProfileEntry,
RandomBytes,
} from "../types/profiler.types.js";
const ConfigSchema = z.object({
endpoint: z.string().url().optional(),
serviceName: z.string().min(1).optional(),
batchSize: z.number().int().positive().optional(),
headers: z.record(z.string(), z.string()).optional(),
});
/**
* Fill an `n`-byte buffer using the strongest available source.
*
* Exported for testability: tests pass a synthetic `crypto` stub with or
* without `getRandomValues` and a deterministic `mathRandom` to exercise
* both branches.
*/
export function fillRandomBytes(
n: number,
cryptoLike?:
| { getRandomValues?: (arr: Uint8Array) => Uint8Array }
| undefined,
mathRandom: () => number = Math.random
): Uint8Array {
const arr = new Uint8Array(n);
if (cryptoLike?.getRandomValues) {
cryptoLike.getRandomValues(arr);
} else {
for (let i = 0; i < n; i++) arr[i] = Math.floor(mathRandom() * 256);
}
return arr;
}
function defaultRandomBytes(n: number): Uint8Array {
const cryptoLike = (
globalThis as {
crypto?: { getRandomValues?: (arr: Uint8Array) => Uint8Array };
}
).crypto;
return fillRandomBytes(n, cryptoLike);
}
function bytesToHex(arr: Uint8Array): string {
let out = "";
for (const b of arr) out += b.toString(16).padStart(2, "0");
return out;
}
const defaultFetch: FetchLike = async (url, init) => {
const res = await fetch(url, init);
return { ok: res.ok };
};
/**
* Converts profiler entries to OTLP-compatible spans and optionally
* exports them via an injectable {@link FetchLike}. Trace/span IDs come
* from an injectable {@link RandomBytes} producer so test runs are
* deterministic.
*/
export class OtelExporter {
private readonly endpoint: string;
private readonly serviceName: string;
private readonly batchSize: number;
private readonly headers: Record<string, string>;
private readonly fetchFn: FetchLike;
private readonly randomBytes: RandomBytes;
private readonly pending: OtelSpan[] = [];
public constructor(config: OtelExporterConfig = {}) {
const parsed = ConfigSchema.safeParse({
endpoint: config.endpoint,
serviceName: config.serviceName,
batchSize: config.batchSize,
headers: config.headers,
});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid OtelExporter options",
{ context: { issues: parsed.error.issues } }
);
}
this.endpoint =
parsed.data.endpoint ?? "http://localhost:4318/v1/traces";
this.serviceName = parsed.data.serviceName ?? "profiler";
this.batchSize = parsed.data.batchSize ?? 100;
this.headers = parsed.data.headers ?? {};
this.fetchFn = config.fetchFn ?? defaultFetch;
this.randomBytes = config.randomBytes ?? defaultRandomBytes;
}
/** Generate a new 16-byte / 32 hex-char trace ID. */
public generateTraceId(): string {
return bytesToHex(this.randomBytes(16));
}
/** Generate a new 8-byte / 16 hex-char span ID. */
public generateSpanId(): string {
return bytesToHex(this.randomBytes(8));
}
/** Convert a single {@link ProfileEntry} to an {@link OtelSpan}. */
public entryToSpan(
entry: ProfileEntry,
traceId?: string,
parentSpanId?: string
): OtelSpan {
const resolvedTraceId = traceId ?? this.generateTraceId();
const startNano = Math.round(entry.startTime * 1_000_000);
const endNano =
entry.endTime !== undefined
? Math.round(entry.endTime * 1_000_000)
: startNano;
const attributes: Record<string, string | number | boolean> = {
"service.name": this.serviceName,
};
if (entry.category) {
attributes["profiler.category"] = entry.category;
}
if (entry.duration !== undefined) {
attributes["profiler.duration_ms"] = entry.duration;
}
if (entry.metadata) {
for (const [key, val] of Object.entries(entry.metadata)) {
if (
typeof val === "string" ||
typeof val === "number" ||
typeof val === "boolean"
) {
attributes[`profiler.meta.${key}`] = val;
}
}
}
return {
traceId: resolvedTraceId,
spanId: this.generateSpanId(),
parentSpanId,
name: entry.label,
startTimeUnixNano: startNano,
endTimeUnixNano: endNano,
attributes,
};
}
/** Convert multiple entries into spans under a shared trace. */
public convertEntries(
entries: readonly ProfileEntry[],
traceId?: string
): OtelSpan[] {
const tid = traceId ?? this.generateTraceId();
const out: OtelSpan[] = [];
for (const e of entries) {
if (e.duration !== undefined) out.push(this.entryToSpan(e, tid));
}
return out;
}
/** Add spans to the pending batch. */
public addToBatch(spans: readonly OtelSpan[]): void {
this.pending.push(...spans);
}
/** Get pending spans count. */
public getPendingCount(): number {
return this.pending.length;
}
/** Flush pending spans and return them (clears the buffer). */
public flush(): OtelSpan[] {
const flushed = [...this.pending];
this.pending.length = 0;
return flushed;
}
/** Export spans to the collector endpoint. */
public async export(spans: readonly OtelSpan[]): Promise<boolean> {
if (spans.length === 0) return true;
const body = this.buildOtlpPayload(spans);
try {
const response = await this.fetchFn(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.headers,
},
body: JSON.stringify(body),
});
return response.ok;
} catch {
return false;
}
}
/** Export pending spans in batches; returns the number of spans exported. */
public async exportBatch(): Promise<number> {
const all = this.flush();
let exported = 0;
for (let i = 0; i < all.length; i += this.batchSize) {
const batch = all.slice(i, i + this.batchSize);
const ok = await this.export(batch);
if (ok) exported += batch.length;
}
return exported;
}
private buildOtlpPayload(
spans: readonly OtelSpan[]
): Record<string, unknown> {
return {
resourceSpans: [
{
resource: {
attributes: [
{
key: "service.name",
value: { stringValue: this.serviceName },
},
],
},
scopeSpans: [
{
scope: { name: "@suaveplan/profiler" },
spans: spans.map((s) => ({
traceId: s.traceId,
spanId: s.spanId,
parentSpanId: s.parentSpanId,
name: s.name,
startTimeUnixNano: s.startTimeUnixNano,
endTimeUnixNano: s.endTimeUnixNano,
attributes: Object.entries(s.attributes).map(
([key, value]) => ({
key,
value: this.toOtlpValue(value),
})
),
})),
},
],
},
],
};
}
private toOtlpValue(
val: string | number | boolean
): Record<string, unknown> {
if (typeof val === "string") return { stringValue: val };
if (typeof val === "number") {
return Number.isInteger(val)
? { intValue: val }
: { doubleValue: val };
}
return { boolValue: val };
}
}

View File

@@ -0,0 +1,2 @@
export type { ProfilerOptions } from "./profiler.js";
export { Profiler } from "./profiler.js";

View File

@@ -0,0 +1,64 @@
# profiler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Profiler` is the core measurement primitive of `@suaveplan/profiler`. It records timing entries between paired `start(label)` / `stop(label)` calls, places named marks on a shared timeline, and offers `measure` / `measureAsync` helpers that wrap sync/async functions and stop the entry even when the wrapped function throws. The wall-clock source is injected through a `Clock` so tests can drive time deterministically and so non-Node environments without `performance.now()` can plug in their own reader.
## Features
- `start(label, category?, metadata?)` — registers an active entry keyed by `label`.
- `stop(label)` — completes the entry, computes `duration`, emits `profile:stop` on the bus, increments `profiler_entries_total`, and records the duration on the histogram.
- `mark(label, metadata?)` — places a single timestamped mark, increments `profiler_marks_total`.
- `measure(label, fn, category?)` — sync wrapper; uses `try/finally` so `stop` runs on throw.
- `measureAsync(label, fn, category?)` — async wrapper with the same `try/finally` discipline.
- `isActive(label)` / `getEntries()` / `getMarks()` / `getEntriesByCategory(cat)` / `clear()` — introspection + reset.
- `events: EventBus<ProfilerEventMap>` — single-payload events compatible with `@suaveplan/events`.
## Usage: Basic
```ts
import { Profiler } from "@suaveplan/profiler";
const profiler = new Profiler();
profiler.start("render", "ui");
// ... work ...
const entry = profiler.stop("render");
console.log(entry?.duration);
```
## Usage: Advanced
```ts
import { Profiler } from "@suaveplan/profiler";
let t = 0;
const profiler = new Profiler({ clock: () => t });
t = 0;
const result = await profiler.measureAsync(
"io",
async () => {
t = 50; // simulate elapsed time
return await loadConfig();
},
"network"
);
const entries = profiler.getEntriesByCategory("network");
// entries[0].duration === 50
```
## API Reference
- `class Profiler` — public constructor `new Profiler(options?: ProfilerOptions)`.
- `interface ProfilerOptions { clock?: Clock }` — `Clock` is `() => number` from `@suaveplan/types`.
- Methods: `start`, `stop`, `mark`, `measure`, `measureAsync`, `getEntries`, `getMarks`, `getEntriesByCategory`, `isActive`, `clear`.
- `events` — `EventBus<ProfilerEventMap>` exposing `profile:start`, `profile:stop`, `profile:mark`, `profile:clear`.
## Implementation Notes
The default clock prefers `performance.now()` (high-resolution monotonic) and falls back to `Date.now()` when `performance` is unavailable. Active entries are stored in a `Map<string, …>` keyed by label, so calling `start` twice with the same label overwrites the start timestamp — that matches the convention of `performance.mark`/`performance.measure`. `measure` and `measureAsync` use `try { return fn(); } finally { stop(label); }` so the entry is always closed even on throw, which is critical for accurate `getEntries()` data when faults are common.

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Profiler, readPlatformTime } from "./profiler.js";
function makeClock(values: number[]): () => number {
let i = 0;
return () => values[i++] ?? values[values.length - 1] ?? 0;
}
describe("Profiler", () => {
it("start + stop records an entry with duration", { timeout: 5000 }, () => {
const profiler = new Profiler({ clock: makeClock([0, 10]) });
profiler.start("op", "cat", { foo: 1 });
expect(profiler.isActive("op")).toBe(true);
const entry = profiler.stop("op");
expect(entry?.duration).toBe(10);
expect(entry?.category).toBe("cat");
expect(entry?.metadata).toEqual({ foo: 1 });
expect(profiler.isActive("op")).toBe(false);
expect(profiler.getEntries()).toHaveLength(1);
});
it("stop returns undefined for an unknown label", { timeout: 5000 }, () => {
const profiler = new Profiler();
expect(profiler.stop("never-started")).toBeUndefined();
});
it("mark records a timestamped mark", { timeout: 5000 }, () => {
const profiler = new Profiler({ clock: makeClock([42]) });
const m = profiler.mark("checkpoint", { phase: "init" });
expect(m.timestamp).toBe(42);
expect(m.metadata).toEqual({ phase: "init" });
expect(profiler.getMarks()).toEqual([m]);
});
it("measure wraps a sync fn and returns its value", {
timeout: 5000,
}, () => {
const profiler = new Profiler({ clock: makeClock([0, 5]) });
const result = profiler.measure("calc", () => 7, "math");
expect(result).toBe(7);
expect(profiler.getEntries()[0]?.duration).toBe(5);
expect(profiler.getEntries()[0]?.category).toBe("math");
});
it("measure stops the entry even when the fn throws", {
timeout: 5000,
}, () => {
const profiler = new Profiler({ clock: makeClock([0, 3]) });
expect(() =>
profiler.measure("boom", () => {
throw new Error("nope");
})
).toThrow("nope");
expect(profiler.isActive("boom")).toBe(false);
expect(profiler.getEntries()[0]?.duration).toBe(3);
});
it("measureAsync awaits and records", { timeout: 5000 }, async () => {
const profiler = new Profiler({ clock: makeClock([0, 8]) });
const result = await profiler.measureAsync(
"io",
async () => "ok",
"net"
);
expect(result).toBe("ok");
expect(profiler.getEntries()[0]?.duration).toBe(8);
expect(profiler.getEntries()[0]?.category).toBe("net");
});
it("measureAsync propagates rejections after stop", {
timeout: 5000,
}, async () => {
const profiler = new Profiler({ clock: makeClock([0, 2]) });
await expect(
profiler.measureAsync("io", async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
expect(profiler.isActive("io")).toBe(false);
expect(profiler.getEntries()).toHaveLength(1);
});
it("getEntriesByCategory filters", { timeout: 5000 }, () => {
const profiler = new Profiler({ clock: makeClock([0, 1, 1, 2]) });
profiler.start("a", "x");
profiler.stop("a");
profiler.start("b", "y");
profiler.stop("b");
expect(profiler.getEntriesByCategory("x")).toHaveLength(1);
expect(profiler.getEntriesByCategory("y")).toHaveLength(1);
expect(profiler.getEntriesByCategory("z")).toHaveLength(0);
});
it("clear empties everything and emits", { timeout: 5000 }, () => {
const profiler = new Profiler({ clock: makeClock([0, 1, 2]) });
profiler.start("a");
profiler.stop("a");
profiler.mark("m");
let cleared = false;
profiler.events.on("profile:clear", () => {
cleared = true;
});
profiler.clear();
expect(profiler.getEntries()).toHaveLength(0);
expect(profiler.getMarks()).toHaveLength(0);
expect(cleared).toBe(true);
});
it("readPlatformTime prefers performance.now", { timeout: 5000 }, () => {
expect(readPlatformTime({ now: () => 12345 })).toBe(12345);
});
it("readPlatformTime falls back to provided clock", {
timeout: 5000,
}, () => {
expect(readPlatformTime(undefined, () => 678)).toBe(678);
});
it("readPlatformTime falls back to Date.now by default", {
timeout: 5000,
}, () => {
const t = readPlatformTime();
expect(typeof t).toBe("number");
});
it("uses the default clock when none injected", { timeout: 5000 }, () => {
const profiler = new Profiler();
profiler.start("ok");
const entry = profiler.stop("ok");
expect(typeof entry?.duration).toBe("number");
});
});

View File

@@ -0,0 +1,167 @@
import { EventBus } from "@suaveplan/events";
import type { Clock } from "@suaveplan/types";
import {
recordEntry,
recordMark,
recordMeasureDuration,
} from "../telemetry/telemetry.js";
import type {
ProfileEntry,
ProfileMark,
ProfilerEventMap,
} from "../types/profiler.types.js";
/**
* Read the current high-resolution time from any platform.
*
* Exported for testability — pass a synthesised `performance`-shaped stub
* to drive the function down the `performance.now()` branch or the
* `Date.now()` fallback.
*/
export function readPlatformTime(
perf?: { now?: () => number } | undefined,
nowFn: () => number = Date.now
): number {
if (perf?.now) return perf.now();
return nowFn();
}
function defaultClock(): number {
const perf = (globalThis as { performance?: { now?: () => number } })
.performance;
return readPlatformTime(perf);
}
/** Options for {@link Profiler}. */
export interface ProfilerOptions {
/** Injectable wall-clock source. Default reads `performance.now` then `Date.now`. */
readonly clock?: Clock;
}
/**
* Records timing measurements, marks, and entries with an injectable clock.
*
* Use `start()`/`stop()` for explicit measurements or `measure()` (sync) /
* `measureAsync()` (async) for function wrapping. Every public completion
* emits OTel counter + histogram points via the package telemetry module.
*/
export class Profiler {
private readonly entries: ProfileEntry[] = [];
private readonly marks: ProfileMark[] = [];
private readonly active = new Map<
string,
{
startTime: number;
category?: string | undefined;
metadata?: Record<string, unknown> | undefined;
}
>();
private readonly clock: Clock;
public readonly events = new EventBus<ProfilerEventMap>();
public constructor(options: ProfilerOptions = {}) {
this.clock = options.clock ?? defaultClock;
}
/** Start a named measurement. */
public start(
label: string,
category?: string,
metadata?: Record<string, unknown>
): void {
this.active.set(label, { startTime: this.clock(), category, metadata });
this.events.emit("profile:start", { label, category });
}
/** Stop a named measurement and record the entry. Returns undefined if unknown. */
public stop(label: string): ProfileEntry | undefined {
const active = this.active.get(label);
if (active === undefined) return undefined;
this.active.delete(label);
const endTime = this.clock();
const duration = endTime - active.startTime;
const entry: ProfileEntry = {
label,
startTime: active.startTime,
endTime,
duration,
category: active.category,
metadata: active.metadata,
};
this.entries.push(entry);
recordEntry(active.category);
recordMeasureDuration(duration);
this.events.emit("profile:stop", { entry });
return entry;
}
/** Add a named mark at the current time. */
public mark(
label: string,
metadata?: Record<string, unknown>
): ProfileMark {
const m: ProfileMark = {
label,
timestamp: this.clock(),
metadata,
};
this.marks.push(m);
recordMark();
this.events.emit("profile:mark", { mark: m });
return m;
}
/** Measure a synchronous function's execution time. */
public measure<T>(label: string, fn: () => T, category?: string): T {
this.start(label, category);
try {
return fn();
} finally {
this.stop(label);
}
}
/** Measure an async function's execution time. */
public async measureAsync<T>(
label: string,
fn: () => Promise<T>,
category?: string
): Promise<T> {
this.start(label, category);
try {
return await fn();
} finally {
this.stop(label);
}
}
/** Get all recorded entries. */
public getEntries(): readonly ProfileEntry[] {
return this.entries;
}
/** Get all marks. */
public getMarks(): readonly ProfileMark[] {
return this.marks;
}
/** Get entries filtered by category. */
public getEntriesByCategory(category: string): ProfileEntry[] {
return this.entries.filter((e) => e.category === category);
}
/** Check if a measurement is currently active. */
public isActive(label: string): boolean {
return this.active.has(label);
}
/** Clear all entries, marks, and active measurements. */
public clear(): void {
this.entries.length = 0;
this.marks.length = 0;
this.active.clear();
this.events.emit("profile:clear", {});
}
}

View File

@@ -0,0 +1,2 @@
export type { ProfileReporterOptions } from "./reporter.js";
export { ProfileReporter } from "./reporter.js";

View File

@@ -0,0 +1,57 @@
# reporter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ProfileReporter` computes summary statistics from profiling entries — count, min, max, mean, median, configurable percentiles, total duration, per-category groupings, and an alerts list for entries above a threshold. It also offers `formatReport(report)` to produce a stable human-readable text rendering suitable for logs and CI annotations.
## Features
- Zod-validated config: `percentiles` MUST be a non-empty array of numbers in `[0, 100]`; `alertThreshold` ≥ 0.
- Configurable percentile list (default `[50, 90, 95, 99]`).
- Linear-interpolation percentile estimator — matches what most statistics libraries call "type 7" / R's default.
- Per-category groupings keyed by `entry.category` (`uncategorized` when absent).
- Alerts: entries whose `duration` strictly exceeds `alertThreshold`.
- Injectable `Clock` so the report's `generatedAt` timestamp is deterministic in tests.
## Usage: Basic
```ts
import { ProfileReporter } from "@suaveplan/profiler";
const reporter = new ProfileReporter();
const out = reporter.generateReport(profiler.getEntries(), profiler.getMarks());
console.log(reporter.formatReport(out));
```
## Usage: Advanced
```ts
import { ProfileReporter } from "@suaveplan/profiler";
const reporter = new ProfileReporter({
percentiles: [75, 90, 95, 99, 99.9],
alertThreshold: 16,
clock: () => Date.parse("2025-01-01T00:00:00Z"),
});
const out = reporter.generateReport(entries);
const renderStats = out.byCategory.render;
if ((renderStats?.percentiles[99] ?? 0) > 16) {
metrics.frameMisses.add(1);
}
```
## API Reference
- `class ProfileReporter` — `new ProfileReporter(options?: ProfileReporterOptions)`.
- `interface ProfileReporterOptions extends ReporterConfig { clock?: Clock }`.
- Methods: `generateReport(entries, marks?)`, `computeStats(durations)`, `formatReport(report)`.
- Errors: `ProfilerError` with `PROFILER.INVALID_OPTIONS` on bad config.
## Implementation Notes
`computeStats` sorts a copy of the input durations and reads min/max from the endpoints. The percentile estimator uses the formula `index = (p/100) * (n-1)`, with linear interpolation between neighbouring sorted values when the index is fractional. Empty input returns a fully-zeroed `ProfileStats` so callers can treat the result uniformly. `formatReport` omits the Alerts section when none fire to keep typical log output compact.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PROFILER_CODES,
type ProfilerError,
} from "../error-codes/error-codes.js";
import type { ProfileEntry, ProfileMark } from "../types/profiler.types.js";
import { ProfileReporter } from "./reporter.js";
function entry(
label: string,
duration: number,
category?: string
): ProfileEntry {
return { label, startTime: 0, endTime: duration, duration, category };
}
describe("ProfileReporter", () => {
it("throws INVALID_OPTIONS for non-empty bad percentiles", {
timeout: 5000,
}, () => {
try {
new ProfileReporter({ percentiles: [200] });
throw new Error("expected throw");
} catch (err) {
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("throws INVALID_OPTIONS for empty percentiles", {
timeout: 5000,
}, () => {
try {
new ProfileReporter({ percentiles: [] });
throw new Error("expected throw");
} catch (err) {
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("computes percentiles", { timeout: 5000 }, () => {
const r = new ProfileReporter({
percentiles: [50, 90],
clock: () => 0,
});
const stats = r.computeStats([10, 20, 30, 40, 50]);
expect(stats.count).toBe(5);
expect(stats.min).toBe(10);
expect(stats.max).toBe(50);
expect(stats.mean).toBe(30);
expect(stats.median).toBe(30);
expect(stats.percentiles[50]).toBe(30);
expect(stats.percentiles[90]).toBeGreaterThan(30);
});
it("handles empty entries", { timeout: 5000 }, () => {
const r = new ProfileReporter();
const stats = r.computeStats([]);
expect(stats).toEqual({
count: 0,
min: 0,
max: 0,
mean: 0,
median: 0,
percentiles: {},
totalDuration: 0,
});
});
it("groups by category and uncategorized falls back", {
timeout: 5000,
}, () => {
const r = new ProfileReporter({ clock: () => 0 });
const report = r.generateReport([
entry("a", 10, "render"),
entry("b", 20, "render"),
entry("c", 30),
]);
expect(report.byCategory.render?.count).toBe(2);
expect(report.byCategory.uncategorized?.count).toBe(1);
});
it("flags alerts above threshold", { timeout: 5000 }, () => {
const r = new ProfileReporter({ alertThreshold: 20, clock: () => 0 });
const report = r.generateReport([entry("ok", 10), entry("bad", 30)]);
expect(report.alerts).toHaveLength(1);
expect(report.alerts[0]?.label).toBe("bad");
});
it("formatReport produces stable text", { timeout: 5000 }, () => {
const r = new ProfileReporter({ clock: () => 0, alertThreshold: 1 });
const report = r.generateReport([entry("slow", 10)]);
const text = r.formatReport(report);
expect(text).toContain("Profile Report");
expect(text).toContain("Alerts (1)");
expect(text).toContain("slow");
});
it("formatReport with no alerts skips alerts section", {
timeout: 5000,
}, () => {
const r = new ProfileReporter({ clock: () => 0 });
const text = r.formatReport(r.generateReport([entry("ok", 10)]));
expect(text).not.toContain("Alerts");
});
it("computePercentile handles single-element exact index", {
timeout: 5000,
}, () => {
const r = new ProfileReporter();
const stats = r.computeStats([42]);
expect(stats.median).toBe(42);
});
it("ignores entries without duration", { timeout: 5000 }, () => {
const r = new ProfileReporter();
const report = r.generateReport(
[{ label: "still", startTime: 0 }, entry("ok", 10)],
[{ label: "mark", timestamp: 1 }] as readonly ProfileMark[]
);
expect(report.stats.count).toBe(1);
expect(report.marks).toHaveLength(1);
});
});

View File

@@ -0,0 +1,167 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import type {
ProfileEntry,
ProfileMark,
ProfileReport,
ProfileStats,
ReporterConfig,
} from "../types/profiler.types.js";
const ConfigSchema = z.object({
percentiles: z.array(z.number().min(0).max(100)).nonempty().optional(),
alertThreshold: z.number().nonnegative().optional(),
});
/** Options for {@link ProfileReporter}. */
export interface ProfileReporterOptions extends ReporterConfig {
/** Injectable wall-clock for the `generatedAt` timestamp. */
readonly clock?: Clock;
}
/**
* Generates statistical reports from profiling entries: percentile
* summaries, per-category groupings, alert lists, and a stable text
* format suitable for logs.
*/
export class ProfileReporter {
private readonly percentiles: readonly number[];
private readonly alertThreshold: number;
private readonly clock: Clock;
public constructor(options: ProfileReporterOptions = {}) {
const parsed = ConfigSchema.safeParse({
percentiles: options.percentiles,
alertThreshold: options.alertThreshold,
});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid ProfileReporter options",
{ context: { issues: parsed.error.issues } }
);
}
this.percentiles = parsed.data.percentiles ?? [50, 90, 95, 99];
this.alertThreshold =
parsed.data.alertThreshold ?? Number.POSITIVE_INFINITY;
this.clock = options.clock ?? (() => Date.now());
}
/** Generate a full report from entries and marks. */
public generateReport(
entries: readonly ProfileEntry[],
marks: readonly ProfileMark[] = []
): ProfileReport {
const completed = entries.filter((e) => e.duration !== undefined);
const durations = completed.map((e) => e.duration as number);
const stats = this.computeStats(durations);
const byCategory = this.computeByCategory(completed);
const alerts = completed.filter(
(e) => e.duration !== undefined && e.duration > this.alertThreshold
);
return {
generatedAt: new Date(this.clock()).toISOString(),
stats,
byCategory,
alerts,
marks: [...marks],
};
}
/** Compute summary statistics for a set of durations. */
public computeStats(durations: readonly number[]): ProfileStats {
if (durations.length === 0) {
return {
count: 0,
min: 0,
max: 0,
mean: 0,
median: 0,
percentiles: {},
totalDuration: 0,
};
}
const sorted = [...durations].sort((a, b) => a - b);
const sum = sorted.reduce((acc, d) => acc + d, 0);
const percentileResults: Record<number, number> = {};
for (const p of this.percentiles) {
percentileResults[p] = this.computePercentile(sorted, p);
}
// Length-zero is handled above; non-null assertions are safe here.
const first = sorted[0] as number;
const last = sorted[sorted.length - 1] as number;
return {
count: sorted.length,
min: first,
max: last,
mean: sum / sorted.length,
median: this.computePercentile(sorted, 50),
percentiles: percentileResults,
totalDuration: sum,
};
}
/** Format a report as a human-readable string. */
public formatReport(report: ProfileReport): string {
const lines: string[] = [];
lines.push(`Profile Report (${report.generatedAt})`);
lines.push(`Total: ${report.stats.count} entries`);
lines.push(`Duration: ${report.stats.totalDuration.toFixed(2)}ms`);
lines.push(`Min: ${report.stats.min.toFixed(2)}ms`);
lines.push(`Max: ${report.stats.max.toFixed(2)}ms`);
lines.push(`Mean: ${report.stats.mean.toFixed(2)}ms`);
lines.push(`Median: ${report.stats.median.toFixed(2)}ms`);
for (const [p, value] of Object.entries(report.stats.percentiles)) {
lines.push(`P${p}: ${value.toFixed(2)}ms`);
}
if (report.alerts.length > 0) {
lines.push(`\nAlerts (${report.alerts.length}):`);
for (const alert of report.alerts) {
lines.push(
` - ${alert.label}: ${(alert.duration as number).toFixed(2)}ms`
);
}
}
return lines.join("\n");
}
private computePercentile(
sorted: readonly number[],
percentile: number
): number {
// Caller `computeStats` returns early for zero-length input, so
// `sorted` is guaranteed non-empty here.
const index = (percentile / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
const lowerVal = sorted[lower] as number;
if (lower === upper) return lowerVal;
const upperVal = sorted[upper] as number;
const weight = index - lower;
return lowerVal * (1 - weight) + upperVal * weight;
}
private computeByCategory(
entries: readonly ProfileEntry[]
): Record<string, ProfileStats> {
const groups = new Map<string, number[]>();
for (const entry of entries) {
// Caller pre-filters completed entries; duration is defined.
const duration = entry.duration as number;
const cat = entry.category ?? "uncategorized";
const existing = groups.get(cat) ?? [];
existing.push(duration);
groups.set(cat, existing);
}
const result: Record<string, ProfileStats> = {};
for (const [category, durations] of groups) {
result[category] = this.computeStats(durations);
}
return result;
}
}

View File

@@ -0,0 +1,2 @@
export type { SamplerOptions } from "./sampler.js";
export { Sampler } from "./sampler.js";

View File

@@ -0,0 +1,71 @@
# sampler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Sampler` collects periodic snapshots of runtime metrics (memory usage by default, plus caller-supplied custom metrics). The timer source is injected through a `TimersLike` so tests can advance virtual time deterministically and so non-`setInterval` runtimes (Workers, Deno, embedded JS) can plug in their own scheduler. Sample storage is capped via FIFO eviction so long-running collectors don't leak memory.
## Features
- Zod-validated options (`intervalMs > 0`, optional positive `maxSamples`, optional `captureMemory`).
- Injectable `Clock`, `TimersLike`, and `MemoryProbe` — the defaults read `Date.now`, `setTimeout`/`clearTimeout`, and `process.memoryUsage`/`performance.memory` respectively.
- `start()` / `stop()` are idempotent — double-`start` and double-`stop` are no-ops.
- `takeSample(customMetrics?)` records a single sample on demand; same code path used by the scheduled tick.
- `getSamples()` / `getSamplesInRange(start, end)` / `clear()` for retrieval and reset.
- `events: EventBus<SamplerEventMap>` emits `sampler:start`, `sampler:stop`, `sampler:sample`.
- Telemetry: `profiler_samples_total` counter incremented on every sample.
## Usage: Basic
```ts
import { Sampler } from "@suaveplan/profiler";
const sampler = new Sampler({ intervalMs: 1000 });
sampler.start();
// ...
sampler.stop();
console.log(sampler.getSamples());
```
## Usage: Advanced
```ts
import { Sampler } from "@suaveplan/profiler";
import type { TimersLike } from "@suaveplan/types";
const queue: Array<{ cb: () => void; handle: number }> = [];
let next = 1;
const fakeTimers: TimersLike = {
setTimer(cb) {
const handle = next++;
queue.push({ cb, handle });
return handle;
},
clearTimer() {},
};
let clockT = 0;
const sampler = new Sampler({
intervalMs: 10,
timers: fakeTimers,
clock: () => clockT,
memoryProbe: () => 4096,
});
sampler.start();
clockT = 10;
queue.shift()?.cb();
```
## API Reference
- `class Sampler` — `new Sampler(options: SamplerOptions)`.
- `interface SamplerOptions extends SamplerConfig { clock?, timers?, memoryProbe? }`.
- Methods: `start`, `stop`, `takeSample`, `getSamples`, `getSamplesInRange`, `isRunning`, `clear`.
- Errors: throws `ProfilerError` with `PROFILER.INVALID_OPTIONS` when options fail Zod validation.
## Implementation Notes
The scheduler uses a self-rescheduling `setTimer` rather than `setInterval` because `TimersLike` only models one-shot timers. Each tick checks `this.running` before taking a sample, which lets a stop() that runs between callback dispatch and execution avoid emitting a stale sample. FIFO eviction uses `splice(0, n)` so the oldest samples drop out when `maxSamples` is exceeded.

View File

@@ -0,0 +1,219 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import { readPlatformMemory, Sampler } from "./sampler.js";
interface Scheduled {
handle: number;
cb: () => void;
delayMs: number;
}
function createFakeTimers(): {
timers: TimersLike;
scheduled: Scheduled[];
cleared: number[];
runNext(): void;
} {
const scheduled: Scheduled[] = [];
const cleared: number[] = [];
let nextHandle = 1;
return {
scheduled,
cleared,
timers: {
setTimer(cb, delayMs) {
const handle = nextHandle++;
scheduled.push({ handle, cb, delayMs });
return handle;
},
clearTimer(handle) {
cleared.push(handle);
},
},
runNext() {
const s = scheduled.shift();
if (s) s.cb();
},
};
}
describe("Sampler", () => {
it("throws INVALID_OPTIONS when intervalMs is non-positive", {
timeout: 5000,
}, () => {
try {
new Sampler({ intervalMs: 0 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ProfilerError);
expect((err as ProfilerError).code).toBe(
PROFILER_CODES.INVALID_OPTIONS
);
}
});
it("start schedules samples via TimersLike", { timeout: 5000 }, () => {
const fake = createFakeTimers();
let clockT = 100;
const sampler = new Sampler({
intervalMs: 10,
timers: fake.timers,
clock: () => clockT,
memoryProbe: () => 1234,
});
sampler.start();
expect(sampler.isRunning()).toBe(true);
expect(fake.scheduled).toHaveLength(1);
clockT = 110;
fake.runNext();
expect(sampler.getSamples()).toHaveLength(1);
expect(sampler.getSamples()[0]?.memoryUsage).toBe(1234);
// schedule should have re-armed
expect(fake.scheduled).toHaveLength(1);
});
it("stop clears the active timer and re-stop is a no-op", {
timeout: 5000,
}, () => {
const fake = createFakeTimers();
const sampler = new Sampler({
intervalMs: 10,
timers: fake.timers,
memoryProbe: () => undefined,
});
sampler.start();
sampler.stop();
expect(sampler.isRunning()).toBe(false);
expect(fake.cleared).toHaveLength(1);
// second stop is a no-op
sampler.stop();
expect(fake.cleared).toHaveLength(1);
});
it("double-start is a no-op", { timeout: 5000 }, () => {
const fake = createFakeTimers();
const sampler = new Sampler({
intervalMs: 10,
timers: fake.timers,
memoryProbe: () => undefined,
});
sampler.start();
sampler.start();
expect(fake.scheduled).toHaveLength(1);
});
it("takeSample evicts oldest beyond maxSamples", { timeout: 5000 }, () => {
const sampler = new Sampler({
intervalMs: 10,
maxSamples: 2,
captureMemory: false,
});
sampler.takeSample({ a: 1 });
sampler.takeSample({ a: 2 });
sampler.takeSample({ a: 3 });
expect(sampler.getSamples()).toHaveLength(2);
expect(sampler.getSamples()[0]?.metrics?.a).toBe(2);
expect(sampler.getSamples()[1]?.metrics?.a).toBe(3);
});
it("getSamplesInRange filters by timestamp", { timeout: 5000 }, () => {
let clockT = 0;
const sampler = new Sampler({
intervalMs: 10,
captureMemory: false,
clock: () => clockT,
});
clockT = 10;
sampler.takeSample();
clockT = 20;
sampler.takeSample();
clockT = 30;
sampler.takeSample();
expect(sampler.getSamplesInRange(15, 25)).toHaveLength(1);
});
it("clear empties samples", { timeout: 5000 }, () => {
const sampler = new Sampler({
intervalMs: 10,
captureMemory: false,
});
sampler.takeSample();
sampler.clear();
expect(sampler.getSamples()).toHaveLength(0);
});
it("scheduled callback returns when stopped between ticks", {
timeout: 5000,
}, () => {
const fake = createFakeTimers();
const sampler = new Sampler({
intervalMs: 10,
timers: fake.timers,
captureMemory: false,
});
sampler.start();
const first = fake.scheduled[0];
sampler.stop();
// simulate stale timer firing after stop
first?.cb();
expect(sampler.getSamples()).toHaveLength(0);
});
it("uses the default TimersLike when none injected", {
timeout: 5000,
}, async () => {
const sampler = new Sampler({
intervalMs: 5,
captureMemory: false,
});
sampler.start();
await new Promise((r) => setTimeout(r, 20));
sampler.stop();
expect(sampler.getSamples().length).toBeGreaterThanOrEqual(1);
});
it("captureMemory true uses default probe when none injected", {
timeout: 5000,
}, () => {
const sampler = new Sampler({ intervalMs: 10 });
const s = sampler.takeSample();
expect(typeof s.memoryUsage === "number").toBe(true);
});
it("readPlatformMemory prefers process.memoryUsage", {
timeout: 5000,
}, () => {
expect(
readPlatformMemory({
memoryUsage: () => ({ heapUsed: 999 }),
})
).toBe(999);
});
it("readPlatformMemory falls back to performance.memory", {
timeout: 5000,
}, () => {
expect(
readPlatformMemory(undefined, {
memory: { usedJSHeapSize: 42 },
})
).toBe(42);
});
it("readPlatformMemory returns undefined when neither is available", {
timeout: 5000,
}, () => {
expect(readPlatformMemory()).toBeUndefined();
expect(readPlatformMemory(undefined, {})).toBeUndefined();
});
it("captureMemory false skips probe", { timeout: 5000 }, () => {
const sampler = new Sampler({
intervalMs: 10,
captureMemory: false,
});
const s = sampler.takeSample();
expect(s.memoryUsage).toBeUndefined();
});
});

View File

@@ -0,0 +1,168 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, TimersLike } from "@suaveplan/types";
import z from "zod";
import { PROFILER_CODES, ProfilerError } from "../error-codes/error-codes.js";
import { recordSample } from "../telemetry/telemetry.js";
import type {
MemoryProbe,
SamplePoint,
SamplerConfig,
SamplerEventMap,
} from "../types/profiler.types.js";
const SamplerConfigSchema = z.object({
intervalMs: z.number().positive(),
captureMemory: z.boolean().optional(),
maxSamples: z.number().int().positive().optional(),
});
function defaultClock(): number {
return Date.now();
}
const defaultTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (handle) => {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
},
};
/**
* Read the current heap-used byte count from any platform.
*
* Exported for testability: the production default constructed below
* binds the function via `globalThis` once at module load, but tests can
* call this helper directly with synthesised `process` / `performance`
* objects to exercise the non-Node fallback paths.
*/
export function readPlatformMemory(
proc?: { memoryUsage?: () => { heapUsed: number } } | undefined,
perf?: { memory?: { usedJSHeapSize: number } } | undefined
): number | undefined {
if (proc?.memoryUsage) return proc.memoryUsage().heapUsed;
if (perf?.memory) return perf.memory.usedJSHeapSize;
return undefined;
}
function defaultMemoryProbe(): number | undefined {
const proc = (globalThis as { process?: typeof process }).process;
const perf = (globalThis as { performance?: unknown }).performance as
| { memory?: { usedJSHeapSize: number } }
| undefined;
return readPlatformMemory(proc, perf);
}
/** Options for {@link Sampler}. Extends {@link SamplerConfig} with injection slots. */
export interface SamplerOptions extends SamplerConfig {
readonly clock?: Clock;
readonly timers?: TimersLike;
readonly memoryProbe?: MemoryProbe;
}
/**
* Periodic sampler driven by an injected {@link TimersLike} so test runs
* can advance virtual time deterministically. Memory readings are taken
* through an injectable {@link MemoryProbe} that defaults to
* `process.memoryUsage` (Node) or `performance.memory` (Chrome).
*/
export class Sampler {
private readonly intervalMs: number;
private readonly captureMemory: boolean;
private readonly maxSamples: number;
private readonly clock: Clock;
private readonly timers: TimersLike;
private readonly memoryProbe: MemoryProbe;
private readonly samples: SamplePoint[] = [];
private timerHandle: number | null = null;
private running = false;
public readonly events = new EventBus<SamplerEventMap>();
public constructor(options: SamplerOptions) {
const parsed = SamplerConfigSchema.safeParse({
intervalMs: options.intervalMs,
captureMemory: options.captureMemory,
maxSamples: options.maxSamples,
});
if (!parsed.success) {
throw new ProfilerError(
PROFILER_CODES.INVALID_OPTIONS,
"Invalid Sampler options",
{ context: { issues: parsed.error.issues } }
);
}
this.intervalMs = parsed.data.intervalMs;
this.captureMemory = parsed.data.captureMemory ?? true;
this.maxSamples = parsed.data.maxSamples ?? 1000;
this.clock = options.clock ?? defaultClock;
this.timers = options.timers ?? defaultTimers;
this.memoryProbe = options.memoryProbe ?? defaultMemoryProbe;
}
/** Start periodic sampling. No-op when already running. */
public start(): void {
if (this.running) return;
this.running = true;
this.schedule();
this.events.emit("sampler:start", {});
}
/** Stop periodic sampling. No-op when not running. */
public stop(): void {
if (!this.running) return;
this.running = false;
// `start` always installs a timer, so `timerHandle` is non-null
// here.
this.timers.clearTimer(this.timerHandle as number);
this.timerHandle = null;
this.events.emit("sampler:stop", {});
}
/** Manually take a single sample. */
public takeSample(customMetrics?: Record<string, number>): SamplePoint {
const sample: SamplePoint = {
timestamp: this.clock(),
memoryUsage: this.captureMemory ? this.memoryProbe() : undefined,
metrics: customMetrics,
};
this.samples.push(sample);
if (this.samples.length > this.maxSamples) {
this.samples.splice(0, this.samples.length - this.maxSamples);
}
recordSample();
this.events.emit("sampler:sample", { sample });
return sample;
}
/** Get all collected samples. */
public getSamples(): readonly SamplePoint[] {
return this.samples;
}
/** Get samples within an inclusive time range. */
public getSamplesInRange(
startTime: number,
endTime: number
): SamplePoint[] {
return this.samples.filter(
(s) => s.timestamp >= startTime && s.timestamp <= endTime
);
}
/** Whether the sampler is currently running. */
public isRunning(): boolean {
return this.running;
}
/** Clear all samples. */
public clear(): void {
this.samples.length = 0;
}
private schedule(): void {
this.timerHandle = this.timers.setTimer(() => {
if (!this.running) return;
this.takeSample();
this.schedule();
}, this.intervalMs);
}
}

View File

@@ -0,0 +1,12 @@
export {
entriesCounter,
marksCounter,
measureDurationHistogram,
memorySnapshotsCounter,
recordEntry,
recordMark,
recordMeasureDuration,
recordMemorySnapshot,
recordSample,
samplesCounter,
} from "./telemetry.js";

View File

@@ -0,0 +1,59 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry meter, counters, and histograms emitted by `@suaveplan/profiler` itself. Every public completion path inside `Profiler`, `Sampler`, and `MemoryProfiler` routes through the `recordX` helpers defined here so consumers can observe the profiler the same way they observe any other instrumented service — counters tag entries by category when present, and the histogram captures the measurement-duration distribution.
## Features
- Lazy-bound meter via `metrics.getMeter("@suaveplan/profiler")` — resolves to a no-op meter when no `MeterProvider` is registered, so importing the module is free in production code paths that opt out of telemetry.
- Five instruments:
- `profiler_entries_total` (counter, tagged by `category` when present)
- `profiler_marks_total` (counter)
- `profiler_samples_total` (counter)
- `profiler_memory_snapshots_total` (counter)
- `profiler_measure_duration_ms` (histogram, unit `ms`)
- Pure record helpers (`recordEntry` / `recordMark` / `recordMeasureDuration` / `recordSample` / `recordMemorySnapshot`) so call sites stay readable.
## Usage: Basic
```ts
import { recordEntry } from "@suaveplan/profiler";
recordEntry("render");
```
## Usage: Advanced
Wire a real `MeterProvider` in tests using `@opentelemetry/sdk-metrics`. The package ships a `tests/setup/polyfills.ts` that registers an `InMemoryMetricExporter` BEFORE the source modules import, so the lazy meter resolves to the test provider:
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
```
## API Reference
- `entriesCounter: Counter`, `marksCounter: Counter`, `samplesCounter: Counter`, `memorySnapshotsCounter: Counter`, `measureDurationHistogram: Histogram` — direct instrument access.
- `recordEntry(category: string | undefined): void`
- `recordMark(): void`
- `recordMeasureDuration(durationMs: number): void`
- `recordSample(): void`
- `recordMemorySnapshot(): void`
## Implementation Notes
The meter is acquired at module load (`metrics.getMeter(METER_NAME)`), which means a test runner that wants real metric points must install its `MeterProvider` via `setupFiles` before the SDK imports. The provided `polyfills.ts` does exactly that. Counters are added with `+1` per event so totals are linear; the histogram is recorded with the measured duration in milliseconds so percentile aggregations work out of the box.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
recordEntry,
recordMark,
recordMeasureDuration,
recordMemorySnapshot,
recordSample,
} from "./telemetry.js";
interface Point {
descriptor: { name: string };
}
function collectNames(metrics: unknown): string[] {
const out: string[] = [];
const top = metrics as Array<{
scopeMetrics: Array<{ metrics: Array<Point> }>;
}>;
for (const t of top) {
for (const sm of t.scopeMetrics) {
for (const m of sm.metrics) out.push(m.descriptor.name);
}
}
return out;
}
describe("telemetry", () => {
it("records entries on stop", { timeout: 5000 }, async () => {
recordEntry("render");
recordEntry(undefined);
const flushed = await flushTestMetrics();
const names = collectNames(flushed);
expect(names).toContain("profiler_entries_total");
});
it("records marks", { timeout: 5000 }, async () => {
recordMark();
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain("profiler_marks_total");
});
it("records measure duration", { timeout: 5000 }, async () => {
recordMeasureDuration(12.3);
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain("profiler_measure_duration_ms");
});
it("records samples", { timeout: 5000 }, async () => {
recordSample();
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain("profiler_samples_total");
});
it("records snapshots", { timeout: 5000 }, async () => {
recordMemorySnapshot();
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain(
"profiler_memory_snapshots_total"
);
});
});

View File

@@ -0,0 +1,73 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/profiler";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns a
* no-op meter, so instruments are safe to import in production code that
* does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for completed profile entries (one per `stop()` call). */
export const entriesCounter: Counter = meter.createCounter(
"profiler_entries_total",
{
description:
"Total profile entries completed via Profiler.stop(), tagged with the entry category when present",
}
);
/** Counter for profile marks (one per `mark()` call). */
export const marksCounter: Counter = meter.createCounter(
"profiler_marks_total",
{ description: "Total marks placed on the profiling timeline" }
);
/** Histogram for measurement durations in milliseconds. */
export const measureDurationHistogram: Histogram = meter.createHistogram(
"profiler_measure_duration_ms",
{
description:
"Duration of profiled measurements in milliseconds, recorded on stop()",
unit: "ms",
}
);
/** Counter for sampler `takeSample()` invocations. */
export const samplesCounter: Counter = meter.createCounter(
"profiler_samples_total",
{ description: "Total samples collected by the Sampler" }
);
/** Counter for memory-profiler heap snapshots. */
export const memorySnapshotsCounter: Counter = meter.createCounter(
"profiler_memory_snapshots_total",
{ description: "Total heap snapshots captured by the MemoryProfiler" }
);
/** Record a single completed-entry event, tagged by category if present. */
export function recordEntry(category: string | undefined): void {
entriesCounter.add(1, category === undefined ? {} : { category });
}
/** Record a single mark event. */
export function recordMark(): void {
marksCounter.add(1);
}
/** Record a measurement duration in milliseconds. */
export function recordMeasureDuration(durationMs: number): void {
measureDurationHistogram.record(durationMs);
}
/** Record a single sample event. */
export function recordSample(): void {
samplesCounter.add(1);
}
/** Record a single memory-snapshot event. */
export function recordMemorySnapshot(): void {
memorySnapshotsCounter.add(1);
}

View File

@@ -0,0 +1,26 @@
export type {
AllocationRecord,
Bottleneck,
BottleneckConfig,
ComparisonReport,
FetchLike,
FlameNode,
HeapCapture,
MemoryProbe,
MemoryProfilerConfig,
MemoryProfilerEventMap,
MemorySnapshot,
OtelExporterConfig,
OtelSpan,
ProfileDelta,
ProfileEntry,
ProfileMark,
ProfileReport,
ProfilerEventMap,
ProfileStats,
RandomBytes,
ReporterConfig,
SamplePoint,
SamplerConfig,
SamplerEventMap,
} from "./profiler.types.js";

View File

@@ -0,0 +1,70 @@
# profiler.types
## Purpose
Shared, value-free type definitions consumed by every feature module of `@suaveplan/profiler`. Centralising the record shapes here keeps cross-module imports cheap (types vanish at build time) and gives downstream consumers a single import surface for the public contract.
## Features
- `ProfileEntry`, `ProfileMark`, `SamplePoint` — the three atomic measurement records.
- `ProfileStats`, `ProfileReport` — output shapes for the reporter.
- `MemorySnapshot`, `AllocationRecord`, `MemoryProfilerConfig`, `HeapCapture`, `MemoryProbe` — memory subsystem records.
- `FlameNode`, `Bottleneck`, `BottleneckConfig`, `ComparisonReport`, `ProfileDelta` — analysis record shapes.
- `OtelSpan`, `OtelExporterConfig`, `FetchLike`, `RandomBytes` — OTLP exporter contract.
- `ProfilerEventMap`, `SamplerEventMap`, `MemoryProfilerEventMap` — single-payload event maps compatible with `@suaveplan/events`'s `EventBus<TMap>` constraint.
- All optional fields are explicitly typed as `T | undefined` so the package compiles cleanly under `exactOptionalPropertyTypes: true`.
## Usage: Basic
```ts
import type { ProfileEntry, ProfilerEventMap } from "@suaveplan/profiler";
const entry: ProfileEntry = {
label: "render",
startTime: 0,
endTime: 16,
duration: 16,
category: "ui",
};
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import type { ProfilerEventMap, ProfileEntry } from "@suaveplan/profiler";
const bus = new EventBus<ProfilerEventMap>();
bus.on("profile:stop", ({ entry }: { entry: ProfileEntry }) => {
console.log(entry.label, entry.duration);
});
```
## API Reference
- `interface ProfileEntry { label, startTime, endTime?, duration?, category?, metadata? }`
- `interface ProfileMark { label, timestamp, metadata? }`
- `interface SamplePoint { timestamp, memoryUsage?, metrics? }`
- `interface SamplerConfig { intervalMs, captureMemory?, maxSamples? }`
- `interface ReporterConfig { percentiles?, alertThreshold? }`
- `interface ProfileStats { count, min, max, mean, median, percentiles, totalDuration }`
- `interface ProfileReport { generatedAt, stats, byCategory, alerts, marks }`
- `interface MemorySnapshot { timestamp, heapUsed, heapTotal, label? }`
- `interface AllocationRecord { label, allocations, totalBytes, peakBytes }`
- `interface MemoryProfilerConfig { sampleIntervalMs?, maxSnapshots?, leakThresholdBytes?, leakWindowSize? }`
- `type HeapCapture = () => { heapUsed: number; heapTotal: number }`
- `type MemoryProbe = () => number | undefined`
- `interface FlameNode { name, value, selfTime, children }`
- `interface BottleneckConfig { timeBudgetMs?, hotSpotFrequency?, maxResults? }`
- `interface Bottleneck { label, category?, avgDuration, frequency, impact, suggestion }`
- `interface OtelSpan { traceId, spanId, parentSpanId?, name, startTimeUnixNano, endTimeUnixNano, attributes }`
- `type RandomBytes = (n: number) => Uint8Array`
- `type FetchLike = (url, init) => Promise<{ ok: boolean }>`
- `interface OtelExporterConfig { endpoint?, serviceName?, batchSize?, headers?, fetchFn?, randomBytes? }`
- `interface ProfileDelta { label, category?, baselineDuration, currentDuration, absoluteChange, percentChange }`
- `interface ComparisonReport { generatedAt, regressions, improvements, unchanged, overallDelta, overallPercentChange }`
- `type ProfilerEventMap`, `type SamplerEventMap`, `type MemoryProfilerEventMap` — single-payload event maps.
## Implementation Notes
Event maps are declared as `type` aliases rather than `interface`s because TypeScript treats an `interface` as not automatically assignable to `Record<string, unknown>` — the constraint `@suaveplan/events`'s `EventBus<TMap extends EventMap>` enforces — while `type` aliases over object literals are. Optional `T | undefined` annotations are deliberate: under `exactOptionalPropertyTypes: true` the form `prop?: T` is strictly distinct from `prop?: T | undefined`, and the codebase frequently assigns `undefined` explicitly when building records programmatically.

View File

@@ -0,0 +1,204 @@
/**
* Shared type definitions for `@suaveplan/profiler`.
*
* Flat-types module per the universal exemption: every value-bearing
* symbol lives in a feature folder; this `types/` directory exposes only
* the immutable record shapes consumed across modules.
*
* Optional fields are explicitly typed as `T | undefined` to keep the
* code base ergonomic under `exactOptionalPropertyTypes: true` — record
* literals built programmatically include the field with `undefined`
* rather than omitting the key.
*/
/** A single profiling entry with timing data. */
export interface ProfileEntry {
readonly label: string;
readonly startTime: number;
readonly endTime?: number | undefined;
readonly duration?: number | undefined;
readonly category?: string | undefined;
readonly metadata?: Record<string, unknown> | undefined;
}
/** A named mark in the profiling timeline. */
export interface ProfileMark {
readonly label: string;
readonly timestamp: number;
readonly metadata?: Record<string, unknown> | undefined;
}
/** A sampled data point from the periodic sampler. */
export interface SamplePoint {
readonly timestamp: number;
readonly memoryUsage?: number | undefined;
readonly metrics?: Record<string, number> | undefined;
}
/** Configuration for the periodic sampler. */
export interface SamplerConfig {
readonly intervalMs: number;
readonly captureMemory?: boolean | undefined;
readonly maxSamples?: number | undefined;
}
/** Configuration for the reporter. */
export interface ReporterConfig {
readonly percentiles?: readonly number[] | undefined;
readonly alertThreshold?: number | undefined;
}
/** Summary statistics for a set of measurements. */
export interface ProfileStats {
readonly count: number;
readonly min: number;
readonly max: number;
readonly mean: number;
readonly median: number;
readonly percentiles: Record<number, number>;
readonly totalDuration: number;
}
/** Full profiling report. */
export interface ProfileReport {
readonly generatedAt: string;
readonly stats: ProfileStats;
readonly byCategory: Record<string, ProfileStats>;
readonly alerts: readonly ProfileEntry[];
readonly marks: readonly ProfileMark[];
}
/** Memory snapshot at a point in time. */
export interface MemorySnapshot {
readonly timestamp: number;
readonly heapUsed: number;
readonly heapTotal: number;
readonly label?: string | undefined;
}
/** Allocation record for a labelled category. */
export interface AllocationRecord {
readonly label: string;
readonly allocations: number;
readonly totalBytes: number;
readonly peakBytes: number;
}
/** Configuration for the memory profiler. */
export interface MemoryProfilerConfig {
readonly sampleIntervalMs?: number | undefined;
readonly maxSnapshots?: number | undefined;
readonly leakThresholdBytes?: number | undefined;
readonly leakWindowSize?: number | undefined;
}
/** Pluggable heap reader. Default uses `process.memoryUsage` or `performance.memory`. */
export type HeapCapture = () => { heapUsed: number; heapTotal: number };
/** Pluggable memory-usage reader returning `heapUsed` only (sampler variant). */
export type MemoryProbe = () => number | undefined;
/** A node in a flame graph. */
export interface FlameNode {
readonly name: string;
readonly value: number;
readonly selfTime: number;
readonly children: readonly FlameNode[];
}
/** Configuration for bottleneck detection. */
export interface BottleneckConfig {
readonly timeBudgetMs?: number | undefined;
readonly hotSpotFrequency?: number | undefined;
readonly maxResults?: number | undefined;
}
/** A detected bottleneck. */
export interface Bottleneck {
readonly label: string;
readonly category?: string | undefined;
readonly avgDuration: number;
readonly frequency: number;
readonly impact: number;
readonly suggestion: string;
}
/** An OTLP-compatible span. */
export interface OtelSpan {
readonly traceId: string;
readonly spanId: string;
readonly parentSpanId?: string | undefined;
readonly name: string;
readonly startTimeUnixNano: number;
readonly endTimeUnixNano: number;
readonly attributes: Record<string, string | number | boolean>;
}
/** Pluggable random-bytes producer for trace/span IDs. */
export type RandomBytes = (n: number) => Uint8Array;
/** Pluggable fetch implementation for OTel export. */
export type FetchLike = (
url: string,
init: { method: string; headers: Record<string, string>; body: string }
) => Promise<{ ok: boolean }>;
/** Configuration for the OTel exporter. */
export interface OtelExporterConfig {
readonly endpoint?: string | undefined;
readonly serviceName?: string | undefined;
readonly batchSize?: number | undefined;
readonly headers?: Record<string, string> | undefined;
readonly fetchFn?: FetchLike | undefined;
readonly randomBytes?: RandomBytes | undefined;
}
/** Delta between two measurements. */
export interface ProfileDelta {
readonly label: string;
readonly category?: string | undefined;
readonly baselineDuration: number;
readonly currentDuration: number;
readonly absoluteChange: number;
readonly percentChange: number;
}
/** Comparison report between two profile runs. */
export interface ComparisonReport {
readonly generatedAt: string;
readonly regressions: readonly ProfileDelta[];
readonly improvements: readonly ProfileDelta[];
readonly unchanged: readonly ProfileDelta[];
readonly overallDelta: number;
readonly overallPercentChange: number;
}
/**
* Event payloads for profiler events (single-payload — genesis EventBus shape).
*
* Declared as `type` aliases (not `interface`) so they satisfy
* `@suaveplan/events`'s `EventMap = Record<string, unknown>` constraint —
* interfaces are not automatically assignable to `Record<string, unknown>`,
* but type-alias object literals are.
*/
export type ProfilerEventMap = {
"profile:start": { label: string; category?: string | undefined };
"profile:stop": { entry: ProfileEntry };
"profile:mark": { mark: ProfileMark };
"profile:clear": Record<string, never>;
};
/** Event payloads for sampler events. */
export type SamplerEventMap = {
"sampler:start": Record<string, never>;
"sampler:stop": Record<string, never>;
"sampler:sample": { sample: SamplePoint };
};
/** Event payloads for memory-profiler events. */
export type MemoryProfilerEventMap = {
"memory:start": Record<string, never>;
"memory:stop": Record<string, never>;
"memory:snapshot": { snapshot: MemorySnapshot };
"memory:leak-detected": { snapshots: readonly MemorySnapshot[] };
};

View File

@@ -0,0 +1,40 @@
/**
* Test setup for @suaveplan/profiler.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Also
* provides a `webcrypto`-backed `globalThis.crypto` for bare-Node runs.
*/
import { webcrypto } from "node:crypto";
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/devtools-tooling-profiler",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"profiler/index": "src/profiler/index.ts",
"sampler/index": "src/sampler/index.ts",
"memory/index": "src/memory/index.ts",
"bottleneck/index": "src/bottleneck/index.ts",
"comparison/index": "src/comparison/index.ts",
"flamegraph/index": "src/flamegraph/index.ts",
"reporter/index": "src/reporter/index.ts",
"otel/index": "src/otel/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});