Repository: open-telemetry/opentelemetry-swift (swift)

Issue:
StableMetric ExemplarReservoir return empty data points
## Summary
When an exemplar reservoir's `collectAndReset` method is called, no care is taken to verify which cells are populated, thus empty exemplar datapoints are returned in the array. 

https://github.com/open-telemetry/opentelemetry-swift/blob/7125823d09aa875b192fefa545c28ee7c4897cea/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ExemplarReservoir.swift#L43

## Detail

<img width="890" alt="Image" src="https://github.com/user-attachments/assets/719ac02f-2ecd-4e0b-9c46-880c26dc16ea" />

The storage is initialized with `size` number of `ReservoirCell`. 

But the buckets have default values, and are only populated when offer measurement is called: 

<img width="951" alt="Image" src="https://github.com/user-attachments/assets/3c925f7b-29cf-4abf-9340-d9f50582eaa8" />

<img width="807" alt="Image" src="https://github.com/user-attachments/assets/f3e9a1e3-cc80-419b-92cf-203181b2c1d4" />

If any cell has measurements, the method will return results from all cells, even if they haven't been populated.

Pull request:
This PR fixes #749 and #750

Patch:
diff --git a/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ExemplarReservoir.swift b/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ExemplarReservoir.swift
index 9357006ce..7b85480dd 100644
--- a/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ExemplarReservoir.swift
+++ b/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ExemplarReservoir.swift
@@ -51,7 +51,7 @@ public class FixedSizedExemplarReservoir: ExemplarReservoir {
     self.reservoirCellSelector = reservoirCellSelector
     self.mapAndResetCell = mapAndResetCell
 
-    for _ in 0 ... size {
+    for _ in 0 ..< size {
       storage.append(ReservoirCell(clock: clock))
     }
   }
diff --git a/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ReservoirCell.swift b/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ReservoirCell.swift
index f3d119091..b69d1788e 100644
--- a/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ReservoirCell.swift
+++ b/Sources/OpenTelemetrySdk/Metrics/Stable/Exemplar/ReservoirCell.swift
@@ -37,13 +37,19 @@ public class ReservoirCell {
     }
   }
 
-  func getAndResetLong(pointAttributes: [String: AttributeValue]) -> LongExemplarData {
+  func getAndResetLong(pointAttributes: [String: AttributeValue]) -> LongExemplarData? {
+    if attributes.isEmpty {
+      return nil
+    }
     let result = LongExemplarData(value: longValue, epochNanos: recordTime, filteredAttributes: filtered(attributes, pointAttributes), spanContext: spanContext)
     reset()
     return result
   }
 
-  func getAndResetDouble(pointAttributes: [String: AttributeValue]) -> DoubleExemplarData {
+  func getAndResetDouble(pointAttributes: [String: AttributeValue]) -> DoubleExemplarData? {
+    if attributes.isEmpty {
+      return nil
+    }
     let result = DoubleExemplarData(value: doubleValue, epochNanos: recordTime, filteredAttributes: filtered(attributes, pointAttributes), spanContext: spanContext)
     reset()
     return result


Test patch:
diff --git a/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ExemplarReservoirTests.swift b/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ExemplarReservoirTests.swift
index 5ab563ad4..f357973d9 100644
--- a/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ExemplarReservoirTests.swift
+++ b/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ExemplarReservoirTests.swift
@@ -32,7 +32,8 @@ class FixedSizedExemplarReservoirTests: XCTestCase {
 
   override func setUp() {
     super.setUp()
-    reservoir = FixedSizedExemplarReservoir(clock: clock, size: 3, reservoirCellSelector: selector, mapAndResetCell: { _, _ in nil })
+    reservoir = FixedSizedExemplarReservoir(clock: clock, size: 4, reservoirCellSelector: selector, mapAndResetCell: { cell,attributes in cell.getAndResetDouble(pointAttributes: attributes)
+ })
   }
 
   func testOfferLongMeasurement() {
@@ -59,15 +60,26 @@ class FixedSizedExemplarReservoirTests: XCTestCase {
     XCTAssertTrue(reservoir.storage.contains { $0.doubleValue == 4.4 })
   }
 
+
+  func testPartiallyFullReservoir(){
+    reservoir.offerDoubleMeasurement(value: 1.1, attributes: attributes)
+    let result = reservoir.collectAndReset(attribute: attributes)
+    XCTAssertEqual(result.count, 1)
+    XCTAssertTrue(result.contains(where: { exemplarData in
+      (exemplarData as! DoubleExemplarData).value == 1.1
+    }))
+    XCTAssertFalse(reservoir.storage.contains { $0.doubleValue == 1.1 })
+  }
+
   func testCollectAndReset() {
     let clock = TestClock()
-    let reservoirSize = 5
+    let reservoirSize = 6
     let reservoirCellSelector = ReservoirCellSelectorMock()
 
     // Create the reservoir
     let reservoir = FixedSizedExemplarReservoir(clock: clock, size: reservoirSize, reservoirCellSelector: reservoirCellSelector, mapAndResetCell: { cell, attributes in
       let exemplar = cell.getAndResetLong(pointAttributes: attributes)
-      return exemplar.value > 0 ? exemplar : nil
+      return exemplar?.value ?? 0 > 0 ? exemplar : nil
     })
 
     // Offer measurements
diff --git a/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ReservoirCellTests.swift b/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ReservoirCellTests.swift
index 371d74b58..8edffb980 100644
--- a/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ReservoirCellTests.swift
+++ b/Tests/OpenTelemetrySdkTests/Metrics/StableMetrics/Exemplar/ReservoirCellTests.swift
@@ -41,10 +41,10 @@ class ReservoirCellTests: XCTestCase {
     reservoirCell.recordLongValue(value: 10, attributes: attributes)
 
     let result = reservoirCell.getAndResetLong(pointAttributes: pointAttributes)
-    XCTAssertEqual(result.value, 10)
-    XCTAssertEqual(result.epochNanos, mockClock.nanoTime)
-    XCTAssertEqual(result.filteredAttributes, attributes)
-    XCTAssertEqual(result.spanContext, nil)
+    XCTAssertEqual(result?.value, 10)
+    XCTAssertEqual(result?.epochNanos, mockClock.nanoTime)
+    XCTAssertEqual(result?.filteredAttributes, attributes)
+    XCTAssertEqual(result?.spanContext, nil)
 
     XCTAssertEqual(reservoirCell.attributes, [:])
     XCTAssertEqual(reservoirCell.longValue, 0)
@@ -58,10 +58,10 @@ class ReservoirCellTests: XCTestCase {
     reservoirCell.recordDoubleValue(value: 3.14, attributes: attributes)
 
     let result = reservoirCell.getAndResetDouble(pointAttributes: pointAttributes)
-    XCTAssertEqual(result.value, 3.14)
-    XCTAssertEqual(result.epochNanos, mockClock.nanoTime)
-    XCTAssertEqual(result.filteredAttributes, attributes)
-    XCTAssertEqual(result.spanContext, nil)
+    XCTAssertEqual(result?.value, 3.14)
+    XCTAssertEqual(result?.epochNanos, mockClock.nanoTime)
+    XCTAssertEqual(result?.filteredAttributes, attributes)
+    XCTAssertEqual(result?.spanContext, nil)
 
     XCTAssertEqual(reservoirCell.attributes, [:])
     XCTAssertEqual(reservoirCell.doubleValue, 0)