Commit b9e257f5cb0 for nodejs
commit b9e257f5cb0c7b8067869a1dffdf63e8eaf134f9
Author: James M Snell <jasnell@gmail.com>
Date: Fri Sep 18 02:11:33 2026 +0000
perf_hooks: add histogram.snapshot()
Cloning a histogram with `structuredClone()` or `postMessage()` shares
the native histogram instead of copying it. Capturing the state of a
histogram at a point in time, while other code keeps recording into
it, requires serializing it with `export()` and parsing the result
with `importHistogram()`.
Add `histogram.snapshot()`, which returns a new, independent
`Histogram` containing a copy of the histogram's configuration,
recorded values, `exceeds` count, and EWMA state, without the
serialization round trip. Values cannot be recorded into the returned
histogram. The method is available on all histograms, including
`RecordableHistogram` and `ELDHistogram` instances.
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: OpenCode
PR-URL: https://github.com/nodejs/node/pull/66099
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/benchmark/perf_hooks/histogram-snapshot.js b/benchmark/perf_hooks/histogram-snapshot.js
new file mode 100644
index 00000000000..79b13fecb0f
--- /dev/null
+++ b/benchmark/perf_hooks/histogram-snapshot.js
@@ -0,0 +1,24 @@
+'use strict';
+
+const assert = require('assert');
+const common = require('../common.js');
+const { createHistogram } = require('perf_hooks');
+
+const bench = common.createBenchmark(main, {
+ n: [1e3],
+ highest: [1e6, Number.MAX_SAFE_INTEGER],
+ figures: [2, 3],
+});
+
+let snapshot;
+
+function main({ n, highest, figures }) {
+ const histogram = createHistogram({ highest, figures });
+ for (let i = 1; i <= 1e4; i++) histogram.record(i);
+
+ bench.start();
+ for (let i = 0; i < n; i++) snapshot = histogram.snapshot();
+ bench.end(n);
+
+ assert.strictEqual(snapshot.count, 1e4);
+}
diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md
index fa7b9f7ba5a..b4a9304dc27 100644
--- a/doc/api/perf_hooks.md
+++ b/doc/api/perf_hooks.md
@@ -2689,6 +2689,38 @@ distribution. A positive value indicates a right-skewed distribution
(longer right tail, common for latency data); a negative value
indicates a left-skewed distribution.
+### `histogram.snapshot()`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Returns: {Histogram}
+
+Returns a new, independent {Histogram} containing a copy of this histogram's
+current state: its configuration, recorded values, `exceeds` count, and EWMA
+state. Values recorded into this histogram after this method returns, and later
+calls to `reset()`, do not change the returned histogram. This provides a stable
+view of a histogram that is still recording, such as an enabled {ELDHistogram}.
+
+Values cannot be recorded into the returned histogram. Taking a snapshot copies
+every bucket, so both its time and memory cost depend on the histogram's
+`lowest`, `highest`, and `figures` configuration rather than on the number of
+recorded values.
+
+```js
+const { monitorEventLoopDelay } = require('node:perf_hooks');
+
+const histogram = monitorEventLoopDelay();
+histogram.enable();
+
+setTimeout(() => {
+ const snapshot = histogram.snapshot();
+ console.log(snapshot.percentile(99));
+ histogram.disable();
+}, 1000);
+```
+
### `histogram.stddev`
<!-- YAML
diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js
index 1cc787404fd..8983289e075 100644
--- a/lib/internal/histogram.js
+++ b/lib/internal/histogram.js
@@ -679,6 +679,18 @@ class Histogram {
this[kHandle]?.reset();
}
+ /**
+ * Returns a new, independent histogram containing a copy of this
+ * histogram's current state. Values cannot be recorded into the returned
+ * histogram.
+ * @returns {Histogram}
+ */
+ snapshot() {
+ if (!isHistogram(this))
+ throw new ERR_INVALID_THIS('Histogram');
+ return new ClonedHistogram(this[kHandle].snapshot());
+ }
+
[kClone]() {
const handle = this[kHandle];
return {
diff --git a/src/histogram.cc b/src/histogram.cc
index 1b9560bea95..765397be2e9 100644
--- a/src/histogram.cc
+++ b/src/histogram.cc
@@ -76,6 +76,51 @@ std::shared_ptr<Histogram> Histogram::Create(const Options& options) {
return std::make_shared<Histogram>(HistogramPointer(histogram), options);
}
+namespace {
+// Copies the recorded data of `source` into `target`, which must have been
+// initialized with the same layout. The caller must hold a lock that prevents
+// `source` from being modified during the copy.
+void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) {
+ CHECK_EQ(target->counts_len, source->counts_len);
+ target->min_value = source->min_value;
+ target->max_value = source->max_value;
+ target->normalizing_index_offset = source->normalizing_index_offset;
+ target->conversion_ratio = source->conversion_ratio;
+ target->total_count = source->total_count;
+ std::memcpy(target->counts,
+ source->counts,
+ source->counts_len * sizeof(*source->counts));
+}
+} // namespace
+
+std::shared_ptr<Histogram> Histogram::Clone() const {
+ // The layout is fixed when the histogram is created, so the copy can be
+ // allocated without holding the lock.
+ hdr_histogram* copy;
+ if (hdr_init(histogram_->lowest_discernible_value,
+ histogram_->highest_trackable_value,
+ histogram_->significant_figures,
+ ©) != 0) {
+ return {};
+ }
+ auto clone = std::make_shared<Histogram>(HistogramPointer(copy), Options{});
+
+ // Every member that holds recorded or statistical state must be copied
+ // here. The recorded snapshot cache is not copied; the clone builds its own
+ // on demand.
+ RwLock::ScopedReadLock lock(mutex_);
+ CopyRecordedData(clone->histogram_.get(), histogram_.get());
+ clone->prev_ = prev_;
+ clone->exceeds_ = exceeds_;
+ clone->ewma_alpha_ = ewma_alpha_;
+ clone->ewma_mean_ = ewma_mean_;
+ clone->ewma_variance_ = ewma_variance_;
+ clone->ewma_initialized_ = ewma_initialized_;
+ clone->threshold_ = threshold_;
+ clone->ewma_error_rate_ = ewma_error_rate_;
+ return clone;
+}
+
void Histogram::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackFieldWithSize("histogram", GetMemorySize());
tracker->TrackFieldWithSize("qrde_snapshot",
@@ -158,16 +203,7 @@ Histogram::RecordedSnapshotSource Histogram::GetRecordedSnapshotSource(
return source;
}
- CHECK_EQ(source.histogram->counts_len, histogram_->counts_len);
- source.histogram->min_value = histogram_->min_value;
- source.histogram->max_value = histogram_->max_value;
- source.histogram->normalizing_index_offset =
- histogram_->normalizing_index_offset;
- source.histogram->conversion_ratio = histogram_->conversion_ratio;
- source.histogram->total_count = histogram_->total_count;
- std::memcpy(source.histogram->counts,
- histogram_->counts,
- histogram_->counts_len * sizeof(*histogram_->counts));
+ CopyRecordedData(source.histogram.get(), histogram_.get());
return source;
}
@@ -1926,6 +1962,7 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local<FunctionTemplate> tmpl) {
GetEwmaErrorRate,
&fast_get_ewma_error_rate_);
SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport);
+ SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot);
SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_);
}
@@ -1975,6 +2012,7 @@ void HistogramImpl::RegisterExternalReferences(
registry->Register(GetEwmaStddev);
registry->Register(GetEwmaErrorRate);
registry->Register(DoExport);
+ registry->Register(DoSnapshot);
registry->Register(fast_get_ewma_mean_);
registry->Register(fast_get_ewma_stddev_);
registry->Register(fast_get_ewma_error_rate_);
@@ -3108,6 +3146,17 @@ void HistogramImpl::DoImport(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(obj);
}
+void HistogramImpl::DoSnapshot(const FunctionCallbackInfo<Value>& args) {
+ Environment* env = Environment::GetCurrent(args);
+ HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
+ std::shared_ptr<Histogram> snapshot = (*histogram)->Clone();
+ if (!snapshot) return THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
+
+ BaseObjectPtr<HistogramBase> result =
+ HistogramBase::Create(env, std::move(snapshot));
+ if (result) args.GetReturnValue().Set(result->object());
+}
+
void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
diff --git a/src/histogram.h b/src/histogram.h
index bc72fa36e10..f526e9cb1cc 100644
--- a/src/histogram.h
+++ b/src/histogram.h
@@ -61,6 +61,10 @@ class Histogram : public MemoryRetainer {
// Factory method that returns nullptr on hdr_init failure.
static std::shared_ptr<Histogram> Create(const Options& options);
+ // Returns an independent copy of this histogram's current state, or nullptr
+ // if the copy cannot be allocated.
+ std::shared_ptr<Histogram> Clone() const;
+
Histogram(HistogramPointer histogram, const Options& options);
virtual ~Histogram() = default;
@@ -237,6 +241,7 @@ class HistogramImpl {
static void GetEwmaErrorRate(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoExport(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoImport(const v8::FunctionCallbackInfo<v8::Value>& args);
+ static void DoSnapshot(const v8::FunctionCallbackInfo<v8::Value>& args);
static void FastReset(v8::Local<v8::Value> receiver);
static double FastGetCount(v8::Local<v8::Value> receiver);
diff --git a/test/parallel/test-perf-hooks-histogram-snapshot.js b/test/parallel/test-perf-hooks-histogram-snapshot.js
new file mode 100644
index 00000000000..ab554373bbc
--- /dev/null
+++ b/test/parallel/test-perf-hooks-histogram-snapshot.js
@@ -0,0 +1,138 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { setTimeout: delay } = require('timers/promises');
+const { inspect } = require('util');
+const {
+ createHistogram,
+ monitorEventLoopDelay,
+} = require('perf_hooks');
+
+function assertSameState(actual, expected) {
+ assert.strictEqual(actual.countBigInt, expected.countBigInt);
+ assert.strictEqual(actual.minBigInt, expected.minBigInt);
+ assert.strictEqual(actual.maxBigInt, expected.maxBigInt);
+ assert.strictEqual(actual.exceedsBigInt, expected.exceedsBigInt);
+ assert.strictEqual(actual.mean, expected.mean);
+ assert.strictEqual(actual.stddev, expected.stddev);
+ assert.strictEqual(actual.ewmaMean, expected.ewmaMean);
+ assert.strictEqual(actual.ewmaStddev, expected.ewmaStddev);
+ assert.strictEqual(actual.ewmaErrorRate, expected.ewmaErrorRate);
+ assert.deepStrictEqual(actual.percentilesBigInt, expected.percentilesBigInt);
+ // The export format also covers the layout, the raw min and max values,
+ // every bucket count, and the EWMA configuration.
+ assert.deepStrictEqual(actual.export(), expected.export());
+}
+
+function assertReadOnly(snapshot) {
+ assert.strictEqual(snapshot.constructor.name, 'Histogram');
+ assert.strictEqual(inspect(snapshot, { depth: -1 }), '[Histogram]');
+ for (const name of [
+ 'record',
+ 'recordDelta',
+ 'recordCorrected',
+ 'add',
+ 'subtract',
+ 'enable',
+ 'disable',
+ ]) {
+ assert.strictEqual(snapshot[name], undefined);
+ }
+}
+
+{
+ const histogram = createHistogram({
+ lowest: 1,
+ highest: 1000,
+ figures: 2,
+ halfLife: 8,
+ threshold: 50,
+ });
+ for (let i = 1; i <= 100; i++) histogram.record(i * 7);
+ histogram.record(1e6);
+ assert.strictEqual(histogram.exceeds, 1);
+ assert.ok(histogram.ewmaErrorRate > 0);
+
+ const snapshot = histogram.snapshot();
+ assert.notStrictEqual(snapshot, histogram);
+ assertReadOnly(snapshot);
+ assertSameState(snapshot, histogram);
+ assert.strictEqual(histogram.ksTest(snapshot), 0);
+
+ // A snapshot can be snapshotted and cloned.
+ const nested = snapshot.snapshot();
+ assertReadOnly(nested);
+ assertSameState(nested, snapshot);
+ assertSameState(structuredClone(snapshot), snapshot);
+
+ // Changes to the source histogram do not change the snapshot.
+ const exported = snapshot.export();
+ histogram.record(3);
+ histogram.recordCorrected(900, 100);
+ histogram.record(1e6);
+ assert.deepStrictEqual(snapshot.export(), exported);
+ assert.strictEqual(snapshot.count, 100);
+ assert.strictEqual(snapshot.exceeds, 1);
+
+ histogram.reset();
+ assert.strictEqual(histogram.count, 0);
+ assert.deepStrictEqual(snapshot.export(), exported);
+
+ // Resetting a snapshot does not change the source histogram or other
+ // snapshots.
+ histogram.record(5);
+ const current = histogram.snapshot();
+ snapshot.reset();
+ assert.strictEqual(snapshot.count, 0);
+ assert.strictEqual(histogram.count, 1);
+ assert.strictEqual(current.count, 1);
+ assert.deepStrictEqual(nested.export(), exported);
+}
+
+{
+ // Empty histograms, with and without EWMA state.
+ for (const options of [undefined, { halfLife: 10, threshold: 1 }]) {
+ const histogram = createHistogram(options);
+ const snapshot = histogram.snapshot();
+ assertReadOnly(snapshot);
+ assert.strictEqual(snapshot.count, 0);
+ assert.strictEqual(snapshot.minBigInt, 9223372036854775807n);
+ assert.strictEqual(snapshot.maxBigInt, 0n);
+ assertSameState(snapshot, histogram);
+ }
+}
+
+{
+ const histogram = createHistogram();
+ assert.throws(() => histogram.snapshot.call({}), {
+ code: 'ERR_INVALID_THIS',
+ });
+}
+
+async function testEventLoopDelay(options) {
+ const histogram = monitorEventLoopDelay(options);
+ histogram.enable();
+ while (histogram.count < 3) await delay(1);
+
+ // Samples are only recorded while the event loop is running, so none can
+ // be added while this code runs synchronously.
+ const snapshot = histogram.snapshot();
+ assertReadOnly(snapshot);
+ assertSameState(snapshot, histogram);
+
+ const exported = snapshot.export();
+ const count = histogram.count;
+ while (histogram.count === count) await delay(1);
+ assert.deepStrictEqual(snapshot.export(), exported);
+
+ histogram.disable();
+ histogram.reset();
+ assert.strictEqual(histogram.count, 0);
+ assert.deepStrictEqual(snapshot.export(), exported);
+}
+
+(async () => {
+ await testEventLoopDelay({ resolution: 1 });
+ await testEventLoopDelay({ samplePerIteration: true });
+})().then(common.mustCall());
diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts
index da9af2df2cb..043be9e7118 100644
--- a/typings/internalBinding/performance.d.ts
+++ b/typings/internalBinding/performance.d.ts
@@ -41,6 +41,7 @@ declare namespace InternalPerformanceBinding {
ewmaMean(): number;
ewmaStddev(): number;
ewmaErrorRate(): number;
+ snapshot(): Histogram;
}
interface ELDHistogram extends HistogramBase {