Commit a3bb551ea70 for nodejs

commit a3bb551ea70c7e0b1377c5b97d5d186cd66095cd
Author: James M Snell <jasnell@gmail.com>
Date:   Fri Sep 18 03:06:27 2026 +0000

    perf_hooks: add histogram.diff()

    Getting the values recorded during an interval requires calling
    `reset()`, which removes them for every other user of the histogram,
    or copying and subtracting histograms, which silently produces a wrong
    result when the source was reset in between.

    Add `histogram.diff(other)`, which returns a new read-only `Histogram`
    containing the values recorded after `other`, an earlier snapshot of
    the histogram, was taken. Neither histogram is changed. Unlike
    `subtract()`, it verifies that both histograms have the same layout,
    and it throws instead of clamping when `other` contains values that
    the histogram does not.

    Add `histogram.resetCount`, the number of calls to `reset()` and
    `subtract()`, which `snapshot()` copies. `diff()` throws when the
    counts differ, so a reset between two snapshots is detected even when
    every bucket has since grown past its previous count.

    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/doc/api/perf_hooks.md b/doc/api/perf_hooks.md
index b4a9304dc27..790e9e262c5 100644
--- a/doc/api/perf_hooks.md
+++ b/doc/api/perf_hooks.md
@@ -2199,6 +2199,52 @@ added:
 Returns the number of recorded values that fall within the equivalent
 value range of the given value.

+### `histogram.diff(other)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `other` {Histogram} An earlier snapshot of this histogram.
+* Returns: {Histogram}
+
+Returns a new {Histogram} containing the values recorded in this histogram after
+`other` was taken. Neither histogram is changed. To get the values recorded
+during each interval without calling `reset()`, compute each difference from a
+snapshot and keep that snapshot as the baseline for the next interval:
+
+```js
+const { monitorEventLoopDelay } = require('node:perf_hooks');
+
+const histogram = monitorEventLoopDelay();
+histogram.enable();
+let previous = histogram.snapshot();
+
+setInterval(() => {
+  const current = histogram.snapshot();
+  // After a reset, use everything recorded since the reset.
+  const delta = current.resetCount === previous.resetCount ?
+    current.diff(previous) : current;
+  console.log(delta.percentile(99));
+  previous = current;
+}, 10_000);
+```
+
+The `count`, `exceeds`, and bucket counts of the returned histogram are the
+differences between the two histograms. Its `min` and `max` are computed from
+the buckets of the difference, it has no EWMA state, and its `resetCount` is
+`0`.
+
+This method throws:
+
+* `ERR_INVALID_ARG_VALUE` if `other` has a different `lowest`, `highest`, or
+  `figures` configuration.
+* `ERR_INVALID_STATE` if values have been removed from this histogram since
+  `other` was taken, which is the case when the `resetCount` of the two
+  histograms differs.
+* `ERR_INVALID_ARG_VALUE` if `other` contains values that are not in this
+  histogram, for example because the histograms were passed in the wrong order.
+
 ### `histogram.exceeds`

 <!-- YAML
@@ -2672,7 +2718,21 @@ boundaries are equal has an infinite density.
 added: v11.10.0
 -->

-Resets the collected histogram data.
+Resets the collected histogram data and increments `histogram.resetCount`.
+
+### `histogram.resetCount`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {number}
+
+The number of times values have been removed from this histogram by `reset()`
+or, for a {RecordableHistogram}, `subtract()`. A snapshot has the `resetCount`
+of its source at the time it was taken, so comparing the `resetCount` of two
+snapshots shows whether the source was reset between them. See
+[`histogram.diff()`][].

 ### `histogram.skewness`

@@ -2878,7 +2938,7 @@ added:

 Subtracts the values of `other` from this histogram. Both histograms should
 have compatible configurations. Bucket counts that would become negative
-are clamped to zero.
+are clamped to zero. Increments `histogram.resetCount`.

 ## Class: `SlidingWindowHistogram`

@@ -3357,6 +3417,7 @@ dns.promises.resolve('localhost');
 [Worker threads]: worker_threads.md#worker-threads
 [`'exit'`]: process.md#event-exit
 [`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options
+[`histogram.diff()`]: #histogramdiffother
 [`histogram.export()`]: #histogramexport
 [`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions
 [`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2
diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js
index 8983289e075..12c888c5421 100644
--- a/lib/internal/histogram.js
+++ b/lib/internal/histogram.js
@@ -679,6 +679,18 @@ class Histogram {
     this[kHandle]?.reset();
   }

+  /**
+   * The number of times values have been removed from the histogram by
+   * `reset()` or `subtract()`.
+   * @readonly
+   * @type {number}
+   */
+  get resetCount() {
+    if (!isHistogram(this))
+      throw new ERR_INVALID_THIS('Histogram');
+    return this[kHandle]?.resetCount();
+  }
+
   /**
    * Returns a new, independent histogram containing a copy of this
    * histogram's current state. Values cannot be recorded into the returned
@@ -691,6 +703,20 @@ class Histogram {
     return new ClonedHistogram(this[kHandle].snapshot());
   }

+  /**
+   * Returns a new histogram containing the values recorded in this histogram
+   * after `other`, an earlier snapshot of it, was taken.
+   * @param {Histogram} other
+   * @returns {Histogram}
+   */
+  diff(other) {
+    if (!isHistogram(this))
+      throw new ERR_INVALID_THIS('Histogram');
+    if (!isHistogram(other))
+      throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other);
+    return new ClonedHistogram(this[kHandle].diff(other[kHandle]));
+  }
+
   [kClone]() {
     const handle = this[kHandle];
     return {
diff --git a/src/histogram-inl.h b/src/histogram-inl.h
index e2704b499f3..87f92c492ef 100644
--- a/src/histogram-inl.h
+++ b/src/histogram-inl.h
@@ -42,6 +42,7 @@ void Histogram::Reset() {
   RwLock::ScopedWriteLock lock(mutex_);
   hdr_reset(histogram_.get());
   InvalidateRecordedSnapshot();
+  reset_count_++;
   exceeds_ = 0;
   prev_ = 0;
   ewma_mean_ = 0;
@@ -90,6 +91,11 @@ size_t Histogram::Exceeds() const {
   return exceeds_;
 }

+uint64_t Histogram::ResetCount() const {
+  RwLock::ScopedReadLock lock(mutex_);
+  return reset_count_;
+}
+
 int64_t Histogram::Min() const {
   RwLock::ScopedReadLock lock(mutex_);
   return hdr_min(histogram_.get());
diff --git a/src/histogram.cc b/src/histogram.cc
index a49f94f12dd..85c69736fb3 100644
--- a/src/histogram.cc
+++ b/src/histogram.cc
@@ -93,17 +93,22 @@ void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) {
 }
 }  // 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;
+std::shared_ptr<Histogram> Histogram::CreateWithSameLayout() const {
+  // The layout is fixed when the histogram is created, so it can be read
+  // without holding the lock.
+  hdr_histogram* histogram;
   if (hdr_init(histogram_->lowest_discernible_value,
                histogram_->highest_trackable_value,
                histogram_->significant_figures,
-               &copy) != 0) {
+               &histogram) != 0) {
     return {};
   }
-  auto clone = std::make_shared<Histogram>(HistogramPointer(copy), Options{});
+  return std::make_shared<Histogram>(HistogramPointer(histogram), Options{});
+}
+
+std::shared_ptr<Histogram> Histogram::Clone() const {
+  std::shared_ptr<Histogram> clone = CreateWithSameLayout();
+  if (!clone) return {};

   // Every member that holds recorded or statistical state must be copied
   // here. The recorded snapshot cache is not copied; the clone builds its own
@@ -112,6 +117,7 @@ std::shared_ptr<Histogram> Histogram::Clone() const {
   CopyRecordedData(clone->histogram_.get(), histogram_.get());
   clone->prev_ = prev_;
   clone->exceeds_ = exceeds_;
+  clone->reset_count_ = reset_count_;
   clone->ewma_alpha_ = ewma_alpha_;
   clone->ewma_mean_ = ewma_mean_;
   clone->ewma_variance_ = ewma_variance_;
@@ -121,6 +127,59 @@ std::shared_ptr<Histogram> Histogram::Clone() const {
   return clone;
 }

+std::shared_ptr<Histogram> Histogram::Diff(const Histogram& other,
+                                           DiffError* error) const {
+  // Counts are subtracted index by index, so both histograms must map values
+  // to the same indexes. None of these fields change after creation.
+  if (!IsCompatible(other) || histogram_->normalizing_index_offset !=
+                                  other.histogram_->normalizing_index_offset) {
+    *error = DiffError::kIncompatible;
+    return {};
+  }
+
+  std::shared_ptr<Histogram> diff = CreateWithSameLayout();
+  if (!diff) {
+    *error = DiffError::kOutOfMemory;
+    return {};
+  }
+
+  // Only the recorded values and the exceeds count carry over. EWMA and timing
+  // state cannot be subtracted.
+  uint64_t reset_count;
+  {
+    RwLock::ScopedReadLock lock(mutex_);
+    CopyRecordedData(diff->histogram_.get(), histogram_.get());
+    diff->exceeds_ = exceeds_;
+    reset_count = reset_count_;
+  }
+
+  // `diff` is not shared yet, so only the lock of `other` is needed from here
+  // on. Never holding both locks at once avoids lock ordering issues.
+  RwLock::ScopedReadLock lock(other.mutex_);
+  if (reset_count != other.reset_count_) {
+    *error = DiffError::kReset;
+    return {};
+  }
+  if (diff->exceeds_ < other.exceeds_) {
+    *error = DiffError::kNotEarlier;
+    return {};
+  }
+
+  hdr_histogram* target = diff->histogram_.get();
+  const hdr_histogram* source = other.histogram_.get();
+  for (int32_t i = 0; i < target->counts_len; i++) {
+    if (target->counts[i] < source->counts[i]) {
+      *error = DiffError::kNotEarlier;
+      return {};
+    }
+    target->counts[i] -= source->counts[i];
+  }
+  diff->exceeds_ -= other.exceeds_;
+  hdr_reset_internal_counters(target);
+  *error = DiffError::kNone;
+  return diff;
+}
+
 void Histogram::MemoryInfo(MemoryTracker* tracker) const {
   tracker->TrackFieldWithSize("histogram", GetMemorySize());
   tracker->TrackFieldWithSize("qrde_snapshot",
@@ -313,6 +372,7 @@ double Histogram::Subtract(const Histogram& other) {
     }
     hdr_reset_internal_counters(histogram_.get());
     InvalidateRecordedSnapshot();
+    reset_count_++;
     exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0;
     return static_cast<double>(dropped);
   };
@@ -1963,6 +2023,8 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local<FunctionTemplate> tmpl) {
                             &fast_get_ewma_error_rate_);
   SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport);
   SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot);
+  SetProtoMethodNoSideEffect(isolate, tmpl, "diff", DoDiff);
+  SetProtoMethodNoSideEffect(isolate, tmpl, "resetCount", GetResetCount);
   SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_);
 }

@@ -2013,6 +2075,8 @@ void HistogramImpl::RegisterExternalReferences(
   registry->Register(GetEwmaErrorRate);
   registry->Register(DoExport);
   registry->Register(DoSnapshot);
+  registry->Register(DoDiff);
+  registry->Register(GetResetCount);
   registry->Register(fast_get_ewma_mean_);
   registry->Register(fast_get_ewma_stddev_);
   registry->Register(fast_get_ewma_error_rate_);
@@ -3173,6 +3237,39 @@ void HistogramImpl::DoSnapshot(const FunctionCallbackInfo<Value>& args) {
   if (result) args.GetReturnValue().Set(result->object());
 }

+void HistogramImpl::DoDiff(const FunctionCallbackInfo<Value>& args) {
+  Environment* env = Environment::GetCurrent(args);
+  HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
+  HistogramImpl* other = HistogramImpl::FromJSObject(args[0]);
+  Histogram::DiffError error;
+  std::shared_ptr<Histogram> diff =
+      (*histogram)->Diff(*(other->histogram()), &error);
+  switch (error) {
+    case Histogram::DiffError::kNone:
+      break;
+    case Histogram::DiffError::kOutOfMemory:
+      return THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
+    case Histogram::DiffError::kIncompatible:
+      return THROW_ERR_INVALID_ARG_VALUE(
+          env, "other must have the same configuration as the histogram");
+    case Histogram::DiffError::kReset:
+      return THROW_ERR_INVALID_STATE(
+          env, "Values were removed from the histogram after other was taken");
+    case Histogram::DiffError::kNotEarlier:
+      return THROW_ERR_INVALID_ARG_VALUE(
+          env, "other contains values that are not in the histogram");
+  }
+
+  BaseObjectPtr<HistogramBase> result =
+      HistogramBase::Create(env, std::move(diff));
+  if (result) args.GetReturnValue().Set(result->object());
+}
+
+void HistogramImpl::GetResetCount(const FunctionCallbackInfo<Value>& args) {
+  HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
+  args.GetReturnValue().Set(static_cast<double>((*histogram)->ResetCount()));
+}
+
 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 473ebf97136..93fe2b267d4 100644
--- a/src/histogram.h
+++ b/src/histogram.h
@@ -65,6 +65,23 @@ class Histogram : public MemoryRetainer {
   // if the copy cannot be allocated.
   std::shared_ptr<Histogram> Clone() const;

+  enum class DiffError {
+    kNone,
+    kOutOfMemory,
+    // `other` has a different layout.
+    kIncompatible,
+    // Values were removed from this histogram after `other` was taken.
+    kReset,
+    // `other` contains values that this histogram does not.
+    kNotEarlier,
+  };
+
+  // Returns a new histogram containing the values recorded in this histogram
+  // after `other`, an earlier copy of it, was taken. Returns nullptr and sets
+  // `error` if the difference cannot be computed.
+  std::shared_ptr<Histogram> Diff(const Histogram& other,
+                                  DiffError* error) const;
+
   Histogram(HistogramPointer histogram, const Options& options);
   virtual ~Histogram() = default;

@@ -80,6 +97,7 @@ class Histogram : public MemoryRetainer {
   inline int64_t Percentile(double percentile) const;
   inline size_t Exceeds() const;
   inline size_t Count() const;
+  inline uint64_t ResetCount() const;

   inline uint64_t RecordDelta();

@@ -165,10 +183,13 @@ class Histogram : public MemoryRetainer {
   inline void UpdateEwma(double value);
   inline void InvalidateRecordedSnapshot();
   size_t GetCachedRecordedSnapshotMemorySize() const;
+  std::shared_ptr<Histogram> CreateWithSameLayout() const;

   HistogramPointer histogram_;
   uint64_t prev_ = 0;
   size_t exceeds_ = 0;
+  // Incremented whenever recorded values are removed by Reset() or Subtract().
+  uint64_t reset_count_ = 0;

   // EWMA state (active when ewma_alpha_ > 0)
   double ewma_alpha_ = 0;
@@ -242,6 +263,8 @@ class HistogramImpl {
   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 DoDiff(const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void GetResetCount(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-diff.js b/test/parallel/test-perf-hooks-histogram-diff.js
new file mode 100644
index 00000000000..45dc375d2d6
--- /dev/null
+++ b/test/parallel/test-perf-hooks-histogram-diff.js
@@ -0,0 +1,169 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { setTimeout: delay } = require('timers/promises');
+const {
+  createHistogram,
+  createSlidingWindowHistogram,
+  importHistogram,
+  monitorEventLoopDelay,
+} = require('perf_hooks');
+
+{
+  const histogram = createHistogram({ highest: 1000, halfLife: 4, threshold: 50 });
+  for (let i = 1; i <= 10; i++) histogram.record(i);
+  histogram.record(2000);
+  const previous = histogram.snapshot();
+  for (let i = 101; i <= 120; i++) histogram.record(i);
+  histogram.record(3000);
+  histogram.record(4000);
+  const current = histogram.snapshot();
+  const exportedPrevious = previous.export();
+  const exportedCurrent = current.export();
+
+  const delta = current.diff(previous);
+  assert.strictEqual(delta.constructor.name, 'Histogram');
+  assert.strictEqual(delta.record, undefined);
+  assert.strictEqual(delta.count, 20);
+  assert.strictEqual(delta.exceeds, 2);
+  assert.strictEqual(delta.min, 101);
+  assert.strictEqual(delta.max, 120);
+  assert.strictEqual(delta.resetCount, 0);
+
+  // The difference has the same distribution as a histogram of the values
+  // recorded between the snapshots, and no EWMA state.
+  const expected = createHistogram({ highest: 1000 });
+  for (let i = 101; i <= 120; i++) expected.record(i);
+  assert.deepStrictEqual(delta.percentiles, expected.percentiles);
+  assert.strictEqual(delta.ewmaMean, 0);
+  assert.strictEqual(delta.ewmaStddev, 0);
+  assert.strictEqual(delta.ewmaErrorRate, 0);
+
+  // Neither histogram is changed.
+  assert.deepStrictEqual(previous.export(), exportedPrevious);
+  assert.deepStrictEqual(current.export(), exportedCurrent);
+
+  assert.strictEqual(histogram.diff(previous).count, 20);
+  assert.strictEqual(histogram.diff(histogram).count, 0);
+  assert.strictEqual(current.diff(current).count, 0);
+
+  // Reversed arguments.
+  assert.throws(() => previous.diff(current), {
+    code: 'ERR_INVALID_ARG_VALUE',
+  });
+}
+
+{
+  // Consumers with different intervals each keep their own previous snapshot.
+  const histogram = createHistogram();
+  const consumers = [3, 10].map((interval) => ({
+    interval,
+    previous: histogram.snapshot(),
+    pending: 0,
+    total: 0,
+  }));
+  for (let i = 1; i <= 100; i++) {
+    histogram.record(i);
+    for (const consumer of consumers) {
+      consumer.pending++;
+      if (i % consumer.interval !== 0) continue;
+      const current = histogram.snapshot();
+      const delta = current.diff(consumer.previous);
+      assert.strictEqual(delta.count, consumer.pending);
+      assert.strictEqual(delta.min, i - consumer.pending + 1);
+      assert.strictEqual(delta.max, i);
+      consumer.total += delta.count;
+      consumer.previous = current;
+      consumer.pending = 0;
+    }
+  }
+  assert.strictEqual(consumers[0].total, 99);
+  assert.strictEqual(consumers[1].total, 100);
+}
+
+{
+  const histogram = createHistogram();
+  assert.strictEqual(histogram.resetCount, 0);
+  histogram.record(1);
+  histogram.recordCorrected(100, 10);
+  histogram.add(createHistogram());
+  assert.strictEqual(histogram.resetCount, 0);
+
+  histogram.reset();
+  assert.strictEqual(histogram.resetCount, 1);
+  histogram.record(1);
+  const previous = histogram.snapshot();
+  histogram.reset();
+  assert.strictEqual(histogram.resetCount, 2);
+  assert.strictEqual(previous.resetCount, 1);
+
+  // A reset is detected even when every count has grown past its previous
+  // value since.
+  for (let i = 0; i < 10; i++) histogram.record(1);
+  assert.throws(() => histogram.diff(previous), {
+    code: 'ERR_INVALID_STATE',
+  });
+
+  // subtract() also removes values. Subtracting an empty histogram leaves
+  // every count unchanged.
+  const snapshot = histogram.snapshot();
+  assert.strictEqual(snapshot.resetCount, 2);
+  histogram.subtract(createHistogram());
+  assert.strictEqual(histogram.resetCount, 3);
+  assert.throws(() => histogram.diff(snapshot), {
+    code: 'ERR_INVALID_STATE',
+  });
+}
+
+{
+  const histogram = createHistogram();
+  for (const options of [{ lowest: 2 }, { highest: 1000 }, { figures: 2 }]) {
+    assert.throws(() => histogram.diff(createHistogram(options)), {
+      code: 'ERR_INVALID_ARG_VALUE',
+    });
+  }
+
+  // Histograms with a different normalizing index offset map values to
+  // different indexes.
+  const data = createHistogram().export();
+  const offset = Buffer.from(data).indexOf(Buffer.from([0x06, 0x00, 0x07, 0x00]));
+  assert.notStrictEqual(offset, -1);
+  data[offset + 3] = 1;
+  assert.throws(() => histogram.diff(importHistogram(data)), {
+    code: 'ERR_INVALID_ARG_VALUE',
+  });
+
+  assert.throws(() => histogram.diff.call({}, histogram), {
+    code: 'ERR_INVALID_THIS',
+  });
+  const { get } = Object.getOwnPropertyDescriptor(
+    Object.getPrototypeOf(histogram.snapshot()), 'resetCount');
+  assert.throws(() => get.call({}), { code: 'ERR_INVALID_THIS' });
+  const window = createSlidingWindowHistogram({ chunks: 1, recordsPerChunk: 1 });
+  for (const other of [undefined, null, {}, 1, window]) {
+    assert.throws(() => histogram.diff(other), {
+      code: 'ERR_INVALID_ARG_TYPE',
+    });
+  }
+}
+
+(async () => {
+  const histogram = monitorEventLoopDelay({ samplePerIteration: true });
+  histogram.enable();
+  while (histogram.count < 2) await delay(1);
+  const previous = histogram.snapshot();
+  while (histogram.count < previous.count + 3) await delay(1);
+
+  // Samples are only recorded while the event loop is running.
+  const current = histogram.snapshot();
+  assert.strictEqual(current.diff(previous).count,
+                     current.count - previous.count);
+
+  histogram.disable();
+  histogram.reset();
+  assert.strictEqual(histogram.resetCount, 1);
+  assert.throws(() => histogram.diff(previous), {
+    code: 'ERR_INVALID_STATE',
+  });
+})().then(common.mustCall());
diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts
index 043be9e7118..98a176ea1ac 100644
--- a/typings/internalBinding/performance.d.ts
+++ b/typings/internalBinding/performance.d.ts
@@ -42,6 +42,8 @@ declare namespace InternalPerformanceBinding {
     ewmaStddev(): number;
     ewmaErrorRate(): number;
     snapshot(): Histogram;
+    diff(other: HistogramBase): Histogram;
+    resetCount(): number;
   }

   interface ELDHistogram extends HistogramBase {