Skip to content

Commit a5671ba

Browse files
authored
Histogram backed timer (#46)
* implement histogram backed timer * hide timer implementation enum; namespace PrometheusMetricsFactory classes * address review * fix test testHistogramBackedTimer_scaleFromNanoseconds * address PR review comments
1 parent 8fb92e7 commit a5671ba

File tree

9 files changed

+176
-34
lines changed

9 files changed

+176
-34
lines changed

Sources/Prometheus/Prometheus.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,10 @@ public class PrometheusClient {
1616
/// Lock used for thread safety
1717
private let lock: Lock
1818

19-
/// Sanitizers used to clean up label values provided through
20-
/// swift-metrics.
21-
public let sanitizer: LabelSanitizer
22-
2319
/// Create a PrometheusClient instance
24-
public init(labelSanitizer sanitizer: LabelSanitizer = PrometheusLabelSanitizer()) {
20+
public init() {
2521
self.metrics = []
2622
self.metricTypeMap = [:]
27-
self.sanitizer = sanitizer
2823
self.lock = Lock()
2924
}
3025

Sources/Prometheus/PrometheusMetrics.swift

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ private class MetricsHistogram: RecorderHandler {
6464
}
6565
}
6666

67+
class MetricsHistogramTimer: TimerHandler {
68+
let histogram: PromHistogram<Int64, DimensionHistogramLabels>
69+
let labels: DimensionHistogramLabels?
70+
71+
init(histogram: PromHistogram<Int64, DimensionHistogramLabels>, dimensions: [(String, String)]) {
72+
self.histogram = histogram
73+
if !dimensions.isEmpty {
74+
self.labels = DimensionHistogramLabels(dimensions)
75+
} else {
76+
self.labels = nil
77+
}
78+
}
79+
80+
func recordNanoseconds(_ duration: Int64) {
81+
return histogram.observe(duration, labels)
82+
}
83+
}
84+
6785
private class MetricsSummary: TimerHandler {
6886
let summary: PromSummary<Int64, DimensionSummaryLabels>
6987
let labels: DimensionSummaryLabels?
@@ -82,7 +100,7 @@ private class MetricsSummary: TimerHandler {
82100
}
83101

84102
func recordNanoseconds(_ duration: Int64) {
85-
summary.observe(duration, labels)
103+
return summary.observe(duration, labels)
86104
}
87105
}
88106

@@ -94,7 +112,7 @@ private class MetricsSummary: TimerHandler {
94112
/// let sanitizer: LabelSanitizer = ...
95113
/// let prometheusLabel = sanitizer.sanitize(nonPrometheusLabel)
96114
///
97-
/// By default `PrometheusLabelSanitizer` is used by `PrometheusClient`
115+
/// By default `PrometheusLabelSanitizer` is used by `PrometheusMetricsFactory`
98116
public protocol LabelSanitizer {
99117
/// Sanitize the passed in label to a Prometheus accepted value.
100118
///
@@ -121,73 +139,119 @@ public struct PrometheusLabelSanitizer: LabelSanitizer {
121139
}
122140
}
123141

124-
extension PrometheusClient: MetricsFactory {
142+
/// A bridge between PrometheusClient and swift-metrics. Prometheus types don't map perfectly on swift-metrics API,
143+
/// which makes bridge implementation non trivial. This class defines how exactly swift-metrics types should be backed
144+
/// with Prometheus types, e.g. how to sanitize labels, what buckets/quantiles to use for recorder/timer, etc.
145+
public struct PrometheusMetricsFactory: MetricsFactory {
146+
147+
/// Prometheus client to bridge swift-metrics API to.
148+
private let client: PrometheusClient
149+
150+
/// Bridge configuration.
151+
private let configuration: Configuration
152+
153+
public init(client: PrometheusClient,
154+
configuration: Configuration = Configuration()) {
155+
self.client = client
156+
self.configuration = configuration
157+
}
158+
125159
public func destroyCounter(_ handler: CounterHandler) {
126160
guard let handler = handler as? MetricsCounter else { return }
127-
self.removeMetric(handler.counter)
161+
client.removeMetric(handler.counter)
128162
}
129163

130164
public func destroyRecorder(_ handler: RecorderHandler) {
131165
if let handler = handler as? MetricsGauge {
132-
self.removeMetric(handler.gauge)
166+
client.removeMetric(handler.gauge)
133167
}
134168
if let handler = handler as? MetricsHistogram {
135-
self.removeMetric(handler.histogram)
169+
client.removeMetric(handler.histogram)
136170
}
137171
}
138-
172+
139173
public func destroyTimer(_ handler: TimerHandler) {
140-
guard let handler = handler as? MetricsSummary else { return }
141-
self.removeMetric(handler.summary)
174+
switch self.configuration.timerImplementation._wrapped {
175+
case .summary:
176+
guard let handler = handler as? MetricsSummary else { return }
177+
client.removeMetric(handler.summary)
178+
case .histogram:
179+
guard let handler = handler as? MetricsHistogramTimer else { return }
180+
client.removeMetric(handler.histogram)
181+
}
142182
}
143183

144184
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
145-
let label = self.sanitizer.sanitize(label)
185+
let label = configuration.labelSanitizer.sanitize(label)
146186
let createHandler = { (counter: PromCounter) -> CounterHandler in
147187
return MetricsCounter(counter: counter, dimensions: dimensions)
148188
}
149-
if let counter: PromCounter<Int64, DimensionLabels> = self.getMetricInstance(with: label, andType: .counter) {
189+
if let counter: PromCounter<Int64, DimensionLabels> = client.getMetricInstance(with: label, andType: .counter) {
150190
return createHandler(counter)
151191
}
152-
return createHandler(self.createCounter(forType: Int64.self, named: label, withLabelType: DimensionLabels.self))
192+
return createHandler(client.createCounter(forType: Int64.self, named: label, withLabelType: DimensionLabels.self))
153193
}
154194

155195
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
156-
let label = self.sanitizer.sanitize(label)
196+
let label = configuration.labelSanitizer.sanitize(label)
157197
return aggregate ? makeHistogram(label: label, dimensions: dimensions) : makeGauge(label: label, dimensions: dimensions)
158198
}
159199

160200
private func makeGauge(label: String, dimensions: [(String, String)]) -> RecorderHandler {
161-
let label = self.sanitizer.sanitize(label)
201+
let label = configuration.labelSanitizer.sanitize(label)
162202
let createHandler = { (gauge: PromGauge) -> RecorderHandler in
163203
return MetricsGauge(gauge: gauge, dimensions: dimensions)
164204
}
165-
if let gauge: PromGauge<Double, DimensionLabels> = self.getMetricInstance(with: label, andType: .gauge) {
205+
if let gauge: PromGauge<Double, DimensionLabels> = client.getMetricInstance(with: label, andType: .gauge) {
166206
return createHandler(gauge)
167207
}
168-
return createHandler(createGauge(forType: Double.self, named: label, withLabelType: DimensionLabels.self))
208+
return createHandler(client.createGauge(forType: Double.self, named: label, withLabelType: DimensionLabels.self))
169209
}
170210

171211
private func makeHistogram(label: String, dimensions: [(String, String)]) -> RecorderHandler {
172-
let label = self.sanitizer.sanitize(label)
212+
let label = configuration.labelSanitizer.sanitize(label)
173213
let createHandler = { (histogram: PromHistogram) -> RecorderHandler in
174214
return MetricsHistogram(histogram: histogram, dimensions: dimensions)
175215
}
176-
if let histogram: PromHistogram<Double, DimensionHistogramLabels> = self.getMetricInstance(with: label, andType: .histogram) {
216+
if let histogram: PromHistogram<Double, DimensionHistogramLabels> = client.getMetricInstance(with: label, andType: .histogram) {
177217
return createHandler(histogram)
178218
}
179-
return createHandler(createHistogram(forType: Double.self, named: label, labels: DimensionHistogramLabels.self))
219+
return createHandler(client.createHistogram(forType: Double.self, named: label, labels: DimensionHistogramLabels.self))
180220
}
181221

182222
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
183-
let label = self.sanitizer.sanitize(label)
223+
switch configuration.timerImplementation._wrapped {
224+
case .summary(let quantiles):
225+
return self.makeSummaryTimer(label: label, dimensions: dimensions, quantiles: quantiles)
226+
case .histogram(let buckets):
227+
return self.makeHistogramTimer(label: label, dimensions: dimensions, buckets: buckets)
228+
}
229+
}
230+
231+
/// There's two different ways to back swift-api `Timer` with Prometheus classes.
232+
/// This method creates `Summary` backed timer implementation
233+
private func makeSummaryTimer(label: String, dimensions: [(String, String)], quantiles: [Double]) -> TimerHandler {
234+
let label = configuration.labelSanitizer.sanitize(label)
184235
let createHandler = { (summary: PromSummary) -> TimerHandler in
185236
return MetricsSummary(summary: summary, dimensions: dimensions)
186237
}
187-
if let summary: PromSummary<Int64, DimensionSummaryLabels> = self.getMetricInstance(with: label, andType: .summary) {
238+
if let summary: PromSummary<Int64, DimensionSummaryLabels> = client.getMetricInstance(with: label, andType: .summary) {
188239
return createHandler(summary)
189240
}
190-
return createHandler(createSummary(forType: Int64.self, named: label, labels: DimensionSummaryLabels.self))
241+
return createHandler(client.createSummary(forType: Int64.self, named: label, quantiles: quantiles, labels: DimensionSummaryLabels.self))
242+
}
243+
244+
/// There's two different ways to back swift-api `Timer` with Prometheus classes.
245+
/// This method creates `Histogram` backed timer implementation
246+
private func makeHistogramTimer(label: String, dimensions: [(String, String)], buckets: Buckets) -> TimerHandler {
247+
let createHandler = { (histogram: PromHistogram) -> TimerHandler in
248+
MetricsHistogramTimer(histogram: histogram, dimensions: dimensions)
249+
}
250+
// PromHistogram should be reused when created for the same label, so we try to look it up
251+
if let histogram: PromHistogram<Int64, DimensionHistogramLabels> = client.getMetricInstance(with: label, andType: .histogram) {
252+
return createHandler(histogram)
253+
}
254+
return createHandler(client.createHistogram(forType: Int64.self, named: label, buckets: buckets, labels: DimensionHistogramLabels.self))
191255
}
192256
}
193257

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
extension PrometheusMetricsFactory {
2+
3+
public struct TimerImplementation {
4+
enum _Wrapped {
5+
case summary(defaultQuantiles: [Double])
6+
case histogram(defaultBuckets: Buckets)
7+
}
8+
9+
var _wrapped: _Wrapped
10+
11+
private init(_ wrapped: _Wrapped) {
12+
self._wrapped = wrapped
13+
}
14+
15+
public static func summary(defaultQuantiles: [Double] = Prometheus.defaultQuantiles) -> TimerImplementation {
16+
return TimerImplementation(.summary(defaultQuantiles: defaultQuantiles))
17+
}
18+
19+
public static func histogram(defaultBuckets: Buckets = Buckets.defaultBuckets) -> TimerImplementation {
20+
return TimerImplementation(.histogram(defaultBuckets: defaultBuckets))
21+
}
22+
}
23+
24+
25+
/// Configuration for PrometheusClient to swift-metrics api bridge.
26+
public struct Configuration {
27+
/// Sanitizers used to clean up label values provided through
28+
/// swift-metrics.
29+
public var labelSanitizer: LabelSanitizer
30+
31+
/// This parameter will define what implementation will be used for bridging `swift-metrics` to Prometheus types.
32+
public var timerImplementation: PrometheusMetricsFactory.TimerImplementation
33+
34+
/// Default buckets for `Recorder` with aggregation.
35+
public var defaultRecorderBuckets: Buckets
36+
37+
public init(labelSanitizer: LabelSanitizer = PrometheusLabelSanitizer(),
38+
timerImplementation: PrometheusMetricsFactory.TimerImplementation = .summary(),
39+
defaultRecorderBuckets: Buckets = .defaultBuckets) {
40+
self.labelSanitizer = labelSanitizer
41+
self.timerImplementation = timerImplementation
42+
self.defaultRecorderBuckets = defaultRecorderBuckets
43+
}
44+
}
45+
}

Sources/PrometheusExample/main.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import NIO
44

55
let myProm = PrometheusClient()
66

7-
MetricsSystem.bootstrap(myProm)
7+
MetricsSystem.bootstrap(PrometheusMetricsFactory(client: myProm))
88

99
for _ in 0...Int.random(in: 10...100) {
1010
let c = Counter(label: "test")

Tests/SwiftPrometheusTests/GaugeTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class GaugeTests: XCTestCase {
2525
override func setUp() {
2626
self.prom = PrometheusClient()
2727
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
28-
MetricsSystem.bootstrapInternal(prom)
28+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
2929
}
3030

3131
override func tearDown() {

Tests/SwiftPrometheusTests/HistogramTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ final class HistogramTests: XCTestCase {
2626
override func setUp() {
2727
self.prom = PrometheusClient()
2828
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
29-
MetricsSystem.bootstrapInternal(prom)
29+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
3030
}
3131

3232
override func tearDown() {

Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ final class PrometheusMetricsTests: XCTestCase {
1414
override func setUp() {
1515
self.prom = PrometheusClient()
1616
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
17-
MetricsSystem.bootstrapInternal(prom)
17+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
1818
}
1919

2020
override func tearDown() {
@@ -145,4 +145,42 @@ final class PrometheusMetricsTests: XCTestCase {
145145
my_gauge 100.0\n
146146
""")
147147
}
148+
149+
func testHistogramBackedTimer() {
150+
let prom = PrometheusClient()
151+
var config = PrometheusMetricsFactory.Configuration()
152+
config.timerImplementation = .histogram()
153+
let metricsFactory = PrometheusMetricsFactory(client: prom, configuration: config)
154+
metricsFactory.makeTimer(label: "duration_nanos", dimensions: []).recordNanoseconds(1)
155+
guard let histogram: PromHistogram<Int64, DimensionHistogramLabels> = prom.getMetricInstance(with: "duration_nanos", andType: .histogram) else {
156+
XCTFail("Timer should be backed by Histogram")
157+
return
158+
}
159+
let result = histogram.collect()
160+
let buckets = result.split(separator: "\n").filter { $0.contains("duration_nanos_bucket") }
161+
XCTAssertFalse(buckets.isEmpty, "default histogram backed timer buckets")
162+
}
163+
164+
func testDestroyHistogramTimer() {
165+
let prom = PrometheusClient()
166+
var config = PrometheusMetricsFactory.Configuration()
167+
config.timerImplementation = .histogram()
168+
let metricsFactory = PrometheusMetricsFactory(client: prom, configuration: config)
169+
let timer = metricsFactory.makeTimer(label: "duration_nanos", dimensions: [])
170+
timer.recordNanoseconds(1)
171+
metricsFactory.destroyTimer(timer)
172+
let histogram: PromHistogram<Int64, DimensionHistogramLabels>? = prom.getMetricInstance(with: "duration_nanos", andType: .histogram)
173+
XCTAssertNil(histogram)
174+
}
175+
func testDestroySummaryTimer() {
176+
let prom = PrometheusClient()
177+
var config = PrometheusMetricsFactory.Configuration()
178+
config.timerImplementation = .summary()
179+
let metricsFactory = PrometheusMetricsFactory(client: prom)
180+
let timer = metricsFactory.makeTimer(label: "duration_nanos", dimensions: [])
181+
timer.recordNanoseconds(1)
182+
metricsFactory.destroyTimer(timer)
183+
let summary: PromSummary<Int64, DimensionSummaryLabels>? = prom.getMetricInstance(with: "duration_nanos", andType: .summary)
184+
XCTAssertNil(summary)
185+
}
148186
}

Tests/SwiftPrometheusTests/SanitizerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ final class SanitizerTests: XCTestCase {
3838

3939
func testIntegratedSanitizer() throws {
4040
let prom = PrometheusClient()
41-
MetricsSystem.bootstrapInternal(prom)
41+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
4242

4343
CoreMetrics.Counter(label: "Test.Counter").increment(by: 10)
4444

Tests/SwiftPrometheusTests/SummaryTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ final class SummaryTests: XCTestCase {
2626
override func setUp() {
2727
self.prom = PrometheusClient()
2828
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
29-
MetricsSystem.bootstrapInternal(prom)
29+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
3030
}
3131

3232
override func tearDown() {

0 commit comments

Comments
 (0)