Introduce FloatingPointCounter

motivation: It is not currently possible to record floating point values via the swift-metrics API even if the metrics backend supports it.

modifications: Adds a `FloatingPointCounter` type to allow users to accumulate non-integral metrics backed by a `FloatingPointCounterHandler`. Introduces a default implementation for creating and destroying `FloatingPointCounterHandler`s for metric backends that do not natively support floating point counters. On such backends, `FloatingPointCounter` is backed by a `AccumulatingRoundingFloatingPointCounter` which accumulates floating point values internally and record increments to a wrapped `CounterHandler` after crossing integer boundaries.

result: Users can create `FloatingPointCounter`s to record floating point values and get enhanced behavior for backends that support floating point values.
This commit is contained in:
Rauhul Varma 2021-07-29 14:18:33 -07:00 committed by Konrad `ktoso` Malawski
parent 99a068b962
commit 42372a8598
5 changed files with 376 additions and 13 deletions

View File

@ -12,6 +12,10 @@
//
//===----------------------------------------------------------------------===//
// MARK: Testing API
internal var _enableAssertions = true
// MARK: User API
extension Counter {
@ -90,6 +94,83 @@ extension Counter: CustomStringConvertible {
}
}
extension FloatingPointCounter {
/// Create a new `FloatingPointCounter`.
///
/// - parameters:
/// - label: The label for the `FloatingPointCounter`.
/// - dimensions: The dimensions for the `FloatingPointCounter`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
let handler = MetricsSystem.factory.makeFloatingPointCounter(label: label, dimensions: dimensions)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Signal the underlying metrics library that this FloatingPointCounter will never be updated again.
/// In response the library MAY decide to eagerly release any resources held by this `FloatingPointCounter`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyFloatingPointCounter(self.handler)
}
}
/// A FloatingPointCounter is a cumulative metric that represents a single monotonically increasing FloatingPointCounter whose value can only increase or be reset to zero.
/// For example, you can use a FloatingPointCounter to represent the number of requests served, tasks completed, or errors.
/// FloatingPointCounter is not supported by all metrics backends, however a default implementation is provided which accumulates floating point increments and records increments to a standard Counter after crossing integer boundaries.
///
/// This is the user-facing FloatingPointCounter API.
///
/// Its behavior depends on the `FloatingCounterHandler` implementation.
public class FloatingPointCounter {
@usableFromInline
var handler: FloatingPointCounterHandler
public let label: String
public let dimensions: [(String, String)]
/// Alternative way to create a new `FloatingPointCounter`, while providing an explicit `FloatingPointCounterHandler`.
///
/// - warning: This initializer provides an escape hatch for situations where one must use a custom factory instead of the global one.
/// We do not expect this API to be used in normal circumstances, so if you find yourself using it make sure it's for a good reason.
///
/// - SeeAlso: Use `init(label:dimensions:)` to create `FloatingPointCounter` instances using the configured metrics backend.
///
/// - parameters:
/// - label: The label for the `FloatingPointCounter`.
/// - dimensions: The dimensions for the `FloatingPointCounter`.
/// - handler: The custom backend.
public init(label: String, dimensions: [(String, String)], handler: FloatingPointCounterHandler) {
self.label = label
self.dimensions = dimensions
self.handler = handler
}
/// Increment the FloatingPointCounter.
///
/// - parameters:
/// - by: Amount to increment by.
@inlinable
public func increment<DataType: BinaryFloatingPoint>(by amount: DataType) {
self.handler.increment(by: Double(amount))
}
/// Increment the FloatingPointCounter by one.
@inlinable
public func increment() {
self.increment(by: 1.0)
}
/// Reset the FloatingPointCounter back to zero.
@inlinable
public func reset() {
self.handler.reset()
}
}
extension FloatingPointCounter: CustomStringConvertible {
public var description: String {
return "FloatingPointCounter(\(self.label), dimensions: \(self.dimensions))"
}
}
public extension Recorder {
/// Create a new `Recorder`.
///
@ -422,6 +503,7 @@ public enum MetricsSystem {
/// The `MetricsFactory` is the bridge between the `MetricsSystem` and the metrics backend implementation.
/// `MetricsFactory`'s role is to initialize concrete implementations of the various metric types:
/// * `Counter` -> `CounterHandler`
/// * `FloatingPointCounter` -> `FloatingPointCounterHandler`
/// * `Recorder` -> `RecorderHandler`
/// * `Timer` -> `TimerHandler`
///
@ -451,6 +533,13 @@ public protocol MetricsFactory {
/// - dimensions: The dimensions for the `CounterHandler`.
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler
/// Create a backing `FloatingPointCounterHandler`.
///
/// - parameters:
/// - label: The label for the `FloatingPointCounterHandler`.
/// - dimensions: The dimensions for the `FloatingPointCounterHandler`.
func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler
/// Create a backing `RecorderHandler`.
///
/// - parameters:
@ -473,6 +562,13 @@ public protocol MetricsFactory {
/// - handler: The handler to be destroyed.
func destroyCounter(_ handler: CounterHandler)
/// Invoked when the corresponding `FloatingPointCounter`'s `destroy()` function is invoked.
/// Upon receiving this signal the factory may eagerly release any resources related to this counter.
///
/// - parameters:
/// - handler: The handler to be destroyed.
func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler)
/// Invoked when the corresponding `Recorder`'s `destroy()` function is invoked.
/// Upon receiving this signal the factory may eagerly release any resources related to this recorder.
///
@ -488,6 +584,106 @@ public protocol MetricsFactory {
func destroyTimer(_ handler: TimerHandler)
}
/// Wraps a CounterHandler, adding support for incrementing by floating point values by storing an accumulated floating point value and recording increments to the underlying CounterHandler after crossing integer boundaries.
internal class AccumulatingRoundingFloatingPointCounter: FloatingPointCounterHandler {
private let lock = Lock()
private let counterHandler: CounterHandler
internal var fraction: Double = 0
init(label: String, dimensions: [(String, String)]) {
self.counterHandler = MetricsSystem
.factory.makeCounter(label: label, dimensions: dimensions)
}
func increment(by amount: Double) {
// Drop values in illegal values (Asserting in debug builds)
guard !amount.isNaN else {
assert(!_enableAssertions, "cannot increment by NaN")
return
}
guard !amount.isInfinite else {
assert(!_enableAssertions, "cannot increment by infinite quantities")
return
}
guard amount.sign == .plus else {
assert(!_enableAssertions, "cannot increment by negative values")
return
}
guard !amount.isZero else {
return
}
// If amount is in Int64.max..<+Inf
if amount.exponent >= 63 {
// Ceil to Int64.max
self.lock.withLockVoid {
self.counterHandler.increment(by: .max)
}
} else {
// Split amount into integer and fraction components
var (increment, fraction) = self.integerAndFractionComponents(of: amount)
self.lock.withLockVoid {
// Add the fractional component to the accumulated fraction.
self.fraction += fraction
// self.fraction may have cross an integer boundary, Split it
// and add any integer component.
let (integer, fraction) = integerAndFractionComponents(of: self.fraction)
increment += integer
self.fraction = fraction
// Increment the handler by the total integer component.
if increment > 0 {
self.counterHandler.increment(by: increment)
}
}
}
}
@inline(__always)
private func integerAndFractionComponents(of value: Double) -> (Int64, Double) {
let integer = Int64(value)
let fraction = value - value.rounded(.towardZero)
return (integer, fraction)
}
func reset() {
self.lock.withLockVoid {
self.fraction = 0
self.counterHandler.reset()
}
}
func destroy() {
MetricsSystem.factory.destroyCounter(self.counterHandler)
}
}
extension MetricsFactory {
/// Create a default backing `FloatingPointCounterHandler` for backends which do not naively support floating point counters.
///
/// The created FloatingPointCounterHandler is a wrapper around a backend's CounterHandler which accumulates floating point increments and records increments to an underlying CounterHandler after crossing integer boundaries.
///
/// - parameters:
/// - label: The label for the `FloatingPointCounterHandler`.
/// - dimensions: The dimensions for the `FloatingPointCounterHandler`.
public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler {
return AccumulatingRoundingFloatingPointCounter(label: label, dimensions: dimensions)
}
/// Invoked when the corresponding `FloatingPointCounter`'s `destroy()` function is invoked.
/// Upon receiving this signal the factory may eagerly release any resources related to this counter.
///
/// `destroyFloatingPointCounter` must be implemented if `makeFloatingPointCounter` is implemented.
///
/// - parameters:
/// - handler: The handler to be destroyed.
public func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler) {
(handler as? AccumulatingRoundingFloatingPointCounter)?.destroy()
}
}
/// A `CounterHandler` represents a backend implementation of a `Counter`.
///
/// This type is an implementation detail and should not be used directly, unless implementing your own metrics backend.
@ -510,6 +706,28 @@ public protocol CounterHandler: AnyObject {
func reset()
}
/// A `FloatingPointCounterHandler` represents a backend implementation of a `FloatingPointCounter`.
///
/// This type is an implementation detail and should not be used directly, unless implementing your own metrics backend.
/// To use the SwiftMetrics API, please refer to the documentation of `FloatingPointCounter`.
///
/// # Implementation requirements
///
/// To implement your own `FloatingPointCounterHandler` you should respect a few requirements that are necessary so applications work
/// as expected regardless of the selected `FloatingPointCounterHandler` implementation.
///
/// - The `FloatingPointCounterHandler` must be a `class`.
public protocol FloatingPointCounterHandler: AnyObject {
/// Increment the counter.
///
/// - parameters:
/// - by: Amount to increment by.
func increment(by: Double)
/// Reset the counter back to zero.
func reset()
}
/// A `RecorderHandler` represents a backend implementation of a `Recorder`.
///
/// This type is an implementation detail and should not be used directly, unless implementing your own metrics backend.
@ -578,6 +796,10 @@ public final class MultiplexMetricsHandler: MetricsFactory {
return MuxCounter(factories: self.factories, label: label, dimensions: dimensions)
}
public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler {
return MuxFloatingPointCounter(factories: self.factories, label: label, dimensions: dimensions)
}
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
return MuxRecorder(factories: self.factories, label: label, dimensions: dimensions, aggregate: aggregate)
}
@ -592,6 +814,12 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
}
public func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler) {
for factory in self.factories {
factory.destroyFloatingPointCounter(handler)
}
}
public func destroyRecorder(_ handler: RecorderHandler) {
for factory in self.factories {
factory.destroyRecorder(handler)
@ -619,6 +847,21 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
}
private class MuxFloatingPointCounter: FloatingPointCounterHandler {
let counters: [FloatingPointCounterHandler]
public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)]) {
self.counters = factories.map { $0.makeFloatingPointCounter(label: label, dimensions: dimensions) }
}
func increment(by amount: Double) {
self.counters.forEach { $0.increment(by: amount) }
}
func reset() {
self.counters.forEach { $0.reset() }
}
}
private class MuxRecorder: RecorderHandler {
let recorders: [RecorderHandler]
public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)], aggregate: Bool) {
@ -647,7 +890,7 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
/// Ships with the metrics module, used for initial bootstrapping.
public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderHandler, TimerHandler {
public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, FloatingPointCounterHandler, RecorderHandler, TimerHandler {
public static let instance = NOOPMetricsHandler()
private init() {}
@ -656,6 +899,10 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderH
return self
}
public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler {
return self
}
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
return self
}
@ -665,10 +912,12 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderH
}
public func destroyCounter(_: CounterHandler) {}
public func destroyFloatingPointCounter(_: FloatingPointCounterHandler) {}
public func destroyRecorder(_: RecorderHandler) {}
public func destroyTimer(_: TimerHandler) {}
public func increment(by: Int64) {}
public func increment(by: Double) {}
public func reset() {}
public func record(_: Int64) {}
public func record(_: Double) {}

View File

@ -27,6 +27,12 @@ extension MetricsTests {
return [
("testCounters", testCounters),
("testCounterBlock", testCounterBlock),
("testDefaultFloatingPointCounter_ignoresNan", testDefaultFloatingPointCounter_ignoresNan),
("testDefaultFloatingPointCounter_ignoresInfinity", testDefaultFloatingPointCounter_ignoresInfinity),
("testDefaultFloatingPointCounter_ignoresNegativeValues", testDefaultFloatingPointCounter_ignoresNegativeValues),
("testDefaultFloatingPointCounter_ignoresZero", testDefaultFloatingPointCounter_ignoresZero),
("testDefaultFloatingPointCounter_ceilsExtremeValues", testDefaultFloatingPointCounter_ceilsExtremeValues),
("testDefaultFloatingPointCounter_accumulatesFloatingPointDecimalValues", testDefaultFloatingPointCounter_accumulatesFloatingPointDecimalValues),
("testRecorders", testRecorders),
("testRecordersInt", testRecordersInt),
("testRecordersFloat", testRecordersFloat),

View File

@ -53,6 +53,114 @@ class MetricsTests: XCTestCase {
XCTAssertEqual(counter.values.count, 0, "expected number of entries to match")
}
func testDefaultFloatingPointCounter_ignoresNan() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// disable assertions to test fallback path
_enableAssertions = false
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let counter = metrics.counters[label] as! TestCounter
fpCounter.increment(by: Double.nan)
fpCounter.increment(by: Double.signalingNaN)
XCTAssertEqual(counter.values.count, 0, "expected nan values to be ignored")
// reenable assertions
_enableAssertions = true
}
func testDefaultFloatingPointCounter_ignoresInfinity() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// disable assertions to test fallback path
_enableAssertions = false
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let counter = metrics.counters[label] as! TestCounter
fpCounter.increment(by: Double.infinity)
fpCounter.increment(by: -Double.infinity)
XCTAssertEqual(counter.values.count, 0, "expected infinite values to be ignored")
// reenable assertions
_enableAssertions = true
}
func testDefaultFloatingPointCounter_ignoresNegativeValues() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// disable assertions to test fallback path
_enableAssertions = false
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let counter = metrics.counters[label] as! TestCounter
fpCounter.increment(by: -100)
XCTAssertEqual(counter.values.count, 0, "expected negative values to be ignored")
// reenable assertions
_enableAssertions = true
}
func testDefaultFloatingPointCounter_ignoresZero() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// disable assertions to test fallback path
_enableAssertions = false
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let counter = metrics.counters[label] as! TestCounter
fpCounter.increment(by: 0)
fpCounter.increment(by: -0)
XCTAssertEqual(counter.values.count, 0, "expected zero values to be ignored")
// reenable assertions
_enableAssertions = true
}
func testDefaultFloatingPointCounter_ceilsExtremeValues() {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let counter = metrics.counters[label] as! TestCounter
// Just larger than Int64
fpCounter.increment(by: Double(sign: .plus, exponent: 63, significand: 1))
// Much larger than Int64
fpCounter.increment(by: Double.greatestFiniteMagnitude)
let values = counter.values.map { $0.1 }
XCTAssertEqual(values.count, 2, "expected number of entries to match")
XCTAssertEqual(values, [Int64.max, Int64.max], "expected extremely large values to be replaced with Int64.max")
}
func testDefaultFloatingPointCounter_accumulatesFloatingPointDecimalValues() {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
let label = "\(#function)-fp-counter-\(UUID())"
let fpCounter = FloatingPointCounter(label: label)
let rawFpCounter = fpCounter.handler as! AccumulatingRoundingFloatingPointCounter
let counter = metrics.counters[label] as! TestCounter
// Increment by a small value (perfectly representable)
fpCounter.increment(by: 0.75)
XCTAssertEqual(counter.values.count, 0, "expected number of entries to match")
// Increment by a small value that should grow the accumulated buffer past 1.0 (perfectly representable)
fpCounter.increment(by: 1.5)
var values = counter.values.map { $0.1 }
XCTAssertEqual(values.count, 1, "expected number of entries to match")
XCTAssertEqual(values, [2], "expected entries to match")
XCTAssertEqual(rawFpCounter.fraction, 0.25, "")
// Increment by a large value that should leave a fraction in the accumulator
// 1110506744053.76
fpCounter.increment(by: Double(sign: .plus, exponent: 40, significand: 1.01))
values = counter.values.map { $0.1 }
XCTAssertEqual(values.count, 2, "expected number of entries to match")
XCTAssertEqual(values, [2, 1_110_506_744_054], "expected entries to match")
XCTAssertEqual(rawFpCounter.fraction, 0.010009765625, "expected fractional accumulated value")
}
func testRecorders() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()

View File

@ -131,25 +131,25 @@ class MetricsExtensionsTests: XCTestCase {
let testTimer = timer.handler as! TestTimer
testTimer.preferDisplayUnit(.nanoseconds)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value * 1000 * 1000 * 1000, accuracy: 1.0, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value * 1000 * 1000 * 1000, accuracy: 1.0, "expected value to match")
testTimer.preferDisplayUnit(.microseconds)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value * 1000 * 1000, accuracy: 0.001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value * 1000 * 1000, accuracy: 0.001, "expected value to match")
testTimer.preferDisplayUnit(.milliseconds)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value * 1000, accuracy: 0.000001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value * 1000, accuracy: 0.000001, "expected value to match")
testTimer.preferDisplayUnit(.seconds)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value, accuracy: 0.000000001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value, accuracy: 0.000000001, "expected value to match")
testTimer.preferDisplayUnit(.minutes)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value / 60, accuracy: 0.000000001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value / 60, accuracy: 0.000000001, "expected value to match")
testTimer.preferDisplayUnit(.hours)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value / (60 * 60), accuracy: 0.000000001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value / (60 * 60), accuracy: 0.000000001, "expected value to match")
testTimer.preferDisplayUnit(.days)
XCTAssertEqual(testTimer.retriveValueInPreferredUnit(atIndex: 0), value / (60 * 60 * 24), accuracy: 0.000000001, "expected value to match")
XCTAssertEqual(testTimer.retrieveValueInPreferredUnit(atIndex: 0), value / (60 * 60 * 24), accuracy: 0.000000001, "expected value to match")
}
}

View File

@ -24,18 +24,18 @@ internal final class TestMetrics: MetricsFactory {
var recorders = [String: RecorderHandler]()
var timers = [String: TimerHandler]()
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
return self.make(label: label, dimensions: dimensions, registry: &self.counters, maker: TestCounter.init)
}
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
let maker = { (label: String, dimensions: [(String, String)]) -> RecorderHandler in
TestRecorder(label: label, dimensions: dimensions, aggregate: aggregate)
}
return self.make(label: label, dimensions: dimensions, registry: &self.recorders, maker: maker)
}
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
return self.make(label: label, dimensions: dimensions, registry: &self.timers, maker: TestTimer.init)
}
@ -154,7 +154,7 @@ internal class TestTimer: TimerHandler, Equatable {
}
}
func retriveValueInPreferredUnit(atIndex i: Int) -> Double {
func retrieveValueInPreferredUnit(atIndex i: Int) -> Double {
return self.lock.withLock {
let value = values[i].1
guard let displayUnit = self.displayUnit else {
@ -171,7 +171,7 @@ internal class TestTimer: TimerHandler, Equatable {
print("recording \(duration) \(self.label)")
}
public static func == (lhs: TestTimer, rhs: TestTimer) -> Bool {
static func == (lhs: TestTimer, rhs: TestTimer) -> Bool {
return lhs.id == rhs.id
}
}