Skip to content

Commit 68bf9c0

Browse files
authored
+ #21 Add LabelSanitizer in order to survive : in label names (e.g. from Vapor) (#22)
* + #21 Add LabelSanitizer in order to survive : in label names (e.g. from Vapor) * linux tests generated * fix docs comment, we're a statsd impl, not prom * Simplify types; metric name sanitizer is just a function now
1 parent 409e036 commit 68bf9c0

File tree

3 files changed

+75
-9
lines changed

3 files changed

+75
-9
lines changed

Sources/StatsdClient/StatsdClient.swift

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,14 @@ public final class StatsdClient: MetricsFactory {
3232
/// - eventLoopGroupProvider: The `EventLoopGroupProvider` to use, uses`createNew` strategy by default.
3333
/// - host: The `statsd` server host.
3434
/// - port: The `statsd` server port.
35-
public init(eventLoopGroupProvider: EventLoopGroupProvider = .createNew, host: String, port: Int) throws {
35+
public init(
36+
eventLoopGroupProvider: EventLoopGroupProvider = .createNew,
37+
host: String,
38+
port: Int,
39+
metricNameSanitizer: @escaping StatsdClient.MetricNameSanitizer = StatsdClient.defaultMetricNameSanitizer
40+
) throws {
3641
let address = try SocketAddress.makeAddressResolvingHost(host, port: port)
37-
self.client = Client(eventLoopGroupProvider: eventLoopGroupProvider, address: address)
42+
self.client = Client(eventLoopGroupProvider: eventLoopGroupProvider, address: address, metricNameSanitizer: metricNameSanitizer)
3843
}
3944

4045
/// Shutdown the client. This is a noop when using a `shared` `EventLoopGroupProvider` strategy.
@@ -77,7 +82,7 @@ public final class StatsdClient: MetricsFactory {
7782
}
7883

7984
private func make<Item>(label: String, dimensions: [(String, String)], registry: inout [String: Item], maker: (String, [(String, String)]) -> Item) -> Item {
80-
let id = StatsdUtils.id(label: label, dimensions: dimensions)
85+
let id = StatsdUtils.id(label: label, dimensions: dimensions, sanitizer: self.client.metricNameSanitizer)
8186
if let item = registry[id] {
8287
return item
8388
}
@@ -128,7 +133,7 @@ private final class StatsdCounter: CounterHandler, Equatable {
128133
var value = NIOAtomic<Int64>.makeAtomic(value: 0)
129134

130135
init(label: String, dimensions: [(String, String)], client: Client) {
131-
self.id = StatsdUtils.id(label: label, dimensions: dimensions)
136+
self.id = StatsdUtils.id(label: label, dimensions: dimensions, sanitizer: client.metricNameSanitizer)
132137
self.client = client
133138
}
134139

@@ -174,7 +179,7 @@ private final class StatsdRecorder: RecorderHandler, Equatable {
174179
let client: Client
175180

176181
init(label: String, dimensions: [(String, String)], aggregate: Bool, client: Client) {
177-
self.id = StatsdUtils.id(label: label, dimensions: dimensions)
182+
self.id = StatsdUtils.id(label: label, dimensions: dimensions, sanitizer: client.metricNameSanitizer)
178183
self.aggregate = aggregate
179184
self.client = client
180185
}
@@ -219,7 +224,7 @@ private final class StatsdTimer: TimerHandler, Equatable {
219224
let client: Client
220225

221226
init(label: String, dimensions: [(String, String)], client: Client) {
222-
self.id = StatsdUtils.id(label: label, dimensions: dimensions)
227+
self.id = StatsdUtils.id(label: label, dimensions: dimensions, sanitizer: client.metricNameSanitizer)
223228
self.client = client
224229
}
225230

@@ -242,6 +247,8 @@ private final class Client {
242247
private let eventLoopGroupProvider: StatsdClient.EventLoopGroupProvider
243248
private let eventLoopGroup: EventLoopGroup
244249

250+
internal let metricNameSanitizer: StatsdClient.MetricNameSanitizer
251+
245252
private let address: SocketAddress
246253

247254
private let isShutdown = NIOAtomic<Bool>.makeAtomic(value: false)
@@ -255,14 +262,19 @@ private final class Client {
255262
case connected(Channel)
256263
}
257264

258-
init(eventLoopGroupProvider: StatsdClient.EventLoopGroupProvider, address: SocketAddress) {
265+
init(
266+
eventLoopGroupProvider: StatsdClient.EventLoopGroupProvider,
267+
address: SocketAddress,
268+
metricNameSanitizer: @escaping StatsdClient.MetricNameSanitizer
269+
) {
259270
self.eventLoopGroupProvider = eventLoopGroupProvider
260271
switch self.eventLoopGroupProvider {
261272
case .shared(let group):
262273
self.eventLoopGroup = group
263274
case .createNew:
264275
self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
265276
}
277+
self.metricNameSanitizer = metricNameSanitizer
266278
self.address = address
267279
}
268280

@@ -349,11 +361,43 @@ private final class Client {
349361
}
350362
}
351363

364+
// MARK: - Metric Name Sanitizer
365+
366+
extension StatsdClient {
367+
/// Used to sanitize labels (and dimensions) into a format compatible with statsd's wire format.
368+
///
369+
/// By default `StatsdClient` uses the `StatsdClient.defaultMetricNameSanitizer`.
370+
public typealias MetricNameSanitizer = (String) -> String
371+
372+
/// Default implementation of `LabelSanitizer` that sanitizes any ":" occurrences by replacing them with a replacement character.
373+
/// Defaults to replacing the illegal characters with "_", e.g. "offending:example" becomes "offending_example".
374+
///
375+
/// See `https://github.com/b/statsd_spec` for more info.
376+
public static let defaultMetricNameSanitizer: StatsdClient.MetricNameSanitizer = { label in
377+
let illegalCharacter: Character = ":"
378+
let replacementCharacter: Character = "_"
379+
380+
guard label.contains(illegalCharacter) else {
381+
return label
382+
}
383+
384+
// replacingOccurrences would be used, but is in Foundation which we try to not depend on here
385+
return String(label.compactMap { (c: Character) -> Character? in
386+
c != illegalCharacter ? c : replacementCharacter
387+
})
388+
}
389+
}
390+
352391
// MARK: - Utility
353392

354393
private enum StatsdUtils {
355-
static func id(label: String, dimensions: [(String, String)]) -> String {
356-
return dimensions.isEmpty ? label : dimensions.reduce(label) { a, b in "\(a).\(b.0).\(b.1)" }
394+
static func id(label: String, dimensions: [(String, String)], sanitizer sanitize: StatsdClient.MetricNameSanitizer) -> String {
395+
if dimensions.isEmpty {
396+
return sanitize(label)
397+
} else {
398+
let labelWithDimensions = dimensions.reduce(label) { a, b in "\(a).\(b.0).\(b.1)" }
399+
return sanitize(labelWithDimensions)
400+
}
357401
}
358402
}
359403

Tests/StatsdClientTests/StatsdClientTests+XCTest.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ extension StatsdClientTests {
3535
("testGaugeDouble", testGaugeDouble),
3636
("testRecorderInteger", testRecorderInteger),
3737
("testRecorderDouble", testRecorderDouble),
38+
("testLabelSanitizer", testLabelSanitizer),
3839
("testCouncurrency", testCouncurrency),
3940
("testNumberOfConnections", testNumberOfConnections),
4041
]

Tests/StatsdClientTests/StatsdClientTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,27 @@ class StatsdClientTests: XCTestCase {
246246
assertTimeoutResult(semaphore.wait(timeout: .now() + .seconds(1)))
247247
}
248248

249+
func testLabelSanitizer() {
250+
let server = TestServer(host: host, port: port)
251+
XCTAssertNoThrow(try server.connect().wait())
252+
defer { XCTAssertNoThrow(try server.shutdown()) }
253+
254+
let illegalID = "hello/:who"
255+
let sanitizedID = "hello/_who"
256+
let value = Double.random(in: 0 ... 1000)
257+
258+
let semaphore = DispatchSemaphore(value: 0)
259+
server.onData { _, data in
260+
defer { semaphore.signal() }
261+
XCTAssertEqual(data, "\(sanitizedID):\(value)|h", "expected entries to match")
262+
}
263+
264+
let recorder = Recorder(label: illegalID)
265+
recorder.record(value)
266+
267+
assertTimeoutResult(semaphore.wait(timeout: .now() + .seconds(1)))
268+
}
269+
249270
func testCouncurrency() {
250271
let server = TestServer(host: host, port: port)
251272
XCTAssertNoThrow(try server.connect().wait())

0 commit comments

Comments
 (0)