@nxtedition/histogram
Advanced tools
+6
-6
@@ -22,12 +22,12 @@ export declare const BUCKETS: readonly number[]; | ||
| export type LagSampler = Disposable & { | ||
| /** Merge pending samples into the decayed histogram and return current percentiles + max. */ | ||
| /** Return decayed-accumulator percentiles, or null if nothing has been sampled yet. */ | ||
| sample(): LagHistogram | null; | ||
| }; | ||
| /** | ||
| * Event-loop lag sampler. An internal setInterval records overshoot into a | ||
| * per-tick histogram at `sampleIntervalMs` cadence; `sample()` folds that into | ||
| * an exponentially-decayed accumulator (per-call decay based on wall-clock | ||
| * elapsed since the previous `sample()`, so the half-life stays accurate even | ||
| * if the caller's tick cadence varies). | ||
| * Event-loop lag sampler backed by `perf_hooks.monitorEventLoopDelay`. Each | ||
| * `sample()` call reads the ELD's percentile CDF, folds an approximate bucket | ||
| * reconstruction into an exponentially-decayed accumulator, resets the ELD, | ||
| * and returns decayed percentiles. The 12h half-life semantics are preserved | ||
| * even though ELD itself has no decay operation. | ||
| */ | ||
| export declare function createLagSampler({ sampleIntervalMs, halfLifeMs, }?: LagSamplerOptions): LagSampler; |
+101
-20
@@ -0,1 +1,3 @@ | ||
| import { monitorEventLoopDelay, performance } from 'node:perf_hooks' | ||
| // Bucket upper bounds (milliseconds). Values above the highest finite bound | ||
@@ -139,12 +141,59 @@ // land in a final `Infinity` bucket and are reported as `BUCKET_MAX` for | ||
| // Credit a single value into the first bucket whose upper bound contains it. | ||
| function creditBucket(hist , valueMs , weight ) { | ||
| for (let i = 0; i < BUCKETS.length; i++) { | ||
| if (valueMs <= BUCKETS[i]) { | ||
| hist[i] += weight | ||
| return | ||
| } | ||
| } | ||
| } | ||
| // Distribute `weight` uniformly across the buckets that overlap the segment | ||
| // (lowerMs, upperMs]. Assuming a uniform distribution within a percentile | ||
| // segment is the best reconstruction we can do from ELD's CDF, and it's | ||
| // unbiased — attributing the entire segment to the upper-bound bucket | ||
| // (the previous approach) systematically inflates p95/p99. | ||
| function creditSegment(hist , lowerMs , upperMs , weight ) { | ||
| if (weight <= 0) { | ||
| return | ||
| } | ||
| const range = upperMs - lowerMs | ||
| if (!(range > 0) || !Number.isFinite(range)) { | ||
| // Degenerate segment (ELD reported the same percentile value on both | ||
| // ends, e.g. all samples equal) — put the whole weight in one bucket. | ||
| creditBucket(hist, upperMs, weight) | ||
| return | ||
| } | ||
| let startMs = lowerMs | ||
| for (let i = 0; i < BUCKETS.length; i++) { | ||
| const bucketTop = BUCKETS[i] | ||
| if (bucketTop < startMs) { | ||
| continue | ||
| } | ||
| if (startMs >= upperMs) { | ||
| return | ||
| } | ||
| const segTop = bucketTop === Infinity ? upperMs : Math.min(bucketTop, upperMs) | ||
| const fraction = (segTop - startMs) / range | ||
| if (fraction > 0) { | ||
| hist[i] += fraction * weight | ||
| } | ||
| startMs = segTop | ||
| if (bucketTop === Infinity) { | ||
| return | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Event-loop lag sampler. An internal setInterval records overshoot into a | ||
| * per-tick histogram at `sampleIntervalMs` cadence; `sample()` folds that into | ||
| * an exponentially-decayed accumulator (per-call decay based on wall-clock | ||
| * elapsed since the previous `sample()`, so the half-life stays accurate even | ||
| * if the caller's tick cadence varies). | ||
| * Event-loop lag sampler backed by `perf_hooks.monitorEventLoopDelay`. Each | ||
| * `sample()` call reads the ELD's percentile CDF, folds an approximate bucket | ||
| * reconstruction into an exponentially-decayed accumulator, resets the ELD, | ||
| * and returns decayed percentiles. The 12h half-life semantics are preserved | ||
| * even though ELD itself has no decay operation. | ||
| */ | ||
@@ -161,15 +210,7 @@ export function createLagSampler({ | ||
| } | ||
| const sampleHist = createHistogram() | ||
| const decayedHist = createHistogram() | ||
| let lastSampleAt = performance.now() | ||
| const timer = setInterval(() => { | ||
| const now = performance.now() | ||
| const delay = Math.max(0, now - lastSampleAt - sampleIntervalMs) | ||
| lastSampleAt = now | ||
| recordValue(sampleHist, delay) | ||
| }, sampleIntervalMs) | ||
| // Node.js only — don't keep the process alive just for sampling. | ||
| ;(timer ).unref?.() | ||
| const eld = monitorEventLoopDelay({ resolution: sampleIntervalMs }) | ||
| eld.enable() | ||
| const decayedHist = createHistogram() | ||
| let lastTickAt = performance.now() | ||
@@ -185,5 +226,45 @@ | ||
| decayHistogram(decayedHist, decayFactor) | ||
| mergeHistogram(decayedHist, sampleHist) | ||
| sampleHist.fill(0) | ||
| // Reconstruct approximate bucket counts from ELD's percentile CDF and | ||
| // fold them into the decayed accumulator. For each adjacent percentile | ||
| // pair (p_{i-1}, v_{i-1}), (p_i, v_i), `(p_i - p_{i-1}) / 100 * count` | ||
| // samples fall in (v_{i-1}, v_i]; we distribute that weight uniformly | ||
| // across the buckets overlapping the segment. ELD timings are in | ||
| // nanoseconds; we bucket in milliseconds. | ||
| // | ||
| // NOTE: `eld.percentiles` and `eld.reset()` are not an atomic pair — | ||
| // a sample recorded by the native timer between the two calls is | ||
| // wiped without being counted. ELD exposes no snapshot-and-clear | ||
| // primitive, so this loss is unavoidable; at `sampleIntervalMs=10` | ||
| // and typical sample cadences it's negligible. | ||
| const count = eld.count | ||
| if (count > 0) { | ||
| const entries = [...eld.percentiles.entries()].sort((a, b) => a[0] - b[0]) | ||
| if (entries.length === 0) { | ||
| // Defensive: ELD reports count > 0 but published no percentile | ||
| // points (would require a racey internal state). Credit the mean | ||
| // so we don't lose the samples entirely. | ||
| const meanMs = eld.mean / 1e6 | ||
| if (Number.isFinite(meanMs) && meanMs >= 0) { | ||
| creditBucket(decayedHist, meanMs, count) | ||
| } | ||
| } else { | ||
| const minMs = eld.min / 1e6 | ||
| let prevValueMs = Number.isFinite(minMs) && minMs >= 0 ? minMs : 0 | ||
| let prevPct = 0 | ||
| for (const [pct, valueNs] of entries) { | ||
| const weight = ((pct - prevPct) / 100) * count | ||
| prevPct = pct | ||
| if (weight <= 0) { | ||
| continue | ||
| } | ||
| const valueMs = valueNs / 1e6 | ||
| creditSegment(decayedHist, prevValueMs, valueMs, weight) | ||
| prevValueMs = valueMs | ||
| } | ||
| } | ||
| } | ||
| eld.reset() | ||
| const pcts = computePercentiles(decayedHist, [0.5, 0.95, 0.99]) | ||
@@ -197,5 +278,5 @@ const max = computeMax(decayedHist) | ||
| [Symbol.dispose]() { | ||
| clearInterval(timer) | ||
| eld.disable() | ||
| }, | ||
| } | ||
| } |
+2
-2
| { | ||
| "name": "@nxtedition/histogram", | ||
| "version": "1.0.1", | ||
| "version": "1.0.2", | ||
| "type": "module", | ||
@@ -37,3 +37,3 @@ "main": "lib/index.js", | ||
| }, | ||
| "gitHead": "45b14f89a7a87553f0da151ba47ca633110d0198" | ||
| "gitHead": "adfca68bef0a78441d030ee4d23afcfd497c878a" | ||
| } |
12341
32.96%269
39.38%