Skip to content

Commit eec1f8f

Browse files
Add support for Histogram metrics (#244)
- Aggregator for histogram with set boundaries to sort into buckets - OTLP exporter support - Stub exporting support for Datadog and Prometheus - Added unit tests for histogram aggregator - Use cumulative aggregation for histogram data - Use a default boundaries for histograms, optionally pass explicit boundaries
1 parent 7387d58 commit eec1f8f

File tree

17 files changed

+513
-1
lines changed

17 files changed

+513
-1
lines changed

Sources/Exporters/DatadogExporter/Metrics/MetricUtils.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal struct MetricUtils: Encodable {
2424
switch metric.aggregationType {
2525
case .doubleSum, .intSum:
2626
return countType
27-
case .doubleSummary, .intSummary, .intGauge, .doubleGauge:
27+
case .doubleSummary, .intSummary, .intGauge, .doubleGauge, .doubleHistogram, .intHistogram:
2828
return gaugeType
2929
}
3030
}

Sources/Exporters/DatadogExporter/Metrics/MetricsExporter.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ internal class MetricsExporter {
8484
case .intSummary, .intGauge:
8585
let summary = metricData as! SummaryData<Int>
8686
return DDMetricPoint(timestamp: metricData.timestamp, value: Double(summary.sum))
87+
case .intHistogram:
88+
let histogram = metricData as! HistogramData<Int>
89+
return DDMetricPoint(timestamp: metricData.timestamp, value: Double(histogram.sum))
90+
case .doubleHistogram:
91+
let histogram = metricData as! HistogramData<Double>
92+
return DDMetricPoint(timestamp: metricData.timestamp, value: histogram.sum)
8793
}
8894
}
8995

Sources/Exporters/OpenTelemetryProtocol/metric/MetricsAdapter.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,48 @@ struct MetricsAdapter {
157157
}
158158

159159
protoMetric.doubleSummary.dataPoints.append(protoDataPoint)
160+
case .intHistogram:
161+
guard let histogramData = $0 as? HistogramData<Int> else {
162+
break
163+
}
164+
var protoDataPoint = Opentelemetry_Proto_Metrics_V1_DoubleHistogramDataPoint()
165+
protoDataPoint.sum = Double(histogramData.sum)
166+
protoDataPoint.count = UInt64(histogramData.count)
167+
protoDataPoint.startTimeUnixNano = histogramData.startTimestamp.timeIntervalSince1970.toNanoseconds
168+
protoDataPoint.timeUnixNano = histogramData.timestamp.timeIntervalSince1970.toNanoseconds
169+
protoDataPoint.explicitBounds = histogramData.buckets.boundaries.map { Double($0) }
170+
protoDataPoint.bucketCounts = histogramData.buckets.counts.map { UInt64($0) }
171+
172+
histogramData.labels.forEach {
173+
var kvp = Opentelemetry_Proto_Common_V1_StringKeyValue()
174+
kvp.key = $0.key
175+
kvp.value = $0.value
176+
protoDataPoint.labels.append(kvp)
177+
}
178+
179+
protoMetric.doubleHistogram.aggregationTemporality = .cumulative
180+
protoMetric.doubleHistogram.dataPoints.append(protoDataPoint)
181+
case .doubleHistogram:
182+
guard let histogramData = $0 as? HistogramData<Double> else {
183+
break
184+
}
185+
var protoDataPoint = Opentelemetry_Proto_Metrics_V1_DoubleHistogramDataPoint()
186+
protoDataPoint.sum = Double(histogramData.sum)
187+
protoDataPoint.count = UInt64(histogramData.count)
188+
protoDataPoint.startTimeUnixNano = histogramData.startTimestamp.timeIntervalSince1970.toNanoseconds
189+
protoDataPoint.timeUnixNano = histogramData.timestamp.timeIntervalSince1970.toNanoseconds
190+
protoDataPoint.explicitBounds = histogramData.buckets.boundaries.map { Double($0) }
191+
protoDataPoint.bucketCounts = histogramData.buckets.counts.map { UInt64($0) }
192+
193+
histogramData.labels.forEach {
194+
var kvp = Opentelemetry_Proto_Common_V1_StringKeyValue()
195+
kvp.key = $0.key
196+
kvp.value = $0.value
197+
protoDataPoint.labels.append(kvp)
198+
}
199+
200+
protoMetric.doubleHistogram.aggregationTemporality = .cumulative
201+
protoMetric.doubleHistogram.dataPoints.append(protoDataPoint)
160202
}
161203
}
162204
return protoMetric

Sources/Exporters/Prometheus/PrometheusExporterExtensions.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ public enum PrometheusExporterExtensions {
4545
let min = summary.min
4646
let max = summary.max
4747
output += PrometheusExporterExtensions.writeSummary(prometheusMetric: prometheusMetric, timeStamp: now, labels: labels, metricName: metric.name, sum: Double(sum), count: count, min: Double(min), max: Double(max))
48+
case .intHistogram, .doubleHistogram:
49+
break
4850
}
4951
}
5052
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// Bound histogram metric
9+
open class BoundHistogramMetric<T> {
10+
public init(explicitBoundaries: Array<T>? = nil) {}
11+
12+
/// Record the given value to the bound histogram metric.
13+
/// - Parameters:
14+
/// - value: the histogram to be recorded.
15+
open func record(value: T) {
16+
}
17+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// Measure instrument.
9+
public protocol HistogramMetric {
10+
associatedtype T
11+
/// Gets the bound histogram metric with given labelset.
12+
/// - Parameters:
13+
/// - labelset: The labelset from which bound instrument should be constructed.
14+
/// - Returns: The bound histogram metric.
15+
16+
func bind(labelset: LabelSet) -> BoundHistogramMetric<T>
17+
18+
/// Gets the bound histogram metric with given labelset.
19+
/// - Parameters:
20+
/// - labels: The labels or dimensions associated with this value.
21+
/// - Returns: The bound histogram metric.
22+
func bind(labels: [String: String]) -> BoundHistogramMetric<T>
23+
}
24+
25+
public extension HistogramMetric {
26+
/// Records a histogram.
27+
/// - Parameters:
28+
/// - value: value to record.
29+
/// - labelset: The labelset associated with this value.
30+
func record(value: T, labelset: LabelSet) {
31+
bind(labelset: labelset).record(value: value)
32+
}
33+
34+
/// Records a histogram.
35+
/// - Parameters:
36+
/// - value: value to record.
37+
/// - labels: The labels or dimensions associated with this value.
38+
func record(value: T, labels: [String: String]) {
39+
bind(labels: labels).record(value: value)
40+
}
41+
}
42+
43+
public struct AnyHistogramMetric<T>: HistogramMetric {
44+
private let _bindLabelSet: (LabelSet) -> BoundHistogramMetric<T>
45+
private let _bindLabels: ([String: String]) -> BoundHistogramMetric<T>
46+
47+
public init<U: HistogramMetric>(_ histogram: U) where U.T == T {
48+
_bindLabelSet = histogram.bind(labelset:)
49+
_bindLabels = histogram.bind(labels:)
50+
}
51+
52+
public func bind(labelset: LabelSet) -> BoundHistogramMetric<T> {
53+
_bindLabelSet(labelset)
54+
}
55+
56+
public func bind(labels: [String: String]) -> BoundHistogramMetric<T> {
57+
_bindLabels(labels)
58+
}
59+
}
60+
61+
public struct NoopHistogramMetric<T>: HistogramMetric {
62+
public init() {}
63+
64+
public func bind(labelset: LabelSet) -> BoundHistogramMetric<T> {
65+
BoundHistogramMetric<T>()
66+
}
67+
68+
public func bind(labels: [String: String]) -> BoundHistogramMetric<T> {
69+
BoundHistogramMetric<T>()
70+
}
71+
}

Sources/OpenTelemetryApi/Metrics/Meter.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ public protocol Meter {
3434
/// - absolute: indicates if only positive values are expected.
3535
/// - Returns:The measure instance.
3636
func createDoubleMeasure(name: String, absolute: Bool) -> AnyMeasureMetric<Double>
37+
38+
/// Creates Int Histogram with given name and boundaries.
39+
/// - Parameters:
40+
/// - name: The name of the measure.
41+
/// - explicitBoundaries: The boundary for sorting values into buckets
42+
/// - absolute: indicates if only positive values are expected.
43+
/// - Returns:The histogram instance.
44+
func createIntHistogram(name: String, explicitBoundaries: Array<Int>?, absolute: Bool) -> AnyHistogramMetric<Int>
45+
46+
/// Creates Double Histogram with given name and boundaries.
47+
/// - Parameters:
48+
/// - name: The name of the measure.
49+
/// - explicitBoundaries: The boundary for sorting values into buckets
50+
/// - absolute: indicates if only positive values are expected.
51+
/// - Returns:The histogram instance.
52+
func createDoubleHistogram(name: String, explicitBoundaries: Array<Double>?, absolute: Bool) -> AnyHistogramMetric<Double>
3753

3854
/// Creates Int Observer with given name.
3955
/// - Parameters:

Sources/OpenTelemetryApi/Metrics/ProxyMeter.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ public struct ProxyMeter: Meter {
2828
public func createDoubleMeasure(name: String, absolute: Bool) -> AnyMeasureMetric<Double> {
2929
return realMeter?.createDoubleMeasure(name: name, absolute: absolute) ?? AnyMeasureMetric<Double>(NoopMeasureMetric<Double>())
3030
}
31+
32+
public func createIntHistogram(name: String, explicitBoundaries: Array<Int>? = nil, absolute: Bool) -> AnyHistogramMetric<Int> {
33+
return realMeter?.createIntHistogram(name: name, explicitBoundaries: explicitBoundaries, absolute: absolute) ?? AnyHistogramMetric<Int>(NoopHistogramMetric<Int>())
34+
}
35+
36+
public func createDoubleHistogram(name: String, explicitBoundaries: Array<Double>?, absolute: Bool) -> AnyHistogramMetric<Double> {
37+
return realMeter?.createDoubleHistogram(name: name, explicitBoundaries: explicitBoundaries, absolute: absolute) ?? AnyHistogramMetric<Double>(NoopHistogramMetric<Double>())
38+
}
3139

3240
public func createIntObservableGauge(name: String, callback: @escaping (IntObserverMetric) -> Void) -> IntObserverMetric {
3341
return realMeter?.createIntObservableGauge(name: name, callback: callback) ?? NoopIntObserverMetric()
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// Aggregator which calculates histogram (bucket distribution, sum, count) from measures.
9+
public class HistogramAggregator<T: SignedNumeric & Comparable>: Aggregator<T> {
10+
fileprivate var histogram: Histogram<T>
11+
fileprivate var pointCheck: Histogram<T>
12+
fileprivate var boundaries: Array<T>
13+
14+
private let lock = Lock()
15+
private let defaultBoundaries: Array<T> = [5, 10, 25, 50, 75, 100, 250, 500, 750, 1_000, 2_500, 5_000, 7_500,
16+
10_000]
17+
18+
public init(explicitBoundaries: Array<T>? = nil) throws {
19+
if let explicitBoundaries = explicitBoundaries, explicitBoundaries.count > 0 {
20+
// we need to an ordered set to be able to correctly compute count for each
21+
// boundary since we'll iterate on each in order.
22+
self.boundaries = explicitBoundaries.sorted { $0 < $1 }
23+
} else {
24+
self.boundaries = defaultBoundaries
25+
}
26+
27+
self.histogram = Histogram<T>(boundaries: self.boundaries)
28+
self.pointCheck = Histogram<T>(boundaries: self.boundaries)
29+
}
30+
31+
override public func update(value: T) {
32+
lock.withLockVoid {
33+
self.histogram.count += 1
34+
self.histogram.sum += value
35+
36+
for i in 0..<self.boundaries.count {
37+
if value < self.boundaries[i] {
38+
self.histogram.buckets.counts[i] += 1
39+
return
40+
}
41+
}
42+
// value is above all observed boundaries
43+
self.histogram.buckets.counts[self.boundaries.count] += 1
44+
}
45+
}
46+
47+
override public func checkpoint() {
48+
lock.withLockVoid {
49+
super.checkpoint()
50+
pointCheck = histogram
51+
histogram = Histogram<T>(boundaries: self.boundaries)
52+
}
53+
}
54+
55+
public override func toMetricData() -> MetricData {
56+
return HistogramData<T>(startTimestamp: lastStart,
57+
timestamp: lastEnd,
58+
buckets: pointCheck.buckets,
59+
count: pointCheck.count,
60+
sum: pointCheck.sum)
61+
}
62+
63+
public override func getAggregationType() -> AggregationType {
64+
if T.self == Double.Type.self {
65+
return .doubleHistogram
66+
} else {
67+
return .intHistogram
68+
}
69+
}
70+
}
71+
72+
private struct Histogram<T> where T: SignedNumeric {
73+
/*
74+
* Buckets are implemented using two different arrays:
75+
* - boundaries: contains every finite bucket boundary, which are inclusive lower bounds
76+
* - counts: contains event counts for each bucket
77+
*
78+
* Note that we'll always have n+1 buckets, where n is the number of boundaries.
79+
* This is because we need to count events that are below the lowest boundary.
80+
*
81+
* Example: if we measure the values: [5, 30, 5, 40, 5, 15, 15, 15, 25]
82+
* with the boundaries [ 10, 20, 30 ], we will have the following state:
83+
*
84+
* buckets: {
85+
* boundaries: [10, 20, 30],
86+
* counts: [3, 3, 1, 2],
87+
* }
88+
*/
89+
var buckets: (
90+
boundaries: Array<T>,
91+
counts: Array<Int>
92+
)
93+
var sum: T
94+
var count: Int
95+
96+
init(boundaries: Array<T>) {
97+
sum = 0
98+
count = 0
99+
buckets = (
100+
boundaries: boundaries,
101+
counts: Array(repeating: 0, count: boundaries.count + 1)
102+
)
103+
}
104+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
import OpenTelemetryApi
8+
9+
internal class BoundHistogramMetricSdk<T: SignedNumeric & Comparable>: BoundHistogramMetricSdkBase<T> {
10+
private var histogramAggregator: HistogramAggregator<T>
11+
12+
override init(explicitBoundaries: Array<T>? = nil) {
13+
self.histogramAggregator = try! HistogramAggregator(explicitBoundaries: explicitBoundaries)
14+
super.init(explicitBoundaries: explicitBoundaries)
15+
}
16+
17+
override func record(value: T) {
18+
histogramAggregator.update(value: value)
19+
}
20+
21+
override func getAggregator() -> HistogramAggregator<T> {
22+
return histogramAggregator
23+
}
24+
}

0 commit comments

Comments
 (0)