Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b93d1f3
[Metrics SDK] Enforce MetricReader-level cardinality limits as a fall…
om7057 Aug 10, 2026
0f545ac
Fix IWYU: meter.cc needs <algorithm>/<cstddef>, sync_metric_storage.c…
om7057 Aug 10, 2026
c1df3f5
Address review: fix overflow-merge data loss, floor bug, and ambiguou…
om7057 Aug 11, 2026
11af0b5
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 12, 2026
618b270
Fix silent merge break: exemplar filter parameter renamed on main, my…
om7057 Aug 12, 2026
e5d7d96
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 12, 2026
798419e
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 13, 2026
e7d5f42
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 17, 2026
07ad198
Address review: represent unspecified cardinality limit for programma…
om7057 Aug 17, 2026
2ee55e5
Address review: encapsulate cardinality limit fields, fix delta fast-…
om7057 Aug 19, 2026
1302f76
Fix IWYU: temporal_metric_storage.cc needs <cstddef>, metric_collecto…
om7057 Aug 19, 2026
4ef4028
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 20, 2026
f06457e
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Aug 30, 2026
2ba4f5f
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Sep 3, 2026
b1c6cf2
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Sep 7, 2026
3e9e9fe
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Sep 10, 2026
7307a45
Merge branch 'main' into feat/metric-reader-cardinality-limit-fallback
om7057 Sep 15, 2026
d3c58fb
Merge branch 'main' into feat/metric-reader-cardinality-limit-fallback
om7057 Sep 16, 2026
6bc98b0
Merge branch 'main' into feat/metric-reader-cardinality-limit-fallback
om7057 Sep 20, 2026
ecb9570
Retrigger CI (apt 403 fetching packages.microsoft.com during environm…
om7057 Sep 21, 2026
efb3600
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Sep 24, 2026
001bac0
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Oct 2, 2026
3e1de26
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Oct 5, 2026
4e31b4d
Fix clang-format alignment and extra blank line in changelog
om7057 Oct 5, 2026
9715dda
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Oct 18, 2026
d6c6940
Merge remote-tracking branch 'origin/main' into feat/metric-reader-ca…
om7057 Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@ Increment the:

## [Unreleased]

* [METRICS SDK] Enforce `MetricReader`-level cardinality limits as a fallback
during collection, when the matching view has no explicit
`aggregation_cardinality_limit` of its own (View > Reader > SDK default).
Shared recording storage for a view with no explicit limit is now sized at
the max limit across all attached readers, so a reader with a higher limit
does not lose data; each reader's own (possibly stricter) limit is then
re-applied to just its own collected output. A programmatically-constructed
`AggregationConfig`/`HistogramAggregationConfig`/
`Base2ExponentialHistogramAggregationConfig` (e.g. one built only to carry
histogram boundaries) is now also correctly treated as not having an
explicit cardinality limit, matching the declarative-configuration path.
`AggregationConfig::cardinality_limit_`/`cardinality_limit_explicit_` are
now private, set together via `SetCardinalityLimit()`, so they cannot be
desynced by direct field assignment. The single-collector delta fast path
in `TemporalMetricStorage::buildMetrics()` now re-caps to the collector's
own limit instead of emitting the raw recording storage unchecked, and the
bound-instrument admission path (preview) now uses the same resolved
recording limit as the unbound path.
[#4387](https://github.com/open-telemetry/opentelemetry-cpp/issues/4387)
* Fix `AsyncMetricStorage::AccumulateDelta()` double-counting a series'
prior contribution whenever it was routed to the shared overflow entry.
It pre-merged the new delta into the existing total itself before
calling `Set()`, but `Set()` also merges into an existing overflow
entry, so the prior total was added in twice. Fixed by writing the
already-complete merged total back as an unconditional overwrite,
targeting whichever key (`attributes`, or the overflow key) actually
held it.

## [1.30.0] 2026-10-22

This release contains:
Expand Down Expand Up @@ -54,9 +82,6 @@ Changes:
* [SDK] Add missing cstdint include to predicate_factory.h
[#4574](https://github.com/open-telemetry/opentelemetry-cpp/pull/4574)

* [BUG] Fix random attribute selection in metrics foo example
[#4585](https://github.com/open-telemetry/opentelemetry-cpp/pull/4585)

* [CODE HEALTH] Add missing include in aggregation_config.h
[#4567](https://github.com/open-telemetry/opentelemetry-cpp/pull/4567)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

#pragma once

#include <cstddef>
#include <cstdint>
#include <limits>
#include <vector>

#include "opentelemetry/sdk/metrics/instruments.h"
Expand All @@ -16,11 +18,21 @@ namespace sdk
namespace metrics
{

// Sentinel passed to AggregationConfig's cardinality_limit constructor argument to mean "the
// caller did not specify a limit", distinct from any real limit value (which is always a small
// positive count). Lets the constructor tell apart e.g. HistogramAggregationConfig() built only
// to carry boundaries_ from HistogramAggregationConfig(500) built to set a real limit of 500,
// without needing <optional> (avoided elsewhere in the SDK for ABI reasons).
constexpr size_t kCardinalityLimitUnspecified = (std::numeric_limits<size_t>::max)();

class AggregationConfig
{
public:
AggregationConfig(size_t cardinality_limit = kAggregationCardinalityLimit)
: cardinality_limit_(cardinality_limit)
AggregationConfig(size_t cardinality_limit = kCardinalityLimitUnspecified)
: cardinality_limit_(cardinality_limit == kCardinalityLimitUnspecified
? kAggregationCardinalityLimit
: cardinality_limit),
cardinality_limit_explicit_(cardinality_limit != kCardinalityLimitUnspecified)
{}

AggregationConfig(const AggregationConfig &) = default;
Expand All @@ -40,14 +52,33 @@ class AggregationConfig
return &default_config;
}

size_t cardinality_limit_;
std::size_t GetCardinalityLimit() const noexcept { return cardinality_limit_; }

// Whether the cardinality limit reflects an intentionally-configured value, as opposed to
// just the compiled-in default it was left at because this config was constructed for some
// other reason (e.g. histogram boundaries) without a cardinality_limit argument. A
// MetricReader-level fallback applies whenever this is false.
bool IsCardinalityLimitExplicit() const noexcept { return cardinality_limit_explicit_; }

// Sets the cardinality limit and marks it explicit, atomically. Use this instead of assigning
// a limit some other way, so the limit and its explicit-ness can never go out of sync.
void SetCardinalityLimit(std::size_t cardinality_limit) noexcept
{
cardinality_limit_ = cardinality_limit;
cardinality_limit_explicit_ = true;
}

virtual ~AggregationConfig() = default;

private:
size_t cardinality_limit_;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This public field can still be assigned directly, but that no longer marks the limit as explicit.

For example:

  HistogramAggregationConfig config;
  config.cardinality_limit_ = 100;

cardinality_limit_explicit_ remains false, so the reader fallback can override the configured limit. Could we keep these two values from getting out of sync, or preserve the existing direct-assignment behavior another way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Made both fields private with a SetCardinalityLimit() setter that sets them together; direct assignment is no longer possible.

bool cardinality_limit_explicit_;
};

class HistogramAggregationConfig : public AggregationConfig
{
public:
HistogramAggregationConfig(size_t cardinality_limit = kAggregationCardinalityLimit)
HistogramAggregationConfig(size_t cardinality_limit = kCardinalityLimitUnspecified)
: AggregationConfig(cardinality_limit)
{}

Expand Down Expand Up @@ -83,7 +114,7 @@ class Base2ExponentialHistogramAggregationConfig : public AggregationConfig
{
public:
Base2ExponentialHistogramAggregationConfig(
size_t cardinality_limit = kAggregationCardinalityLimit)
size_t cardinality_limit = kCardinalityLimitUnspecified)
: AggregationConfig(cardinality_limit)
{}

Expand Down
7 changes: 7 additions & 0 deletions sdk/include/opentelemetry/sdk/metrics/meter_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ class MeterContext : public std::enable_shared_from_this<MeterContext>
* Note: This reader may not receive any in-flight meter data, but will get newly created meter
* data.
* Note: This method is not thread safe, and should ideally be called from main thread.
* Note: For an instrument that already exists when this reader is added, this reader's
* cardinality limit for that instrument only takes effect up to whatever recording capacity
* was already resolved (as the highest limit across readers attached at instrument-creation
* time) when the instrument was first created; that capacity is fixed for the lifetime of the
* instrument and is not grown retroactively. A reader added with a higher limit than any
* reader present at instrument-creation time will not recover attribute sets that had already
* collapsed into the instrument's overflow point before this reader was attached.
*/
void AddMetricReader(std::shared_ptr<MetricReader> reader,
std::unique_ptr<MetricFilter> metric_filter = nullptr) noexcept;
Expand Down
7 changes: 7 additions & 0 deletions sdk/include/opentelemetry/sdk/metrics/meter_provider.h
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ class OPENTELEMETRY_EXPORT MeterProvider final : public opentelemetry::metrics::
* Note: This reader may not receive any in-flight meter data, but will get newly created meter
* data.
* Note: This method is not thread safe, and should ideally be called from main thread.
* Note: For an instrument that already exists when this reader is added, this reader's
* cardinality limit for that instrument only takes effect up to whatever recording capacity
* was already resolved (as the highest limit across readers attached at instrument-creation
* time) when the instrument was first created; that capacity is fixed for the lifetime of the
* instrument and is not grown retroactively. A reader added with a higher limit than any
* reader present at instrument-creation time will not recover attribute sets that had already
* collapsed into the instrument's overflow point before this reader was attached.
*/
void AddMetricReader(std::shared_ptr<MetricReader> reader,
std::unique_ptr<MetricFilter> metric_filter = nullptr) noexcept;
Expand Down
5 changes: 2 additions & 3 deletions sdk/include/opentelemetry/sdk/metrics/metric_reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,8 @@ class MetricReader
/**
* Set per-instrument-type cardinality limits for this reader.
*
* TODO: Reader-level limits are stored but not yet enforced as a per-collector
* fallback during the collection path. Enforcement will be added in a follow-up.
* View-level limits (via AggregationConfig) are enforced today.
* Enforced as a fallback during the collection path whenever the matching view has no
* explicit AggregationConfig cardinality limit of its own (View > Reader > SDK default).
*
* @param limits The cardinality limits to apply
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ namespace metrics
class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStorage
{
public:
// Back-compat overload preserving the original constructor signature for any external caller.
// See SyncMetricStorage's constructor comment for what `recording_cardinality_limit` is for.
AsyncMetricStorage(const InstrumentDescriptor &instrument_descriptor,
const AggregationType aggregation_type,
std::shared_ptr<const AttributesProcessor> attributes_processor,
Expand All @@ -46,17 +48,36 @@ class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStora
nostd::shared_ptr<ExemplarReservoir> &&exemplar_reservoir,
#endif
const AggregationConfig *aggregation_config)
: AsyncMetricStorage(
instrument_descriptor,
aggregation_type,
std::move(attributes_processor),
#ifdef ENABLE_METRICS_EXEMPLAR_PREVIEW
exemplar_filter_type,
std::move(exemplar_reservoir),
#endif
aggregation_config,
AggregationConfig::GetOrDefault(aggregation_config)->GetCardinalityLimit())
{}

AsyncMetricStorage(const InstrumentDescriptor &instrument_descriptor,
const AggregationType aggregation_type,
std::shared_ptr<const AttributesProcessor> attributes_processor,
#ifdef ENABLE_METRICS_EXEMPLAR_PREVIEW
ExemplarFilterType exemplar_filter_type,
nostd::shared_ptr<ExemplarReservoir> &&exemplar_reservoir,
#endif
const AggregationConfig *aggregation_config,
std::size_t recording_cardinality_limit)
: instrument_descriptor_(instrument_descriptor),
aggregation_type_{aggregation_type},
aggregation_config_{AggregationConfig::GetOrDefault(aggregation_config)},
recording_cardinality_limit_(recording_cardinality_limit),
attributes_processor_{std::move(attributes_processor)},
is_monotonic_sum_{IsMonotonicSum(aggregation_type, instrument_descriptor)},
last_observed_hash_map_(
std::make_unique<AttributesHashMap>(aggregation_config_->cardinality_limit_)),
delta_hash_map_(
std::make_unique<AttributesHashMap>(aggregation_config_->cardinality_limit_)),
round_hash_map_(
std::make_unique<AttributesHashMap>(aggregation_config_->cardinality_limit_)),
last_observed_hash_map_(std::make_unique<AttributesHashMap>(recording_cardinality_limit_)),
delta_hash_map_(std::make_unique<AttributesHashMap>(recording_cardinality_limit_)),
round_hash_map_(std::make_unique<AttributesHashMap>(recording_cardinality_limit_)),
#ifdef ENABLE_METRICS_EXEMPLAR_PREVIEW
exemplar_filter_type_(exemplar_filter_type),
exemplar_reservoir_(std::move(exemplar_reservoir)),
Expand Down Expand Up @@ -139,9 +160,8 @@ class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStora
{
BuildDeltaFromRound();
}
delta_metrics = std::move(delta_hash_map_);
delta_hash_map_ =
std::make_unique<AttributesHashMap>(aggregation_config_->cardinality_limit_);
delta_metrics = std::move(delta_hash_map_);
delta_hash_map_ = std::make_unique<AttributesHashMap>(recording_cardinality_limit_);
}

auto status =
Expand Down Expand Up @@ -183,12 +203,22 @@ class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStora
* a view collapses together and the observations from different callbacks all contribute.
* Over-the-limit attribute sets resolve to the same otel.metric.overflow entry, which merges
* too.
*
* `merged` here already equals the bucket's full new total (whatever GetOrSetDefault resolved
* `attributes` to, plus `delta`), so it must be written back with an unconditional overwrite,
* not through Set()'s own overflow-merge path: Set() merges on the assumption that what it is
* given is a fresh, independent contribution still waiting to be added, and applying that a
* second time here would double-count the bucket's prior total. Resolving the same key
* GetOrSetDefault used (the real attributes, or the shared overflow entry if they were over
* capacity) and overwriting that key directly sidesteps Set()'s overflow routing entirely.
*/
void AccumulateDelta(const MetricAttributes &attributes, const Aggregation &delta)
{
auto merged =
delta_hash_map_->GetOrSetDefault(attributes, create_default_aggregation_)->Merge(delta);
delta_hash_map_->Set(attributes, std::move(merged));
const MetricAttributes &target_key =
delta_hash_map_->Has(attributes) ? attributes : GetOverflowAttributes();
delta_hash_map_->Set(target_key, std::move(merged));
}

/**
Expand Down Expand Up @@ -216,7 +246,7 @@ class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStora
return true;
});

round_hash_map_ = std::make_unique<AttributesHashMap>(aggregation_config_->cardinality_limit_);
round_hash_map_ = std::make_unique<AttributesHashMap>(recording_cardinality_limit_);
}

/**
Expand Down Expand Up @@ -277,6 +307,9 @@ class AsyncMetricStorage : public MetricStorage, public AsyncWritableMetricStora
InstrumentDescriptor instrument_descriptor_;
AggregationType aggregation_type_;
const AggregationConfig *aggregation_config_;
// Capacity used to (re)size last_observed_hash_map_/delta_hash_map_/round_hash_map_. See the
// constructor comment.
const std::size_t recording_cardinality_limit_;
std::shared_ptr<const AttributesProcessor> attributes_processor_;
bool is_monotonic_sum_;
std::function<std::unique_ptr<Aggregation>()> create_default_aggregation_;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ class AttributesHashMapWithCustomHash
return result.first->second.get();
}
/**
* Set the value for given key, overwriting the value if already present
* Set the value for given key, overwriting the value if already present.
*
* When `attributes` is a new key that has to be routed to the overflow entry (capacity
* reached), this merges into any existing overflow aggregation rather than replacing it, so
* that a second and later over-capacity attribute set doesn't discard values already
* accumulated there from an earlier one.
*/
void Set(const MetricAttributes &attributes, std::unique_ptr<Aggregation> aggr)
{
Expand All @@ -133,7 +138,7 @@ class AttributesHashMapWithCustomHash
}
else if (IsOverflowAttributes(attributes))
{
hash_map_[GetOverflowAttributes()] = std::move(aggr);
SetOverflowMerged(std::move(aggr));
}
else
{
Expand All @@ -150,7 +155,7 @@ class AttributesHashMapWithCustomHash
}
else if (IsOverflowAttributes(attributes))
{
hash_map_[GetOverflowAttributes()] = std::move(aggr);
SetOverflowMerged(std::move(aggr));
}
else
{
Expand Down Expand Up @@ -207,6 +212,23 @@ class AttributesHashMapWithCustomHash
return result.first->second.get();
}

// Merges `agg` into the existing overflow aggregation if one is already present, otherwise
// installs it as the overflow entry. Used by Set() when routing a new over-capacity key to
// overflow, so multiple distinct attribute sets funneled into the single overflow bucket
// accumulate rather than each replacing the last.
void SetOverflowMerged(std::unique_ptr<Aggregation> agg)
{
auto it = hash_map_.find(GetOverflowAttributes());
if (it != hash_map_.end())
{
hash_map_[GetOverflowAttributes()] = it->second->Merge(*agg);
}
else
{
hash_map_[GetOverflowAttributes()] = std::move(agg);
}
}

bool IsOverflowAttributes(const MetricAttributes &attributes) const
{
// If the incoming attributes are exactly the overflow sentinel, route
Expand Down
Loading
Loading