Proposal Review: SSWG-0002 (Server Metrics API)
After the discussion thread, we are proposing this as a final revision of this proposal and enter the proposal review phase which will run until the 22th March 2019.
We have integrated most of the feedback from the discussion thread so even if you have read the previous version, you will find some changes that you hopefully agree with. To highlight a few of the major changes:
- use constructors instead of factory
- remove caching module
The feedback model will be very similar to the one known from Swift Evolution. The community is asked to provide feedback in the way outlined below and after the review period finishes, the SSWG will -- based on the community feedback -- decide whether to promote the proposal to the Sandbox maturity level or not.
What goes into a review of a proposal?
The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the evolution of the server-side Swift ecosystem.
When reviewing a proposal, here are some questions to consider:
-
What is your evaluation of the proposal?
-
Is the problem being addressed significant enough?
-
Does this proposal fit well with the feel and direction of Swift on Server?
-
If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
-
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
Thank you for contributing to the Swift Server Work Group!
What happens if the proposal gets accepted?
If this proposal gets accepted, the official repository will be created and the code (minus examples, the proposal text, etc) will be submitted. The repository will then become usable as a SwiftPM package and a version (likely 0.1.0
) will be tagged. The development (in form of pull requests) will continue as a regular open-source project.
Server Metrics API
- Proposal: SSWG-0002
- Authors: Tom Doron
- Preferred initial maturity level: Sandbox
- Name:
swift-metrics
- Sponsor: Apple
- Status: Active review (7th...23th March, 2019)
- Implementation: https://github.com/tomerd/swift-server-metrics-api-proposal/, if accepted, a fresh repository will be created under Apple ยท GitHub
- External dependencies: none
- License: if accepted, it will be released under the Apache 2 license
- Pitch: Server: Pitches/Metrics
- Description: A flexible API package that aims to become the standard metrics API which Swift packages can use to emit metrics. The delivery, aggregation and persistence of the events is handled by other packages and configurable by the individual applications without requiring users of the API package to change.
Introduction
Almost all production server software needs to emit metrics information for observability. The SSWG aims to provide a number of packages that can be shared across the whole Swift Server ecosystem so we need some amount of standardization. Because it's unlikely that all parties can agree on one full metrics implementation, this proposal is attempting to establish a metrics API that can be implemented by various metrics backends which then post the metrics data to backends like prometheus, graphite, publish over statsd, write to disk, etc.
Motivation
As outlined above, we should standardize on an API that if well adopted would allow application owners to mix and match libraries from different parties with a consistent metrics collection solution.
Proposed solution
The proposed solution is to introduce the following types that encapsulate metrics data:
Counter: A counter is a cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart. For example, you can use a counter to represent the number of requests served, tasks completed, or errors.
counter.increment(100)
Recorder: A recorder collects observations within a time window (usually things like response sizes) and can provides aggregated information about the data sample, for example count, sum, min, max and various quantiles.
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 Recorder
with a sample size of 1 and that does not perform any aggregation.
gauge.record(100)
Timer: 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.
timer.recordMilliseconds(100)
How would you use counter
, recorder
, gauge
and timer
in you application or library? Here is a contrived example for request processing code that emits metrics for: total request count per url, request size and duration and response size:
func processRequest(request: Request) -> Response {
let requestCounter = Counter("request.count", ["url": request.url])
let requestTimer = Timer("request.duration", ["url": request.url])
let requestSizeRecorder = Recorder("request.size", ["url": request.url])
let responseSizeRecorder = Recorder("response.size", ["url": request.url])
requestCounter.increment()
requestSizeRecorder.record(request.size)
let start = Date()
let response = ...
requestTimer.record(Date().timeIntervalSince(start))
responseSizeRecorder.record(response.size)
}
Detailed design
As seen above, the constructor functions Counter
, Timer
, Gauge
and Recorder
provides a concrete metric object. This raises the question of what metrics backend will you actually get? The answer is that it's configurable per application. The application sets up the metrics backend it wishes the whole application to use when it first starts. Libraries should never change the metrics implementation as that is something owned by the application. Configuring the metrics backend is straightforward:
MetricsSystem.bootstrap(MyFavoriteMetricsImplementation())
This instructs the MetricsSystem
to install MyFavoriteMetricsImplementation
as the metrics backend to use. This can only be done once at the beginning of the program.
Metrics Types
Counter
Following is the user facing Counter
API. It must have reference semantics, and its behavior depends on the CounterHandler
implementation.
public class Counter: CounterHandler {
@usableFromInline
var handler: CounterHandler
public let label: String
public let dimensions: [(String, String)]
public init(label: String, dimensions: [(String, String)], handler: CounterHandler) {
self.label = label
self.dimensions = dimensions
self.handler = handler
}
@inlinable
public func increment<DataType: BinaryInteger>(_ value: DataType) {
self.handler.increment(value)
}
@inlinable
public func increment() {
self.increment(1)
}
}
Recorder
Following is the user facing Recorder
API. It must have reference semantics, and its behavior depends on the RecorderHandler
implementation.
public class Recorder: RecorderHandler {
@usableFromInline
var handler: RecorderHandler
public let label: String
public let dimensions: [(String, String)]
public let aggregate: Bool
public init(label: String, dimensions: [(String, String)], aggregate: Bool, handler: RecorderHandler) {
self.label = label
self.dimensions = dimensions
self.aggregate = aggregate
self.handler = handler
}
@inlinable
public func record<DataType: BinaryInteger>(_ value: DataType) {
self.handler.record(value)
}
@inlinable
public func record<DataType: BinaryFloatingPoint>(_ value: DataType) {
self.handler.record(value)
}
}
Gauge
Gauge
is a specialized Recorder
that does not preform aggregation.
public class Gauge: Recorder {
public convenience init(label: String, dimensions: [(String, String)] = []) {
self.init(label: label, dimensions: dimensions, aggregate: false)
}
}
Timer
Following is the user facing Timer
API. It must have reference semantics, and its behavior depends on the TimerHandler
implementation.
public class Timer: TimerHandler {
@usableFromInline
var handler: TimerHandler
public let label: String
public let dimensions: [(String, String)]
public init(label: String, dimensions: [(String, String)], handler: TimerHandler) {
self.label = label
self.dimensions = dimensions
self.handler = handler
}
@inlinable
public func recordNanoseconds(_ duration: Int64) {
self.handler.recordNanoseconds(duration)
}
}
Implementing a metrics backend (eg prometheus client library)
An implementation of a metric backend needs to conform to the MetricsFactory
protocol:
public protocol MetricsFactory {
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler
}
Having CounterHandler
, TimerHandler
and RecorderHandler
define the metric capturing API:
public protocol CounterHandler: AnyObject {
func increment<DataType: BinaryInteger>(_ value: DataType)
}
public protocol TimerHandler: AnyObject {
func recordNanoseconds(_ duration: Int64)
}
public protocol RecorderHandler: AnyObject {
func record<DataType: BinaryInteger>(_ value: DataType)
func record<DataType: BinaryFloatingPoint>(_ value: DataType)
}
Here is an example of contrived in-memory implementation:
class SimpleMetrics: MetricsFactory {
init() {}
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
return ExampleCounter(label, dimensions)
}
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
let maker:(String, [(String, String)]) -> Recorder = aggregate ? ExampleRecorder.init : ExampleGauge.init
return maker(label, dimensions)
}
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
return ExampleTimer(label, dimensions)
}
private class ExampleCounter: CounterHandler {
init(_: String, _: [(String, String)]) {}
let lock = NSLock()
var value: Int64 = 0
func increment<DataType: BinaryInteger>(_ value: DataType) {
self.lock.withLock {
self.value += Int64(value)
}
}
}
private class ExampleRecorder: RecorderHandler {
init(_: String, _: [(String, String)]) {}
private let lock = NSLock()
var values = [(Int64, Double)]()
func record<DataType: BinaryInteger>(_ value: DataType) {
self.record(Double(value))
}
func record<DataType: BinaryFloatingPoint>(_ value: DataType) {
// this may loose precision, but good enough as an example
let v = Double(value)
// TODO: sliding window
lock.withLock {
values.append((Date().nanoSince1970, v))
self._count += 1
self._sum += v
self._min = min(self._min, v)
self._max = max(self._max, v)
}
}
var _sum: Double = 0
var sum: Double {
return self.lock.withLock { _sum }
}
private var _count: Int = 0
var count: Int {
return self.lock.withLock { _count }
}
private var _min: Double = 0
var min: Double {
return self.lock.withLock { _min }
}
private var _max: Double = 0
var max: Double {
return self.lock.withLock { _max }
}
}
private class ExampleGauge: RecorderHandler {
init(_: String, _: [(String, String)]) {}
let lock = NSLock()
var _value: Double = 0
func record<DataType: BinaryInteger>(_ value: DataType) {
self.record(Double(value))
}
func record<DataType: BinaryFloatingPoint>(_ value: DataType) {
// this may loose precision but good enough as an example
self.lock.withLock { _value = Double(value) }
}
}
private class ExampleTimer: ExampleRecorder, TimerHandler {
func recordNanoseconds(_ duration: Int64) {
super.record(duration)
}
}
}