Consider a type preserving API
fabianfett opened this issue · 3 comments
This is a very raw first draft for a new API proposal.
Motivation
Swift Metrics API currently follows the same design principles as Swift Logging. The Server Metrics API forums thread links out to a PR.
Currently users create new metrics by calling initializers on different Metric types:
Counter(label: String, dimensions: [(String, String)])
When users call this, internally we will reach out to the registered MetricFactory
through the global MetricSystem.factory
to create a new CounterHandler
, so that the new Counter
can be backed by the just created CounterHandler
.
Generally I think this behavior is great and I do not want to change it.
However whenever I use Swift Metrics, I pass around the MetricsFactory
manually instead of going through the global MetricSystem.factory
. The reason for this is to allow better testability, when using the TestMetrics
from the MetricsTestKit
module. If I don't use the global MetricSystem.factory
, I can easily create a new TestMetrics
instance for each test run, which even allows me to run tests in parallel.
Currently the MetricsFactory
protocol creates existential MetricHandlers. For each supported Metric type, the factory returns an existential MetricHandler (CounterHandler
, TimerHandler
, etc.). The reason for not returning explicitly typed handlers here, is that the MetricsFactory
needed to be stored as an existential itself in the MetricsSystem.global
. Before Swift 5.7 existentials were not able to have associated types.
However thanks to SE-309 Unlock existentials for all protocols we are now able to make MetricsFactory
type safe.
Proposed Solution
Since I don't want to break API, I propose a new Protocol that adopters can implement:
public protocol TypePreservingMetricsFactory: MetricsFactory {
associatedtype Counter: CounterProtocol
associatedtype FloatingPointCounter: FloatingPointCounterProtocol
associatedtype Timer: TimerProtocol
associatedtype Recorder: RecorderProtocol
func makeSomeCounter(label: String, dimensions: [(String, String)]) -> Self.Counter
func makeSomeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> Self.FloatingPointCounter
func makeSomeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> Self.Recorder
func makeSomeTimer(label: String, dimensions: [(String, String)]) -> Self.Timer
func destroyCounter(_ handler: Self.Counter)
func destroyFloatingPointCounter(_ handler: Self.FloatingPointCounter)
func destroyRecorder(_ handler: Self.Recorder)
func destroyTimer(_ handler: Self.Timer)
}
I added the Some
word into the make
function calls to ensure those methods are not overloaded by return type. Other naming suggestions are highly welcome. The new CounterProtocol
is very close to the existing CounterHandler
protocol. However it adds requirements for the label and the dimensions.
The reason for this is simple: I would like to remove the current Counter
, Timer
and Recorder
wrapper classes, that add one level of indirection, that isn't needed.
public protocol CounterProtocol: CounterHandler {
var label: String { get }
var dimensions: [(String, String)] { get }
}
public protocol FloatingPointCounterProtocol: FloatingPointCounterHandler {
var label: String { get }
var dimensions: [(String, String)] { get }
}
If we want to preserve the option to create an untyped Metric in the future with an init, we could use callAsFunction
on top of a protocol.
If a user implements TypePreservingMetricsFactory
all methods needed for MetricsFactory
are implemented by a default implementation:
// This extension ensures TypePreservingMetricsFactory also implements MetricsFactory
extension TypePreservingMetricsFactory {
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
self.makeSomeRecorder(label: label, dimensions: dimensions, aggregate: aggregate)
}
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
self.makeSomeTimer(label: label, dimensions: dimensions)
}
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
self.makeCounter(label: label, dimensions: dimensions)
}
public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler {
self.makeSomeFloatingPointCounter(label: label, dimensions: dimensions)
}
public func destroyTimer(_ handler: TimerHandler) {
if let handler = handler as? Self.Timer {
self.destroySomeTimer(handler)
}
}
public func destroyCounter(_ handler: CounterHandler) {
if let handler = handler as? Self.Counter {
self.destroySomeCounter(handler)
}
}
public func destroyRecorder(_ handler: RecorderHandler) {
if let handler = handler as? Self.Recorder {
self.destroySomeRecorder(handler)
}
}
public func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler) {
if let handler = handler as? Self.FloatingPointCounter {
self.destroySomeFloatingPointCounter(handler)
}
}
}
Future ideas
- The
make
anddestroy
methods on theTypePreservingMetricsFactory
should be async to allow the use of an actor to create, maintain and destroy the created metrics. However this is a breaking change. - We should make the
Gauge
type stand byitself and not be implemented on top of recorder. - We should potentially offer simple
Counter
,Timer
andRecorder
implementations, that implementors of backends can use right away. So backend developers only have to worry about metric lifecycle management and exporting.
@PeterAdams-A mentioned that TypePreservingMetricsFactory
should probably not inherit from MetricsFactory
, but that we should have an internal translation layer... I agree with this and will adjust the proposal later...
Do you have a proposed timeline for adopting this change? Without 5.7 it’s very hard to use, so we’ll presumably have to gate its availability, which is tricky to communicate downstream.