add "meter" - a new type of metric and metric handler (#123)

motivation: seperate gauge from recorder in a backwards compatible way

changes:
* add new meter and meter handler pair
* add increment and decrement to meter handler
* add default implementation based on recorder for backwards compatibility
* add and adjust tests
This commit is contained in:
tomer doron 2023-05-24 13:55:56 -07:00 committed by GitHub
parent 862b99bc11
commit 32eef8ae84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 694 additions and 165 deletions

View File

@ -10,4 +10,7 @@
--stripunusedargs unnamed-only
--ifdef no-indent
# Configure the placement of an extension's access control keyword.
--extensionacl on-declarations
# rules

View File

@ -28,8 +28,7 @@ let package = Package(
],
targets: [
.target(
name: "CoreMetrics",
dependencies: []
name: "CoreMetrics"
),
.target(
name: "Metrics",

View File

@ -96,6 +96,18 @@ The API supports four metric types:
```swift
counter.increment(by: 100)
```
`Gauge`: A Gauge is a metric that represents a single numerical value that can arbitrarily go up and down. Gauges are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads. Gauges are modeled as a `Recorder` with a sample size of 1 that does not perform any aggregation.
```swift
gauge.record(100)
```
`Meter`: A Meter is similar to `Gauge` - a metric that represents a single numerical value that can arbitrarily go up and down. Meters are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads. Unlike `Gauge`, `Meter` also supports atomic incerements and decerements.
```swift
meter.record(100)
```
`Recorder`: A recorder collects observations within a time window (usually things like response sizes) and *can* provide aggregated information about the data sample, for example count, sum, min, max and various quantiles.
@ -104,12 +116,6 @@ counter.increment(by: 100)
recorder.record(100)
```
`Gauge`: A Gauge is a metric that represents a single numerical value that can arbitrarily go up and down. Gauges are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads. Gauges are modeled as a `Recorder` with a sample size of 1 that does not perform any aggregation.
```swift
gauge.record(100)
```
`Timer`: A timer collects observations within a time window (usually things like request duration) and provides aggregated information about the data sample, for example min, max and various quantiles. It is similar to a `Recorder` but specialized for values that represent durations.
```swift
@ -120,7 +126,7 @@ timer.recordMilliseconds(100)
Note: Unless you need to implement a custom metrics backend, everything in this section is likely not relevant, so please feel free to skip.
As seen above, each constructor for `Counter`, `Timer`, `Recorder` and `Gauge` provides a metric object. This uncertainty obscures the selected metrics backend calling these constructors by design. _Each application_ can select and configure its desired backend. The application sets up the metrics backend it wishes to use. Configuring the metrics backend is straightforward:
As seen above, each constructor for `Counter`, `Gauge`, `Meter`, `Recorder` and `Timer` provides a metric object. This uncertainty obscures the selected metrics backend calling these constructors by design. _Each application_ can select and configure its desired backend. The application sets up the metrics backend it wishes to use. Configuring the metrics backend is straightforward:
```swift
let metricsImplementation = MyFavoriteMetricsImplementation()
@ -134,10 +140,12 @@ Given the above, an implementation of a metric backend needs to conform to `prot
```swift
public protocol MetricsFactory {
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler
func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler
func destroyCounter(_ handler: CounterHandler)
func destroyMeter(_ handler: MeterHandler)
func destroyRecorder(_ handler: RecorderHandler)
func destroyTimer(_ handler: TimerHandler)
}
@ -154,11 +162,14 @@ public protocol CounterHandler: AnyObject {
}
```
**Timer**
**Meter**
```swift
public protocol TimerHandler: AnyObject {
func recordNanoseconds(_ duration: Int64)
public protocol MeterHandler: AnyObject {
func set(_ value: Int64)
func set(_ value: Double)
func increment(by: Double)
func decrement(by: Double)
}
```
@ -171,9 +182,17 @@ public protocol RecorderHandler: AnyObject {
}
```
**Timer**
```swift
public protocol TimerHandler: AnyObject {
func recordNanoseconds(_ duration: Int64)
}
```
#### Dealing with Overflows
Implementaton of metric objects that deal with integers, like `Counter` and `Timer` should be careful with overflow. The expected behavior is to cap at `.max`, and never crash the program due to overflow . For example:
Implementation of metric objects that deal with integers, like `Counter` and `Timer` should be careful with overflow. The expected behavior is to cap at `.max`, and never crash the program due to overflow . For example:
```swift
class ExampleCounter: CounterHandler {
@ -201,9 +220,12 @@ class SimpleMetricsLibrary: MetricsFactory {
return ExampleCounter(label, dimensions)
}
func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return ExampleMeter(label, dimensions)
}
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
let maker: (String, [(String, String)]) -> RecorderHandler = aggregate ? ExampleRecorder.init : ExampleGauge.init
return maker(label, dimensions)
return ExampleRecorder(label, dimensions, aggregate)
}
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
@ -212,7 +234,8 @@ class SimpleMetricsLibrary: MetricsFactory {
// implementation is stateless, so nothing to do on destroy calls
func destroyCounter(_ handler: CounterHandler) {}
func destroyRecorder(_ handler: RecorderHandler) {}
func destroyMeter(_ handler: TimerHandler) {}
func destroyRecorder(_ handler: RecorderHandler) {}
func destroyTimer(_ handler: TimerHandler) {}
private class ExampleCounter: CounterHandler {
@ -233,9 +256,32 @@ class SimpleMetricsLibrary: MetricsFactory {
}
}
private class ExampleRecorder: RecorderHandler {
private class ExampleMeter: MeterHandler {
init(_: String, _: [(String, String)]) {}
let lock = NSLock()
var _value: Double = 0
func set(_ value: Int64) {
self.set(Double(value))
}
func set(_ value: Double) {
self.lock.withLock { _value = value }
}
func increment(by value: Double) {
self.lock.withLock { self._value += value }
}
func decrement(by value: Double) {
self.lock.withLock { self._value -= value }
}
}
private class ExampleRecorder: RecorderHandler {
init(_: String, _: [(String, String)], _: Bool) {}
private let lock = NSLock()
var values = [(Int64, Double)]()
func record(_ value: Int64) {
@ -274,23 +320,14 @@ class SimpleMetricsLibrary: MetricsFactory {
}
}
private class ExampleGauge: RecorderHandler {
private class ExampleTimer: TimerHandler {
init(_: String, _: [(String, String)]) {}
let lock = NSLock()
var _value: Double = 0
func record(_ value: Int64) {
self.record(Double(value))
}
var _value: Int64 = 0
func record(_ value: Double) {
self.lock.withLock { _value = value }
}
}
private class ExampleTimer: ExampleRecorder, TimerHandler {
func recordNanoseconds(_ duration: Int64) {
super.record(duration)
self.lock.withLock { _value = duration }
}
}
}

View File

@ -27,7 +27,7 @@ and to your application/library target, add "Metrics" to your dependencies:
.target(
name: "BestExampleApp",
dependencies: [
// ...
// ...
.product(name: "Metrics", package: "swift-metrics"),
]
),
@ -90,7 +90,8 @@ This API was designed with the contributors to the Swift on Server community and
### Metric types
- ``Gauge``
- ``Recorder``
- ``Counter``
- ``Meter``
- ``Recorder``
- ``Timer``

View File

@ -114,7 +114,7 @@ extension Lock {
/// - Parameter body: The block to execute while holding the lock.
/// - Returns: The value returned by the block.
@inlinable
internal func withLock<T>(_ body: () throws -> T) rethrows -> T {
func withLock<T>(_ body: () throws -> T) rethrows -> T {
self.lock()
defer {
self.unlock()
@ -124,7 +124,7 @@ extension Lock {
// specialise Void return (for performance)
@inlinable
internal func withLockVoid(_ body: () throws -> Void) rethrows {
func withLockVoid(_ body: () throws -> Void) rethrows {
try self.withLock(body)
}
}
@ -234,7 +234,7 @@ extension ReadWriteLock {
/// - Parameter body: The block to execute while holding the reader lock.
/// - Returns: The value returned by the block.
@inlinable
internal func withReaderLock<T>(_ body: () throws -> T) rethrows -> T {
func withReaderLock<T>(_ body: () throws -> T) rethrows -> T {
self.lockRead()
defer {
self.unlock()
@ -251,7 +251,7 @@ extension ReadWriteLock {
/// - Parameter body: The block to execute while holding the writer lock.
/// - Returns: The value returned by the block.
@inlinable
internal func withWriterLock<T>(_ body: () throws -> T) rethrows -> T {
func withWriterLock<T>(_ body: () throws -> T) rethrows -> T {
self.lockWrite()
defer {
self.unlock()
@ -261,13 +261,13 @@ extension ReadWriteLock {
// specialise Void return (for performance)
@inlinable
internal func withReaderLockVoid(_ body: () throws -> Void) rethrows {
func withReaderLockVoid(_ body: () throws -> Void) rethrows {
try self.withReaderLock(body)
}
// specialise Void return (for performance)
@inlinable
internal func withWriterLockVoid(_ body: () throws -> Void) rethrows {
func withWriterLockVoid(_ body: () throws -> Void) rethrows {
try self.withWriterLock(body)
}
}

View File

@ -16,25 +16,6 @@
// MARK: - Counter
extension Counter {
/// Create a new `Counter`.
///
/// - parameters:
/// - label: The label for the `Counter`.
/// - dimensions: The dimensions for the `Counter`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
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 the library MAY decide to eagerly release any resources held by this `Counter`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyCounter(self._handler)
}
}
/// A counter is a cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero.
/// For example, you can use a counter to represent the number of requests served, tasks completed, or errors.
///
@ -86,6 +67,25 @@ public final class Counter {
}
}
extension Counter {
/// Create a new `Counter`.
///
/// - parameters:
/// - label: The label for the `Counter`.
/// - dimensions: The dimensions for the `Counter`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
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 the library MAY decide to eagerly release any resources held by this `Counter`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyCounter(self._handler)
}
}
extension Counter: CustomStringConvertible {
public var description: String {
return "Counter(\(self.label), dimensions: \(self.dimensions))"
@ -94,25 +94,6 @@ extension Counter: CustomStringConvertible {
// MARK: - FloatingPointCounter
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 values and records increments to a standard Counter after crossing integer boundaries.
@ -165,33 +146,120 @@ public final class FloatingPointCounter {
}
}
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)
}
}
extension FloatingPointCounter: CustomStringConvertible {
public var description: String {
return "FloatingPointCounter(\(self.label), dimensions: \(self.dimensions))"
}
}
// MARK: - Recorder
// MARK: - Gauge
extension Recorder {
/// Create a new `Recorder`.
/// A gauge is a metric that represents a single numerical value that can arbitrarily go up and down.
/// Gauges are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads.
/// Gauges are modeled as `Recorder` with a sample size of 1 and that does not perform any aggregation.
public final class Gauge: Recorder {
/// Create a new `Gauge`.
///
/// - parameters:
/// - label: The label for the `Gauge`.
/// - dimensions: The dimensions for the `Gauge`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
self.init(label: label, dimensions: dimensions, aggregate: false)
}
}
// MARK: - Meter
/// A meter is similar to a gauge, it is a metric that represents a single numerical value that can arbitrarily go up and down.
/// Meters are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads.
public final class Meter {
/// ``_handler`` is only public to allow access from `MetricsTestKit`. Do not consider it part of the public API.
public let _handler: MeterHandler
public let label: String
public let dimensions: [(String, String)]
/// Alternative way to create a new `Meter`, while providing an explicit `MeterHandler`.
///
/// - 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 `Meter` instances using the configured metrics backend.
///
/// - parameters:
/// - label: The label for the `Recorder`.
/// - dimensions: The dimensions for the `Recorder`.
public convenience init(label: String, dimensions: [(String, String)] = [], aggregate: Bool = true) {
let handler = MetricsSystem.factory.makeRecorder(label: label, dimensions: dimensions, aggregate: aggregate)
self.init(label: label, dimensions: dimensions, aggregate: aggregate, handler: handler)
/// - handler: The custom backend.
public init(label: String, dimensions: [(String, String)], handler: MeterHandler) {
self.label = label
self.dimensions = dimensions
self._handler = handler
}
/// Set a value.
///
/// - parameters:
/// - value: Value to set.
@inlinable
public func set<DataType: BinaryInteger>(_ value: DataType) {
self._handler.set(Int64(value))
}
/// Set a value.
///
/// - parameters:
/// - value: Value to est.
@inlinable
public func set<DataType: BinaryFloatingPoint>(_ value: DataType) {
self._handler.set(Double(value))
}
}
extension Meter {
/// Create a new `Meter`.
///
/// - parameters:
/// - label: The label for the `Meter`.
/// - dimensions: The dimensions for the `Meter`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
let handler = MetricsSystem.factory.makeMeter(label: label, dimensions: dimensions)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Signal the underlying metrics library that this recorder will never be updated again.
/// In response the library MAY decide to eagerly release any resources held by this `Recorder`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyRecorder(self._handler)
MetricsSystem.factory.destroyMeter(self._handler)
}
}
extension Meter: CustomStringConvertible {
public var description: String {
return "\(type(of: self))(\(self.label), dimensions: \(self.dimensions))"
}
}
// MARK: - Recorder
/// A recorder collects observations within a time window (usually things like response sizes) and *can* provide aggregated information about the data sample, for example, count, sum, min, max and various quantiles.
///
/// This is the user-facing Recorder API.
@ -214,6 +282,7 @@ public class Recorder {
/// - parameters:
/// - label: The label for the `Recorder`.
/// - dimensions: The dimensions for the `Recorder`.
/// - aggregate: aggregate recorded values to produce statistics across a sample size
/// - handler: The custom backend.
public init(label: String, dimensions: [(String, String)], aggregate: Bool, handler: RecorderHandler) {
self.label = label
@ -247,25 +316,29 @@ public class Recorder {
}
}
extension Recorder: CustomStringConvertible {
public var description: String {
return "\(type(of: self))(\(self.label), dimensions: \(self.dimensions), aggregate: \(self.aggregate))"
extension Recorder {
/// Create a new `Recorder`.
///
/// - parameters:
/// - label: The label for the `Recorder`.
/// - dimensions: The dimensions for the `Recorder`.
/// - aggregate: aggregate recorded values to produce statistics across a sample size
public convenience init(label: String, dimensions: [(String, String)] = [], aggregate: Bool = true) {
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 the library MAY decide to eagerly release any resources held by this `Recorder`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyRecorder(self._handler)
}
}
// MARK: - Gauge
/// A gauge is a metric that represents a single numerical value that can arbitrarily go up and down.
/// Gauges are typically used for measured values like temperatures or current memory usage, but also "counts" that can go up and down, like the number of active threads.
/// Gauges are modeled as `Recorder` with a sample size of 1 and that does not perform any aggregation.
public final class Gauge: Recorder {
/// Create a new `Gauge`.
///
/// - parameters:
/// - label: The label for the `Gauge`.
/// - dimensions: The dimensions for the `Gauge`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
self.init(label: label, dimensions: dimensions, aggregate: false)
extension Recorder: CustomStringConvertible {
public var description: String {
return "\(type(of: self))(\(self.label), dimensions: \(self.dimensions), aggregate: \(self.aggregate))"
}
}
@ -301,37 +374,6 @@ public struct TimeUnit: Equatable {
public static let days = TimeUnit(code: .days, scaleFromNanoseconds: 24 * TimeUnit.hours.scaleFromNanoseconds)
}
public extension Timer {
/// Create a new `Timer`.
///
/// - parameters:
/// - label: The label for the `Timer`.
/// - dimensions: The dimensions for the `Timer`.
convenience init(label: String, dimensions: [(String, String)] = []) {
let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Create a new `Timer`.
///
/// - parameters:
/// - label: The label for the `Timer`.
/// - dimensions: The dimensions for the `Timer`.
/// - displayUnit: A hint to the backend responsible for presenting the data of the preferred display unit. This is not guaranteed to be supported by all backends.
convenience init(label: String, dimensions: [(String, String)] = [], preferredDisplayUnit displayUnit: TimeUnit) {
let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions)
handler.preferDisplayUnit(displayUnit)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Signal the underlying metrics library that this timer will never be updated again.
/// In response the library MAY decide to eagerly release any resources held by this `Timer`.
@inlinable
func destroy() {
MetricsSystem.factory.destroyTimer(self._handler)
}
}
/// A timer collects observations within a time window (usually things like request durations) and provides aggregated information about the data sample,
/// for example, min, max and various quantiles. It is similar to a `Recorder` but specialized for values that represent durations.
///
@ -455,6 +497,37 @@ public final class Timer {
}
}
extension Timer {
/// Create a new `Timer`.
///
/// - parameters:
/// - label: The label for the `Timer`.
/// - dimensions: The dimensions for the `Timer`.
public convenience init(label: String, dimensions: [(String, String)] = []) {
let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Create a new `Timer`.
///
/// - parameters:
/// - label: The label for the `Timer`.
/// - dimensions: The dimensions for the `Timer`.
/// - displayUnit: A hint to the backend responsible for presenting the data of the preferred display unit. This is not guaranteed to be supported by all backends.
public convenience init(label: String, dimensions: [(String, String)] = [], preferredDisplayUnit displayUnit: TimeUnit) {
let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions)
handler.preferDisplayUnit(displayUnit)
self.init(label: label, dimensions: dimensions, handler: handler)
}
/// Signal the underlying metrics library that this timer will never be updated again.
/// In response the library MAY decide to eagerly release any resources held by this `Timer`.
@inlinable
public func destroy() {
MetricsSystem.factory.destroyTimer(self._handler)
}
}
extension Timer: CustomStringConvertible {
public var description: String {
return "Timer(\(self.label), dimensions: \(self.dimensions))"
@ -570,6 +643,13 @@ public protocol MetricsFactory: _SwiftMetricsSendableProtocol {
/// - dimensions: The dimensions for the `FloatingPointCounterHandler`.
func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler
/// Create a backing `MeterHandler`.
///
/// - parameters:
/// - label: The label for the `MeterHandler`.
/// - dimensions: The dimensions for the `MeterHandler`.
func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler
/// Create a backing `RecorderHandler`.
///
/// - parameters:
@ -592,6 +672,13 @@ public protocol MetricsFactory: _SwiftMetricsSendableProtocol {
/// - handler: The handler to be destroyed.
func destroyCounter(_ handler: CounterHandler)
/// Invoked when the corresponding `Meter`'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 destroyMeter(_ handler: MeterHandler)
/// 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.
///
@ -679,6 +766,54 @@ internal final class AccumulatingRoundingFloatingPointCounter: FloatingPointCoun
}
}
/// Wraps a RecorderHandler, adding support for incrementing values by storing an accumulated value and recording increments to the underlying CounterHandler after crossing integer boundaries.
internal final class AccumulatingMeter: MeterHandler {
private let recorderHandler: RecorderHandler
// FIXME: use atomics when available
private var value: Double = 0
private let lock = Lock()
init(label: String, dimensions: [(String, String)]) {
self.recorderHandler = MetricsSystem
.factory.makeRecorder(label: label, dimensions: dimensions, aggregate: true)
}
func set(_ value: Int64) {
self._set(Double(value))
}
func set(_ value: Double) {
self._set(value)
}
func increment(by amount: Double) {
let newValue: Double = self.lock.withLock {
self.value += amount
return self.value
}
self.recorderHandler.record(newValue)
}
func decrement(by amount: Double) {
let newValue: Double = self.lock.withLock {
self.value -= amount
return self.value
}
self.recorderHandler.record(newValue)
}
private func _set(_ value: Double) {
self.lock.withLockVoid {
self.value = value
}
self.recorderHandler.record(value)
}
func destroy() {
MetricsSystem.factory.destroyRecorder(self.recorderHandler)
}
}
extension MetricsFactory {
/// Create a default backing `FloatingPointCounterHandler` for backends which do not naively support floating point counters.
///
@ -703,6 +838,30 @@ extension MetricsFactory {
}
}
extension MetricsFactory {
/// Create a default backing `MeterHandler` for backends which do not naively support meters.
///
/// The created MeterHandler is a wrapper around a backend's RecorderHandler which records current values.
///
/// - parameters:
/// - label: The label for the `MeterHandler`.
/// - dimensions: The dimensions for the `MeterHandler`.
public func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return AccumulatingMeter(label: label, dimensions: dimensions)
}
/// Invoked when the corresponding `Meter`'s `destroy()` function is invoked.
/// Upon receiving this signal the factory may eagerly release any resources related to this counter.
///
/// `destroyMeter` must be implemented if `makeMeter` is implemented.
///
/// - parameters:
/// - handler: The handler to be destroyed.
public func destroyMeter(_ handler: MeterHandler) {
(handler as? AccumulatingMeter)?.destroy()
}
}
// MARK: - Backend Handlers
/// A `CounterHandler` represents a backend implementation of a `Counter`.
@ -773,6 +932,42 @@ public protocol RecorderHandler: AnyObject, _SwiftMetricsSendableProtocol {
func record(_ value: Double)
}
/// A `MeterHandler` represents a backend implementation of a `Meter`.
///
/// 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 `Meter`.
///
/// # Implementation requirements
///
/// To implement your own `MeterHandler` you should respect a few requirements that are necessary so applications work
/// as expected regardless of the selected `MeterHandler` implementation.
///
/// - The `RecorderHandler` must be a `class`.
public protocol MeterHandler: AnyObject, _SwiftMetricsSendableProtocol {
/// Set a value.
///
/// - parameters:
/// - value: Value to set.
func set(_ value: Int64)
/// Set a value.
///
/// - parameters:
/// - value: Value to set.
func set(_ value: Double)
/// Increment the value.
///
/// - parameters:
/// - by: Amount to increment by.
func increment(by: Double)
/// Decrement the value.
///
/// - parameters:
/// - by: Amount to increment by.
func decrement(by: Double)
}
/// A `TimerHandler` represents a backend implementation of a `Timer`.
///
/// This type is an implementation detail and should not be used directly, unless implementing your own metrics backend.
@ -821,6 +1016,10 @@ public final class MultiplexMetricsHandler: MetricsFactory {
return MuxFloatingPointCounter(factories: self.factories, label: label, dimensions: dimensions)
}
public func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return MuxMeter(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)
}
@ -841,6 +1040,12 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
}
public func destroyMeter(_ handler: MeterHandler) {
for factory in self.factories {
factory.destroyMeter(handler)
}
}
public func destroyRecorder(_ handler: RecorderHandler) {
for factory in self.factories {
factory.destroyRecorder(handler)
@ -883,6 +1088,29 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
}
private final class MuxMeter: MeterHandler {
let meters: [MeterHandler]
public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)]) {
self.meters = factories.map { $0.makeMeter(label: label, dimensions: dimensions) }
}
func set(_ value: Int64) {
self.meters.forEach { $0.set(value) }
}
func set(_ value: Double) {
self.meters.forEach { $0.set(value) }
}
func increment(by amount: Double) {
self.meters.forEach { $0.increment(by: amount) }
}
func decrement(by amount: Double) {
self.meters.forEach { $0.decrement(by: amount) }
}
}
private final class MuxRecorder: RecorderHandler {
let recorders: [RecorderHandler]
public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)], aggregate: Bool) {
@ -915,7 +1143,7 @@ public final class MultiplexMetricsHandler: MetricsFactory {
}
/// Ships with the metrics module, used for initial bootstrapping.
public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, FloatingPointCounterHandler, RecorderHandler, TimerHandler {
public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, FloatingPointCounterHandler, MeterHandler, RecorderHandler, TimerHandler {
public static let instance = NOOPMetricsHandler()
private init() {}
@ -928,6 +1156,10 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, FloatingP
return self
}
public func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return self
}
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
return self
}
@ -938,15 +1170,19 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, FloatingP
public func destroyCounter(_: CounterHandler) {}
public func destroyFloatingPointCounter(_: FloatingPointCounterHandler) {}
public func destroyMeter(_: MeterHandler) {}
public func destroyRecorder(_: RecorderHandler) {}
public func destroyTimer(_: TimerHandler) {}
public func increment(by: Int64) {}
public func increment(by: Double) {}
public func decrement(by: Double) {}
public func reset() {}
public func record(_: Int64) {}
public func record(_: Double) {}
public func recordNanoseconds(_: Int64) {}
public func set(_: Int64) {}
public func set(_: Double) {}
}
// MARK: - Sendable support helpers
@ -958,6 +1194,7 @@ extension FloatingPointCounter: Sendable {}
// must be @unchecked since Gauge inherits Recorder :(
extension Recorder: @unchecked Sendable {}
extension Timer: Sendable {}
extension Meter: Sendable {}
// ideally we would not be using @unchecked here, but concurrency-safety checks do not recognize locks
extension AccumulatingRoundingFloatingPointCounter: @unchecked Sendable {}
#endif

View File

@ -27,11 +27,11 @@ final class ExampleTests: XCTestCase {
func test_example() async throws {
// Create a metric using the bootstrapped test metrics backend:
Gauge(label: "example").record(100)
Recorder(label: "example").record(100)
// extract the `TestGauge` out of the
let gauge = try self.metrics.expectGauge("example")
gauge.lastValue?.shouldEqual(6)
// extract the `TestRecorder` out of the
let recorder = try self.metrics.expectRecorder("example")
recorder.lastValue?.shouldEqual(6)
}
}
```
@ -40,6 +40,7 @@ final class ExampleTests: XCTestCase {
### Test metrics
- ``TestRecorder``
- ``TestCounter``
- ``TestTimer``
- ``TestMeter``
- ``TestRecorder``
- ``TestTimer``

View File

@ -49,6 +49,7 @@ public final class TestMetrics: MetricsFactory {
}
private var counters = [FullKey: CounterHandler]()
private var meters = [FullKey: MeterHandler]()
private var recorders = [FullKey: RecorderHandler]()
private var timers = [FullKey: TimerHandler]()
@ -61,6 +62,7 @@ public final class TestMetrics: MetricsFactory {
public func reset() {
self.lock.withLock {
self.counters = [:]
self.meters = [:]
self.recorders = [:]
self.timers = [:]
}
@ -77,6 +79,17 @@ public final class TestMetrics: MetricsFactory {
}
}
public func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return self.lock.withLock { () -> 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) -> RecorderHandler {
return self.lock.withLock { () -> RecorderHandler in
if let existing = self.recorders[.init(label: label, dimensions: dimensions)] {
@ -101,15 +114,23 @@ public final class TestMetrics: MetricsFactory {
public func destroyCounter(_ handler: CounterHandler) {
if let testCounter = handler as? TestCounter {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.counters.removeValue(forKey: testCounter.key)
}
}
}
public func destroyMeter(_ handler: MeterHandler) {
if let testMeter = handler as? TestMeter {
self.lock.withLock { () in
self.meters.removeValue(forKey: testMeter.key)
}
}
}
public func destroyRecorder(_ handler: RecorderHandler) {
if let testRecorder = handler as? TestRecorder {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.recorders.removeValue(forKey: testRecorder.key)
}
}
@ -117,7 +138,7 @@ public final class TestMetrics: MetricsFactory {
public func destroyTimer(_ handler: TimerHandler) {
if let testTimer = handler as? TestTimer {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.timers.removeValue(forKey: testTimer.key)
}
}
@ -174,6 +195,28 @@ extension TestMetrics {
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 maybeMeter = maybeItem else {
throw TestMetricsError.missingMetric(label: label, dimensions: dimensions)
}
guard let testMeter = maybeMeter as? TestMeter else {
throw TestMetricsError.illegalMetricType(metric: maybeMeter, expected: "\(TestMeter.self)")
}
return testMeter
}
// MARK: - Recorder
public func expectRecorder(_ metric: Recorder) throws -> TestRecorder {
@ -283,6 +326,68 @@ public final class TestCounter: TestMetric, CounterHandler, Equatable {
}
}
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 = NSUUID().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 loose precision but good enough as an example
values.append((Date(), Double(value)))
}
}
public func increment(by amount: Double) {
self.lock.withLock {
let lastValue = self.values.last?.1 ?? 0
let newValue = lastValue - amount
values.append((Date(), Double(newValue)))
}
}
public func decrement(by amount: Double) {
self.lock.withLock {
let lastValue = self.values.last?.1 ?? 0
let newValue = lastValue - amount
values.append((Date(), Double(newValue)))
}
}
public var lastValue: Double? {
return self.lock.withLock {
values.last?.1
}
}
public var last: (Date, Double)? {
return self.lock.withLock {
values.last
}
}
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
@ -297,7 +402,7 @@ public final class TestRecorder: TestMetric, RecorderHandler, Equatable {
private var values = [(Date, Double)]()
init(label: String, dimensions: [(String, String)], aggregate: Bool) {
self.id = UUID().uuidString
self.id = NSUUID().uuidString
self.label = label
self.dimensions = dimensions
self.aggregate = aggregate
@ -397,6 +502,7 @@ public final class TestTimer: TestMetric, TimerHandler, Equatable {
}
extension NSLock {
@discardableResult
fileprivate func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer {
@ -427,6 +533,7 @@ public enum TestMetricsError: Error {
// 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 {}
#endif

View File

@ -44,11 +44,15 @@ extension MetricsTests {
("testTimerHandlesUnsignedOverflow", testTimerHandlesUnsignedOverflow),
("testGauge", testGauge),
("testGaugeBlock", testGaugeBlock),
("testMeter", testMeter),
("testMeterBlock", testMeterBlock),
("testMUX_Counter", testMUX_Counter),
("testMUX_Meter", testMUX_Meter),
("testMUX_Recorder", testMUX_Recorder),
("testMUX_Timer", testMUX_Timer),
("testCustomFactory", testCustomFactory),
("testDestroyingGauge", testDestroyingGauge),
("testDestroyingMeter", testDestroyingMeter),
("testDestroyingCounter", testDestroyingCounter),
("testDestroyingTimer", testDestroyingTimer),
("testDescriptions", testDescriptions),

View File

@ -361,6 +361,33 @@ class MetricsTests: XCTestCase {
XCTAssertEqual(recorder.values[0].1, value, "expected value to match")
}
func testMeter() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// run the test
let name = "meter-\(NSUUID().uuidString)"
let value = Double.random(in: -1000 ... 1000)
let meter = Meter(label: name)
meter.set(value)
let testMeter = meter._handler as! TestMeter
XCTAssertEqual(testMeter.values.count, 1, "expected number of entries to match")
XCTAssertEqual(testMeter.values[0].1, value, "expected value to match")
}
func testMeterBlock() throws {
// bootstrap with our test metrics
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
// run the test
let name = "meter-\(NSUUID().uuidString)"
let value = Double.random(in: -1000 ... 1000)
Meter(label: name).set(value)
let testMeter = metrics.meters[name] as! TestMeter
XCTAssertEqual(testMeter.values.count, 1, "expected number of entries to match")
XCTAssertEqual(testMeter.values[0].1, value, "expected value to match")
}
func testMUX_Counter() throws {
// bootstrap with our test metrics
let factories = [TestMetrics(), TestMetrics(), TestMetrics()]
@ -383,6 +410,23 @@ class MetricsTests: XCTestCase {
}
}
func testMUX_Meter() throws {
// bootstrap with our test metrics
let factories = [TestMetrics(), TestMetrics(), TestMetrics()]
MetricsSystem.bootstrapInternal(MultiplexMetricsHandler(factories: factories))
// run the test
let name = NSUUID().uuidString
let value = Double.random(in: 0 ... 1)
let muxMeter = Meter(label: name)
muxMeter.set(value)
factories.forEach { factory in
let meter = factory.meters.first?.1 as! TestMeter
XCTAssertEqual(meter.label, name, "expected label to match")
XCTAssertEqual(meter.values.count, 1, "expected number of entries to match")
XCTAssertEqual(meter.values[0].1, value, "expected value to match")
}
}
func testMUX_Recorder() throws {
// bootstrap with our test metrics
let factories = [TestMetrics(), TestMetrics(), TestMetrics()]
@ -461,6 +505,36 @@ class MetricsTests: XCTestCase {
XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity")
}
func testDestroyingMeter() throws {
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
let name = "meter-\(NSUUID().uuidString)"
let value = Double.random(in: -1000 ... 1000)
let meter = Meter(label: name)
meter.set(value)
let testMeter = meter._handler as! TestMeter
XCTAssertEqual(testMeter.values.count, 1, "expected number of entries to match")
XCTAssertEqual(testMeter.values.first!.1, value, "expected value to match")
XCTAssertEqual(metrics.meters.count, 1, "recorder should have been stored")
let identity = ObjectIdentifier(testMeter)
meter.destroy()
XCTAssertEqual(metrics.recorders.count, 0, "recorder should have been released")
let meterAgain = Meter(label: name)
meterAgain.set(-value)
let testMeterAgain = meterAgain._handler as! TestMeter
XCTAssertEqual(testMeterAgain.values.count, 1, "expected number of entries to match")
XCTAssertEqual(testMeterAgain.values.first!.1, -value, "expected value to match")
let identityAgain = ObjectIdentifier(testMeterAgain)
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)
@ -524,15 +598,18 @@ class MetricsTests: XCTestCase {
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
let timer = Timer(label: "hello.timer")
XCTAssertEqual("\(timer)", "Timer(hello.timer, dimensions: [])")
let counter = Counter(label: "hello.counter")
XCTAssertEqual("\(counter)", "Counter(hello.counter, dimensions: [])")
let gauge = Gauge(label: "hello.gauge")
XCTAssertEqual("\(gauge)", "Gauge(hello.gauge, dimensions: [], aggregate: false)")
let meter = Meter(label: "hello.meter")
XCTAssertEqual("\(meter)", "Meter(hello.meter, dimensions: [])")
let timer = Timer(label: "hello.timer")
XCTAssertEqual("\(timer)", "Timer(hello.timer, dimensions: [])")
let recorder = Recorder(label: "hello.recorder")
XCTAssertEqual("\(recorder)", "Recorder(hello.recorder, dimensions: [], aggregate: true)")
}

View File

@ -21,6 +21,7 @@ import Foundation
internal final class TestMetrics: MetricsFactory {
private let lock = NSLock()
var counters = [String: CounterHandler]()
var meters = [String: MeterHandler]()
var recorders = [String: RecorderHandler]()
var timers = [String: TimerHandler]()
@ -28,6 +29,10 @@ internal final class TestMetrics: MetricsFactory {
return self.make(label: label, dimensions: dimensions, registry: &self.counters, maker: TestCounter.init)
}
func makeMeter(label: String, dimensions: [(String, String)]) -> MeterHandler {
return self.make(label: label, dimensions: dimensions, registry: &self.meters, maker: TestMeter.init)
}
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)
@ -49,15 +54,23 @@ internal final class TestMetrics: MetricsFactory {
func destroyCounter(_ handler: CounterHandler) {
if let testCounter = handler as? TestCounter {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.counters.removeValue(forKey: testCounter.label)
}
}
}
func destroyMeter(_ handler: MeterHandler) {
if let testMeter = handler as? TestMeter {
self.lock.withLock { () in
self.meters.removeValue(forKey: testMeter.label)
}
}
}
func destroyRecorder(_ handler: RecorderHandler) {
if let testRecorder = handler as? TestRecorder {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.recorders.removeValue(forKey: testRecorder.label)
}
}
@ -65,14 +78,14 @@ internal final class TestMetrics: MetricsFactory {
func destroyTimer(_ handler: TimerHandler) {
if let testTimer = handler as? TestTimer {
self.lock.withLock { () -> Void in
self.lock.withLock { () in
self.timers.removeValue(forKey: testTimer.label)
}
}
}
}
internal class TestCounter: CounterHandler, Equatable {
internal final class TestCounter: CounterHandler, Equatable {
let id: String
let label: String
let dimensions: [(String, String)]
@ -105,7 +118,58 @@ internal class TestCounter: CounterHandler, Equatable {
}
}
internal class TestRecorder: RecorderHandler, Equatable {
internal final class TestMeter: MeterHandler, Equatable {
let id: String
let label: String
let dimensions: [(String, String)]
let lock = NSLock()
var values = [(Date, Double)]()
init(label: String, dimensions: [(String, String)]) {
self.id = NSUUID().uuidString
self.label = label
self.dimensions = dimensions
}
func set(_ value: Int64) {
self.set(Double(value))
}
func set(_ value: Double) {
self.lock.withLock {
// this may loose precision but good enough as an example
values.append((Date(), Double(value)))
}
print("setting \(value) in \(self.label)")
}
func increment(by amount: Double) {
let newValue: Double = self.lock.withLock {
let lastValue = self.values.last?.1 ?? 0
let newValue = lastValue + amount
values.append((Date(), Double(newValue)))
return newValue
}
print("recording \(newValue) in \(self.label)")
}
func decrement(by amount: Double) {
let newValue: Double = self.lock.withLock {
let lastValue = self.values.last?.1 ?? 0
let newValue = lastValue - amount
values.append((Date(), Double(newValue)))
return newValue
}
print("recording \(newValue) in \(self.label)")
}
public static func == (lhs: TestMeter, rhs: TestMeter) -> Bool {
return lhs.id == rhs.id
}
}
internal final class TestRecorder: RecorderHandler, Equatable {
let id: String
let label: String
let dimensions: [(String, String)]
@ -115,7 +179,7 @@ internal class TestRecorder: RecorderHandler, Equatable {
var values = [(Date, Double)]()
init(label: String, dimensions: [(String, String)], aggregate: Bool) {
self.id = UUID().uuidString
self.id = NSUUID().uuidString
self.label = label
self.dimensions = dimensions
self.aggregate = aggregate
@ -138,7 +202,7 @@ internal class TestRecorder: RecorderHandler, Equatable {
}
}
internal class TestTimer: TimerHandler, Equatable {
internal final class TestTimer: TimerHandler, Equatable {
let id: String
let label: String
var displayUnit: TimeUnit?
@ -183,6 +247,7 @@ internal class TestTimer: TimerHandler, Equatable {
}
extension NSLock {
@discardableResult
fileprivate func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer {
@ -197,6 +262,7 @@ extension NSLock {
#if compiler(>=5.6)
// ideally we would not be using @unchecked here, but concurrency-safety checks do not recognize locks
extension TestCounter: @unchecked Sendable {}
extension TestMeter: @unchecked Sendable {}
extension TestRecorder: @unchecked Sendable {}
extension TestTimer: @unchecked Sendable {}
#endif

View File

@ -68,13 +68,13 @@ class SendableTest: XCTestCase {
}
do {
let name = "gauge-\(UUID().uuidString)"
let name = "meter-\(NSUUID().uuidString)"
let value = Double.random(in: -1000 ... 1000)
let gauge = Gauge(label: name)
let meter = Meter(label: name)
let task = Task.detached { () -> [Double] in
gauge.record(value)
let handler = gauge._handler as! TestRecorder
meter.set(value)
let handler = meter._handler as! TestMeter
return handler.values.map { $0.1 }
}
let values = await task.value

View File

@ -1,5 +1,5 @@
ARG swift_version=5.0
ARG ubuntu_version=bionic
ARG swift_version=5.7
ARG ubuntu_version=focal
ARG base_image=swift:$swift_version-$ubuntu_version
FROM $base_image
# needed to do again after FROM due to docker limitation
@ -18,9 +18,6 @@ RUN apt-get update && apt-get install -y lsof dnsutils netcat-openbsd net-tools
# ruby and jazzy for docs generation
RUN apt-get update && apt-get install -y ruby ruby-dev libsqlite3-dev build-essential
# jazzy no longer works on xenial as ruby is too old.
RUN if [ "${ubuntu_version}" = "focal" ] ; then echo "gem: --no-document" > ~/.gemrc; fi
RUN if [ "${ubuntu_version}" = "focal" ] ; then gem install jazzy; fi
# tools
RUN mkdir -p $HOME/.tools
@ -28,7 +25,7 @@ RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile
# swiftformat (until part of the toolchain)
ARG swiftformat_version=0.44.6
RUN git clone --branch $swiftformat_version --depth 1 https://github.com/nicklockwood/SwiftFormat $HOME/.tools/swift-format
RUN cd $HOME/.tools/swift-format && swift build -c release
RUN ln -s $HOME/.tools/swift-format/.build/release/swiftformat $HOME/.tools/swiftformat
ARG swiftformat_version=0.49.6
RUN if [ "${swift_version}" = "5.7" ]; then git clone --branch $swiftformat_version --depth 1 https://github.com/nicklockwood/SwiftFormat $HOME/.tools/swift-format; fi
RUN if [ "${swift_version}" = "5.7" ]; then cd $HOME/.tools/swift-format && swift build -c release; fi
RUN if [ "${swift_version}" = "5.7" ]; then ln -s $HOME/.tools/swift-format/.build/release/swiftformat $HOME/.tools/swiftformat; fi