Skip to content

Commit 3caacd3

Browse files
authored
Perf improvements (#50)
Motivation: Metrics collecting systems should be efficient enough so that their impact on the system being instrumented is negligible. Modifications: - Store `PromMetric`s in a dictionary keyed by label in `PrometheusClient`; this gives fast lookup when checking if a metric already exists - Remove the `metricTypeMap`, we can recover the same information from `PromMetric` - Remove calls to `getMetricInstrumet` in `PrometheusMetricsFactory` these were redundant as the same check is done in `createCounter` etc. - In each `createCounter` (etc.) call we now hold the lock for the duration of the call to avoid races between checking the cache and creating and storing a new metric. - The `PrometheusLabelSanitizer` now checks whether input needs santizing instead of santizing all input - Sanitizing is done in one step, mapping each utf8 code point to a sanitized code point rather than lowercasing and then checking the validity of each character. Result: - Sanitizing pre-sanitized labels is ~100x faster - Sanitizing non-sanitized label is ~20x faster - Incrementing 10 counters is ~20x faster - Incrementing 100 counters is ~40x faster - Incrementing 1000 counters is ~250x faster - (Similar results for other metrics.)
1 parent 42edfe0 commit 3caacd3

File tree

3 files changed

+120
-103
lines changed

3 files changed

+120
-103
lines changed

Sources/Prometheus/Prometheus.swift

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,14 @@ import NIO
77
public class PrometheusClient {
88

99
/// Metrics tracked by this Prometheus instance
10-
private var metrics: [PromMetric]
11-
12-
/// To keep track of the type of a metric since it can not change
13-
/// througout the lifetime of the program
14-
private var metricTypeMap: [String: PromMetricType]
10+
private var metrics: [String: PromMetric]
1511

1612
/// Lock used for thread safety
1713
private let lock: Lock
1814

1915
/// Create a PrometheusClient instance
2016
public init() {
21-
self.metrics = []
22-
self.metricTypeMap = [:]
17+
self.metrics = [:]
2318
self.lock = Lock()
2419
}
2520

@@ -31,7 +26,7 @@ public class PrometheusClient {
3126
/// - succeed: Closure that will be called with a newline separated string with metrics for all Metrics this PrometheusClient handles
3227
public func collect(_ succeed: (String) -> ()) {
3328
self.lock.withLock {
34-
succeed(self.metrics.isEmpty ? "": "\(self.metrics.map { $0.collect() }.joined(separator: "\n"))\n")
29+
succeed(self.metrics.isEmpty ? "": "\(self.metrics.values.map { $0.collect() }.joined(separator: "\n"))\n")
3530
}
3631
}
3732

@@ -50,7 +45,7 @@ public class PrometheusClient {
5045
public func collect(_ succeed: (ByteBuffer) -> ()) {
5146
self.lock.withLock {
5247
var buffer = ByteBufferAllocator().buffer(capacity: 0)
53-
self.metrics.forEach {
48+
self.metrics.values.forEach {
5449
$0.collect(into: &buffer)
5550
buffer.writeString("\n")
5651
}
@@ -72,13 +67,21 @@ public class PrometheusClient {
7267
// `metricTypeMap` is left untouched as those must be consistent
7368
// throughout the lifetime of a program.
7469
return lock.withLock {
75-
self.metrics.removeAll { $0._type == metric._type && $0.name == metric.name }
70+
self.metrics.removeValue(forKey: metric.name)
7671
}
7772
}
7873

79-
public func getMetricInstance<T>(with name: String, andType type: PromMetricType) -> T? where T: PromMetric {
74+
public func getMetricInstance<Metric>(with name: String, andType type: PromMetricType) -> Metric? where Metric: PromMetric {
8075
return lock.withLock {
81-
self.metrics.compactMap { $0 as? T }.filter { $0.name == name && $0._type == type }.first
76+
self._getMetricInstance(with: name, andType: type)
77+
}
78+
}
79+
80+
private func _getMetricInstance<Metric>(with name: String, andType type: PromMetricType) -> Metric? where Metric: PromMetric {
81+
if let metric = self.metrics[name], metric._type == type {
82+
return metric as? Metric
83+
} else {
84+
return nil
8285
}
8386
}
8487

@@ -101,17 +104,14 @@ public class PrometheusClient {
101104
initialValue: T = 0,
102105
withLabelType labelType: U.Type) -> PromCounter<T, U>
103106
{
104-
if let counter: PromCounter<T, U> = getMetricInstance(with: name, andType: .counter) {
105-
return counter
106-
}
107-
108107
return self.lock.withLock {
109-
if let type = metricTypeMap[name] {
110-
precondition(type == .counter, "Label \(name) was associated with \(type) before. Can not be used for a counter now.")
108+
if let cachedCounter: PromCounter<T, U> = self._getMetricInstance(with: name, andType: .counter) {
109+
return cachedCounter
111110
}
111+
112112
let counter = PromCounter<T, U>(name, helpText, initialValue, self)
113-
self.metricTypeMap[name] = .counter
114-
self.metrics.append(counter)
113+
let oldInstrument = self.metrics.updateValue(counter, forKey: name)
114+
precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).")
115115
return counter
116116
}
117117
}
@@ -153,17 +153,14 @@ public class PrometheusClient {
153153
initialValue: T = 0,
154154
withLabelType labelType: U.Type) -> PromGauge<T, U>
155155
{
156-
if let gauge: PromGauge<T, U> = getMetricInstance(with: name, andType: .gauge) {
157-
return gauge
158-
}
159-
160156
return self.lock.withLock {
161-
if let type = metricTypeMap[name] {
162-
precondition(type == .gauge, "Label \(name) was associated with \(type) before. Can not be used for a gauge now.")
157+
if let cachedGauge: PromGauge<T, U> = self._getMetricInstance(with: name, andType: .gauge) {
158+
return cachedGauge
163159
}
160+
164161
let gauge = PromGauge<T, U>(name, helpText, initialValue, self)
165-
self.metricTypeMap[name] = .gauge
166-
self.metrics.append(gauge)
162+
let oldInstrument = self.metrics.updateValue(gauge, forKey: name)
163+
precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).")
167164
return gauge
168165
}
169166
}
@@ -205,17 +202,14 @@ public class PrometheusClient {
205202
buckets: Buckets = .defaultBuckets,
206203
labels: U.Type) -> PromHistogram<T, U>
207204
{
208-
if let histogram: PromHistogram<T, U> = getMetricInstance(with: name, andType: .histogram) {
209-
return histogram
210-
}
211-
212205
return self.lock.withLock {
213-
if let type = metricTypeMap[name] {
214-
precondition(type == .histogram, "Label \(name) was associated with \(type) before. Can not be used for a histogram now.")
206+
if let cachedHistogram: PromHistogram<T, U> = self._getMetricInstance(with: name, andType: .histogram) {
207+
return cachedHistogram
215208
}
209+
216210
let histogram = PromHistogram<T, U>(name, helpText, U(), buckets, self)
217-
self.metricTypeMap[name] = .histogram
218-
self.metrics.append(histogram)
211+
let oldInstrument = self.metrics.updateValue(histogram, forKey: name)
212+
precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).")
219213
return histogram
220214
}
221215
}
@@ -257,17 +251,14 @@ public class PrometheusClient {
257251
quantiles: [Double] = Prometheus.defaultQuantiles,
258252
labels: U.Type) -> PromSummary<T, U>
259253
{
260-
if let summary: PromSummary<T, U> = getMetricInstance(with: name, andType: .summary) {
261-
return summary
262-
}
263-
264254
return self.lock.withLock {
265-
if let type = metricTypeMap[name] {
266-
precondition(type == .summary, "Label \(name) was associated with \(type) before. Can not be used for a summary now.")
255+
if let cachedSummary: PromSummary<T, U> = self._getMetricInstance(with: name, andType: .summary) {
256+
return cachedSummary
267257
}
258+
268259
let summary = PromSummary<T, U>(name, helpText, U(), quantiles, self)
269-
self.metricTypeMap[name] = .summary
270-
self.metrics.append(summary)
260+
let oldInstrument = self.metrics.updateValue(summary, forKey: name)
261+
precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).")
271262
return summary
272263
}
273264
}

Sources/Prometheus/PrometheusMetrics.swift

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,57 @@ public protocol LabelSanitizer {
128128
///
129129
/// See `https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels` for more info.
130130
public struct PrometheusLabelSanitizer: LabelSanitizer {
131-
let allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_:"
132-
131+
private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z")
132+
private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z")
133+
private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9")
134+
133135
public init() { }
134136

135137
public func sanitize(_ label: String) -> String {
136-
return String(label
137-
.lowercased()
138-
.map { (c: Character) -> Character in if allowedCharacters.contains(c) { return c }; return "_" })
138+
if PrometheusLabelSanitizer.isSanitized(label) {
139+
return label
140+
} else {
141+
return PrometheusLabelSanitizer.sanitizeLabel(label)
142+
}
143+
}
144+
145+
/// Returns a boolean indicating whether the label is already sanitized.
146+
private static func isSanitized(_ label: String) -> Bool {
147+
return label.utf8.allSatisfy(PrometheusLabelSanitizer.isValidCharacter(_:))
148+
}
149+
150+
/// Returns a boolean indicating whether the character may be used in a label.
151+
private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool {
152+
switch codePoint {
153+
case PrometheusLabelSanitizer.lowercaseAThroughZ,
154+
PrometheusLabelSanitizer.zeroThroughNine,
155+
UInt8(ascii: ":"),
156+
UInt8(ascii: "_"):
157+
return true
158+
default:
159+
return false
160+
}
161+
}
162+
163+
private static func sanitizeLabel(_ label: String) -> String {
164+
let sanitized: [UInt8] = label.utf8.map { character in
165+
if PrometheusLabelSanitizer.isValidCharacter(character) {
166+
return character
167+
} else {
168+
return PrometheusLabelSanitizer.sanitizeCharacter(character)
169+
}
170+
}
171+
172+
return String(decoding: sanitized, as: UTF8.self)
173+
}
174+
175+
private static func sanitizeCharacter(_ character: UInt8) -> UInt8 {
176+
if PrometheusLabelSanitizer.uppercaseAThroughZ.contains(character) {
177+
// Uppercase, so shift to lower case.
178+
return character + (UInt8(ascii: "a") - UInt8(ascii: "A"))
179+
} else {
180+
return UInt8(ascii: "_")
181+
}
139182
}
140183
}
141184

@@ -191,13 +234,8 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
191234

192235
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
193236
let label = configuration.labelSanitizer.sanitize(label)
194-
let createHandler = { (counter: PromCounter) -> CounterHandler in
195-
return MetricsCounter(counter: counter, dimensions: dimensions)
196-
}
197-
if let counter: PromCounter<Int64, DimensionLabels> = client.getMetricInstance(with: label, andType: .counter) {
198-
return createHandler(counter)
199-
}
200-
return createHandler(client.createCounter(forType: Int64.self, named: label, withLabelType: DimensionLabels.self))
237+
let counter = client.createCounter(forType: Int64.self, named: label, withLabelType: DimensionLabels.self)
238+
return MetricsCounter(counter: counter, dimensions: dimensions)
201239
}
202240

203241
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
@@ -207,24 +245,14 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
207245

208246
private func makeGauge(label: String, dimensions: [(String, String)]) -> RecorderHandler {
209247
let label = configuration.labelSanitizer.sanitize(label)
210-
let createHandler = { (gauge: PromGauge) -> RecorderHandler in
211-
return MetricsGauge(gauge: gauge, dimensions: dimensions)
212-
}
213-
if let gauge: PromGauge<Double, DimensionLabels> = client.getMetricInstance(with: label, andType: .gauge) {
214-
return createHandler(gauge)
215-
}
216-
return createHandler(client.createGauge(forType: Double.self, named: label, withLabelType: DimensionLabels.self))
248+
let gauge = client.createGauge(forType: Double.self, named: label, withLabelType: DimensionLabels.self)
249+
return MetricsGauge(gauge: gauge, dimensions: dimensions)
217250
}
218251

219252
private func makeHistogram(label: String, dimensions: [(String, String)]) -> RecorderHandler {
220253
let label = configuration.labelSanitizer.sanitize(label)
221-
let createHandler = { (histogram: PromHistogram) -> RecorderHandler in
222-
return MetricsHistogram(histogram: histogram, dimensions: dimensions)
223-
}
224-
if let histogram: PromHistogram<Double, DimensionHistogramLabels> = client.getMetricInstance(with: label, andType: .histogram) {
225-
return createHandler(histogram)
226-
}
227-
return createHandler(client.createHistogram(forType: Double.self, named: label, labels: DimensionHistogramLabels.self))
254+
let histogram = client.createHistogram(forType: Double.self, named: label, labels: DimensionHistogramLabels.self)
255+
return MetricsHistogram(histogram: histogram, dimensions: dimensions)
228256
}
229257

230258
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
@@ -240,26 +268,16 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
240268
/// This method creates `Summary` backed timer implementation
241269
private func makeSummaryTimer(label: String, dimensions: [(String, String)], quantiles: [Double]) -> TimerHandler {
242270
let label = configuration.labelSanitizer.sanitize(label)
243-
let createHandler = { (summary: PromSummary) -> TimerHandler in
244-
return MetricsSummary(summary: summary, dimensions: dimensions)
245-
}
246-
if let summary: PromSummary<Int64, DimensionSummaryLabels> = client.getMetricInstance(with: label, andType: .summary) {
247-
return createHandler(summary)
248-
}
249-
return createHandler(client.createSummary(forType: Int64.self, named: label, quantiles: quantiles, labels: DimensionSummaryLabels.self))
271+
let summary = client.createSummary(forType: Int64.self, named: label, quantiles: quantiles, labels: DimensionSummaryLabels.self)
272+
return MetricsSummary(summary: summary, dimensions: dimensions)
250273
}
251274

252275
/// There's two different ways to back swift-api `Timer` with Prometheus classes.
253276
/// This method creates `Histogram` backed timer implementation
254277
private func makeHistogramTimer(label: String, dimensions: [(String, String)], buckets: Buckets) -> TimerHandler {
255-
let createHandler = { (histogram: PromHistogram) -> TimerHandler in
256-
MetricsHistogramTimer(histogram: histogram, dimensions: dimensions)
257-
}
258-
// PromHistogram should be reused when created for the same label, so we try to look it up
259-
if let histogram: PromHistogram<Int64, DimensionHistogramLabels> = client.getMetricInstance(with: label, andType: .histogram) {
260-
return createHandler(histogram)
261-
}
262-
return createHandler(client.createHistogram(forType: Int64.self, named: label, buckets: buckets, labels: DimensionHistogramLabels.self))
278+
let label = configuration.labelSanitizer.sanitize(label)
279+
let histogram = client.createHistogram(forType: Int64.self, named: label, buckets: buckets, labels: DimensionHistogramLabels.self)
280+
return MetricsHistogramTimer(histogram: histogram, dimensions: dimensions)
263281
}
264282
}
265283

Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,19 @@ final class PrometheusMetricsTests: XCTestCase {
121121
prom.collect(promise.succeed)
122122
var buffer = try promise.futureResult.wait()
123123

124-
XCTAssertEqual(buffer.readString(length: buffer.readableBytes),
125-
"""
126-
# TYPE my_counter counter
127-
my_counter 10
128-
my_counter{x="x", a="aaa"} 4
129-
# TYPE my_gauge gauge
130-
my_gauge 100.0\n
131-
""")
124+
let collected = buffer.readString(length: buffer.readableBytes)!
125+
126+
// We can't guarantee order so check the output contains the expected metrics.
127+
XCTAssertTrue(collected.contains("""
128+
# TYPE my_counter counter
129+
my_counter 10
130+
my_counter{x="x", a="aaa"} 4
131+
"""))
132+
133+
XCTAssertTrue(collected.contains("""
134+
# TYPE my_gauge gauge
135+
my_gauge 100.0
136+
"""))
132137
}
133138

134139
func testCollectAFewMetricsIntoString() {
@@ -141,16 +146,19 @@ final class PrometheusMetricsTests: XCTestCase {
141146

142147
let promise = self.eventLoop.makePromise(of: String.self)
143148
prom.collect(promise.succeed)
144-
let string = try! promise.futureResult.wait()
145-
146-
XCTAssertEqual(string,
147-
"""
148-
# TYPE my_counter counter
149-
my_counter 10
150-
my_counter{x="x", a="aaa"} 4
151-
# TYPE my_gauge gauge
152-
my_gauge 100.0\n
153-
""")
149+
let collected = try! promise.futureResult.wait()
150+
151+
// We can't guarantee order so check the output contains the expected metrics.
152+
XCTAssertTrue(collected.contains("""
153+
# TYPE my_counter counter
154+
my_counter 10
155+
my_counter{x="x", a="aaa"} 4
156+
"""))
157+
158+
XCTAssertTrue(collected.contains("""
159+
# TYPE my_gauge gauge
160+
my_gauge 100.0
161+
"""))
154162
}
155163

156164
func testHistogramBackedTimer() {

0 commit comments

Comments
 (0)