Skip to content

Commit 938b2a2

Browse files
MrLotUktoso
andauthored
Add LabelSanitizers (#32)
* Add s * Add docs * Update Sources/Prometheus/PrometheusMetrics.swift Co-Authored-By: Konrad `ktoso` Malawski <konrad_malawski@apple.com> * Remarks Co-authored-by: Konrad `ktoso` Malawski <konrad_malawski@apple.com>
1 parent 1e22ec2 commit 938b2a2

File tree

4 files changed

+110
-1
lines changed

4 files changed

+110
-1
lines changed

Sources/Prometheus/Prometheus.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@ 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+
1923
/// Create a PrometheusClient instance
20-
public init() {
24+
public init(labelSanitizer sanitizer: LabelSanitizer = PrometheusLabelSanitizer()) {
2125
self.metrics = []
2226
self.metricTypeMap = [:]
27+
self.sanitizer = sanitizer
2328
self.lock = Lock()
2429
}
2530

Sources/Prometheus/PrometheusMetrics.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,41 @@ private class MetricsSummary: TimerHandler {
8686
}
8787
}
8888

89+
/// Used to sanitize labels into a format compatible with Prometheus label requirements.
90+
/// Useful when using `PrometheusMetrics` via `SwiftMetrics` with clients which do not necessarily know
91+
/// about prometheus label formats, and may be using e.g. `.` or upper-case letters in labels (which Prometheus
92+
/// does not allow).
93+
///
94+
/// let sanitizer: LabelSanitizer = ...
95+
/// let prometheusLabel = sanitizer.sanitize(nonPrometheusLabel)
96+
///
97+
/// By default `PrometheusLabelSanitizer` is used by `PrometheusClient`
98+
public protocol LabelSanitizer {
99+
/// Sanitize the passed in label to a Prometheus accepted value.
100+
///
101+
/// - parameters:
102+
/// - label: The created label that needs to be sanitized.
103+
///
104+
/// - returns: A sanitized string that a Prometheus backend will accept.
105+
func sanitize(_ label: String) -> String
106+
}
107+
108+
/// Default implementation of `LabelSanitizer` that sanitizes any characters not
109+
/// allowed by Prometheus to an underscore (`_`).
110+
///
111+
/// See `https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels` for more info.
112+
public struct PrometheusLabelSanitizer: LabelSanitizer {
113+
let allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_:"
114+
115+
public init() { }
116+
117+
public func sanitize(_ label: String) -> String {
118+
return String(label
119+
.lowercased()
120+
.map { (c: Character) -> Character in if allowedCharacters.contains(c) { return c }; return "_" })
121+
}
122+
}
123+
89124
extension PrometheusClient: MetricsFactory {
90125
public func destroyCounter(_ handler: CounterHandler) {
91126
guard let handler = handler as? MetricsCounter else { return }
@@ -107,6 +142,7 @@ extension PrometheusClient: MetricsFactory {
107142
}
108143

109144
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
145+
let label = self.sanitizer.sanitize(label)
110146
let createHandler = { (counter: PromCounter) -> CounterHandler in
111147
return MetricsCounter(counter: counter, dimensions: dimensions)
112148
}
@@ -117,10 +153,12 @@ extension PrometheusClient: MetricsFactory {
117153
}
118154

119155
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
156+
let label = self.sanitizer.sanitize(label)
120157
return aggregate ? makeHistogram(label: label, dimensions: dimensions) : makeGauge(label: label, dimensions: dimensions)
121158
}
122159

123160
private func makeGauge(label: String, dimensions: [(String, String)]) -> RecorderHandler {
161+
let label = self.sanitizer.sanitize(label)
124162
let createHandler = { (gauge: PromGauge) -> RecorderHandler in
125163
return MetricsGauge(gauge: gauge, dimensions: dimensions)
126164
}
@@ -131,6 +169,7 @@ extension PrometheusClient: MetricsFactory {
131169
}
132170

133171
private func makeHistogram(label: String, dimensions: [(String, String)]) -> RecorderHandler {
172+
let label = self.sanitizer.sanitize(label)
134173
let createHandler = { (histogram: PromHistogram) -> RecorderHandler in
135174
return MetricsHistogram(histogram: histogram, dimensions: dimensions)
136175
}
@@ -141,6 +180,7 @@ extension PrometheusClient: MetricsFactory {
141180
}
142181

143182
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
183+
let label = self.sanitizer.sanitize(label)
144184
let createHandler = { (summary: PromSummary) -> TimerHandler in
145185
return MetricsSummary(summary: summary, dimensions: dimensions)
146186
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import XCTest
2+
import NIO
3+
@testable import Prometheus
4+
@testable import CoreMetrics
5+
6+
final class SanitizerTests: XCTestCase {
7+
8+
var group: EventLoopGroup!
9+
var eventLoop: EventLoop {
10+
return group.next()
11+
}
12+
13+
override func setUp() {
14+
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
15+
}
16+
17+
override func tearDown() {
18+
try! self.group.syncShutdownGracefully()
19+
}
20+
21+
func testDefaultSanitizer() throws {
22+
let sanitizer = PrometheusLabelSanitizer()
23+
24+
XCTAssertEqual(sanitizer.sanitize("MyMetrics.RequestDuration"), "mymetrics_requestduration")
25+
XCTAssertEqual(sanitizer.sanitize("My-Metrics.request-Duration"), "my_metrics_request_duration")
26+
}
27+
28+
func testCustomSanitizer() throws {
29+
struct Sanitizer: LabelSanitizer {
30+
func sanitize(_ label: String) -> String {
31+
return String(label.reversed())
32+
}
33+
}
34+
35+
let sanitizer = Sanitizer()
36+
XCTAssertEqual(sanitizer.sanitize("MyMetrics.RequestDuration"), "noitaruDtseuqeR.scirteMyM")
37+
}
38+
39+
func testIntegratedSanitizer() throws {
40+
let prom = PrometheusClient()
41+
MetricsSystem.bootstrapInternal(prom)
42+
43+
CoreMetrics.Counter(label: "Test.Counter").increment(by: 10)
44+
45+
let promise = eventLoop.makePromise(of: String.self)
46+
prom.collect(into: promise)
47+
XCTAssertEqual(try! promise.futureResult.wait(), """
48+
# TYPE test_counter counter
49+
test_counter 10
50+
""")
51+
}
52+
}

Tests/SwiftPrometheusTests/XCTestManifests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ extension PrometheusMetricsTests {
3636
]
3737
}
3838

39+
extension SanitizerTests {
40+
// DO NOT MODIFY: This is autogenerated, use:
41+
// `swift test --generate-linuxmain`
42+
// to regenerate.
43+
static let __allTests__SanitizerTests = [
44+
("testCustomSanitizer", testCustomSanitizer),
45+
("testDefaultSanitizer", testDefaultSanitizer),
46+
("testIntegratedSanitizer", testIntegratedSanitizer),
47+
]
48+
}
49+
3950
extension SummaryTests {
4051
// DO NOT MODIFY: This is autogenerated, use:
4152
// `swift test --generate-linuxmain`
@@ -63,6 +74,7 @@ public func __allTests() -> [XCTestCaseEntry] {
6374
testCase(GaugeTests.__allTests__GaugeTests),
6475
testCase(HistogramTests.__allTests__HistogramTests),
6576
testCase(PrometheusMetricsTests.__allTests__PrometheusMetricsTests),
77+
testCase(SanitizerTests.__allTests__SanitizerTests),
6678
testCase(SummaryTests.__allTests__SummaryTests),
6779
testCase(SwiftPrometheusTests.__allTests__SwiftPrometheusTests),
6880
]

0 commit comments

Comments
 (0)