Files
suaveplan/openspec/archive/2026-05-24-profiler/specs/profiler/spec.md
2026-05-27 23:29:53 +01:00

78 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Profiler — Spec Delta
## ADDED Requirements
### Requirement: Profiler captures named measurements with injectable clock
The `Profiler` class SHALL expose `start(label, category?, metadata?)`, `stop(label)`, `mark(label, metadata?)`, `measure(label, fn, category?)`, and `measureAsync(label, fn, category?)`. Wall-clock readings MUST come from an injectable `Clock`; the default MUST be `() => Date.now()` (or `performance.now()` when available — the constructor accepts a `Clock` override). `measure` and `measureAsync` MUST call `stop` even when the wrapped function throws.
**Verified by:** `profiler.test.ts` › "start + stop records an entry with duration", "measure wraps a sync fn and propagates throws", "measureAsync wraps an async fn and propagates throws", "uses the injected clock for timing".
### Requirement: Sampler runs on TimersLike, not setInterval
The `Sampler` class SHALL drive its periodic sampling through an injected `TimersLike.setTimer` and SHALL stop cleanly by calling `TimersLike.clearTimer`. Calling `start()` while already running MUST be a no-op; calling `stop()` while not running MUST be a no-op. Samples MUST be capped at `maxSamples` via FIFO eviction.
**Verified by:** `sampler.test.ts` › "start schedules samples via TimersLike", "stop clears the active timer", "double-start is a no-op", "double-stop is a no-op", "evicts oldest samples beyond maxSamples".
### Requirement: MemoryProfiler accepts injectable HeapCapture
The `MemoryProfiler` SHALL accept an injectable `HeapCapture` function returning `{ heapUsed, heapTotal }`. The default capture function MUST read `process.memoryUsage()` when available, fall back to `performance.memory` (Chrome), and return zeros otherwise. Leak detection SHALL emit `memory:leak-detected` when the last `leakWindowSize` snapshots show monotonic non-decreasing growth ≥ `leakThresholdBytes`.
**Verified by:** `memory.test.ts` › "uses the injected heap-capture", "default capture handles missing process and performance.memory", "detectLeak fires when growth exceeds threshold", "detectLeak ignores stable windows".
### Requirement: PROFILER_CODES registry via defineCodes
The package MUST publish a stable `PROFILER_CODES` registry built with `defineCodes("PROFILER", [...])` and an accompanying `ProfilerError extends SuaveplanError<ProfilerCode>` class with a public constructor delegating to `super`. Every public option-validation failure MUST throw `ProfilerError` with a code from the registry.
**Verified by:** `error-codes.test.ts` › "every code is namespaced under PROFILER.", "ProfilerError carries the supplied code and message", "Profiler throws INVALID_OPTIONS for non-positive percentiles", "Sampler throws INVALID_OPTIONS for non-positive intervalMs".
### Requirement: Telemetry instruments emit on public calls
The package MUST register an OTel meter at `@suaveplan/profiler` exposing:
- `profiler_entries_total` counter — incremented on every completed `stop()`
- `profiler_marks_total` counter — incremented on every `mark()`
- `profiler_measure_duration_ms` histogram — recorded on every completed `stop()`
- `profiler_samples_total` counter — incremented on every `takeSample()`
- `profiler_memory_snapshots_total` counter — incremented on every `takeSnapshot()`
A real in-memory `MeterProvider` registered before module import MUST see metric points after public calls.
**Verified by:** `telemetry.test.ts` › "records entries on stop", "records marks", "records samples", "records snapshots", "records measure duration".
### Requirement: BottleneckDetector ranks hot spots deterministically
`BottleneckDetector.analyze` MUST return up to `maxResults` bottlenecks sorted by `impact = avgDuration * frequency` descending. `findHotSpots` MUST return entries that exceed `timeBudgetMs` and occur ≥ `hotSpotFrequency` times. `findBudgetViolations` MUST return entries whose `duration` exceeds `timeBudgetMs`.
**Verified by:** `bottleneck.test.ts` › "analyze ranks by impact", "findHotSpots requires both threshold + frequency", "findBudgetViolations returns over-budget entries", "respects maxResults cap".
### Requirement: ProfileComparator classifies deltas by regressionThreshold
`ProfileComparator` MUST classify each per-label delta into regressions, improvements, or unchanged based on `percentChange` vs `regressionThreshold`. Regressions MUST be sorted by `percentChange` descending; improvements ascending. Both `compareReports` (ProfileReport pair) and `compareEntries` (entry-array pair) MUST be supported.
**Verified by:** `comparison.test.ts` › "classifies regressions and improvements", "computeReports works at category granularity", "handles zero-baseline edge case", "sorts regressions descending and improvements ascending".
### Requirement: FlameGraphGenerator builds a containment tree
`FlameGraphGenerator.generate(entries)` MUST return a root `FlameNode` whose children are populated by start/end-time containment. `selfTime` MUST equal `value` minus the sum of immediate children's `value`. `collapseRecursive` MUST collapse consecutive same-name parent/child nodes. `toD3Format` MUST emit `{ name, value, children }` JSON.
**Verified by:** `flamegraph.test.ts` › "builds a containment tree", "computes selfTime as value minus children", "collapses recursive nodes", "toD3Format emits d3-compatible JSON".
### Requirement: ProfileReporter computes percentile statistics
`ProfileReporter.generateReport` MUST return `count`, `min`, `max`, `mean`, `median`, configurable `percentiles`, total duration, per-category stats, alerts above `alertThreshold`, and a copy of marks. Empty-input MUST return a zero-stats object.
**Verified by:** `reporter.test.ts` › "computes percentiles", "handles empty entries", "groups by category", "flags alerts above threshold", "formatReport produces stable text output".
### Requirement: OtelExporter emits OTLP-compatible JSON with injectable fetch + rng
`OtelExporter` MUST convert entries to OTLP-compatible spans with `traceId` (16 bytes hex) and `spanId` (8 bytes hex). A `Fetch` function and `Rng` (random-bytes) MUST be injectable. `export(spans)` MUST return `true` on `response.ok`, `false` otherwise (including thrown errors). `exportBatch()` MUST chunk pending spans into `batchSize` slices.
**Verified by:** `otel.test.ts` › "converts entries to OTLP spans", "respects injected fetch", "returns false on non-ok response", "returns false on fetch throw", "exportBatch chunks pending spans".
### Requirement: Sibling .md per implementation .ts
Every non-barrel, non-test implementation `.ts` file MUST have a sibling `.md` file with the six required sections (Purpose, Features, Usage: Basic, Usage: Advanced, API Reference, Implementation Notes), ≥200 words excluding code.
**Verified by:** `bun run docs:check` at the package level (script is wired in `package.json`).