Skip to content

Commit 96cb1a3

Browse files
authored
Get Histogram up to Prometheus spec (#25)
* Add Histogram bucket generation methods * Make buckets a separate type * Linux tests * Make buckets ExpressibleByArrayLiteral * Add histogram.time() * Use DispatchTime.now().uptimeNanoseconds instead of Date() * Apply suggestions from code review Co-Authored-By: Konrad `ktoso` Malawski <[email protected]> * Fix final review remarks
1 parent 9ad57b5 commit 96cb1a3

File tree

8 files changed

+242
-90
lines changed

8 files changed

+242
-90
lines changed

Sources/Prometheus/MetricTypes/Histogram.swift

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,72 @@
11
import NIOConcurrencyHelpers
2+
import Dispatch
3+
4+
/// Buckets are used by Histograms to bucket their values.
5+
///
6+
/// See https://prometheus.io/docs/concepts/metric_types/#Histogram
7+
public struct Buckets: ExpressibleByArrayLiteral {
8+
public typealias ArrayLiteralElement = Double
9+
10+
public init(arrayLiteral elements: Double...) {
11+
self.init(elements)
12+
}
13+
14+
fileprivate init (_ r: [Double]) {
15+
if r.isEmpty {
16+
self = Buckets.defaultBuckets
17+
return
18+
}
19+
var r = r
20+
if !r.contains(Double.greatestFiniteMagnitude) {
21+
r.append(Double.greatestFiniteMagnitude)
22+
}
23+
assert(r == r.sorted(by: <), "Buckets are not in increasing order")
24+
assert(Array(Set(r)).sorted(by: <) == r.sorted(by: <), "Buckets contain duplicate values.")
25+
self.buckets = r
26+
}
27+
28+
/// The upper bounds
29+
public let buckets: [Double]
30+
31+
/// Default buckets used by Histograms
32+
public static let defaultBuckets: Buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
33+
34+
/// Create linear buckets used by Histograms
35+
///
36+
/// - Parameters:
37+
/// - start: Start value for your buckets. This will be the upper bound of your first bucket.
38+
/// - width: Width of each bucket.
39+
/// - count: Amount of buckets to generate, should be larger than zero. The +Inf bucket is not included in this count.
40+
public static func linear(start: Double, width: Double, count: Int) -> Buckets {
41+
assert(count >= 1, "Bucket.linear needs a count larger than 1")
42+
var arr = [Double]()
43+
var s = start
44+
for x in 0..<count {
45+
arr[x] = s
46+
s += width
47+
}
48+
return Buckets(arr)
49+
}
50+
51+
/// Create exponential buckets used by Histograms
52+
///
53+
/// - Parameters:
54+
/// - start: Start value for your buckets, should be larger than 0. This will be the upper bound of your first bucket.
55+
/// - factor: Factor to increase each upper bound by, based on the upper bound of the last bucket. Should be larger than 1.
56+
/// - count: Amount of buckets to generate, should be larger than zero. The +Inf bucket is not included in this count.
57+
public static func exponential(start: Double, factor: Double, count: Int) -> Buckets {
58+
assert(count > 1, "Bucket.exponential needs a count greater than 1")
59+
assert(start > 0, "Bucket.exponential needs a start larger than 0")
60+
assert(factor > 1, "Bucket.exponential needs a factor larger than 1")
61+
var arr = [Double]()
62+
var s = start
63+
for x in 0..<count {
64+
arr[x] = s
65+
s *= factor
66+
}
67+
return Buckets(arr)
68+
}
69+
}
270

371
/// Label type Histograms can use
472
public protocol HistogramLabels: MetricLabels {
@@ -55,7 +123,7 @@ public class PromHistogram<NumType: DoubleRepresentable, Labels: HistogramLabels
55123
/// - labels: Labels for the Histogram
56124
/// - buckets: Buckets to use for the Histogram
57125
/// - p: Prometheus instance creating this Histogram
58-
internal init(_ name: String, _ help: String? = nil, _ labels: Labels = Labels(), _ buckets: [Double] = Prometheus.defaultBuckets, _ p: PrometheusClient) {
126+
internal init(_ name: String, _ help: String? = nil, _ labels: Labels = Labels(), _ buckets: Buckets = .defaultBuckets, _ p: PrometheusClient) {
59127
self.name = name
60128
self.help = help
61129

@@ -65,11 +133,11 @@ public class PromHistogram<NumType: DoubleRepresentable, Labels: HistogramLabels
65133

66134
self.labels = labels
67135

68-
self.upperBounds = buckets
136+
self.upperBounds = buckets.buckets
69137

70138
self.lock = Lock()
71139

72-
buckets.forEach { _ in
140+
buckets.buckets.forEach { _ in
73141
self.buckets.append(.init("\(name)_bucket", nil, 0, p))
74142
}
75143
}
@@ -144,7 +212,21 @@ public class PromHistogram<NumType: DoubleRepresentable, Labels: HistogramLabels
144212
}
145213
}
146214
}
147-
215+
216+
/// Time the duration of a closure and observe the resulting time in seconds.
217+
///
218+
/// - parameters:
219+
/// - labels: Labels to attach to the resulting value.
220+
/// - body: Closure to run & record.
221+
@inlinable
222+
public func time<T>(_ labels: Labels? = nil, _ body: @escaping () throws -> T) rethrows -> T {
223+
let start = DispatchTime.now().uptimeNanoseconds
224+
defer {
225+
let delta = Double(DispatchTime.now().uptimeNanoseconds - start)
226+
self.observe(.init(delta / 1_000_000_000), labels)
227+
}
228+
return try body()
229+
}
148230
}
149231

150232
extension PrometheusClient {
@@ -158,7 +240,7 @@ extension PrometheusClient {
158240
if let histogram = histograms.first {
159241
return histogram
160242
} else {
161-
let newHistogram = PromHistogram<T, U>(histogram.name, histogram.help, labels, histogram.upperBounds, self)
243+
let newHistogram = PromHistogram<T, U>(histogram.name, histogram.help, labels, Buckets(histogram.upperBounds), self)
162244
histogram.subHistograms.append(newHistogram)
163245
return newHistogram
164246
}

Sources/Prometheus/MetricTypes/PromMetric.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,8 @@ public enum PromMetricType: String {
1313
}
1414

1515
public enum Prometheus {
16-
/// Default buckets used by Histograms
17-
public static let defaultBuckets = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, Double.greatestFiniteMagnitude]
18-
1916
/// Default quantiles used by Summaries
2017
public static let defaultQuantiles = [0.01, 0.05, 0.5, 0.9, 0.95, 0.99, 0.999]
21-
2218
}
2319

2420
/// Metric protocol

Sources/Prometheus/Prometheus.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ public class PrometheusClient {
202202
forType type: T.Type,
203203
named name: String,
204204
helpText: String? = nil,
205-
buckets: [Double] = Prometheus.defaultBuckets,
205+
buckets: Buckets = .defaultBuckets,
206206
labels: U.Type) -> PromHistogram<T, U>
207207
{
208208
if let histogram: PromHistogram<T, U> = getMetricInstance(with: name, andType: .histogram) {
@@ -233,7 +233,7 @@ public class PrometheusClient {
233233
forType type: T.Type,
234234
named name: String,
235235
helpText: String? = nil,
236-
buckets: [Double] = Prometheus.defaultBuckets) -> PromHistogram<T, EmptyHistogramLabels>
236+
buckets: Buckets = .defaultBuckets) -> PromHistogram<T, EmptyHistogramLabels>
237237
{
238238
return self.createHistogram(forType: type, named: name, helpText: helpText, buckets: buckets, labels: EmptyHistogramLabels.self)
239239
}

Sources/Prometheus/Utils.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ extension Double {
5959
public protocol DoubleRepresentable: Numeric {
6060
/// Double value of the number
6161
var doubleValue: Double {get}
62+
63+
init(_ double: Double)
6264
}
6365

6466
/// Numbers that convert to other types
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import XCTest
2+
import NIO
3+
@testable import Prometheus
4+
@testable import CoreMetrics
5+
6+
final class HistogramTests: XCTestCase {
7+
struct BaseHistogramLabels: HistogramLabels {
8+
var le: String = ""
9+
let myValue: String
10+
11+
init() {
12+
self.myValue = "*"
13+
}
14+
15+
init(myValue: String) {
16+
self.myValue = myValue
17+
}
18+
}
19+
20+
var prom: PrometheusClient!
21+
var group: EventLoopGroup!
22+
var eventLoop: EventLoop {
23+
return group.next()
24+
}
25+
26+
override func setUp() {
27+
self.prom = PrometheusClient()
28+
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
29+
MetricsSystem.bootstrapInternal(prom)
30+
}
31+
32+
override func tearDown() {
33+
self.prom = nil
34+
try! self.group.syncShutdownGracefully()
35+
}
36+
37+
func testHistogramSwiftMetrics() {
38+
let recorder = Recorder(label: "my_histogram")
39+
recorder.record(1)
40+
recorder.record(2)
41+
recorder.record(3)
42+
43+
let recorderTwo = Recorder(label: "my_histogram", dimensions: [("myValue", "labels")])
44+
recorderTwo.record(3)
45+
46+
let promise = self.eventLoop.makePromise(of: String.self)
47+
prom.collect(promise.succeed)
48+
49+
XCTAssertEqual(try! promise.futureResult.wait(), """
50+
# TYPE my_histogram histogram
51+
my_histogram_bucket{le="0.005"} 0.0
52+
my_histogram_bucket{le="0.01"} 0.0
53+
my_histogram_bucket{le="0.025"} 0.0
54+
my_histogram_bucket{le="0.05"} 0.0
55+
my_histogram_bucket{le="0.1"} 0.0
56+
my_histogram_bucket{le="0.25"} 0.0
57+
my_histogram_bucket{le="0.5"} 0.0
58+
my_histogram_bucket{le="1.0"} 1.0
59+
my_histogram_bucket{le="2.5"} 2.0
60+
my_histogram_bucket{le="5.0"} 4.0
61+
my_histogram_bucket{le="10.0"} 4.0
62+
my_histogram_bucket{le="+Inf"} 4.0
63+
my_histogram_count 4.0
64+
my_histogram_sum 9.0
65+
my_histogram_bucket{myValue="labels", le="0.005"} 0.0
66+
my_histogram_bucket{myValue="labels", le="0.01"} 0.0
67+
my_histogram_bucket{myValue="labels", le="0.025"} 0.0
68+
my_histogram_bucket{myValue="labels", le="0.05"} 0.0
69+
my_histogram_bucket{myValue="labels", le="0.1"} 0.0
70+
my_histogram_bucket{myValue="labels", le="0.25"} 0.0
71+
my_histogram_bucket{myValue="labels", le="0.5"} 0.0
72+
my_histogram_bucket{myValue="labels", le="1.0"} 0.0
73+
my_histogram_bucket{myValue="labels", le="2.5"} 0.0
74+
my_histogram_bucket{myValue="labels", le="5.0"} 1.0
75+
my_histogram_bucket{myValue="labels", le="10.0"} 1.0
76+
my_histogram_bucket{myValue="labels", le="+Inf"} 1.0
77+
my_histogram_count{myValue="labels"} 1.0
78+
my_histogram_sum{myValue="labels"} 3.0
79+
""")
80+
}
81+
82+
func testHistogramTime() {
83+
let histogram = prom.createHistogram(forType: Double.self, named: "my_histogram")
84+
let delay = 0.05
85+
histogram.time {
86+
Thread.sleep(forTimeInterval: delay)
87+
}
88+
// Using starts(with:) here since the exact subseconds might differ per-test.
89+
XCTAssert(histogram.collect().starts(with: """
90+
# TYPE my_histogram histogram
91+
my_histogram_bucket{le="0.005"} 0.0
92+
my_histogram_bucket{le="0.01"} 0.0
93+
my_histogram_bucket{le="0.025"} 0.0
94+
my_histogram_bucket{le="0.05"} 0.0
95+
my_histogram_bucket{le="0.1"} 1.0
96+
my_histogram_bucket{le="0.25"} 1.0
97+
my_histogram_bucket{le="0.5"} 1.0
98+
my_histogram_bucket{le="1.0"} 1.0
99+
my_histogram_bucket{le="2.5"} 1.0
100+
my_histogram_bucket{le="5.0"} 1.0
101+
my_histogram_bucket{le="10.0"} 1.0
102+
my_histogram_bucket{le="+Inf"} 1.0
103+
my_histogram_count 1.0
104+
my_histogram_sum 0.05
105+
"""))
106+
}
107+
108+
func testHistogramStandalone() {
109+
let histogram = prom.createHistogram(forType: Double.self, named: "my_histogram", helpText: "Histogram for testing", buckets: [0.5, 1, 2, 3, 5, Double.greatestFiniteMagnitude], labels: BaseHistogramLabels.self)
110+
let histogramTwo = prom.createHistogram(forType: Double.self, named: "my_histogram", helpText: "Histogram for testing", buckets: [0.5, 1, 2, 3, 5, Double.greatestFiniteMagnitude], labels: BaseHistogramLabels.self)
111+
112+
histogram.observe(1)
113+
histogram.observe(2)
114+
histogramTwo.observe(3)
115+
116+
histogram.observe(3, .init(myValue: "labels"))
117+
118+
XCTAssertEqual(histogram.collect(), """
119+
# HELP my_histogram Histogram for testing
120+
# TYPE my_histogram histogram
121+
my_histogram_bucket{myValue="*", le="0.5"} 0.0
122+
my_histogram_bucket{myValue="*", le="1.0"} 1.0
123+
my_histogram_bucket{myValue="*", le="2.0"} 2.0
124+
my_histogram_bucket{myValue="*", le="3.0"} 4.0
125+
my_histogram_bucket{myValue="*", le="5.0"} 4.0
126+
my_histogram_bucket{myValue="*", le="+Inf"} 4.0
127+
my_histogram_count{myValue="*"} 4.0
128+
my_histogram_sum{myValue="*"} 9.0
129+
my_histogram_bucket{myValue="labels", le="0.5"} 0.0
130+
my_histogram_bucket{myValue="labels", le="1.0"} 0.0
131+
my_histogram_bucket{myValue="labels", le="2.0"} 0.0
132+
my_histogram_bucket{myValue="labels", le="3.0"} 1.0
133+
my_histogram_bucket{myValue="labels", le="5.0"} 1.0
134+
my_histogram_bucket{myValue="labels", le="+Inf"} 1.0
135+
my_histogram_count{myValue="labels"} 1.0
136+
my_histogram_sum{myValue="labels"} 3.0
137+
""")
138+
}
139+
}

Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -58,57 +58,6 @@ final class PrometheusMetricsTests: XCTestCase {
5858
""")
5959
}
6060

61-
func testHistogram() {
62-
let recorder = Recorder(label: "my_histogram")
63-
recorder.record(1)
64-
recorder.record(2)
65-
recorder.record(3)
66-
67-
let recorderTwo = Recorder(label: "my_histogram", dimensions: [("myValue", "labels")])
68-
recorderTwo.record(3)
69-
70-
let promise = self.eventLoop.makePromise(of: String.self)
71-
prom.collect(promise.succeed)
72-
73-
XCTAssertEqual(try! promise.futureResult.wait(), """
74-
# TYPE my_histogram histogram
75-
my_histogram_bucket{le="0.005"} 0.0
76-
my_histogram_bucket{le="0.01"} 0.0
77-
my_histogram_bucket{le="0.025"} 0.0
78-
my_histogram_bucket{le="0.05"} 0.0
79-
my_histogram_bucket{le="0.075"} 0.0
80-
my_histogram_bucket{le="0.1"} 0.0
81-
my_histogram_bucket{le="0.25"} 0.0
82-
my_histogram_bucket{le="0.5"} 0.0
83-
my_histogram_bucket{le="0.75"} 0.0
84-
my_histogram_bucket{le="1.0"} 1.0
85-
my_histogram_bucket{le="2.5"} 2.0
86-
my_histogram_bucket{le="5.0"} 4.0
87-
my_histogram_bucket{le="7.5"} 4.0
88-
my_histogram_bucket{le="10.0"} 4.0
89-
my_histogram_bucket{le="+Inf"} 4.0
90-
my_histogram_count 4.0
91-
my_histogram_sum 9.0
92-
my_histogram_bucket{myValue="labels", le="0.005"} 0.0
93-
my_histogram_bucket{myValue="labels", le="0.01"} 0.0
94-
my_histogram_bucket{myValue="labels", le="0.025"} 0.0
95-
my_histogram_bucket{myValue="labels", le="0.05"} 0.0
96-
my_histogram_bucket{myValue="labels", le="0.075"} 0.0
97-
my_histogram_bucket{myValue="labels", le="0.1"} 0.0
98-
my_histogram_bucket{myValue="labels", le="0.25"} 0.0
99-
my_histogram_bucket{myValue="labels", le="0.5"} 0.0
100-
my_histogram_bucket{myValue="labels", le="0.75"} 0.0
101-
my_histogram_bucket{myValue="labels", le="1.0"} 0.0
102-
my_histogram_bucket{myValue="labels", le="2.5"} 0.0
103-
my_histogram_bucket{myValue="labels", le="5.0"} 1.0
104-
my_histogram_bucket{myValue="labels", le="7.5"} 1.0
105-
my_histogram_bucket{myValue="labels", le="10.0"} 1.0
106-
my_histogram_bucket{myValue="labels", le="+Inf"} 1.0
107-
my_histogram_count{myValue="labels"} 1.0
108-
my_histogram_sum{myValue="labels"} 3.0
109-
""")
110-
}
111-
11261
func testSummary() {
11362
let summary = Timer(label: "my_summary")
11463

0 commit comments

Comments
 (0)