Skip to content

Commit e3f3833

Browse files
authored
Sanitize Dimensions (#68)
* Add failing test for sanitising dimension * Fix test * Add new DimensionsSanitizer
1 parent 36740d1 commit e3f3833

File tree

6 files changed

+163
-84
lines changed

6 files changed

+163
-84
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
build/
66
.swiftpm/
77
.idea
8+
.vscode/

Sources/Prometheus/PrometheusMetrics.swift

Lines changed: 15 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -124,84 +124,6 @@ private class MetricsSummary: TimerHandler {
124124
}
125125
}
126126

127-
/// Used to sanitize labels into a format compatible with Prometheus label requirements.
128-
/// Useful when using `PrometheusMetrics` via `SwiftMetrics` with clients which do not necessarily know
129-
/// about prometheus label formats, and may be using e.g. `.` or upper-case letters in labels (which Prometheus
130-
/// does not allow).
131-
///
132-
/// let sanitizer: LabelSanitizer = ...
133-
/// let prometheusLabel = sanitizer.sanitize(nonPrometheusLabel)
134-
///
135-
/// By default `PrometheusLabelSanitizer` is used by `PrometheusMetricsFactory`
136-
public protocol LabelSanitizer {
137-
/// Sanitize the passed in label to a Prometheus accepted value.
138-
///
139-
/// - parameters:
140-
/// - label: The created label that needs to be sanitized.
141-
///
142-
/// - returns: A sanitized string that a Prometheus backend will accept.
143-
func sanitize(_ label: String) -> String
144-
}
145-
146-
/// Default implementation of `LabelSanitizer` that sanitizes any characters not
147-
/// allowed by Prometheus to an underscore (`_`).
148-
///
149-
/// See `https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels` for more info.
150-
public struct PrometheusLabelSanitizer: LabelSanitizer {
151-
private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z")
152-
private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z")
153-
private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9")
154-
155-
public init() { }
156-
157-
public func sanitize(_ label: String) -> String {
158-
if PrometheusLabelSanitizer.isSanitized(label) {
159-
return label
160-
} else {
161-
return PrometheusLabelSanitizer.sanitizeLabel(label)
162-
}
163-
}
164-
165-
/// Returns a boolean indicating whether the label is already sanitized.
166-
private static func isSanitized(_ label: String) -> Bool {
167-
return label.utf8.allSatisfy(PrometheusLabelSanitizer.isValidCharacter(_:))
168-
}
169-
170-
/// Returns a boolean indicating whether the character may be used in a label.
171-
private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool {
172-
switch codePoint {
173-
case PrometheusLabelSanitizer.lowercaseAThroughZ,
174-
PrometheusLabelSanitizer.zeroThroughNine,
175-
UInt8(ascii: ":"),
176-
UInt8(ascii: "_"):
177-
return true
178-
default:
179-
return false
180-
}
181-
}
182-
183-
private static func sanitizeLabel(_ label: String) -> String {
184-
let sanitized: [UInt8] = label.utf8.map { character in
185-
if PrometheusLabelSanitizer.isValidCharacter(character) {
186-
return character
187-
} else {
188-
return PrometheusLabelSanitizer.sanitizeCharacter(character)
189-
}
190-
}
191-
192-
return String(decoding: sanitized, as: UTF8.self)
193-
}
194-
195-
private static func sanitizeCharacter(_ character: UInt8) -> UInt8 {
196-
if PrometheusLabelSanitizer.uppercaseAThroughZ.contains(character) {
197-
// Uppercase, so shift to lower case.
198-
return character + (UInt8(ascii: "a") - UInt8(ascii: "A"))
199-
} else {
200-
return UInt8(ascii: "_")
201-
}
202-
}
203-
}
204-
205127
/// Defines the base for a bridge between PrometheusClient and swift-metrics.
206128
/// Used by `SwiftMetrics.prometheus()` to get an instance of `PrometheusClient` from `MetricsSystem`
207129
///
@@ -260,13 +182,13 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
260182
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
261183
let label = configuration.labelSanitizer.sanitize(label)
262184
let counter = client.createCounter(forType: Int64.self, named: label, withLabelType: DimensionLabels.self)
263-
return MetricsCounter(counter: counter, dimensions: dimensions)
185+
return MetricsCounter(counter: counter, dimensions: dimensions.sanitized())
264186
}
265187

266188
public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler {
267189
let label = configuration.labelSanitizer.sanitize(label)
268190
let counter = client.createCounter(forType: Double.self, named: label, withLabelType: DimensionLabels.self)
269-
return MetricsFloatingPointCounter(counter: counter, dimensions: dimensions)
191+
return MetricsFloatingPointCounter(counter: counter, dimensions: dimensions.sanitized())
270192
}
271193

272194
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
@@ -277,13 +199,13 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
277199
private func makeGauge(label: String, dimensions: [(String, String)]) -> RecorderHandler {
278200
let label = configuration.labelSanitizer.sanitize(label)
279201
let gauge = client.createGauge(forType: Double.self, named: label, withLabelType: DimensionLabels.self)
280-
return MetricsGauge(gauge: gauge, dimensions: dimensions)
202+
return MetricsGauge(gauge: gauge, dimensions: dimensions.sanitized())
281203
}
282204

283205
private func makeHistogram(label: String, dimensions: [(String, String)]) -> RecorderHandler {
284206
let label = configuration.labelSanitizer.sanitize(label)
285207
let histogram = client.createHistogram(forType: Double.self, named: label, labels: DimensionHistogramLabels.self)
286-
return MetricsHistogram(histogram: histogram, dimensions: dimensions)
208+
return MetricsHistogram(histogram: histogram, dimensions: dimensions.sanitized())
287209
}
288210

289211
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
@@ -300,15 +222,24 @@ public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory {
300222
private func makeSummaryTimer(label: String, dimensions: [(String, String)], quantiles: [Double]) -> TimerHandler {
301223
let label = configuration.labelSanitizer.sanitize(label)
302224
let summary = client.createSummary(forType: Int64.self, named: label, quantiles: quantiles, labels: DimensionSummaryLabels.self)
303-
return MetricsSummary(summary: summary, dimensions: dimensions)
225+
return MetricsSummary(summary: summary, dimensions: dimensions.sanitized())
304226
}
305227

306228
/// There's two different ways to back swift-api `Timer` with Prometheus classes.
307229
/// This method creates `Histogram` backed timer implementation
308230
private func makeHistogramTimer(label: String, dimensions: [(String, String)], buckets: Buckets) -> TimerHandler {
309231
let label = configuration.labelSanitizer.sanitize(label)
310232
let histogram = client.createHistogram(forType: Int64.self, named: label, buckets: buckets, labels: DimensionHistogramLabels.self)
311-
return MetricsHistogramTimer(histogram: histogram, dimensions: dimensions)
233+
return MetricsHistogramTimer(histogram: histogram, dimensions: dimensions.sanitized())
234+
}
235+
}
236+
237+
extension Array where Element == (String, String) {
238+
func sanitized() -> [(String, String)] {
239+
let sanitizer = DimensionsSanitizer()
240+
return self.map {
241+
(sanitizer.sanitize($0.0), $0.1)
242+
}
312243
}
313244
}
314245

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
struct DimensionsSanitizer: LabelSanitizer {
2+
private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z")
3+
private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z")
4+
private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9")
5+
6+
public init() { }
7+
8+
public func sanitize(_ label: String) -> String {
9+
if DimensionsSanitizer.isSanitized(label) {
10+
return label
11+
} else {
12+
return DimensionsSanitizer.sanitizeLabel(label)
13+
}
14+
}
15+
16+
/// Returns a boolean indicating whether the label is already sanitized.
17+
private static func isSanitized(_ label: String) -> Bool {
18+
return label.utf8.allSatisfy(DimensionsSanitizer.isValidCharacter(_:))
19+
}
20+
21+
/// Returns a boolean indicating whether the character may be used in a label.
22+
private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool {
23+
switch codePoint {
24+
case DimensionsSanitizer.lowercaseAThroughZ,
25+
DimensionsSanitizer.uppercaseAThroughZ,
26+
DimensionsSanitizer.zeroThroughNine,
27+
UInt8(ascii: ":"),
28+
UInt8(ascii: "_"):
29+
return true
30+
default:
31+
return false
32+
}
33+
}
34+
35+
private static func sanitizeLabel(_ label: String) -> String {
36+
let sanitized: [UInt8] = label.utf8.map { character in
37+
if DimensionsSanitizer.isValidCharacter(character) {
38+
return character
39+
} else {
40+
return DimensionsSanitizer.sanitizeCharacter(character)
41+
}
42+
}
43+
44+
return String(decoding: sanitized, as: UTF8.self)
45+
}
46+
47+
private static func sanitizeCharacter(_ character: UInt8) -> UInt8 {
48+
if DimensionsSanitizer.uppercaseAThroughZ.contains(character) {
49+
// Uppercase, so shift to lower case.
50+
return character + (UInt8(ascii: "a") - UInt8(ascii: "A"))
51+
} else {
52+
return UInt8(ascii: "_")
53+
}
54+
}
55+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/// Used to sanitize labels into a format compatible with Prometheus label requirements.
2+
/// Useful when using `PrometheusMetrics` via `SwiftMetrics` with clients which do not necessarily know
3+
/// about prometheus label formats, and may be using e.g. `.` or upper-case letters in labels (which Prometheus
4+
/// does not allow).
5+
///
6+
/// let sanitizer: LabelSanitizer = ...
7+
/// let prometheusLabel = sanitizer.sanitize(nonPrometheusLabel)
8+
///
9+
/// By default `PrometheusLabelSanitizer` is used by `PrometheusMetricsFactory`
10+
public protocol LabelSanitizer {
11+
/// Sanitize the passed in label to a Prometheus accepted value.
12+
///
13+
/// - parameters:
14+
/// - label: The created label that needs to be sanitized.
15+
///
16+
/// - returns: A sanitized string that a Prometheus backend will accept.
17+
func sanitize(_ label: String) -> String
18+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/// Default implementation of `LabelSanitizer` that sanitizes any characters not
2+
/// allowed by Prometheus to an underscore (`_`).
3+
///
4+
/// See `https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels` for more info.
5+
public struct PrometheusLabelSanitizer: LabelSanitizer {
6+
private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z")
7+
private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z")
8+
private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9")
9+
10+
public init() { }
11+
12+
public func sanitize(_ label: String) -> String {
13+
if PrometheusLabelSanitizer.isSanitized(label) {
14+
return label
15+
} else {
16+
return PrometheusLabelSanitizer.sanitizeLabel(label)
17+
}
18+
}
19+
20+
/// Returns a boolean indicating whether the label is already sanitized.
21+
private static func isSanitized(_ label: String) -> Bool {
22+
return label.utf8.allSatisfy(PrometheusLabelSanitizer.isValidCharacter(_:))
23+
}
24+
25+
/// Returns a boolean indicating whether the character may be used in a label.
26+
private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool {
27+
switch codePoint {
28+
case PrometheusLabelSanitizer.lowercaseAThroughZ,
29+
PrometheusLabelSanitizer.zeroThroughNine,
30+
UInt8(ascii: ":"),
31+
UInt8(ascii: "_"):
32+
return true
33+
default:
34+
return false
35+
}
36+
}
37+
38+
private static func sanitizeLabel(_ label: String) -> String {
39+
let sanitized: [UInt8] = label.utf8.map { character in
40+
if PrometheusLabelSanitizer.isValidCharacter(character) {
41+
return character
42+
} else {
43+
return PrometheusLabelSanitizer.sanitizeCharacter(character)
44+
}
45+
}
46+
47+
return String(decoding: sanitized, as: UTF8.self)
48+
}
49+
50+
private static func sanitizeCharacter(_ character: UInt8) -> UInt8 {
51+
if PrometheusLabelSanitizer.uppercaseAThroughZ.contains(character) {
52+
// Uppercase, so shift to lower case.
53+
return character + (UInt8(ascii: "a") - UInt8(ascii: "A"))
54+
} else {
55+
return UInt8(ascii: "_")
56+
}
57+
}
58+
}

Tests/SwiftPrometheusTests/SanitizerTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,20 @@ final class SanitizerTests: XCTestCase {
4949
test_counter 10\n
5050
""")
5151
}
52+
53+
func testIntegratedSanitizerForDimensions() throws {
54+
let prom = PrometheusClient()
55+
MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom))
56+
57+
let dimensions: [(String, String)] = [("invalid-service.dimension", "something")]
58+
CoreMetrics.Counter(label: "dimensions_total", dimensions: dimensions).increment()
59+
60+
let promise = eventLoop.makePromise(of: String.self)
61+
prom.collect(into: promise)
62+
XCTAssertEqual(try! promise.futureResult.wait(), """
63+
# TYPE dimensions_total counter
64+
dimensions_total 0
65+
dimensions_total{invalid_service_dimension="something"} 1\n
66+
""")
67+
}
5268
}

0 commit comments

Comments
 (0)