diff --git a/CHANGELOG.md b/CHANGELOG.md index 821e2fb2b..929f2d5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Increment the: ## [Unreleased] +* [METRICS SDK] Enforce a runtime minimum scale of `-11` for + `Base2ExponentialHistogramAggregation`, so a recording that spans the full + double range no longer downscales without end. + [#4353](https://github.com/open-telemetry/opentelemetry-cpp/pull/4353) * [CONFIGURATION] Apply general `attribute_limits` per individual limit field. If a model-specific limit is set it is used, otherwise the matching general limit, otherwise the model-specific default. Limit fields on diff --git a/sdk/include/opentelemetry/sdk/metrics/aggregation/aggregation_config.h b/sdk/include/opentelemetry/sdk/metrics/aggregation/aggregation_config.h index 8c8071584..ef11b5f60 100644 --- a/sdk/include/opentelemetry/sdk/metrics/aggregation/aggregation_config.h +++ b/sdk/include/opentelemetry/sdk/metrics/aggregation/aggregation_config.h @@ -72,6 +72,12 @@ constexpr std::int32_t kMaxScaleMin = -10; constexpr std::int32_t kMaxScaleMax = 20; constexpr std::size_t kMaxSizeMin = 2; +// Lower bound for the scale chosen at runtime by automatic downscaling. The specification only +// requires a "reasonable minimum". At -11 every finite double maps to a bucket index in [-1, 0], +// so the whole range fits in kMaxSizeMin buckets and no configuration needs more than its +// max_size. This bounds the runtime scale only; max_scale still starts no lower than kMaxScaleMin. +constexpr std::int32_t kMinRuntimeScale = -11; + class Base2ExponentialHistogramAggregationConfig : public AggregationConfig { public: diff --git a/sdk/include/opentelemetry/sdk/metrics/aggregation/base2_exponential_histogram_aggregation.h b/sdk/include/opentelemetry/sdk/metrics/aggregation/base2_exponential_histogram_aggregation.h index 5cebfe1c0..75d539f57 100644 --- a/sdk/include/opentelemetry/sdk/metrics/aggregation/base2_exponential_histogram_aggregation.h +++ b/sdk/include/opentelemetry/sdk/metrics/aggregation/base2_exponential_histogram_aggregation.h @@ -47,12 +47,20 @@ class Base2ExponentialHistogramAggregation : public Aggregation private: void AggregateIntoBuckets(std::unique_ptr &buckets, double value) noexcept; - void Downscale(uint32_t by) noexcept; + + /* Reduces the scale by up to `by`, stopping at kMinRuntimeScale. Returns the reduction that was + * actually applied, which callers must use to shift bucket indices. */ + uint32_t Downscale(uint32_t by) noexcept; mutable opentelemetry::common::SpinLockMutex lock_; Base2ExponentialHistogramPointData point_data_; Base2ExponentialHistogramIndexer indexer_; bool record_min_max_ = true; + // Keeps the scale floor warning off the record hot path after the first occurrence. + bool floor_warning_emitted_ = false; + // Same for the dropped-recording error, which repeats on every call once point data arrives with + // buckets that do not match its scale. + bool bucket_index_error_emitted_ = false; }; } // namespace metrics diff --git a/sdk/src/metrics/aggregation/base2_exponential_histogram_aggregation.cc b/sdk/src/metrics/aggregation/base2_exponential_histogram_aggregation.cc index 103f01f14..6b4074962 100644 --- a/sdk/src/metrics/aggregation/base2_exponential_histogram_aggregation.cc +++ b/sdk/src/metrics/aggregation/base2_exponential_histogram_aggregation.cc @@ -35,7 +35,10 @@ namespace uint32_t GetScaleReduction(int32_t start_index, int32_t end_index, size_t max_buckets) noexcept { uint32_t scale_reduction = 0; - while (static_cast(end_index) - start_index + 1 > static_cast(max_buckets)) + // Both indices have collapsed to -1 or 0 after 31 shifts, so further iterations cannot narrow + // the span; the bound keeps a degenerate max_buckets from spinning forever. + while (scale_reduction < 31 && + static_cast(end_index) - start_index + 1 > static_cast(max_buckets)) { start_index >>= 1; end_index >>= 1; @@ -94,6 +97,102 @@ void DownscaleBuckets(std::unique_ptr &buckets, u buckets->Downscale(by); } +// Guards point data that arrives through the public constructors with a smaller budget than the +// configuration validator would ever produce. A configured max_size is at least kMaxSizeMin, so +// this never allocates more buckets than the user asked for. +size_t BucketCapacity(size_t max_buckets) noexcept +{ + return (std::max)(max_buckets, kMaxSizeMin); +} + +// Point data handed to the public constructors carries buffers the caller sized, which can be +// narrower than the capacity this class guarantees; move the counts into a wide enough buffer. +void EnsureBucketCapacity(std::unique_ptr &buckets, + size_t capacity) noexcept +{ + if (!buckets || buckets->MaxSize() >= capacity) + { + return; + } + + auto widened = std::make_unique(capacity); + if (!buckets->Empty()) + { + for (int32_t index = buckets->StartIndex(); index <= buckets->EndIndex(); ++index) + { + const uint64_t count = buckets->Get(index); + if (count > 0 && !widened->Increment(index, count)) + { + OTEL_INTERNAL_LOG_ERROR( + "[Base2ExponentialHistogramAggregation::EnsureBucketCapacity] bucket index " + << index << " out of range; count " << count << " dropped. SDK invariant violation"); + assert(false && "EnsureBucketCapacity: bucket index out of range"); + } + } + } + buckets = std::move(widened); +} + +// Truncates `requested` to the reduction that can be applied without pushing `current_scale` below +// kMinRuntimeScale. Returns 0 once the floor is reached. +uint32_t ClampScaleReduction(int32_t current_scale, uint32_t requested) noexcept +{ + const int64_t headroom = static_cast(current_scale) - kMinRuntimeScale; + if (headroom <= 0) + { + return 0; + } + return static_cast((std::min)(static_cast(requested), headroom)); +} + +// Single entry point for scale reduction: clamps to the runtime floor, folds both bucket arrays by +// the clamped amount and moves scale_ by exactly that amount. Returns the reduction applied. +uint32_t ApplyDownscale(Base2ExponentialHistogramPointData &point_data, uint32_t requested) noexcept +{ + const uint32_t applied = ClampScaleReduction(point_data.scale_, requested); + if (applied == 0) + { + return 0; + } + + if (point_data.positive_buckets_) + { + DownscaleBuckets(point_data.positive_buckets_, applied); + } + if (point_data.negative_buckets_) + { + DownscaleBuckets(point_data.negative_buckets_, applied); + } + point_data.scale_ -= static_cast(applied); + return applied; +} + +// Folds `high_res` onto `target_scale`. The bucket shift has to match the scale delta exactly, so +// the runtime floor is deliberately not applied here: it bounds the reductions the SDK chooses, +// not the alignment of an operand that already sits lower. +void AlignToScale(Base2ExponentialHistogramPointData &high_res, int32_t target_scale) noexcept +{ + if (high_res.scale_ <= target_scale) + { + return; + } + + // AdaptingCircularBufferCounter::Downscale() saturates at 31, which is idempotent for int32_t + // indices, so a larger delta needs no special handling. + const int64_t delta = static_cast(high_res.scale_) - target_scale; + const uint32_t by = delta > 31 ? 31u : static_cast(delta); + + if (high_res.positive_buckets_) + { + DownscaleBuckets(high_res.positive_buckets_, by); + } + if (high_res.negative_buckets_) + { + DownscaleBuckets(high_res.negative_buckets_, by); + } + high_res.scale_ = target_scale; +} + } // namespace Base2ExponentialHistogramAggregation::Base2ExponentialHistogramAggregation( @@ -132,9 +231,9 @@ Base2ExponentialHistogramAggregation::Base2ExponentialHistogramAggregation( // Initialize buckets point_data_.positive_buckets_ = - std::make_unique(point_data_.max_buckets_); + std::make_unique(BucketCapacity(point_data_.max_buckets_)); point_data_.negative_buckets_ = - std::make_unique(point_data_.max_buckets_); + std::make_unique(BucketCapacity(point_data_.max_buckets_)); indexer_ = Base2ExponentialHistogramIndexer(point_data_.scale_); } @@ -164,6 +263,9 @@ Base2ExponentialHistogramAggregation::Base2ExponentialHistogramAggregation( point_data_.negative_buckets_ = std::make_unique(*point_data.negative_buckets_); } + + EnsureBucketCapacity(point_data_.positive_buckets_, BucketCapacity(point_data_.max_buckets_)); + EnsureBucketCapacity(point_data_.negative_buckets_, BucketCapacity(point_data_.max_buckets_)); } Base2ExponentialHistogramAggregation::Base2ExponentialHistogramAggregation( @@ -171,7 +273,10 @@ Base2ExponentialHistogramAggregation::Base2ExponentialHistogramAggregation( : point_data_{std::move(point_data)}, indexer_(point_data_.scale_), record_min_max_{point_data_.record_min_max_} -{} +{ + EnsureBucketCapacity(point_data_.positive_buckets_, BucketCapacity(point_data_.max_buckets_)); + EnsureBucketCapacity(point_data_.negative_buckets_, BucketCapacity(point_data_.max_buckets_)); +} void Base2ExponentialHistogramAggregation::Aggregate( int64_t value, @@ -221,45 +326,70 @@ void Base2ExponentialHistogramAggregation::AggregateIntoBuckets( { if (!buckets) { - buckets = std::make_unique(point_data_.max_buckets_); + buckets = + std::make_unique(BucketCapacity(point_data_.max_buckets_)); } if (buckets->MaxSize() == 0) { - buckets = std::make_unique(point_data_.max_buckets_); + buckets = + std::make_unique(BucketCapacity(point_data_.max_buckets_)); } const int32_t index = indexer_.ComputeIndex(value); - if (!buckets->Increment(index, 1)) + if (buckets->Increment(index, 1)) { - const int32_t start_index = (std::min)(buckets->StartIndex(), index); - const int32_t end_index = (std::max)(buckets->EndIndex(), index); - const uint32_t scale_reduction = - GetScaleReduction(start_index, end_index, point_data_.max_buckets_); - Downscale(scale_reduction); + return; + } - buckets->Increment(index >> scale_reduction, 1); + // A configured max_size is never below kMaxSizeMin, so the buffer capacity equals max_buckets_ + // and the failure above already means the span exceeds the budget. + const uint32_t scale_reduction = + GetScaleReduction((std::min)(buckets->StartIndex(), index), + (std::max)(buckets->EndIndex(), index), point_data_.max_buckets_); + + // Downscale() may stop short of the request at the floor, so shift the index by what was + // actually applied. + const uint32_t applied = Downscale(scale_reduction); + if (!buckets->Increment(index >> applied, 1) && !bucket_index_error_emitted_) + { + // Unreachable for buckets this class produced: at the floor every finite double maps to -1 or + // 0, which fits kMaxSizeMin. It is reachable through the point data constructors, so it is a + // caller input error rather than an assertable invariant. + bucket_index_error_emitted_ = true; + OTEL_INTERNAL_LOG_ERROR( + "[Base2ExponentialHistogramAggregation::AggregateIntoBuckets] bucket index " + << (index >> applied) << " does not fit the buckets supplied at scale " + << point_data_.scale_ + << "; recording dropped. Further drops on this aggregation are not logged"); } } -void Base2ExponentialHistogramAggregation::Downscale(uint32_t by) noexcept +uint32_t Base2ExponentialHistogramAggregation::Downscale(uint32_t by) noexcept { if (by == 0) { - return; + return 0; } - if (point_data_.positive_buckets_) + const uint32_t applied = ApplyDownscale(point_data_, by); + + if (applied < by && !floor_warning_emitted_) { - DownscaleBuckets(point_data_.positive_buckets_, by); + floor_warning_emitted_ = true; + OTEL_INTERNAL_LOG_WARN("[Base2ExponentialHistogramAggregation] scale " + << point_data_.scale_ << " reached the runtime minimum " + << kMinRuntimeScale + << "; recorded values now share buckets instead of downscaling further"); } - if (point_data_.negative_buckets_) + + if (applied == 0) { - DownscaleBuckets(point_data_.negative_buckets_, by); + return 0; } - point_data_.scale_ -= static_cast(by); indexer_ = Base2ExponentialHistogramIndexer(point_data_.scale_); + return applied; } // Merge A and B into a new circular buffer C. @@ -329,7 +459,6 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Merge( result_value.count_ = low_res.count_ + high_res.count_; result_value.sum_ = low_res.sum_ + high_res.sum_; result_value.zero_count_ = low_res.zero_count_ + high_res.zero_count_; - result_value.scale_ = (std::min)(low_res.scale_, high_res.scale_); result_value.max_buckets_ = low_res.max_buckets_ >= high_res.max_buckets_ ? low_res.max_buckets_ : high_res.max_buckets_; result_value.record_min_max_ = low_res.record_min_max_ && high_res.record_min_max_; @@ -340,16 +469,7 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Merge( result_value.max_ = (std::max)(low_res.max_, high_res.max_); } - { - auto scale_reduction = high_res.scale_ - low_res.scale_; - - if (scale_reduction > 0) - { - DownscaleBuckets(high_res.positive_buckets_, scale_reduction); - DownscaleBuckets(high_res.negative_buckets_, scale_reduction); - high_res.scale_ -= scale_reduction; - } - } + AlignToScale(high_res, low_res.scale_); // positive_buckets_ and negative_buckets_ share a single scale_; apply // the maximum required reduction across both bucket types. @@ -359,21 +479,18 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Merge( GetScaleReductionForUnion(*low_res.negative_buckets_, *high_res.negative_buckets_, result_value.max_buckets_)); - if (scale_reduction > 0) - { - DownscaleBuckets(low_res.positive_buckets_, scale_reduction); - DownscaleBuckets(high_res.positive_buckets_, scale_reduction); - DownscaleBuckets(low_res.negative_buckets_, scale_reduction); - DownscaleBuckets(high_res.negative_buckets_, scale_reduction); - low_res.scale_ -= static_cast(scale_reduction); - high_res.scale_ -= static_cast(scale_reduction); - result_value.scale_ -= static_cast(scale_reduction); - } + // Both operands share a scale after the alignment above, so one clamped amount applies to both. + const uint32_t applied = ClampScaleReduction(low_res.scale_, scale_reduction); + ApplyDownscale(low_res, applied); + ApplyDownscale(high_res, applied); + result_value.scale_ = low_res.scale_; - result_value.positive_buckets_ = std::make_unique(MergeBuckets( - result_value.max_buckets_, *low_res.positive_buckets_, *high_res.positive_buckets_)); - result_value.negative_buckets_ = std::make_unique(MergeBuckets( - result_value.max_buckets_, *low_res.negative_buckets_, *high_res.negative_buckets_)); + result_value.positive_buckets_ = std::make_unique( + MergeBuckets(BucketCapacity(result_value.max_buckets_), *low_res.positive_buckets_, + *high_res.positive_buckets_)); + result_value.negative_buckets_ = std::make_unique( + MergeBuckets(BucketCapacity(result_value.max_buckets_), *low_res.negative_buckets_, + *high_res.negative_buckets_)); return std::unique_ptr{ new Base2ExponentialHistogramAggregation(std::move(result_value))}; @@ -389,24 +506,7 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Diff( auto &low_res = left.scale_ < right.scale_ ? left : right; auto &high_res = left.scale_ < right.scale_ ? right : left; - { - const auto scale_reduction = high_res.scale_ - low_res.scale_; - - if (scale_reduction > 0) - { - if (high_res.positive_buckets_) - { - DownscaleBuckets(high_res.positive_buckets_, scale_reduction); - } - - if (high_res.negative_buckets_) - { - DownscaleBuckets(high_res.negative_buckets_, scale_reduction); - } - - high_res.scale_ -= scale_reduction; - } - } + AlignToScale(high_res, low_res.scale_); // positive_buckets_ and negative_buckets_ share a single scale_; apply // the maximum required reduction across both bucket types. @@ -416,15 +516,10 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Diff( GetScaleReductionForUnion(*low_res.negative_buckets_, *high_res.negative_buckets_, low_res.max_buckets_)); - if (scale_reduction > 0) - { - DownscaleBuckets(low_res.positive_buckets_, scale_reduction); - DownscaleBuckets(high_res.positive_buckets_, scale_reduction); - DownscaleBuckets(low_res.negative_buckets_, scale_reduction); - DownscaleBuckets(high_res.negative_buckets_, scale_reduction); - low_res.scale_ -= static_cast(scale_reduction); - high_res.scale_ -= static_cast(scale_reduction); - } + // Both operands share a scale after the alignment above, so one clamped amount applies to both. + const uint32_t applied = ClampScaleReduction(low_res.scale_, scale_reduction); + ApplyDownscale(low_res, applied); + ApplyDownscale(high_res, applied); Base2ExponentialHistogramPointData result_value; result_value.scale_ = low_res.scale_; @@ -436,9 +531,9 @@ std::unique_ptr Base2ExponentialHistogramAggregation::Diff( (right.zero_count_ >= left.zero_count_) ? (right.zero_count_ - left.zero_count_) : 0; result_value.positive_buckets_ = - std::make_unique(right.max_buckets_); + std::make_unique(BucketCapacity(right.max_buckets_)); result_value.negative_buckets_ = - std::make_unique(right.max_buckets_); + std::make_unique(BucketCapacity(right.max_buckets_)); if (!left.positive_buckets_->Empty() || !right.positive_buckets_->Empty()) { diff --git a/sdk/src/metrics/aggregation/base2_exponential_histogram_indexer.cc b/sdk/src/metrics/aggregation/base2_exponential_histogram_indexer.cc index 20c4dad7c..f21cb8031 100644 --- a/sdk/src/metrics/aggregation/base2_exponential_histogram_indexer.cc +++ b/sdk/src/metrics/aggregation/base2_exponential_histogram_indexer.cc @@ -62,8 +62,17 @@ int32_t Base2ExponentialHistogramIndexer::ComputeIndex(double value) const } // For scale zero, compute the exact index by extracting the exponent. // For negative scales, compute the exact index by extracting the exponent and shifting it to - // the right by -scale - return MapToIndexScaleZero(abs_value) >> -scale_; + // the right by -scale. + const int32_t index = MapToIndexScaleZero(abs_value); + // Every finite double has an exponent in [-1075, 1023], so shifting by 11 or more already + // collapses the index to -1 or 0. Returning that directly keeps the shift well inside the width + // of int32_t, and testing the scale rather than its negation keeps INT32_MIN from overflowing. + constexpr int32_t kCollapsedShift = 11; + if (scale_ < -kCollapsedShift) + { + return index < 0 ? -1 : 0; + } + return index >> -scale_; } } // namespace metrics diff --git a/sdk/test/metrics/aggregation_test.cc b/sdk/test/metrics/aggregation_test.cc index 8a02afd9b..40f1bc646 100644 --- a/sdk/test/metrics/aggregation_test.cc +++ b/sdk/test/metrics/aggregation_test.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/nostd/variant.h" +#include "opentelemetry/sdk/common/global_log_handler.h" #include "opentelemetry/sdk/metrics/aggregation/aggregation.h" #include "opentelemetry/sdk/metrics/aggregation/aggregation_config.h" #include "opentelemetry/sdk/metrics/aggregation/base2_exponential_histogram_aggregation.h" @@ -23,6 +25,7 @@ #include "opentelemetry/sdk/metrics/data/circular_buffer.h" #include "opentelemetry/sdk/metrics/data/point_data.h" #include "opentelemetry/sdk/metrics/instruments.h" +#include "opentelemetry/test_common/sdk/common/scoped_test_log_handler.h" using namespace opentelemetry::sdk::metrics; namespace nostd = opentelemetry::nostd; @@ -1117,3 +1120,319 @@ TEST(Aggregation, Base2ExponentialHistogramAggregationRecordPathRepeatedDownscal ExpectBucketsMatchIndexer(recorded, *point.positive_buckets_, point.scale_, 1.0, "positive"); ExpectBucketsMatchIndexer(recorded, *point.negative_buckets_, point.scale_, -1.0, "negative"); } + +namespace +{ +// The widest span a double can produce: the smallest subnormal and the largest finite value. +constexpr double kTiny = (std::numeric_limits::denorm_min)(); +constexpr double kHuge = (std::numeric_limits::max)(); + +// The most demanding configuration: the lowest scale the schema allows for max_scale and only two +// buckets budgeted, so the full double range forces the last downscale to the runtime floor. +Base2ExponentialHistogramAggregationConfig FloorConfig() +{ + return MakeAggregationConfig(kMaxScaleMin, kMaxSizeMin); +} + +int32_t BucketSpan(const AdaptingCircularBufferCounter &buckets) +{ + return buckets.Empty() ? 0 : buckets.EndIndex() - buckets.StartIndex() + 1; +} + +// Point data as an external caller may legitimately supply it: already at the runtime floor and +// holding kTiny, but with a buffer narrower than the budget the same point advertises. +Base2ExponentialHistogramPointData MakeFloorPointDataWithNarrowBuckets() +{ + Base2ExponentialHistogramPointData point; + point.max_buckets_ = kMaxSizeMin; + point.scale_ = kMinRuntimeScale; + point.count_ = 1; + point.sum_ = kTiny; + point.min_ = kTiny; + point.max_ = kTiny; + point.record_min_max_ = true; + + point.positive_buckets_ = std::make_unique(1); + point.negative_buckets_ = std::make_unique(1); + EXPECT_TRUE(point.positive_buckets_->Increment(-1, 1)); + return point; +} + +// A budget the configuration validator never produces, so no downscale can ever satisfy it. Only +// the iteration bound in GetScaleReduction() stops the search. +Base2ExponentialHistogramPointData MakeDegenerateBudgetPointData(size_t max_buckets) +{ + Base2ExponentialHistogramPointData point; + point.max_buckets_ = max_buckets; + return point; +} + +// Buckets that cannot be reconciled with the scale they are labelled with: at the runtime floor no +// finite double reaches index 100, and the floor leaves no downscale to merge it away. +Base2ExponentialHistogramPointData MakeMismatchedBucketPointData() +{ + Base2ExponentialHistogramPointData point; + point.max_buckets_ = kMaxSizeMin; + point.scale_ = kMinRuntimeScale; + point.count_ = 1; + + point.positive_buckets_ = std::make_unique(kMaxSizeMin); + EXPECT_TRUE(point.positive_buckets_->Increment(100, 1)); + return point; +} + +void ExpectFloorPointDataAcceptsFullRange(const Base2ExponentialHistogramPointData &point, + nostd::string_view label) +{ + SCOPED_TRACE(label); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + EXPECT_EQ(point.max_buckets_, kMaxSizeMin); + ExpectCountInvariant(2u, point, label); + EXPECT_GE(point.positive_buckets_->MaxSize(), kMaxSizeMin); + EXPECT_LE(point.positive_buckets_->MaxSize(), point.max_buckets_); + EXPECT_EQ(BucketSpan(*point.positive_buckets_), static_cast(kMaxSizeMin)); +} +} // namespace + +TEST(Aggregation, Base2ExponentialHistogramAggregationScaleFloorFullRange) +{ + const auto config = FloorConfig(); + Base2ExponentialHistogramAggregation aggr(&config); + + aggr.Aggregate(kTiny, {}); + aggr.Aggregate(kHuge, {}); + + const auto point = MakePointData(aggr); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + EXPECT_EQ(point.max_buckets_, kMaxSizeMin); + ExpectCountInvariant(2u, point, "ScaleFloorFullRange"); + EXPECT_TRUE(point.negative_buckets_->Empty()); + EXPECT_EQ(BucketSpan(*point.positive_buckets_), static_cast(kMaxSizeMin)); + // The budget is a hard cap: reaching the floor must not buy extra buckets. + EXPECT_LE(point.positive_buckets_->MaxSize(), point.max_buckets_); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationScaleFloorFullRangeNegative) +{ + const auto config = FloorConfig(); + Base2ExponentialHistogramAggregation aggr(&config); + + aggr.Aggregate(-kTiny, {}); + aggr.Aggregate(-kHuge, {}); + + const auto point = MakePointData(aggr); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + ExpectCountInvariant(2u, point, "ScaleFloorFullRangeNegative"); + EXPECT_TRUE(point.positive_buckets_->Empty()); + EXPECT_EQ(BucketSpan(*point.negative_buckets_), static_cast(kMaxSizeMin)); + EXPECT_LE(point.negative_buckets_->MaxSize(), point.max_buckets_); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationScaleFloorMixedSign) +{ + const auto config = FloorConfig(); + Base2ExponentialHistogramAggregation aggr(&config); + + // Both bucket arrays are driven to the floor while sharing a single scale_. + aggr.Aggregate(kTiny, {}); + aggr.Aggregate(-kHuge, {}); + aggr.Aggregate(kHuge, {}); + aggr.Aggregate(-kTiny, {}); + aggr.Aggregate(0.0, {}); + + const auto point = MakePointData(aggr); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + ExpectCountInvariant(5u, point, "ScaleFloorMixedSign"); + EXPECT_EQ(point.zero_count_, 1u); + EXPECT_EQ(BucketSpan(*point.positive_buckets_), static_cast(kMaxSizeMin)); + EXPECT_EQ(BucketSpan(*point.negative_buckets_), static_cast(kMaxSizeMin)); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationScaleFloorIsIdempotent) +{ + const auto config = FloorConfig(); + Base2ExponentialHistogramAggregation aggr(&config); + + for (int i = 0; i < 10; ++i) + { + aggr.Aggregate(kTiny, {}); + aggr.Aggregate(kHuge, {}); + aggr.Aggregate(1.0, {}); + EXPECT_EQ(MakePointData(aggr).scale_, kMinRuntimeScale) << "iteration " << i; + } + + ExpectCountInvariant(30u, MakePointData(aggr), "ScaleFloorIsIdempotent"); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationMergeAtScaleFloor) +{ + const auto config = FloorConfig(); + + // Each operand spans the full double range on one sign, so both are already pinned at the floor + // before the merge rather than merely starting at the lowest configurable scale. + Base2ExponentialHistogramAggregation positive(&config); + positive.Aggregate(kTiny, {}); + positive.Aggregate(kHuge, {}); + Base2ExponentialHistogramAggregation negative(&config); + negative.Aggregate(-kTiny, {}); + negative.Aggregate(-kHuge, {}); + + ASSERT_EQ(MakePointData(positive).scale_, kMinRuntimeScale); + ASSERT_EQ(MakePointData(negative).scale_, kMinRuntimeScale); + + const auto merged = MakePointData(*positive.Merge(negative)); + EXPECT_EQ(merged.scale_, kMinRuntimeScale); + ExpectCountInvariant(4u, merged, "MergeAtScaleFloor"); + EXPECT_EQ(BucketSpan(*merged.positive_buckets_), static_cast(kMaxSizeMin)); + EXPECT_EQ(BucketSpan(*merged.negative_buckets_), static_cast(kMaxSizeMin)); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationMergeFloorWithHighScale) +{ + const auto floor_config = FloorConfig(); + Base2ExponentialHistogramAggregation at_floor(&floor_config); + at_floor.Aggregate(kTiny, {}); + at_floor.Aggregate(kHuge, {}); + + const Base2ExponentialHistogramAggregationConfig default_config; + Base2ExponentialHistogramAggregation fine_grained(&default_config); + // Both recordings land in the same bucket, so this operand keeps its configured max_scale. + fine_grained.Aggregate(1.0, {}); + fine_grained.Aggregate(1.0, {}); + + const auto floor_point = MakePointData(at_floor); + const auto fine_point = MakePointData(fine_grained); + ASSERT_EQ(floor_point.scale_, kMinRuntimeScale); + ASSERT_EQ(fine_point.scale_, default_config.max_scale_); + + // The finer-grained operand is folded onto the coarser scale by exactly the scale delta. + const auto merged = MakePointData(*at_floor.Merge(fine_grained)); + EXPECT_EQ(merged.scale_, kMinRuntimeScale); + ExpectCountInvariant(4u, merged, "MergeFloorWithHighScale"); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationDiffAtScaleFloor) +{ + const auto config = FloorConfig(); + + Base2ExponentialHistogramAggregation left(&config); + left.Aggregate(kTiny, {}); + + Base2ExponentialHistogramAggregation extra(&config); + extra.Aggregate(kHuge, {}); + + const auto right = left.Merge(extra); + ASSERT_EQ(MakePointData(*right).scale_, kMinRuntimeScale); + + const auto diffed = MakePointData(*left.Diff(*right)); + EXPECT_GE(diffed.scale_, kMinRuntimeScale); + ExpectCountInvariant(1u, diffed, "DiffAtScaleFloor"); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationDefaultConfigStaysAboveFloor) +{ + // The default budget is wide enough to hold the full double range well above the floor, so the + // floor capacity guarantee must not change the scale a normal configuration settles on. + const Base2ExponentialHistogramAggregationConfig config; + Base2ExponentialHistogramAggregation aggr(&config); + + aggr.Aggregate(kTiny, {}); + aggr.Aggregate(kHuge, {}); + + const auto point = MakePointData(aggr); + EXPECT_GT(point.scale_, kMinRuntimeScale); + EXPECT_LT(point.scale_, config.max_scale_); + EXPECT_EQ(point.max_buckets_, config.max_size_); + ExpectCountInvariant(2u, point, "DefaultConfigStaysAboveFloor"); + EXPECT_LE(BucketSpan(*point.positive_buckets_), static_cast(config.max_size_)); +} + +TEST(Aggregation, Base2ExponentialHistogramIndexerSaturatesShiftAtExtremeNegativeScale) +{ + // Scales this low are only reachable through the point data constructors, but the shift must + // stay defined: every index has collapsed to -1 or 0 by then. + const Base2ExponentialHistogramIndexer indexer(-40); + EXPECT_EQ(indexer.ComputeIndex(1.0), -1); + EXPECT_EQ(indexer.ComputeIndex(kTiny), -1); + EXPECT_EQ(indexer.ComputeIndex(4.0), 0); + EXPECT_EQ(indexer.ComputeIndex(kHuge), 0); + + // -11 still shifts while the lower scales return the collapsed index directly; the two paths + // have to agree, otherwise the saturation would change results instead of just defining them. + const std::vector values = {kTiny, 1e-300, 0.5, 1.0, 4.0, 1e300, kHuge}; + const Base2ExponentialHistogramIndexer shifted(-11); + for (int32_t scale : {-12, -20, -31, -40}) + { + const Base2ExponentialHistogramIndexer collapsed(scale); + for (double value : values) + { + EXPECT_EQ(collapsed.ComputeIndex(value), shifted.ComputeIndex(value)) + << "scale " << scale << ", value " << value; + } + } +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationCopiedPointDataKeepsFloorCapacity) +{ + // The caller's buffer is narrower than the budget the same point advertises, so the copy has to + // be widened or the next full-range recording is dropped. + const auto point_data = MakeFloorPointDataWithNarrowBuckets(); + Base2ExponentialHistogramAggregation aggr(point_data); + + aggr.Aggregate(kHuge, {}); + + ExpectFloorPointDataAcceptsFullRange(MakePointData(aggr), "CopiedPointDataKeepsFloorCapacity"); + EXPECT_EQ(point_data.positive_buckets_->MaxSize(), 1u); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationMovedPointDataKeepsFloorCapacity) +{ + Base2ExponentialHistogramAggregation aggr(MakeFloorPointDataWithNarrowBuckets()); + + aggr.Aggregate(kHuge, {}); + + ExpectFloorPointDataAcceptsFullRange(MakePointData(aggr), "MovedPointDataKeepsFloorCapacity"); +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationDegenerateMaxBucketsTerminates) +{ + // max_buckets_ below kMaxSizeMin only arrives through the point data constructors. The span can + // never shrink to fit, so the reduction search has to stop and the recording still has to land. + for (size_t max_buckets : {size_t{0}, size_t{1}}) + { + SCOPED_TRACE(max_buckets); + Base2ExponentialHistogramAggregation aggr(MakeDegenerateBudgetPointData(max_buckets)); + + aggr.Aggregate(kTiny, {}); + aggr.Aggregate(kHuge, {}); + + const auto point = MakePointData(aggr); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + ExpectCountInvariant(2u, point, "DegenerateMaxBucketsTerminates"); + EXPECT_EQ(point.positive_buckets_->MaxSize(), kMaxSizeMin); + EXPECT_EQ(BucketSpan(*point.positive_buckets_), static_cast(kMaxSizeMin)); + } +} + +TEST(Aggregation, Base2ExponentialHistogramAggregationMismatchedBucketsDropRecordings) +{ + // Already at the floor, so Downscale() cannot merge the stale bucket away and every recording is + // dropped. The point of the test is that this reports and continues instead of aborting. + opentelemetry::test_common::ScopedTestLogHandler log_handler{ + opentelemetry::sdk::common::internal_log::LogLevel::Error}; + + Base2ExponentialHistogramAggregation aggr(MakeMismatchedBucketPointData()); + + aggr.Aggregate(1.0, {}); + aggr.Aggregate(kHuge, {}); + + const auto point = MakePointData(aggr); + EXPECT_EQ(point.scale_, kMinRuntimeScale); + EXPECT_EQ(point.count_, 3u); + EXPECT_EQ(SumAllBuckets(point), 1u); + EXPECT_EQ(BucketSpan(*point.positive_buckets_), 1); + + // Two drops, one log line. Nothing about the state can change between recordings, so repeating + // the message would flood the handler from the record path. + EXPECT_EQ(log_handler.Drain().size(), 1u); +}