diff --git a/.gitignore b/.gitignore index fc56b72..78fd939 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /*.xcodeproj .xcode .SourceKitten +*.orig diff --git a/README.md b/README.md index adc8ddf..f2f065f 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ class SimpleMetricsLibrary: MetricsFactory { func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler { return ExampleTimer(label, dimensions) } + + // implementation is stateless, so nothing to do on destroy calls + func destroyCounter(_ handler: CounterHandler) {} + func destroyRecorder(_ handler: RecorderHandler) {} + func destroyTimer(_ handler: TimerHandler) {} private class ExampleCounter: CounterHandler { init(_: String, _: [(String, String)]) {} diff --git a/Sources/CoreMetrics/Metrics.swift b/Sources/CoreMetrics/Metrics.swift index 839c4ae..49eeaf0 100644 --- a/Sources/CoreMetrics/Metrics.swift +++ b/Sources/CoreMetrics/Metrics.swift @@ -29,6 +29,7 @@ public protocol CounterHandler: AnyObject { /// - parameters: /// - by: Amount to increment by. func increment(by: Int64) + /// Reset the counter back to zero. func reset() } @@ -77,6 +78,7 @@ public class Counter { public func reset() { self.handler.reset() } + } public extension Counter { @@ -89,6 +91,13 @@ public extension Counter { let handler = MetricsSystem.factory.makeCounter(label: label, dimensions: dimensions) self.init(label: label, dimensions: dimensions, handler: handler) } + + /// Signal the underlying metrics library that this counter will never be updated again. + /// In response library MAY decide to eagerly release any resources held by this counter. + @inlinable + func destroy() { + MetricsSystem.factory.destroyCounter(self.handler) + } } /// A `RecorderHandler` represents a backend implementation of a `Recorder`. @@ -169,6 +178,13 @@ public extension Recorder { let handler = MetricsSystem.factory.makeRecorder(label: label, dimensions: dimensions, aggregate: aggregate) self.init(label: label, dimensions: dimensions, aggregate: aggregate, handler: handler) } + + /// Signal the underlying metrics library that this recorder will never be updated again. + /// In response library MAY decide to eagerly release any resources held by this recorder. + @inlinable + func destroy() { + MetricsSystem.factory.destroyRecorder(self.handler) + } } /// A Gauge is a metric that represents a single numerical value that can arbitrarily go up and down. @@ -302,6 +318,13 @@ public extension Timer { let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions) self.init(label: label, dimensions: dimensions, handler: handler) } + + /// Signal the underlying metrics library that this timer will never be updated again. + /// In response library MAY decide to eagerly release any resources held by this timer. + @inlinable + func destroy() { + MetricsSystem.factory.destroyTimer(self.handler) + } } /// The `MetricsFactory` is the bridge between the `MetricsSystem` and the metrics backend implementation. @@ -312,6 +335,22 @@ public extension Timer { /// /// 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 `MetricsSystem`. +/// +/// # Destroying metrics +/// +/// Since _some_ metrics implementations may need to allocate (potentially "heavy") resources for metrics, destroying +/// metrics offers a signal to libraries when a metric is "known to never be updated again." +/// +/// While many metrics are bound to the entire lifetime of an application and thus never need to be destroyed eagerly, +/// some metrics have well defined unique life-cycles, and it may be beneficial to release any resources held by them +/// more eagerly than awaiting the applications termination. In such cases, a library or application should invoke +/// a metric's appropriate `destroy()` method, which in turn results in the corresponding handler that it is backed by +/// to be passed to `destroyCounter(handler:)`, `destroyRecorder(handler:)` or `destroyTimer(handler:)` where the factory +/// can decide to free any corresponding resources. +/// +/// While some libraries may not need to implement this destroying as they may be stateless or similar, +/// libraries using the metrics API should always assume a library WILL make use of this signal, and shall not +/// neglect calling these methods when appropriate. public protocol MetricsFactory { /// Create a backing `CounterHandler`. /// @@ -319,6 +358,7 @@ public protocol MetricsFactory { /// - label: The label for the CounterHandler. /// - dimensions: The dimensions for the CounterHandler. func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler + /// Create a backing `RecorderHandler`. /// /// - parameters: @@ -326,12 +366,34 @@ public protocol MetricsFactory { /// - dimensions: The dimensions for the RecorderHandler. /// - aggregate: Is data aggregation expected. func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler + /// Create a backing `TimerHandler`. /// /// - parameters: /// - label: The label for the TimerHandler. /// - dimensions: The dimensions for the TimerHandler. func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler + + /// Invoked when the corresponding `Counter`'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 destroyCounter(_ handler: CounterHandler) + + /// 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. + /// + /// - parameters: + /// - handler: The handler to be destroyed. + func destroyRecorder(_ handler: RecorderHandler) + + /// Invoked when the corresponding `Timer`'s `destroy()` function is invoked. + /// Upon receiving this signal the factory may eagerly release any resources related to this timer. + /// + /// - parameters: + /// - handler: The handler to be destroyed. + func destroyTimer(_ handler: TimerHandler) } /// The `MetricsSystem` is a global facility where the default metrics backend implementation (`MetricsFactory`) can be @@ -363,7 +425,7 @@ public enum MetricsSystem { } } - /// Returns a refernece to the configured factory. + /// Returns a reference to the configured factory. public static var factory: MetricsFactory { return self.lock.withReaderLock { self._factory } } @@ -388,6 +450,25 @@ public final class MultiplexMetricsHandler: MetricsFactory { return MuxTimer(factories: self.factories, label: label, dimensions: dimensions) } + public func destroyCounter(_ handler: CounterHandler) { + for factory in self.factories { + factory.destroyCounter(handler) + } + } + + public func destroyRecorder(_ handler: RecorderHandler) { + for factory in self.factories { + factory.destroyRecorder(handler) + } + } + + public func destroyTimer(_ handler: TimerHandler) { + for factory in self.factories { + factory.destroyTimer(handler) + } + } + + private class MuxCounter: CounterHandler { let counters: [CounterHandler] public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)]) { @@ -432,6 +513,7 @@ public final class MultiplexMetricsHandler: MetricsFactory { /// Ships with the metrics module, used for initial bootstraping. public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderHandler, TimerHandler { + public static let instance = NOOPMetricsHandler() private init() {} @@ -439,15 +521,17 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderH public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler { return self } - public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler { return self } - public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler { return self } + public func destroyCounter(_ handler: CounterHandler) {} + public func destroyRecorder(_ handler: RecorderHandler) {} + public func destroyTimer(_ handler: TimerHandler) {} + public func increment(by: Int64) {} public func reset() {} public func record(_: Int64) {} diff --git a/Tests/MetricsTests/CoreMetricsTests+XCTest.swift b/Tests/MetricsTests/CoreMetricsTests+XCTest.swift index 0a67c9f..b188e8f 100644 --- a/Tests/MetricsTests/CoreMetricsTests+XCTest.swift +++ b/Tests/MetricsTests/CoreMetricsTests+XCTest.swift @@ -38,6 +38,9 @@ extension MetricsTests { ("testGaugeBlock", testGaugeBlock), ("testMUX", testMUX), ("testCustomFactory", testCustomFactory), + ("testDestroyingGauge", testDestroyingGauge), + ("testDestroyingCounter", testDestroyingCounter), + ("testDestroyingTimer", testDestroyingTimer), ] } } diff --git a/Tests/MetricsTests/CoreMetricsTests.swift b/Tests/MetricsTests/CoreMetricsTests.swift index 01741c4..f0f6eb3 100644 --- a/Tests/MetricsTests/CoreMetricsTests.swift +++ b/Tests/MetricsTests/CoreMetricsTests.swift @@ -240,4 +240,93 @@ class MetricsTests: XCTestCase { let counter2 = Counter(label: "foo", dimensions: [], handler: CustomHandler()) XCTAssertTrue(counter2.handler is CustomHandler, "expected custom log handler") } + + func testDestroyingGauge() throws { + let metrics = TestMetrics() + MetricsSystem.bootstrapInternal(metrics) + + let name = "gauge-\(NSUUID().uuidString)" + let value = Double.random(in: -1000 ... 1000) + + let gauge = Gauge(label: name) + gauge.record(value) + + let recorder = gauge.handler as! TestRecorder + XCTAssertEqual(recorder.values.count, 1, "expected number of entries to match") + XCTAssertEqual(recorder.values.first!.1, value, "expected value to match") + XCTAssertEqual(metrics.recorders.count, 1, "recorder should have been stored") + + let identity = ObjectIdentifier(recorder) + gauge.destroy() + XCTAssertEqual(metrics.recorders.count, 0, "recorder should have been released") + + let gaugeAgain = Gauge(label: name) + gaugeAgain.record(-value) + + let recorderAgain = gaugeAgain.handler as! TestRecorder + XCTAssertEqual(recorderAgain.values.count, 1, "expected number of entries to match") + XCTAssertEqual(recorderAgain.values.first!.1, -value, "expected value to match") + + let identityAgain = ObjectIdentifier(recorderAgain) + XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity") + } + + func testDestroyingCounter() throws { + let metrics = TestMetrics() + MetricsSystem.bootstrapInternal(metrics) + + let name = "counter-\(NSUUID().uuidString)" + let value = Int.random(in: 0 ... 1000) + + let counter = Counter(label: name) + counter.increment(by: value) + + let testCounter = counter.handler as! TestCounter + XCTAssertEqual(testCounter.values.count, 1, "expected number of entries to match") + XCTAssertEqual(testCounter.values.first!.1, Int64(value), "expected value to match") + XCTAssertEqual(metrics.counters.count, 1, "counter should have been stored") + + let identity = ObjectIdentifier(counter) + counter.destroy() + XCTAssertEqual(metrics.counters.count, 0, "counter should have been released") + + let counterAgain = Counter(label: name) + counterAgain.increment(by: value) + + let testCounterAgain = counterAgain.handler as! TestCounter + XCTAssertEqual(testCounterAgain.values.count, 1, "expected number of entries to match") + XCTAssertEqual(testCounterAgain.values.first!.1, Int64(value), "expected value to match") + + let identityAgain = ObjectIdentifier(counterAgain) + XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity") + } + + func testDestroyingTimer() throws { + let metrics = TestMetrics() + MetricsSystem.bootstrapInternal(metrics) + + let name = "timer-\(NSUUID().uuidString)" + let value = Int64.random(in: 0 ... 1000) + + let timer = Timer(label: name) + timer.recordNanoseconds(value) + + let testTimer = timer.handler as! TestTimer + XCTAssertEqual(testTimer.values.count, 1, "expected number of entries to match") + XCTAssertEqual(testTimer.values.first!.1, value, "expected value to match") + XCTAssertEqual(metrics.timers.count, 1, "timer should have been stored") + + let identity = ObjectIdentifier(timer) + timer.destroy() + XCTAssertEqual(metrics.timers.count, 0, "timer should have been released") + + let timerAgain = Timer(label: name) + timerAgain.recordNanoseconds(value) + let testTimerAgain = timerAgain.handler as! TestTimer + XCTAssertEqual(testTimerAgain.values.count, 1, "expected number of entries to match") + XCTAssertEqual(testTimerAgain.values.first!.1, value, "expected value to match") + + let identityAgain = ObjectIdentifier(timerAgain) + XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity") + } } diff --git a/Tests/MetricsTests/TestMetrics.swift b/Tests/MetricsTests/TestMetrics.swift index 5dfc4fe..e5b1385 100644 --- a/Tests/MetricsTests/TestMetrics.swift +++ b/Tests/MetricsTests/TestMetrics.swift @@ -16,8 +16,10 @@ @testable import class CoreMetrics.Timer import Foundation -internal class TestMetrics: MetricsFactory { - private let lock = NSLock() // TODO: consider lock per cache? +/// Metrics factory which allows inspecting recorded metrics programmatically. +/// Only intended for tests of the Metrics API itself. +internal final class TestMetrics: MetricsFactory { + private let lock = NSLock() var counters = [String: CounterHandler]() var recorders = [String: RecorderHandler]() var timers = [String: TimerHandler]() @@ -38,12 +40,30 @@ internal class TestMetrics: MetricsFactory { } private func make(label: String, dimensions: [(String, String)], registry: inout [String: Item], maker: (String, [(String, String)]) -> Item) -> Item { - let item = maker(label, dimensions) return self.lock.withLock { + let item = maker(label, dimensions) registry[label] = item return item } } + + func destroyCounter(_ handler: CounterHandler) { + if let testCounter = handler as? TestCounter { + self.counters.removeValue(forKey: testCounter.label) + } + } + + func destroyRecorder(_ handler: RecorderHandler) { + if let testRecorder = handler as? TestRecorder { + self.recorders.removeValue(forKey: testRecorder.label) + } + } + + func destroyTimer(_ handler: TimerHandler) { + if let testTimer = handler as? TestTimer { + self.timers.removeValue(forKey: testTimer.label) + } + } } internal class TestCounter: CounterHandler, Equatable {