From cbc2196b7add3afb476cb00635e97f7da578b005 Mon Sep 17 00:00:00 2001 From: Paul Toffoloni Date: Wed, 28 Aug 2024 14:58:43 +0200 Subject: [PATCH] Address feedback --- Package.swift | 2 +- Package@swift-5.9.swift | 4 +- Sources/Queues/AsyncQueue.swift | 5 +- Sources/Queues/Queue.swift | 11 +- Tests/QueuesTests/MetricsTests.swift | 14 +- .../Utilities/CapturingMetricsSystem.swift | 160 ----- Tests/QueuesTests/Utilities/TestMetrics.swift | 591 ++++++++++++++++++ 7 files changed, 612 insertions(+), 175 deletions(-) delete mode 100644 Tests/QueuesTests/Utilities/CapturingMetricsSystem.swift create mode 100644 Tests/QueuesTests/Utilities/TestMetrics.swift diff --git a/Package.swift b/Package.swift index 7fae7b9..9edbf4f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.101.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "2.0.0"), + .package(url: "https://github.com/apple/swift-metrics.git", from: "2.5.0"), ], targets: [ .target( diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 1092a83..79feedf 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -11,11 +11,12 @@ let package = Package( ], products: [ .library(name: "Queues", targets: ["Queues"]), - .library(name: "XCTQueues", targets: ["XCTQueues"]) + .library(name: "XCTQueues", targets: ["XCTQueues"]), ], dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.101.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/apple/swift-metrics.git", from: "2.5.0"), ], targets: [ .target( @@ -23,6 +24,7 @@ let package = Package( dependencies: [ .product(name: "Vapor", package: "vapor"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "Metrics", package: "swift-metrics"), ], swiftSettings: swiftSettings ), diff --git a/Sources/Queues/AsyncQueue.swift b/Sources/Queues/AsyncQueue.swift index a0bd785..577981c 100644 --- a/Sources/Queues/AsyncQueue.swift +++ b/Sources/Queues/AsyncQueue.swift @@ -83,7 +83,10 @@ extension Queue { logger.trace("Pusing job to queue") try await self.push(id).get() logger.info("Dispatched job") - Counter(label: "dispatched.jobs.counter", dimensions: [("queueName", self.queueName.string)]).increment() + Counter(label: "dispatched.jobs.counter", dimensions: [ + ("queueName", self.queueName.string), + ("jobName", J.name), + ]).increment() await self.sendNotification(of: "dispatch", logger: logger) { try await $0.dispatched(job: .init(id: id.string, queueName: self.queueName.string, jobData: storage), eventLoop: self.eventLoop).get() diff --git a/Sources/Queues/Queue.swift b/Sources/Queues/Queue.swift index 6df5de1..ef7b420 100644 --- a/Sources/Queues/Queue.swift +++ b/Sources/Queues/Queue.swift @@ -96,12 +96,11 @@ extension Queue { logger.trace("Pusing job to queue") return self.push(id) }.flatMapWithEventLoop { _, eventLoop in - Counter(label: "dispatched.jobs.counter", dimensions: [("queueName", self.queueName.string)]).increment() - self.logger.info("Dispatched queue job", metadata: [ - "job_id": .string(id.string), - "job_name": .string(job.name), - "queue": .string(self.queueName.string), - ]) + Counter(label: "dispatched.jobs.counter", dimensions: [ + ("queueName", self.queueName.string), + ("jobName", J.name), + ]).increment() + self.logger.info("Dispatched queue job") return self.sendNotification(of: "dispatch", logger: logger) { $0.dispatched(job: .init(id: id.string, queueName: self.queueName.string, jobData: storage), eventLoop: eventLoop) } diff --git a/Tests/QueuesTests/MetricsTests.swift b/Tests/QueuesTests/MetricsTests.swift index 4bea5a4..c09036d 100644 --- a/Tests/QueuesTests/MetricsTests.swift +++ b/Tests/QueuesTests/MetricsTests.swift @@ -8,10 +8,10 @@ import XCTVapor final class MetricsTests: XCTestCase { var app: Application! - var metrics: CapturingMetricsSystem! + var metrics: TestMetrics! override func setUp() async throws { - self.metrics = CapturingMetricsSystem() + self.metrics = TestMetrics() MetricsSystem.bootstrapInternal(self.metrics) self.app = try await Application.make(.testing) @@ -38,7 +38,7 @@ final class MetricsTests: XCTestCase { try await self.app.queues.queue.worker.run() - let timer = try XCTUnwrap(self.metrics.timers["some-id.jobDurationTimer"] as? TestTimer) + let timer = try XCTUnwrap(self.metrics.timers.first(where: { $0.label == "some-id.jobDurationTimer" })) let successDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "success" })) let idDimension = try XCTUnwrap(timer.dimensions.first(where: { $0.0 == "id" })) XCTAssertEqual(successDimension.1, "true") @@ -62,7 +62,7 @@ final class MetricsTests: XCTestCase { } try await self.app.queues.queue.worker.run() - let counter = try XCTUnwrap(self.metrics.counters["success.completed.jobs.counter"] as? TestCounter) + let counter = try XCTUnwrap(self.metrics.counters.first(where: { $0.label == "success.completed.jobs.counter" })) let queueNameDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "queueName" })) XCTAssertEqual(queueNameDimension.1, self.app.queues.queue.queueName.string) } @@ -82,7 +82,7 @@ final class MetricsTests: XCTestCase { } try await self.app.queues.queue.worker.run() - let counter = try XCTUnwrap(self.metrics.counters["error.completed.jobs.counter"] as? TestCounter) + let counter = try XCTUnwrap(self.metrics.counters.first(where: { $0.label == "error.completed.jobs.counter" })) let queueNameDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "queueName" })) XCTAssertEqual(queueNameDimension.1, self.app.queues.queue.queueName.string) } @@ -104,8 +104,10 @@ final class MetricsTests: XCTestCase { try await self.app.queues.queue.worker.run() - let counter = try XCTUnwrap(self.metrics.counters["dispatched.jobs.counter"] as? TestCounter) + let counter = try XCTUnwrap(self.metrics.counters.first(where: { $0.label == "dispatched.jobs.counter" })) let queueNameDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "queueName" })) + let jobNameDimension = try XCTUnwrap(counter.dimensions.first(where: { $0.0 == "jobName" })) XCTAssertEqual(queueNameDimension.1, self.app.queues.queue.queueName.string) + XCTAssertEqual(jobNameDimension.1, MyAsyncJob.name) } } diff --git a/Tests/QueuesTests/Utilities/CapturingMetricsSystem.swift b/Tests/QueuesTests/Utilities/CapturingMetricsSystem.swift deleted file mode 100644 index 27afcca..0000000 --- a/Tests/QueuesTests/Utilities/CapturingMetricsSystem.swift +++ /dev/null @@ -1,160 +0,0 @@ -@testable import CoreMetrics -import NIOConcurrencyHelpers -import Vapor - -final class CapturingMetricsSystem: MetricsFactory { - private let lock = NIOLock() - var counters = [String: any CounterHandler]() - var recorders = [String: any RecorderHandler]() - var timers = [String: any TimerHandler]() - - public func makeCounter(label: String, dimensions: [(String, String)]) -> any CounterHandler { - return self.make(label: label, dimensions: dimensions, registry: &self.counters, maker: TestCounter.init) - } - - public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> any RecorderHandler { - let maker = { (label: String, dimensions: [(String, String)]) 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)]) -> any TimerHandler { - return self.make(label: label, dimensions: dimensions, registry: &self.timers, maker: TestTimer.init) - } - - private func make(label: String, dimensions: [(String, String)], registry: inout [String: Item], maker: (String, [(String, String)]) -> Item) -> Item { - return self.lock.withLock { - let item = maker(label, dimensions) - registry[label] = item - return item - } - } - - func destroyCounter(_ handler: any CounterHandler) { - if let testCounter = handler as? TestCounter { - self.counters.removeValue(forKey: testCounter.label) - } - } - - func destroyRecorder(_ handler: any RecorderHandler) { - if let testRecorder = handler as? TestRecorder { - self.recorders.removeValue(forKey: testRecorder.label) - } - } - - func destroyTimer(_ handler: any TimerHandler) { - if let testTimer = handler as? TestTimer { - self.timers.removeValue(forKey: testTimer.label) - } - } -} - -final class TestCounter: CounterHandler, Equatable { - let id: String - let label: String - let dimensions: [(String, String)] - - let lock = NIOLock() - var values = [(Date, Int64)]() - - init(label: String, dimensions: [(String, String)]) { - self.id = UUID().uuidString - self.label = label - self.dimensions = dimensions - } - - func increment(by amount: Int64) { - self.lock.withLock { - self.values.append((Date(), amount)) - } - print("adding \(amount) to \(self.label)") - } - - func reset() { - self.lock.withLock { - self.values = [] - } - print("resetting \(self.label)") - } - - public static func == (lhs: TestCounter, rhs: TestCounter) -> Bool { - return lhs.id == rhs.id - } -} - -final class TestRecorder: RecorderHandler, Equatable { - let id: String - let label: String - let dimensions: [(String, String)] - let aggregate: Bool - - let lock = NIOLock() - var values = [(Date, Double)]() - - init(label: String, dimensions: [(String, String)], aggregate: Bool) { - self.id = UUID().uuidString - self.label = label - self.dimensions = dimensions - self.aggregate = aggregate - } - - func record(_ value: Int64) { - self.record(Double(value)) - } - - func record(_ value: Double) { - self.lock.withLock { - self.values.append((Date(), value)) - } - print("recording \(value) in \(self.label)") - } - - public static func == (lhs: TestRecorder, rhs: TestRecorder) -> Bool { - return lhs.id == rhs.id - } -} - -final class TestTimer: TimerHandler, Equatable { - let id: String - let label: String - var displayUnit: TimeUnit? - let dimensions: [(String, String)] - - let lock = NIOLock() - var values = [(Date, Int64)]() - - init(label: String, dimensions: [(String, String)]) { - self.id = UUID().uuidString - self.label = label - self.displayUnit = nil - self.dimensions = dimensions - } - - func preferDisplayUnit(_ unit: TimeUnit) { - self.lock.withLock { - self.displayUnit = unit - } - } - - func retrieveValueInPreferredUnit(atIndex i: Int) -> Double { - return self.lock.withLock { - let value = self.values[i].1 - guard let displayUnit = self.displayUnit else { - return Double(value) - } - return Double(value) / Double(displayUnit.scaleFromNanoseconds) - } - } - - func recordNanoseconds(_ duration: Int64) { - self.lock.withLock { - self.values.append((Date(), duration)) - } - print("recording \(duration) \(self.label)") - } - - public static func == (lhs: TestTimer, rhs: TestTimer) -> Bool { - return lhs.id == rhs.id - } -} diff --git a/Tests/QueuesTests/Utilities/TestMetrics.swift b/Tests/QueuesTests/Utilities/TestMetrics.swift new file mode 100644 index 0000000..40b0243 --- /dev/null +++ b/Tests/QueuesTests/Utilities/TestMetrics.swift @@ -0,0 +1,591 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Metrics API open source project +// +// Copyright (c) 2021 Apple Inc. and the Swift Metrics API project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Metrics API project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Cluster Membership open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift Cluster Membership project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.md for the list of Swift Cluster Membership project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import CoreMetrics +import Metrics +import XCTest + +/// Taken directly from `swift-cluster-memberships`'s own test target package, which +/// adopts the `TestMetrics` from `swift-metrics`. +/// +/// Metrics factory which allows inspecting recorded metrics programmatically. +/// Only intended for tests of the Metrics API itself. +/// +/// Created Handlers will store Metrics until they are explicitly destroyed. +/// +public final class TestMetrics: MetricsFactory { + private let lock = NSLock() + + public typealias Label = String + public typealias Dimensions = String + + public struct FullKey: Sendable { + let label: Label + let dimensions: [(String, String)] + } + + private var _counters = [FullKey: TestCounter]() + private var _meters = [FullKey: TestMeter]() + private var _recorders = [FullKey: TestRecorder]() + private var _timers = [FullKey: TestTimer]() + + public init() { + // nothing to do + } + + /// Reset method to destroy all created ``TestCounter``, ``TestMeter``, ``TestRecorder`` and ``TestTimer``. + /// Invoke this method in between test runs to verify that Counters are created as needed. + public func reset() { + self.lock.withLock { + self._counters = [:] + self._recorders = [:] + self._meters = [:] + self._timers = [:] + } + } + + public func makeCounter(label: String, dimensions: [(String, String)]) -> any CounterHandler { + return self.lock.withLock { () -> any CounterHandler in + if let existing = self._counters[.init(label: label, dimensions: dimensions)] { + return existing + } + let item = TestCounter(label: label, dimensions: dimensions) + self._counters[.init(label: label, dimensions: dimensions)] = item + return item + } + } + + public func makeMeter(label: String, dimensions: [(String, String)]) -> any MeterHandler { + return self.lock.withLock { () -> any MeterHandler in + if let existing = self._meters[.init(label: label, dimensions: dimensions)] { + return existing + } + let item = TestMeter(label: label, dimensions: dimensions) + self._meters[.init(label: label, dimensions: dimensions)] = item + return item + } + } + + public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> any RecorderHandler { + return self.lock.withLock { () -> any RecorderHandler in + if let existing = self._recorders[.init(label: label, dimensions: dimensions)] { + return existing + } + let item = TestRecorder(label: label, dimensions: dimensions, aggregate: aggregate) + self._recorders[.init(label: label, dimensions: dimensions)] = item + return item + } + } + + public func makeTimer(label: String, dimensions: [(String, String)]) -> any TimerHandler { + return self.lock.withLock { () -> any TimerHandler in + if let existing = self._timers[.init(label: label, dimensions: dimensions)] { + return existing + } + let item = TestTimer(label: label, dimensions: dimensions) + self._timers[.init(label: label, dimensions: dimensions)] = item + return item + } + } + + public func destroyCounter(_ handler: any CounterHandler) { + if let testCounter = handler as? TestCounter { + self.lock.withLock { + self._counters.removeValue(forKey: testCounter.key) + } + } + } + + public func destroyMeter(_ handler: any MeterHandler) { + if let testMeter = handler as? TestMeter { + self.lock.withLock { () in + self._meters.removeValue(forKey: testMeter.key) + } + } + } + + public func destroyRecorder(_ handler: any RecorderHandler) { + if let testRecorder = handler as? TestRecorder { + self.lock.withLock { + self._recorders.removeValue(forKey: testRecorder.key) + } + } + } + + public func destroyTimer(_ handler: any TimerHandler) { + if let testTimer = handler as? TestTimer { + self.lock.withLock { + self._timers.removeValue(forKey: testTimer.key) + } + } + } +} + +extension TestMetrics.FullKey: Hashable { + public func hash(into hasher: inout Hasher) { + self.label.hash(into: &hasher) + for dim in self.dimensions { + dim.0.hash(into: &hasher) + dim.1.hash(into: &hasher) + } + } + + public static func == (lhs: TestMetrics.FullKey, rhs: TestMetrics.FullKey) -> Bool { + return lhs.label == rhs.label && + Dictionary(uniqueKeysWithValues: lhs.dimensions) == Dictionary(uniqueKeysWithValues: rhs.dimensions) + } +} + +// MARK: - Assertions + +extension TestMetrics { + // MARK: - Counter + + public func expectCounter(_ metric: Counter) throws -> TestCounter { + guard let counter = metric._handler as? TestCounter else { + throw TestMetricsError.illegalMetricType(metric: metric._handler, expected: "\(TestCounter.self)") + } + return counter + } + + public func expectCounter(_ label: String, _ dimensions: [(String, String)] = []) throws -> TestCounter { + let maybeItem = self.lock.withLock { + self._counters[.init(label: label, dimensions: dimensions)] + } + guard let testCounter = maybeItem else { + throw TestMetricsError.missingMetric(label: label, dimensions: dimensions) + } + return testCounter + } + + /// All the counters which have been created and not destroyed + public var counters: [TestCounter] { + let counters = self.lock.withLock { + self._counters + } + return Array(counters.values) + } + + // MARK: - Gauge + + public func expectGauge(_ metric: Gauge) throws -> TestRecorder { + return try self.expectRecorder(metric) + } + + public func expectGauge(_ label: String, _ dimensions: [(String, String)] = []) throws -> TestRecorder { + return try self.expectRecorder(label, dimensions) + } + + // MARK: - Meter + + public func expectMeter(_ metric: Meter) throws -> TestMeter { + guard let meter = metric._handler as? TestMeter else { + throw TestMetricsError.illegalMetricType(metric: metric._handler, expected: "\(TestMeter.self)") + } + return meter + } + + public func expectMeter(_ label: String, _ dimensions: [(String, String)] = []) throws -> TestMeter { + let maybeItem = self.lock.withLock { + self._meters[.init(label: label, dimensions: dimensions)] + } + guard let testMeter = maybeItem else { + throw TestMetricsError.missingMetric(label: label, dimensions: dimensions) + } + return testMeter + } + + /// All the meters which have been created and not destroyed + public var meters: [TestMeter] { + let meters = self.lock.withLock { + self._meters + } + return Array(meters.values) + } + + // MARK: - Recorder + + public func expectRecorder(_ metric: Recorder) throws -> TestRecorder { + guard let recorder = metric._handler as? TestRecorder else { + throw TestMetricsError.illegalMetricType(metric: metric._handler, expected: "\(TestRecorder.self)") + } + return recorder + } + + public func expectRecorder(_ label: String, _ dimensions: [(String, String)] = []) throws -> TestRecorder { + let maybeItem = self.lock.withLock { + self._recorders[.init(label: label, dimensions: dimensions)] + } + guard let testRecorder = maybeItem else { + throw TestMetricsError.missingMetric(label: label, dimensions: dimensions) + } + return testRecorder + } + + /// All the recorders which have been created and not destroyed + public var recorders: [TestRecorder] { + let recorders = self.lock.withLock { + self._recorders + } + return Array(recorders.values) + } + + // MARK: - Timer + + public func expectTimer(_ metric: CoreMetrics.Timer) throws -> TestTimer { + guard let timer = metric._handler as? TestTimer else { + throw TestMetricsError.illegalMetricType(metric: metric._handler, expected: "\(TestTimer.self)") + } + return timer + } + + public func expectTimer(_ label: String, _ dimensions: [(String, String)] = []) throws -> TestTimer { + let maybeItem = self.lock.withLock { + self._timers[.init(label: label, dimensions: dimensions)] + } + guard let testTimer = maybeItem else { + throw TestMetricsError.missingMetric(label: label, dimensions: dimensions) + } + return testTimer + } + + /// All the timers which have been created and not destroyed + public var timers: [TestTimer] { + let timers = self.lock.withLock { + self._timers + } + return Array(timers.values) + } +} + +// MARK: - Metric type implementations + +public protocol TestMetric { + associatedtype Value + + var key: TestMetrics.FullKey { get } + + var lastValue: Value? { get } + var last: (Date, Value)? { get } +} + +public final class TestCounter: TestMetric, CounterHandler, Equatable { + public let id: String + public let label: String + public let dimensions: [(String, String)] + + public var key: TestMetrics.FullKey { + return TestMetrics.FullKey(label: self.label, dimensions: self.dimensions) + } + + let lock = NSLock() + private var _values = [(Date, Int64)]() + + init(label: String, dimensions: [(String, String)]) { + self.id = UUID().uuidString + self.label = label + self.dimensions = dimensions + } + + public func increment(by amount: Int64) { + self.lock.withLock { + self._values.append((Date(), amount)) + } + } + + public func reset() { + return self.lock.withLock { + self._values = [] + } + } + + public var lastValue: Int64? { + return self.last?.1 + } + + public var totalValue: Int64 { + return self.values.reduce(0, +) + } + + public var last: (Date, Int64)? { + return self.lock.withLock { + self._values.last + } + } + + public var values: [Int64] { + return self.lock.withLock { + self._values.map { $0.1 } + } + } + + public static func == (lhs: TestCounter, rhs: TestCounter) -> Bool { + return lhs.id == rhs.id + } +} + +public final class TestMeter: TestMetric, MeterHandler, Equatable { + public let id: String + public let label: String + public let dimensions: [(String, String)] + + public var key: TestMetrics.FullKey { + return TestMetrics.FullKey(label: self.label, dimensions: self.dimensions) + } + + let lock = NSLock() + private var _values = [(Date, Double)]() + + init(label: String, dimensions: [(String, String)]) { + self.id = UUID().uuidString + self.label = label + self.dimensions = dimensions + } + + public func set(_ value: Int64) { + self.set(Double(value)) + } + + public func set(_ value: Double) { + self.lock.withLock { + // this may lose precision but good enough as an example + self._values.append((Date(), Double(value))) + } + } + + public func increment(by amount: Double) { + // Drop illegal values + // - cannot increment by NaN + guard !amount.isNaN else { + return + } + // - cannot increment by infinite quantities + guard !amount.isInfinite else { + return + } + // - cannot increment by negative values + guard amount.sign == .plus else { + return + } + // - cannot increment by zero + guard !amount.isZero else { + return + } + + self.lock.withLock { + let lastValue: Double = self._values.last?.1 ?? 0 + let newValue = lastValue + amount + self._values.append((Date(), newValue)) + } + } + + public func decrement(by amount: Double) { + // Drop illegal values + // - cannot decrement by NaN + guard !amount.isNaN else { + return + } + // - cannot decrement by infinite quantities + guard !amount.isInfinite else { + return + } + // - cannot decrement by negative values + guard amount.sign == .plus else { + return + } + // - cannot decrement by zero + guard !amount.isZero else { + return + } + + self.lock.withLock { + let lastValue: Double = self._values.last?.1 ?? 0 + let newValue = lastValue - amount + self._values.append((Date(), newValue)) + } + } + + public var lastValue: Double? { + return self.last?.1 + } + + public var last: (Date, Double)? { + return self.lock.withLock { + self._values.last + } + } + + public var values: [Double] { + return self.lock.withLock { + self._values.map { $0.1 } + } + } + + public static func == (lhs: TestMeter, rhs: TestMeter) -> Bool { + return lhs.id == rhs.id + } +} + +public final class TestRecorder: TestMetric, RecorderHandler, Equatable { + public let id: String + public let label: String + public let dimensions: [(String, String)] + public let aggregate: Bool + + public var key: TestMetrics.FullKey { + return TestMetrics.FullKey(label: self.label, dimensions: self.dimensions) + } + + let lock = NSLock() + private var _values = [(Date, Double)]() + + init(label: String, dimensions: [(String, String)], aggregate: Bool) { + self.id = UUID().uuidString + self.label = label + self.dimensions = dimensions + self.aggregate = aggregate + } + + public func record(_ value: Int64) { + self.record(Double(value)) + } + + public func record(_ value: Double) { + self.lock.withLock { + // this may lose precision but good enough as an example + self._values.append((Date(), Double(value))) + } + } + + public var lastValue: Double? { + return self.last?.1 + } + + public var last: (Date, Double)? { + return self.lock.withLock { + self._values.last + } + } + + public var values: [Double] { + return self.lock.withLock { + self._values.map { $0.1 } + } + } + + public static func == (lhs: TestRecorder, rhs: TestRecorder) -> Bool { + return lhs.id == rhs.id + } +} + +public final class TestTimer: TestMetric, TimerHandler, Equatable { + public let id: String + public let label: String + public var displayUnit: TimeUnit? + public let dimensions: [(String, String)] + + public var key: TestMetrics.FullKey { + return TestMetrics.FullKey(label: self.label, dimensions: self.dimensions) + } + + let lock = NSLock() + private var _values = [(Date, Int64)]() + + init(label: String, dimensions: [(String, String)]) { + self.id = UUID().uuidString + self.label = label + self.displayUnit = nil + self.dimensions = dimensions + } + + public func preferDisplayUnit(_ unit: TimeUnit) { + self.lock.withLock { + self.displayUnit = unit + } + } + + public func valueInPreferredUnit(atIndex i: Int) -> Double { + let value = self.values[i] + guard let displayUnit = self.displayUnit else { + return Double(value) + } + return Double(value) / Double(displayUnit.scaleFromNanoseconds) + } + + public func recordNanoseconds(_ duration: Int64) { + self.lock.withLock { + self._values.append((Date(), duration)) + } + } + + public var lastValue: Int64? { + return self.last?.1 + } + + public var values: [Int64] { + return self.lock.withLock { + self._values.map { $0.1 } + } + } + + public var last: (Date, Int64)? { + return self.lock.withLock { + self._values.last + } + } + + public static func == (lhs: TestTimer, rhs: TestTimer) -> Bool { + return lhs.id == rhs.id + } +} + +extension NSLock { + @discardableResult + fileprivate func withLock(_ body: () -> T) -> T { + self.lock() + defer { + self.unlock() + } + return body() + } +} + +// MARK: - Errors + +public enum TestMetricsError: Error { + case missingMetric(label: String, dimensions: [(String, String)]) + case illegalMetricType(metric: any Sendable, expected: String) +} + +// MARK: - Sendable support + +// ideally we would not be using @unchecked here, but concurrency-safety checks do not recognize locks +extension TestMetrics: @unchecked Sendable {} +extension TestCounter: @unchecked Sendable {} +extension TestMeter: @unchecked Sendable {} +extension TestRecorder: @unchecked Sendable {} +extension TestTimer: @unchecked Sendable {}