diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..b5e7f9e --- /dev/null +++ b/.swiftformat @@ -0,0 +1,24 @@ +# file options + +--swiftversion 5.7 +--exclude .build + +# format options + +--self insert +--patternlet inline +--ranges nospace +--stripunusedargs unnamed-only +--ifdef no-indent +--extensionacl on-declarations +--disable typeSugar # https://github.com/nicklockwood/SwiftFormat/issues/636 +--disable andOperator +--disable wrapMultilineStatementBraces +--disable enumNamespaces +--disable redundantExtensionACL +--disable redundantReturn +--disable preferKeyPath +--disable sortedSwitchCases +--disable numberFormatting + +# rules diff --git a/Package.swift b/Package.swift index c8c37ed..ed52f8a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,34 +1,49 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.7 +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// import PackageDescription let package = Package( - name: "SwiftPrometheus", + name: "swift-prometheus", + platforms: [.macOS(.v13), .iOS(.v16), .watchOS(.v9), .tvOS(.v16)], products: [ .library( - name: "SwiftPrometheus", - targets: ["Prometheus"]) + name: "Prometheus", + targets: ["Prometheus"] + ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-metrics.git", from: "2.2.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), + .package(url: "https://github.com/apple/swift-metrics.git", from: "2.4.1"), + + // ~~~ SwiftPM Plugins ~~~ + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), ], targets: [ .target( name: "Prometheus", dependencies: [ + .product(name: "Atomics", package: "swift-atomics"), .product(name: "CoreMetrics", package: "swift-metrics"), - .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), - .product(name: "NIO", package: "swift-nio"), - ]), - .target( - name: "PrometheusExample", - dependencies: [ - .target(name: "Prometheus"), - .product(name: "Metrics", package: "swift-metrics"), - ]), + ] + ), .testTarget( - name: "SwiftPrometheusTests", - dependencies: [.target(name: "Prometheus")]), + name: "PrometheusTests", + dependencies: [ + "Prometheus", + ] + ), ] ) diff --git a/Sources/Prometheus/Counter.swift b/Sources/Prometheus/Counter.swift new file mode 100644 index 0000000..5c9ab81 --- /dev/null +++ b/Sources/Prometheus/Counter.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import CoreMetrics + +/// A counter is a cumulative metric that represents a single monotonically increasing counter whose value +/// can only increase or be ``reset()`` to zero on restart. +/// +/// For example, you can use a counter to represent the number of requests served, tasks completed, or errors. +/// +/// Do not use a counter to expose a value that can decrease. For example, do not use a counter for the +/// number of currently running processes; instead use a ``Gauge``. +public final class Counter: Sendable { + private let intAtomic = ManagedAtomic(Int64(0)) + private let floatAtomic = ManagedAtomic(Double(0).bitPattern) + + let name: String + let labels: [(String, String)] + private let prerenderedExport: [UInt8] + + init(name: String, labels: [(String, String)]) { + self.name = name + self.labels = labels + + var prerendered = [UInt8]() + // 64 bytes is a good tradeoff to prevent reallocs lots of reallocs when appending names + // and memory footprint. + prerendered.reserveCapacity(64) + prerendered.append(contentsOf: name.utf8) + if let prerenderedLabels = Self.prerenderLabels(labels) { + prerendered.append(UInt8(ascii: "{")) + prerendered.append(contentsOf: prerenderedLabels) + prerendered.append(contentsOf: #"} "#.utf8) + } else { + prerendered.append(UInt8(ascii: " ")) + } + + self.prerenderedExport = prerendered + } + + public func increment() { + self.increment(by: Int64(1)) + } + + public func increment(by amount: Int64) { + precondition(amount >= 0) + self.intAtomic.wrappingIncrement(by: amount, ordering: .relaxed) + } + + public func increment(by amount: Double) { + precondition(amount >= 0) + // We busy loop here until we can update the atomic successfully. + // Using relaxed ordering here is sufficient, since the as-if rules guarantess that + // the following operations are executed in the order presented here. Every statement + // depends on the execution before. + while true { + let bits = self.floatAtomic.load(ordering: .relaxed) + let value = Double(bitPattern: bits) + amount + let (exchanged, _) = self.floatAtomic.compareExchange(expected: bits, desired: value.bitPattern, ordering: .relaxed) + if exchanged { + break + } + } + } + + public func reset() { + self.intAtomic.store(0, ordering: .relaxed) + self.floatAtomic.store(Double.zero.bitPattern, ordering: .relaxed) + } +} + +extension Counter: CoreMetrics.CounterHandler {} +extension Counter: CoreMetrics.FloatingPointCounterHandler {} + +extension Counter: PrometheusMetric { + func emit(into buffer: inout [UInt8]) { + buffer.append(contentsOf: self.prerenderedExport) + let doubleValue = Double(bitPattern: self.floatAtomic.load(ordering: .relaxed)) + let intValue = self.intAtomic.load(ordering: .relaxed) + if doubleValue == .zero { + buffer.append(contentsOf: "\(intValue)".utf8) + } else { + buffer.append(contentsOf: "\(doubleValue + Double(intValue))".utf8) + } + buffer.append(UInt8(ascii: "\n")) + } +} diff --git a/Sources/Prometheus/Docs.docc/index.md b/Sources/Prometheus/Docs.docc/index.md new file mode 100644 index 0000000..b72b320 --- /dev/null +++ b/Sources/Prometheus/Docs.docc/index.md @@ -0,0 +1,19 @@ +# ``Prometheus`` + +A prometheus client library for Swift. + +## Overview + +``Prometheus`` supports creating ``Counter``s, ``Gauge``s and ``Histogram``s and exporting their +values in the Prometheus export format. + +## Topics + +### Collectors + +- ``Counter`` +- ``Gauge`` +- ``Histogram`` + - ``Bucketable`` +- ``DurationHistogram`` +- ``ValueHistogram`` diff --git a/Sources/Prometheus/Gauge.swift b/Sources/Prometheus/Gauge.swift new file mode 100644 index 0000000..af63ef6 --- /dev/null +++ b/Sources/Prometheus/Gauge.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Atomics +import CoreMetrics + +/// A gauge is a metric that represents a single numerical value that can arbitrarily go up and down. +/// +/// Gauges are typically used for measured values like temperatures or current memory usage, but +/// also "counts" that can go up and down, like the number of concurrent requests. +public final class Gauge: Sendable { + let atomic = ManagedAtomic(Double.zero.bitPattern) + + let name: String + let labels: [(String, String)] + let prerenderedExport: [UInt8] + + init(name: String, labels: [(String, String)]) { + self.name = name + self.labels = labels + + var prerendered = [UInt8]() + // 64 bytes is a good tradeoff to prevent reallocs lots of reallocs when appending names + // and memory footprint. + prerendered.reserveCapacity(64) + prerendered.append(contentsOf: name.utf8) + if let prerenderedLabels = Self.prerenderLabels(labels) { + prerendered.append(UInt8(ascii: "{")) + prerendered.append(contentsOf: prerenderedLabels) + prerendered.append(contentsOf: #"} "#.utf8) + } else { + prerendered.append(UInt8(ascii: " ")) + } + + self.prerenderedExport = prerendered + } + + public func set(to value: Double) { + self.atomic.store(value.bitPattern, ordering: .relaxed) + } + + public func increment(by amount: Double = 1.0) { + // We busy loop here until we can update the atomic successfully. + // Using relaxed ordering here is sufficient, since the as-if rules guarantess that + // the following operations are executed in the order presented here. Every statement + // depends on the execution before. + while true { + let bits = self.atomic.load(ordering: .relaxed) + let value = Double(bitPattern: bits) + amount + let (exchanged, _) = self.atomic.compareExchange(expected: bits, desired: value.bitPattern, ordering: .relaxed) + if exchanged { + break + } + } + } + + public func decrement(by amount: Double = 1.0) { + self.increment(by: -amount) + } +} + +extension Gauge: CoreMetrics.RecorderHandler { + public func record(_ value: Int64) { + self.record(Double(value)) + } + + public func record(_ value: Double) { + self.set(to: value) + } +} + +extension Gauge: PrometheusMetric { + func emit(into buffer: inout [UInt8]) { + let value = Double(bitPattern: self.atomic.load(ordering: .relaxed)) + + buffer.append(contentsOf: self.prerenderedExport) + buffer.append(contentsOf: "\(value)".utf8) + buffer.append(UInt8(ascii: "\n")) + } +} diff --git a/Sources/Prometheus/Histogram.swift b/Sources/Prometheus/Histogram.swift new file mode 100644 index 0000000..dabe2a0 --- /dev/null +++ b/Sources/Prometheus/Histogram.swift @@ -0,0 +1,186 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import CoreMetrics + +/// A type that can be used in a ``Histogram`` to create bucket boundaries +public protocol Bucketable: AdditiveArithmetic, Comparable, Sendable { + /// A string representation that is used in the Prometheus export + var bucketRepresentation: String { get } +} + +/// A Histogram to record timings +public typealias DurationHistogram = Histogram +/// A Histogram to record floating point values +public typealias ValueHistogram = Histogram + +/// A generic Histogram implementation +public final class Histogram: Sendable { + let name: String + let labels: [(String, String)] + + @usableFromInline + struct State: Sendable { + @usableFromInline var buckets: [(Value, Int)] + @usableFromInline var sum: Value + @usableFromInline var count: Int + + @inlinable + init(buckets: [Value]) { + self.sum = .zero + self.count = 0 + self.buckets = buckets.map { ($0, 0) } + } + } + + @usableFromInline let box: NIOLockedValueBox + let prerenderedLabels: [UInt8]? + + init(name: String, labels: [(String, String)], buckets: [Value]) { + self.name = name + self.labels = labels + + self.prerenderedLabels = Self.prerenderLabels(labels) + + self.box = .init(.init(buckets: buckets)) + } + + public func record(_ value: Value) { + self.box.withLockedValue { state in + for i in state.buckets.startIndex..= value { + state.buckets[i].1 += 1 + } + } + state.sum += value + state.count += 1 + } + } +} + +extension Histogram: _SwiftMetricsSendableProtocol {} + +extension Histogram: CoreMetrics.TimerHandler where Value == Duration { + public func recordNanoseconds(_ duration: Int64) { + let value = Duration.nanoseconds(duration) + self.record(value) + } +} + +extension Histogram: CoreMetrics.RecorderHandler where Value == Double { + public func record(_ value: Int64) { + self.record(Double(value)) + } +} + +extension Histogram: PrometheusMetric { + func emit(into buffer: inout [UInt8]) { + let state = self.box.withLockedValue { $0 } + + for bucket in state.buckets { + buffer.append(contentsOf: self.name.utf8) + buffer.append(contentsOf: #"_bucket{"#.utf8) + if let prerenderedLabels { + buffer.append(contentsOf: prerenderedLabels) + buffer.append(UInt8(ascii: #","#)) + } + buffer.append(contentsOf: #"le=""#.utf8) + buffer.append(contentsOf: "\(bucket.0.bucketRepresentation)".utf8) + buffer.append(UInt8(ascii: #"""#)) + buffer.append(contentsOf: #"} "#.utf8) + buffer.append(contentsOf: "\(bucket.1)".utf8) + buffer.append(contentsOf: #"\#n"#.utf8) + } + + // +Inf + buffer.append(contentsOf: self.name.utf8) + buffer.append(contentsOf: #"_bucket{"#.utf8) + if let prerenderedLabels { + buffer.append(contentsOf: prerenderedLabels) + buffer.append(UInt8(ascii: ",")) + } + buffer.append(contentsOf: #"le="+Inf"} "#.utf8) + buffer.append(contentsOf: "\(state.count)".utf8) + buffer.append(contentsOf: #"\#n"#.utf8) + + // sum + buffer.append(contentsOf: self.name.utf8) + buffer.append(contentsOf: #"_sum"#.utf8) + if let prerenderedLabels { + buffer.append(UInt8(ascii: "{")) + buffer.append(contentsOf: prerenderedLabels) + buffer.append(contentsOf: #"} "#.utf8) + } else { + buffer.append(UInt8(ascii: " ")) + } + buffer.append(contentsOf: "\(state.sum.bucketRepresentation)".utf8) + buffer.append(contentsOf: #"\#n"#.utf8) + + // count + buffer.append(contentsOf: self.name.utf8) + buffer.append(contentsOf: #"_count"#.utf8) + if let prerenderedLabels { + buffer.append(UInt8(ascii: "{")) + buffer.append(contentsOf: prerenderedLabels) + buffer.append(contentsOf: #"} "#.utf8) + } else { + buffer.append(UInt8(ascii: " ")) + } + buffer.append(contentsOf: "\(state.count)".utf8) + buffer.append(contentsOf: #"\#n"#.utf8) + } +} + +extension Duration: Bucketable { + public var bucketRepresentation: String { + let attos = String(unsafeUninitializedCapacity: 18) { buffer in + var num = self.components.attoseconds + + var positions = 17 + var length: Int? + while positions >= 0 { + defer { + positions -= 1 + num = num / 10 + } + let remainder = num % 10 + + if length != nil { + buffer[positions] = UInt8(ascii: "0") + UInt8(remainder) + } else { + if remainder == 0 { + continue + } + + length = positions + 1 + buffer[positions] = UInt8(ascii: "0") + UInt8(remainder) + } + } + + if length == nil { + buffer[0] = UInt8(ascii: "0") + length = 1 + } + + return length! + } + return "\(self.components.seconds).\(attos)" + } +} + +extension Double: Bucketable { + public var bucketRepresentation: String { + self.description + } +} diff --git a/Sources/Prometheus/MetricTypes/Counter.swift b/Sources/Prometheus/MetricTypes/Counter.swift deleted file mode 100644 index 85dae80..0000000 --- a/Sources/Prometheus/MetricTypes/Counter.swift +++ /dev/null @@ -1,103 +0,0 @@ -import NIOConcurrencyHelpers - -/// Prometheus Counter metric -/// -/// See: https://prometheus.io/docs/concepts/metric_types/#counter -public class PromCounter: PromMetric { - /// Name of the Counter, required - public let name: String - /// Help text of the Counter, optional - public let help: String? - - /// Type of the metric, used for formatting - public let _type: PromMetricType = .counter - - /// Current value of the counter - internal var value: NumType - - /// Initial value of the counter - private let initialValue: NumType - - /// Storage of values that have labels attached - internal var metrics: [DimensionLabels: NumType] = [:] - - /// Lock used for thread safety - internal let lock: Lock - - /// Creates a new instance of a Counter - /// - /// - Parameters: - /// - name: Name of the Counter - /// - help: Help text of the Counter - /// - initialValue: Initial value to set the counter to - /// - p: Prometheus instance that created this counter - internal init(_ name: String, _ help: String? = nil, _ initialValue: NumType = 0) { - self.name = name - self.help = help - self.initialValue = initialValue - self.value = initialValue - self.lock = Lock() - } - - /// Gets the metric string for this counter - /// - /// - Returns: - /// Newline separated Prometheus formatted metric string - public func collect() -> String { - let (value, metrics) = self.lock.withLock { - (self.value, self.metrics) - } - var output = [String]() - - if let help = self.help { - output.append("# HELP \(self.name) \(help)") - } - output.append("# TYPE \(self.name) \(self._type)") - - output.append("\(self.name) \(value)") - - metrics.forEach { (labels, value) in - let labelsString = encodeLabels(labels) - output.append("\(self.name)\(labelsString) \(value)") - } - - return output.joined(separator: "\n") - } - - /// Increments the Counter - /// - /// - Parameters: - /// - amount: Amount to increment the counter with - /// - labels: Labels to attach to the value - /// - @discardableResult - public func inc(_ amount: NumType = 1, _ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - var val = self.metrics[labels] ?? self.initialValue - val += amount - self.metrics[labels] = val - return val - } else { - self.value += amount - return self.value - } - } - } - - /// Gets the value of the Counter - /// - /// - Parameters: - /// - labels: Labels to get the value for - /// - /// - Returns: The value of the Counter attached to the provided labels - public func get(_ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - return self.metrics[labels] ?? initialValue - } else { - return self.value - } - } - } -} diff --git a/Sources/Prometheus/MetricTypes/Gauge.swift b/Sources/Prometheus/MetricTypes/Gauge.swift deleted file mode 100644 index a7ddbbe..0000000 --- a/Sources/Prometheus/MetricTypes/Gauge.swift +++ /dev/null @@ -1,219 +0,0 @@ -import struct Foundation.Date -import Dispatch -import NIOConcurrencyHelpers - -/// Prometheus Gauge metric -/// -/// See https://prometheus.io/docs/concepts/metric_types/#gauge -public class PromGauge: PromMetric { - /// Name of the Gauge, required - public let name: String - /// Help text of the Gauge, optional - public let help: String? - - /// Type of the metric, used for formatting - public let _type: PromMetricType = .gauge - - /// Current value of the counter - private var value: NumType - - /// Initial value of the Gauge - private let initialValue: NumType - - /// Storage of values that have labels attached - private var metrics: [DimensionLabels: NumType] = [:] - - /// Lock used for thread safety - private let lock: Lock - - /// Creates a new instance of a Gauge - /// - /// - Parameters: - /// - name: Name of the Gauge - /// - help: Help text of the Gauge - /// - initialValue: Initial value to set the Gauge to - /// - p: Prometheus instance that created this Gauge - /// - internal init(_ name: String, _ help: String? = nil, _ initialValue: NumType = 0) { - self.name = name - self.help = help - self.initialValue = initialValue - self.value = initialValue - self.lock = Lock() - } - - /// Gets the metric string for this Gauge - /// - /// - Returns: - /// Newline separated Prometheus formatted metric string - public func collect() -> String { - let (value, metrics) = self.lock.withLock { - (self.value, self.metrics) - } - var output = [String]() - - if let help = self.help { - output.append("# HELP \(self.name) \(help)") - } - output.append("# TYPE \(self.name) \(self._type)") - - output.append("\(self.name) \(value)") - - metrics.forEach { (labels, value) in - let labelsString = encodeLabels(labels) - output.append("\(self.name)\(labelsString) \(value)") - } - - return output.joined(separator: "\n") - } - - /// Sets the Gauge to the current unix-time in seconds - /// - /// - Parameters: - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func setToCurrentTime(_ labels: DimensionLabels? = nil) -> NumType { - return self.set(.init(Date().timeIntervalSince1970), labels) - } - - /// Tracks in progress blocks of code or functions. - /// - /// func someFunc() -> String { return "ABC" } - /// let newFunc = myGauge.trackInProgress(someFunc) - /// newFunc() // returns "ABC" and increments & decrements Gauge - /// - /// - Parameters: - /// - labels: Labels to attach to the value - /// - body: Function to wrap progress tracker around - /// - /// - Returns: The same type of function passed in for `body`, but wrapped to track progress. - @inlinable - public func trackInProgress(_ labels: DimensionLabels? = nil, _ body: @escaping () throws -> T) -> (() throws -> T) { - return { - self.inc() - defer { - self.dec() - } - return try body() - } - } - /// Time the execution duration of a closure and observe the resulting time in seconds. - /// - /// - parameters: - /// - labels: Labels to attach to the resulting value. - /// - body: Closure to run & record execution time of. - @inlinable - public func time(_ labels: DimensionLabels? = nil, _ body: @escaping () throws -> T) rethrows -> T { - let start = DispatchTime.now().uptimeNanoseconds - defer { - let delta = Double(DispatchTime.now().uptimeNanoseconds - start) - self.set(.init(delta / 1_000_000_000), labels) - } - return try body() - } - - - /// Sets the Gauge - /// - /// - Parameters: - /// - amount: Amount to set the gauge to - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func set(_ amount: NumType, _ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - self.metrics[labels] = amount - return amount - } else { - self.value = amount - return self.value - } - } - } - - /// Increments the Gauge - /// - /// - Parameters: - /// - amount: Amount to increment the Gauge with - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func inc(_ amount: NumType, _ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - var val = self.metrics[labels] ?? self.initialValue - val += amount - self.metrics[labels] = val - return val - } else { - self.value += amount - return self.value - } - } - } - - /// Increments the Gauge - /// - /// - Parameters: - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func inc(_ labels: DimensionLabels? = nil) -> NumType { - return self.inc(1, labels) - } - - /// Decrements the Gauge - /// - /// - Parameters: - /// - amount: Amount to decrement the Gauge with - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func dec(_ amount: NumType, _ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - var val = self.metrics[labels] ?? self.initialValue - val -= amount - self.metrics[labels] = val - return val - } else { - self.value -= amount - return self.value - } - } - } - - /// Decrements the Gauge - /// - /// - Parameters: - /// - labels: Labels to attach to the value - /// - /// - Returns: The value of the Gauge attached to the provided labels - @discardableResult - public func dec(_ labels: DimensionLabels? = nil) -> NumType { - return self.dec(1, labels) - } - - /// Gets the value of the Gauge - /// - /// - Parameters: - /// - labels: Labels to get the value for - /// - /// - Returns: The value of the Gauge attached to the provided labels - public func get(_ labels: DimensionLabels? = nil) -> NumType { - return self.lock.withLock { - if let labels = labels { - return self.metrics[labels] ?? initialValue - } else { - return self.value - } - } - } -} diff --git a/Sources/Prometheus/MetricTypes/Histogram.swift b/Sources/Prometheus/MetricTypes/Histogram.swift deleted file mode 100644 index ccdc064..0000000 --- a/Sources/Prometheus/MetricTypes/Histogram.swift +++ /dev/null @@ -1,243 +0,0 @@ -import NIOConcurrencyHelpers -import Dispatch - -/// Buckets are used by Histograms to bucket their values. -/// -/// See https://prometheus.io/docs/concepts/metric_types/#Histogram -public struct Buckets: ExpressibleByArrayLiteral { - public typealias ArrayLiteralElement = Double - - public init(arrayLiteral elements: Double...) { - self.init(elements) - } - - fileprivate init(_ r: [Double]) { - if r.isEmpty { - self = Buckets.defaultBuckets - return - } - var r = r - if !r.contains(Double.greatestFiniteMagnitude) { - r.append(Double.greatestFiniteMagnitude) - } - assert(r == r.sorted(by: <), "Buckets are not in increasing order") - assert(Array(Set(r)).sorted(by: <) == r.sorted(by: <), "Buckets contain duplicate values.") - self.buckets = r - } - - /// The upper bounds - public let buckets: [Double] - - /// Default buckets used by Histograms - public static let defaultBuckets: Buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] - - /// Create linear buckets used by Histograms - /// - /// - Parameters: - /// - start: Start value for your buckets. This will be the upper bound of your first bucket. - /// - width: Width of each bucket. - /// - count: Amount of buckets to generate, should be larger than zero. The +Inf bucket is not included in this count. - public static func linear(start: Double, width: Double, count: Int) -> Buckets { - assert(count >= 1, "Bucket.linear needs a count larger than 1") - let arr = (0.. Buckets { - assert(count > 1, "Bucket.exponential needs a count greater than 1") - assert(start > 0, "Bucket.exponential needs a start larger than 0") - assert(factor > 1, "Bucket.exponential needs a factor larger than 1") - var arr = [Double]() - var s = start - for _ in 0..: PromMetric { - /// Name of this Histogram, required - public let name: String - /// Help text of this Histogram, optional - public let help: String? - - /// Type of the metric, used for formatting - public let _type: PromMetricType = .histogram - - /// Bucketed values for this Histogram - private var buckets: [PromCounter] = [] - - /// Buckets used by this Histogram - internal let upperBounds: [Double] - - /// Sub Histograms for this Histogram - fileprivate var subHistograms: [DimensionLabels: PromHistogram] = [:] - - /// Total value of the Histogram - private let sum: PromCounter - - /// Lock used for thread safety - private let lock: Lock - - /// Creates a new Histogram - /// - /// - Parameters: - /// - name: Name of the Histogram - /// - help: Help text of the Histogram - /// - buckets: Buckets to use for the Histogram - /// - p: Prometheus instance creating this Histogram - internal init(_ name: String, _ help: String? = nil, _ buckets: Buckets = .defaultBuckets) { - self.name = name - self.help = help - - self.sum = .init("\(self.name)_sum", nil, 0) - - self.upperBounds = buckets.buckets - - self.lock = Lock() - - buckets.buckets.forEach { _ in - self.buckets.append(.init("\(name)_bucket", nil, 0)) - } - } - - /// Gets the metric string for this Histogram - /// - /// - Returns: - /// Newline separated Prometheus formatted metric string - public func collect() -> String { - let (buckets, subHistograms) = self.lock.withLock { - (self.buckets, self.subHistograms) - } - - var output = [String]() - // HELP/TYPE + (histogram + subHistograms) * (buckets + sum + count) - output.reserveCapacity(2 + (subHistograms.count + 1) * (buckets.count + 2)) - - if let help = self.help { - output.append("# HELP \(self.name) \(help)") - } - output.append("# TYPE \(self.name) \(self._type)") - collectBuckets(buckets: buckets, - upperBounds: self.upperBounds, - name: self.name, - labels: nil, - sum: self.sum.get(), - into: &output) - - subHistograms.forEach { subHistogram in - let (subHistogramBuckets, subHistogramLabels) = self.lock.withLock { - (subHistogram.value.buckets, subHistogram.key) - } - collectBuckets(buckets: subHistogramBuckets, - upperBounds: subHistogram.value.upperBounds, - name: subHistogram.value.name, - labels: subHistogramLabels, - sum: subHistogram.value.sum.get(), - into: &output) - } - return output.joined(separator: "\n") - } - - private func collectBuckets(buckets: [PromCounter], - upperBounds: [Double], - name: String, - labels: DimensionLabels?, - sum: NumType, - into output: inout [String]) { - var acc: NumType = 0 - for (i, bound) in upperBounds.enumerated() { - acc += buckets[i].get() - let labelsString = encodeLabels(EncodableHistogramLabels(labels: labels, le: bound.description)) - output.append("\(name)_bucket\(labelsString) \(acc)") - } - - let labelsString = encodeLabels(EncodableHistogramLabels(labels: labels)) - output.append("\(name)_count\(labelsString) \(acc)") - - output.append("\(name)_sum\(labelsString) \(sum)") - } - - /// Observe a value - /// - /// - Parameters: - /// - value: Value to observe - /// - labels: Labels to attach to the observed value - public func observe(_ value: NumType, _ labels: DimensionLabels? = nil) { - if let labels = labels { - self.getOrCreateHistogram(with: labels) - .observe(value) - } - self.sum.inc(value) - - for (i, bound) in self.upperBounds.enumerated() { - if bound >= value.doubleValue { - self.buckets[i].inc() - return - } - } - } - - /// Time the duration of a closure and observe the resulting time in seconds. - /// - /// - parameters: - /// - labels: Labels to attach to the resulting value. - /// - body: Closure to run & record. - @inlinable - public func time(_ labels: DimensionLabels? = nil, _ body: @escaping () throws -> T) rethrows -> T { - let start = DispatchTime.now().uptimeNanoseconds - defer { - let delta = Double(DispatchTime.now().uptimeNanoseconds - start) - self.observe(.init(delta / 1_000_000_000), labels) - } - return try body() - } - - /// Helper for histograms & labels - fileprivate func getOrCreateHistogram(with labels: DimensionLabels) -> PromHistogram { - let subHistograms = lock.withLock { self.subHistograms } - if let histogram = subHistograms[labels] { - precondition(histogram.name == self.name, - """ - Somehow got 2 subHistograms with the same data type / labels - but different names: expected \(self.name), got \(histogram.name) - """) - precondition(histogram.help == self.help, - """ - Somehow got 2 subHistograms with the same data type / labels - but different help messages: expected \(self.help ?? "nil"), got \(histogram.help ?? "nil") - """) - return histogram - } else { - return lock.withLock { - if let histogram = self.subHistograms[labels] { - precondition(histogram.name == self.name, - """ - Somehow got 2 subHistograms with the same data type / labels - but different names: expected \(self.name), got \(histogram.name) - """) - precondition(histogram.help == self.help, - """ - Somehow got 2 subHistograms with the same data type / labels - but different help messages: expected \(self.help ?? "nil"), got \(histogram.help ?? "nil") - """) - return histogram - } - let newHistogram = PromHistogram(self.name, self.help, Buckets(self.upperBounds)) - self.subHistograms[labels] = newHistogram - return newHistogram - } - } - } -} diff --git a/Sources/Prometheus/MetricTypes/PromMetric.swift b/Sources/Prometheus/MetricTypes/PromMetric.swift deleted file mode 100644 index 72f2ac8..0000000 --- a/Sources/Prometheus/MetricTypes/PromMetric.swift +++ /dev/null @@ -1,46 +0,0 @@ -import NIO - -/// Different types of metrics supported by SwiftPrometheus -public enum PromMetricType: String { - /// See `PromCounter` - case counter - /// See `PromGauge` - case gauge - /// See `PromHistogram` - case histogram - /// See `PromSummary` - case summary -} - -public enum Prometheus { - /// Default capacity of Summaries - public static let defaultSummaryCapacity = 500 - - /// Default quantiles used by Summaries - public static let defaultQuantiles = [0.01, 0.05, 0.5, 0.9, 0.95, 0.99, 0.999] -} - -/// Metric protocol -/// -/// See https://prometheus.io/docs/concepts/metric_types/ -public protocol PromMetric { - /// Name of the metric - var name: String { get } - /// Optional help of the metric - var help: String? { get } - /// Type of the metric - var _type: PromMetricType { get } - - /// Retrieves the Prometheus-formatted metric data - func collect() -> String -} - -extension PromMetric { - /// Helper method to record metrics into a `ByteBuffer` directly - /// - /// - Parameters: - /// - buffer: `ByteBuffer` to collect into - func collect(into buffer: inout ByteBuffer) { - buffer.writeString(collect()) - } -} diff --git a/Sources/Prometheus/MetricTypes/Summary.swift b/Sources/Prometheus/MetricTypes/Summary.swift deleted file mode 100644 index ba061e2..0000000 --- a/Sources/Prometheus/MetricTypes/Summary.swift +++ /dev/null @@ -1,246 +0,0 @@ -import NIOConcurrencyHelpers -import NIO -import struct CoreMetrics.TimeUnit -import Dispatch - -/// Prometheus Summary metric -/// -/// See https://prometheus.io/docs/concepts/metric_types/#summary -public class PromSummary: PromMetric { - /// Name of this Summary, required - public let name: String - /// Help text of this Summary, optional - public let help: String? - - /// Type of the metric, used for formatting - public let _type: PromMetricType = .summary - - private var displayUnit: TimeUnit? - - /// Sum of the values in this Summary - private let sum: PromCounter - - /// Amount of values in this Summary - private let count: PromCounter - - /// Values in this Summary - private var values: CircularBuffer - - /// Number of values to keep for calculating quantiles - internal let capacity: Int - - /// Quantiles used by this Summary - internal let quantiles: [Double] - - /// Sub Summaries for this Summary - fileprivate var subSummaries: [DimensionLabels: PromSummary] = [:] - - /// Lock used for thread safety - private let lock: Lock - - /// Creates a new Summary - /// - /// - Parameters: - /// - name: Name of the Summary - /// - help: Help text of the Summary - /// - capacity: Number of values to keep for calculating quantiles - /// - quantiles: Quantiles to use for the Summary - /// - p: Prometheus instance creating this Summary - internal init(_ name: String, _ help: String? = nil, _ capacity: Int = Prometheus.defaultSummaryCapacity, _ quantiles: [Double] = Prometheus.defaultQuantiles) { - self.name = name - self.help = help - - self.displayUnit = nil - - self.sum = .init("\(self.name)_sum", nil, 0) - - self.count = .init("\(self.name)_count", nil, 0) - - self.values = CircularBuffer(initialCapacity: capacity) - - self.capacity = capacity - - self.quantiles = quantiles - - self.lock = Lock() - } - - /// Gets the metric string for this Summary - /// - /// - Returns: - /// Newline separated Prometheus formatted metric string - public func collect() -> String { - let (subSummaries, values) = lock.withLock { - (self.subSummaries, self.values) - } - - var output = [String]() - // HELP/TYPE + (summary + subSummaries) * (quantiles + sum + count) - output.reserveCapacity(2 + (subSummaries.count + 1) * (quantiles.count + 2)) - - if let help = self.help { - output.append("# HELP \(self.name) \(help)") - } - output.append("# TYPE \(self.name) \(self._type)") - calculateQuantiles(quantiles: self.quantiles, values: values.map { $0.doubleValue }).sorted { $0.key < $1.key }.forEach { (arg) in - let (q, v) = arg - let labelsString = encodeLabels(EncodableSummaryLabels(labels: nil, quantile: "\(q)")) - output.append("\(self.name)\(labelsString) \(format(v))") - } - - output.append("\(self.name)_count \(self.count.get())") - output.append("\(self.name)_sum \(format(self.sum.get().doubleValue))") - - subSummaries.forEach { labels, subSum in - let subSumValues = subSum.lock.withLock { subSum.values } - calculateQuantiles(quantiles: self.quantiles, values: subSumValues.map { $0.doubleValue }).sorted { $0.key < $1.key }.forEach { (arg) in - let (q, v) = arg - let labelsString = encodeLabels(EncodableSummaryLabels(labels: labels, quantile: "\(q)")) - output.append("\(subSum.name)\(labelsString) \(format(v))") - } - - let labelsString = encodeLabels(EncodableSummaryLabels(labels: labels, quantile: nil)) - output.append("\(subSum.name)_count\(labelsString) \(subSum.count.get())") - output.append("\(subSum.name)_sum\(labelsString) \(format(subSum.sum.get().doubleValue))") - } - - return output.joined(separator: "\n") - } - - // Updated for SwiftMetrics 2.0 to be unit agnostic if displayUnit is set or default to nanoseconds. - private func format(_ v: Double) -> Double { - let displayUnit = lock.withLock { self.displayUnit } - let displayUnitScale = displayUnit?.scaleFromNanoseconds ?? 1 - return v / Double(displayUnitScale) - } - - internal func preferDisplayUnit(_ unit: TimeUnit) { - self.lock.withLock { - self.displayUnit = unit - } - } - - /// Record a value - /// - /// - Parameters: - /// - duration: Duration to record - public func recordNanoseconds(_ duration: Int64) { - guard let v = NumType.init(exactly: duration) else { return } - self.observe(v) - } - - /// Observe a value - /// - /// - Parameters: - /// - value: Value to observe - /// - labels: Labels to attach to the observed value - public func observe(_ value: NumType, _ labels: DimensionLabels? = nil) { - if let labels = labels { - let sum = self.getOrCreateSummary(withLabels: labels) - sum.observe(value) - } - self.count.inc(1) - self.sum.inc(value) - self.lock.withLock { - if self.values.count == self.capacity { - _ = self.values.popFirst() - } - self.values.append(value) - } - } - - /// Time the duration of a closure and observe the resulting time in seconds. - /// - /// - parameters: - /// - labels: Labels to attach to the resulting value. - /// - body: Closure to run & record. - @inlinable - public func time(_ labels: DimensionLabels? = nil, _ body: @escaping () throws -> T) rethrows -> T { - let start = DispatchTime.now().uptimeNanoseconds - defer { - let delta = Double(DispatchTime.now().uptimeNanoseconds - start) - self.observe(.init(delta / 1_000_000_000), labels) - } - return try body() - } - fileprivate func getOrCreateSummary(withLabels labels: DimensionLabels) -> PromSummary { - let subSummaries = self.lock.withLock { self.subSummaries } - if let summary = subSummaries[labels] { - precondition(summary.name == self.name, - """ - Somehow got 2 subSummaries with the same data type / labels - but different names: expected \(self.name), got \(summary.name) - """) - precondition(summary.help == self.help, - """ - Somehow got 2 subSummaries with the same data type / labels - but different help messages: expected \(self.help ?? "nil"), got \(summary.help ?? "nil") - """) - return summary - } else { - return lock.withLock { - if let summary = self.subSummaries[labels] { - precondition(summary.name == self.name, - """ - Somehow got 2 subSummaries with the same data type / labels - but different names: expected \(self.name), got \(summary.name) - """) - precondition(summary.help == self.help, - """ - Somehow got 2 subSummaries with the same data type / labels - but different help messages: expected \(self.help ?? "nil"), got \(summary.help ?? "nil") - """) - return summary - } - let newSummary = PromSummary(self.name, self.help, self.capacity, self.quantiles) - self.subSummaries[labels] = newSummary - return newSummary - } - } - } -} - -/// Calculates values per quantile -/// -/// - Parameters: -/// - quantiles: Quantiles to divide values over -/// - values: Values to divide over quantiles -/// -/// - Returns: Dictionary of type [Quantile: Value] -func calculateQuantiles(quantiles: [Double], values: [Double]) -> [Double: Double] { - let values = values.sorted() - var quantilesMap: [Double: Double] = [:] - quantiles.forEach { (q) in - quantilesMap[q] = quantile(q, values) - } - return quantilesMap -} - -/// Calculates value for quantile -/// -/// - Parameters: -/// - q: Quantile to calculate value for -/// - values: Values to calculate quantile from -/// -/// - Returns: Calculated quantile -func quantile(_ q: Double, _ values: [Double]) -> Double { - if values.count == 0 { - return 0 - } - if values.count == 1 { - return values[0] - } - - let n = Double(values.count) - if let pos = Int(exactly: n*q) { - if pos < 2 { - return values[0] - } else if pos == values.count { - return values[pos - 1] - } - return (values[pos - 1] + values[pos]) / 2.0 - } else { - let pos = Int((n*q).rounded(.up)) - return values[pos - 1] - } -} diff --git a/Sources/Prometheus/NIOLock.swift b/Sources/Prometheus/NIOLock.swift new file mode 100644 index 0000000..b8508dc --- /dev/null +++ b/Sources/Prometheus/NIOLock.swift @@ -0,0 +1,277 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif os(Windows) +import ucrt +import WinSDK +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#else +#error("The concurrency NIOLock module was unable to identify your C library.") +#endif + +#if os(Windows) +@usableFromInline +typealias LockPrimitive = SRWLOCK +#else +@usableFromInline +typealias LockPrimitive = pthread_mutex_t +#endif + +@usableFromInline +enum LockOperations { } + +extension LockOperations { + @inlinable + static func create(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + +#if os(Windows) + InitializeSRWLock(mutex) +#else + var attr = pthread_mutexattr_t() + pthread_mutexattr_init(&attr) + debugOnly { + pthread_mutexattr_settype(&attr, .init(PTHREAD_MUTEX_ERRORCHECK)) + } + + let err = pthread_mutex_init(mutex, &attr) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") +#endif + } + + @inlinable + static func destroy(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + +#if os(Windows) + // SRWLOCK does not need to be free'd +#else + let err = pthread_mutex_destroy(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") +#endif + } + + @inlinable + static func lock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + +#if os(Windows) + AcquireSRWLockExclusive(mutex) +#else + let err = pthread_mutex_lock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") +#endif + } + + @inlinable + static func unlock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + +#if os(Windows) + ReleaseSRWLockExclusive(mutex) +#else + let err = pthread_mutex_unlock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") +#endif + } +} + +// Tail allocate both the mutex and a generic value using ManagedBuffer. +// Both the header pointer and the elements pointer are stable for +// the class's entire lifetime. +// +// However, for safety reasons, we elect to place the lock in the "elements" +// section of the buffer instead of the head. The reasoning here is subtle, +// so buckle in. +// +// _As a practical matter_, the implementation of ManagedBuffer ensures that +// the pointer to the header is stable across the lifetime of the class, and so +// each time you call `withUnsafeMutablePointers` or `withUnsafeMutablePointerToHeader` +// the value of the header pointer will be the same. This is because ManagedBuffer uses +// `Builtin.addressOf` to load the value of the header, and that does ~magic~ to ensure +// that it does not invoke any weird Swift accessors that might copy the value. +// +// _However_, the header is also available via the `.header` field on the ManagedBuffer. +// This presents a problem! The reason there's an issue is that `Builtin.addressOf` and friends +// do not interact with Swift's exclusivity model. That is, the various `with` functions do not +// conceptually trigger a mutating access to `.header`. For elements this isn't a concern because +// there's literally no other way to perform the access, but for `.header` it's entirely possible +// to accidentally recursively read it. +// +// Our implementation is free from these issues, so we don't _really_ need to worry about it. +// However, out of an abundance of caution, we store the Value in the header, and the LockPrimitive +// in the trailing elements. We still don't use `.header`, but it's better to be safe than sorry, +// and future maintainers will be happier that we were cautious. +// +// See also: https://github.com/apple/swift/pull/40000 +@usableFromInline +final class LockStorage: ManagedBuffer { + + @inlinable + static func create(value: Value) -> Self { + let buffer = Self.create(minimumCapacity: 1) { _ in + return value + } + let storage = unsafeDowncast(buffer, to: Self.self) + + storage.withUnsafeMutablePointers { _, lockPtr in + LockOperations.create(lockPtr) + } + + return storage + } + + @inlinable + func lock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.lock(lockPtr) + } + } + + @inlinable + func unlock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.unlock(lockPtr) + } + } + + @inlinable + deinit { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.destroy(lockPtr) + } + } + + @inlinable + func withLockPrimitive(_ body: (UnsafeMutablePointer) throws -> T) rethrows -> T { + try self.withUnsafeMutablePointerToElements { lockPtr in + return try body(lockPtr) + } + } + + @inlinable + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + try self.withUnsafeMutablePointers { valuePtr, lockPtr in + LockOperations.lock(lockPtr) + defer { LockOperations.unlock(lockPtr) } + return try mutate(&valuePtr.pointee) + } + } +} + +extension LockStorage: @unchecked Sendable { } + +/// A threading lock based on `libpthread` instead of `libdispatch`. +/// +/// - note: ``NIOLock`` has reference semantics. +/// +/// This object provides a lock on top of a single `pthread_mutex_t`. This kind +/// of lock is safe to use with `libpthread`-based threading models, such as the +/// one used by NIO. On Windows, the lock is based on the substantially similar +/// `SRWLOCK` type. +struct NIOLock { + @usableFromInline + internal let _storage: LockStorage + + /// Create a new lock. + @inlinable + init() { + self._storage = .create(value: ()) + } + + /// Acquire the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `unlock`, to simplify lock handling. + @inlinable + func lock() { + self._storage.lock() + } + + /// Release the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `lock`, to simplify lock handling. + @inlinable + func unlock() { + self._storage.unlock() + } + + @inlinable + internal func withLockPrimitive(_ body: (UnsafeMutablePointer) throws -> T) rethrows -> T { + return try self._storage.withLockPrimitive(body) + } +} + +extension NIOLock { + /// Acquire the lock for the duration of the given block. + /// + /// This convenience method should be preferred to `lock` and `unlock` in + /// most situations, as it ensures that the lock will be released regardless + /// of how `body` exits. + /// + /// - Parameter body: The block to execute while holding the lock. + /// - Returns: The value returned by the block. + @inlinable + func withLock(_ body: () throws -> T) rethrows -> T { + self.lock() + defer { + self.unlock() + } + return try body() + } + + @inlinable + func withLockVoid(_ body: () throws -> Void) rethrows -> Void { + try self.withLock(body) + } +} + +extension NIOLock: Sendable {} + +extension UnsafeMutablePointer { + @inlinable + func assertValidAlignment() { + assert(UInt(bitPattern: self) % UInt(MemoryLayout.alignment) == 0) + } +} + +/// A utility function that runs the body code only in debug builds, without +/// emitting compiler warnings. +/// +/// This is currently the only way to do this in Swift: see +/// https://forums.swift.org/t/support-debug-only-code/11037 for a discussion. +@inlinable +internal func debugOnly(_ body: () -> Void) { + assert({ body(); return true }()) +} + diff --git a/Sources/Prometheus/NIOLockedValueBox.swift b/Sources/Prometheus/NIOLockedValueBox.swift new file mode 100644 index 0000000..9dcef47 --- /dev/null +++ b/Sources/Prometheus/NIOLockedValueBox.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Provides locked access to `Value`. +/// +/// - note: ``NIOLockedValueBox`` has reference semantics and holds the `Value` +/// alongside a lock behind a reference. +/// +/// This is no different than creating a ``Lock`` and protecting all +/// accesses to a value using the lock. But it's easy to forget to actually +/// acquire/release the lock in the correct place. ``NIOLockedValueBox`` makes +/// that much easier. +@usableFromInline +struct NIOLockedValueBox { + + @usableFromInline + internal let _storage: LockStorage + + /// Initialize the `Value`. + @inlinable + init(_ value: Value) { + self._storage = .create(value: value) + } + + /// Access the `Value`, allowing mutation of it. + @inlinable + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + return try self._storage.withLockedValue(mutate) + } +} + +extension NIOLockedValueBox: Sendable where Value: Sendable {} diff --git a/Sources/Prometheus/Prometheus.swift b/Sources/Prometheus/Prometheus.swift deleted file mode 100644 index 0975d31..0000000 --- a/Sources/Prometheus/Prometheus.swift +++ /dev/null @@ -1,243 +0,0 @@ -import NIOConcurrencyHelpers -import NIO - -/// Prometheus class -/// -/// See https://prometheus.io/docs/introduction/overview/ -public class PrometheusClient { - - /// Metrics tracked by this Prometheus instance - private var metrics: [String: PromMetric] - - /// Lock used for thread safety - private let lock: Lock - - /// Create a PrometheusClient instance - public init() { - self.metrics = [:] - self.lock = Lock() - } - - // MARK: - Collection - -#if swift(>=5.5) - /// Creates prometheus formatted metrics - /// - /// - returns: A newline separated string with metrics for all Metrics this PrometheusClient handles - @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public func collect() async -> String { - let metrics = self.lock.withLock { self.metrics } - let task = Task { - return metrics.isEmpty ? "" : "\(metrics.values.map { $0.collect() }.joined(separator: "\n"))\n" - } - return await task.value - } -#endif - - /// Creates prometheus formatted metrics - /// - /// - Parameters: - /// - succeed: Closure that will be called with a newline separated string with metrics for all Metrics this PrometheusClient handles - public func collect(_ succeed: (String) -> ()) { - let metrics = self.lock.withLock { self.metrics } - succeed(metrics.isEmpty ? "" : "\(metrics.values.map { $0.collect() }.joined(separator: "\n"))\n") - } - - /// Creates prometheus formatted metrics - /// - /// - Parameters: - /// - promise: Promise that will succeed with a newline separated string with metrics for all Metrics this PrometheusClient handles - public func collect(into promise: EventLoopPromise) { - collect(promise.succeed) - } - -#if swift(>=5.5) - /// Creates prometheus formatted metrics - /// - /// - returns: A `ByteBuffer` containing a newline separated string with metrics for all Metrics this PrometheusClient handles - @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public func collect() async -> ByteBuffer { - let metrics = self.lock.withLock { self.metrics } - let task = Task { () -> ByteBuffer in - var buffer = ByteBufferAllocator().buffer(capacity: 0) - metrics.values.forEach { - $0.collect(into: &buffer) - buffer.writeString("\n") - } - return buffer - } - return await task.value - } -#endif - - /// Creates prometheus formatted metrics - /// - /// - Parameters: - /// - succeed: Closure that will be called with a `ByteBuffer` containing a newline separated string with metrics for all Metrics this PrometheusClient handles - public func collect(_ succeed: (ByteBuffer) -> ()) { - var buffer = ByteBufferAllocator().buffer(capacity: 0) - let metrics = self.lock.withLock { self.metrics } - metrics.values.forEach { - $0.collect(into: &buffer) - buffer.writeString("\n") - } - succeed(buffer) - } - - /// Creates prometheus formatted metrics - /// - /// - Parameters: - /// - promise: Promise that will succeed with a `ByteBuffer` containing a newline separated string with metrics for all Metrics this PrometheusClient handles - public func collect(into promise: EventLoopPromise) { - collect(promise.succeed) - } - - // MARK: - Metric Access - - public func removeMetric(_ metric: PromMetric) { - // `metricTypeMap` is left untouched as those must be consistent - // throughout the lifetime of a program. - return lock.withLock { - self.metrics.removeValue(forKey: metric.name) - } - } - - public func getMetricInstance(with name: String, andType type: PromMetricType) -> Metric? where Metric: PromMetric { - return lock.withLock { - self._getMetricInstance(with: name, andType: type) - } - } - - private func _getMetricInstance(with name: String, andType type: PromMetricType) -> Metric? where Metric: PromMetric { - if let metric = self.metrics[name], metric._type == type { - return metric as? Metric - } else { - return nil - } - } - - // MARK: - Counter - - /// Creates a counter with the given values - /// - /// - Parameters: - /// - type: Type the counter will count - /// - name: Name of the counter - /// - helpText: Help text for the counter. Usually a short description - /// - initialValue: An initial value to set the counter to, defaults to 0 - /// - /// - Returns: Counter instance - public func createCounter( - forType type: T.Type, - named name: String, - helpText: String? = nil, - initialValue: T = 0) -> PromCounter - { - return self.lock.withLock { - if let cachedCounter: PromCounter = self._getMetricInstance(with: name, andType: .counter) { - return cachedCounter - } - - let counter = PromCounter(name, helpText, initialValue) - let oldInstrument = self.metrics.updateValue(counter, forKey: name) - precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).") - return counter - } - } - - // MARK: - Gauge - - /// Creates a gauge with the given values - /// - /// - Parameters: - /// - type: Type the gauge will hold - /// - name: Name of the gauge - /// - helpText: Help text for the gauge. Usually a short description - /// - initialValue: An initial value to set the gauge to, defaults to 0 - /// - /// - Returns: Gauge instance - public func createGauge( - forType type: T.Type, - named name: String, - helpText: String? = nil, - initialValue: T = 0) -> PromGauge - { - return self.lock.withLock { - if let cachedGauge: PromGauge = self._getMetricInstance(with: name, andType: .gauge) { - return cachedGauge - } - - let gauge = PromGauge(name, helpText, initialValue) - let oldInstrument = self.metrics.updateValue(gauge, forKey: name) - precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).") - return gauge - } - } - - // MARK: - Histogram - - /// Creates a histogram with the given values - /// - /// - Parameters: - /// - type: The type the histogram will observe - /// - name: Name of the histogram - /// - helpText: Help text for the histogram. Usually a short description - /// - buckets: Buckets to divide values over - /// - /// - Returns: Histogram instance - public func createHistogram( - forType type: T.Type, - named name: String, - helpText: String? = nil, - buckets: Buckets = .defaultBuckets) -> PromHistogram - { - return self.lock.withLock { - if let cachedHistogram: PromHistogram = self._getMetricInstance(with: name, andType: .histogram) { - return cachedHistogram - } - - let histogram = PromHistogram(name, helpText, buckets) - let oldInstrument = self.metrics.updateValue(histogram, forKey: name) - precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).") - return histogram - } - } - - // MARK: - Summary - - /// Creates a summary with the given values - /// - /// - Parameters: - /// - type: The type the summary will observe - /// - name: Name of the summary - /// - helpText: Help text for the summary. Usually a short description - /// - capacity: Number of observations to keep for calculating quantiles - /// - quantiles: Quantiles to calculate - /// - /// - Returns: Summary instance - public func createSummary( - forType type: T.Type, - named name: String, - helpText: String? = nil, - capacity: Int = Prometheus.defaultSummaryCapacity, - quantiles: [Double] = Prometheus.defaultQuantiles) -> PromSummary - { - return self.lock.withLock { - if let cachedSummary: PromSummary = self._getMetricInstance(with: name, andType: .summary) { - return cachedSummary - } - let summary = PromSummary(name, helpText, capacity, quantiles) - let oldInstrument = self.metrics.updateValue(summary, forKey: name) - precondition(oldInstrument == nil, "Label \(oldInstrument!.name) is already associated with a \(oldInstrument!._type).") - return summary - } - } -} - -/// Prometheus specific errors -public enum PrometheusError: Error { - /// Thrown when a user tries to retrieve - /// a `PrometheusClient` from `MetricsSystem` - /// but there was no `PrometheusClient` bootstrapped - case prometheusFactoryNotBootstrapped(bootstrappedWith: String) -} diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift new file mode 100644 index 0000000..f50807e --- /dev/null +++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift @@ -0,0 +1,554 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import CoreMetrics + +/// A promtheus collector registry to create and store collectors. +/// +/// It creates and stores collectors. Further you can use the ``PrometheusCollectorRegistry/emit(into:)`` +/// method to export the metrics form registered collectors into a Prometheus compatible format. +/// +/// To use a ``PrometheusCollectorRegistry`` with `swift-metrics` use the ``PrometheusMetricsFactory``. +public final class PrometheusCollectorRegistry: Sendable { + private struct LabelsKey: Hashable, Sendable { + var labels: [(String, String)] + + init(_ labels: [(String, String)]) { + self.labels = labels + } + + static func == (lhs: Self, rhs: Self) -> Bool { + guard lhs.labels.count == rhs.labels.count else { return false } + + for (lhs, rhs) in zip(lhs.labels, rhs.labels) { + guard lhs.0 == rhs.0 && lhs.1 == rhs.1 else { + return false + } + } + return true + } + + func hash(into hasher: inout Hasher) { + for (key, value) in self.labels { + key.hash(into: &hasher) + value.hash(into: &hasher) + } + } + } + + private enum Metric { + case counter(Counter) + case counterWithLabels([String], [LabelsKey: Counter]) + case gauge(Gauge) + case gaugeWithLabels([String], [LabelsKey: Gauge]) + case durationHistogram(DurationHistogram) + case durationHistogramWithLabels([String], [LabelsKey: DurationHistogram], [Duration]) + case valueHistogram(ValueHistogram) + case valueHistogramWithLabels([String], [LabelsKey: ValueHistogram], [Double]) + } + + private let box = NIOLockedValueBox([String: Metric]()) + + /// Create a new collector registry + public init() {} + + // MARK: Creating Metrics + + /// Creates a new ``Counter`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Counter`` will be part of the export. + /// + /// - Parameter name: A name to identify ``Counter``'s value. + /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeCounter(name: String) -> Counter { + self.box.withLockedValue { store -> Counter in + if let value = store[name] { + guard case .counter(let counter) = value else { + fatalError(""" + Could not make Counter with name: \(name), since another metric type + already exists for the same name. + """ + ) + } + + return counter + } else { + let counter = Counter(name: name, labels: []) + store[name] = .counter(counter) + return counter + } + } + } + + /// Creates a new ``Counter`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Counter`` will be part of the export. + /// + /// - Parameter name: A name to identify ``Counter``'s value. + /// - Parameter labels: Labels are sets of key-value pairs that allow us to characterize and organize + /// what’s actually being measured in a Prometheus metric. + /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeCounter(name: String, labels: [(String, String)]) -> Counter { + guard !labels.isEmpty else { + return self.makeCounter(name: name) + } + + return self.box.withLockedValue { store -> Counter in + if let value = store[name] { + guard case .counterWithLabels(let labelNames, var dimensionLookup) = value else { + fatalError(""" + Could not make Counter with name: \(name) and labels: \(labels), since another + metric type already exists for the same name. + """ + ) + } + + let key = LabelsKey(labels) + if let counter = dimensionLookup[key] { + return counter + } + + // check if all labels match the already existing ones. + if labelNames != labels.allLabelNames { + fatalError(""" + Could not make Counter with name: \(name) and labels: \(labels), since the + label names don't match the label names of previously registered Counters with + the same name. + """ + ) + } + + let counter = Counter(name: name, labels: labels) + dimensionLookup[key] = counter + store[name] = .counterWithLabels(labelNames, dimensionLookup) + return counter + } else { + let labelNames = labels.allLabelNames + let counter = Counter(name: name, labels: labels) + + store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter]) + return counter + } + } + } + + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Gauge`` will be part of the export. + /// + /// - Parameter name: A name to identify ``Gauge``'s value. + /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeGauge(name: String) -> Gauge { + self.box.withLockedValue { store -> Gauge in + if let value = store[name] { + guard case .gauge(let gauge) = value else { + fatalError(""" + Could not make Gauge with name: \(name), since another metric type already + exists for the same name. + """ + ) + } + + return gauge + } else { + let gauge = Gauge(name: name, labels: []) + store[name] = .gauge(gauge) + return gauge + } + } + } + + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Gauge`` will be part of the export. + /// + /// - Parameter name: A name to identify ``Gauge``'s value. + /// - Parameter labels: Labels are sets of key-value pairs that allow us to characterize and organize + /// what’s actually being measured in a Prometheus metric. + /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeGauge(name: String, labels: [(String, String)]) -> Gauge { + guard !labels.isEmpty else { + return self.makeGauge(name: name) + } + + return self.box.withLockedValue { store -> Gauge in + if let value = store[name] { + guard case .gaugeWithLabels(let labelNames, var dimensionLookup) = value else { + fatalError(""" + Could not make Gauge with name: \(name) and labels: \(labels), since another + metric type already exists for the same name. + """ + ) + } + + let key = LabelsKey(labels) + if let gauge = dimensionLookup[key] { + return gauge + } + + // check if all labels match the already existing ones. + if labelNames != labels.allLabelNames { + fatalError(""" + Could not make Gauge with name: \(name) and labels: \(labels), since the + label names don't match the label names of previously registered Gauges with + the same name. + """ + ) + } + + let gauge = Gauge(name: name, labels: labels) + dimensionLookup[key] = gauge + store[name] = .gaugeWithLabels(labelNames, dimensionLookup) + return gauge + } else { + let labelNames = labels.allLabelNames + let gauge = Gauge(name: name, labels: labels) + + store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge]) + return gauge + } + } + } + + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``DurationHistogram`` will be part of the export. + /// + /// - Parameter name: A name to identify ``DurationHistogram``'s value. + /// - Parameter buckets: Define the buckets that shall be used within the ``DurationHistogram`` + /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeDurationHistogram(name: String, buckets: [Duration]) -> DurationHistogram { + self.box.withLockedValue { store -> DurationHistogram in + if let value = store[name] { + guard case .durationHistogram(let histogram) = value else { + fatalError(""" + Could not make DurationHistogram with name: \(name), since another + metric type already exists for the same name. + """ + ) + } + + return histogram + } else { + let gauge = DurationHistogram(name: name, labels: [], buckets: buckets) + store[name] = .durationHistogram(gauge) + return gauge + } + } + } + + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``DurationHistogram`` will be part of the export. + /// + /// - Parameter name: A name to identify ``DurationHistogram``'s value. + /// - Parameter labels: Labels are sets of key-value pairs that allow us to characterize and organize + /// what’s actually being measured in a Prometheus metric. + /// - Parameter buckets: Define the buckets that shall be used within the ``DurationHistogram`` + /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeDurationHistogram(name: String, labels: [(String, String)], buckets: [Duration]) -> DurationHistogram { + guard !labels.isEmpty else { + return self.makeDurationHistogram(name: name, buckets: buckets) + } + + return self.box.withLockedValue { store -> DurationHistogram in + if let value = store[name] { + guard case .durationHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets) = value else { + fatalError(""" + Could not make DurationHistogram with name: \(name) and labels: \(labels), since another + metric type already exists for the same name. + """ + ) + } + + let key = LabelsKey(labels) + if let histogram = dimensionLookup[key] { + return histogram + } + + // check if all labels match the already existing ones. + if labelNames != labels.allLabelNames { + fatalError(""" + Could not make DurationHistogram with name: \(name) and labels: \(labels), since the + label names don't match the label names of previously registered Gauges with + the same name. + """ + ) + } + if storedBuckets != buckets { + fatalError(""" + Could not make DurationHistogram with name: \(name) and labels: \(labels), since the + buckets don't match the buckets of previously registered TimeHistograms with + the same name. + """ + ) + } + + precondition(storedBuckets == buckets) + + let histogram = DurationHistogram(name: name, labels: labels, buckets: storedBuckets) + dimensionLookup[key] = histogram + store[name] = .durationHistogramWithLabels(labelNames, dimensionLookup, storedBuckets) + return histogram + } else { + let labelNames = labels.allLabelNames + let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets) + + store[name] = .durationHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets) + return histogram + } + } + } + + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``ValueHistogram`` will be part of the export. + /// + /// - Parameter name: A name to identify ``ValueHistogram``'s value. + /// - Parameter buckets: Define the buckets that shall be used within the ``ValueHistogram`` + /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeValueHistogram(name: String, buckets: [Double]) -> ValueHistogram { + self.box.withLockedValue { store -> ValueHistogram in + if let value = store[name] { + guard case .valueHistogram(let histogram) = value else { + fatalError() + } + + return histogram + } else { + let gauge = ValueHistogram(name: name, labels: [], buckets: buckets) + store[name] = .valueHistogram(gauge) + return gauge + } + } + } + + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``ValueHistogram`` will be part of the export. + /// + /// - Parameter name: A name to identify ``ValueHistogram``'s value. + /// - Parameter labels: Labels are sets of key-value pairs that allow us to characterize and organize + /// what’s actually being measured in a Prometheus metric. + /// - Parameter buckets: Define the buckets that shall be used within the ``ValueHistogram`` + /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeValueHistogram(name: String, labels: [(String, String)], buckets: [Double]) -> ValueHistogram { + guard !labels.isEmpty else { + return self.makeValueHistogram(name: name, buckets: buckets) + } + + return self.box.withLockedValue { store -> ValueHistogram in + if let value = store[name] { + guard case .valueHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets) = value else { + fatalError() + } + + let key = LabelsKey(labels) + if let histogram = dimensionLookup[key] { + return histogram + } + + // check if all labels match the already existing ones. + precondition(labelNames == labels.allLabelNames) + precondition(storedBuckets == buckets) + + let histogram = ValueHistogram(name: name, labels: labels, buckets: storedBuckets) + dimensionLookup[key] = histogram + store[name] = .valueHistogramWithLabels(labelNames, dimensionLookup, storedBuckets) + return histogram + } else { + let labelNames = labels.allLabelNames + let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets) + + store[name] = .valueHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets) + return histogram + } + } + } + + // MARK: Destroying Metrics + + public func destroyCounter(_ counter: Counter) { + self.box.withLockedValue { store in + switch store[counter.name] { + case .counter(let storedCounter): + guard storedCounter === counter else { return } + store.removeValue(forKey: counter.name) + case .counterWithLabels(let labelNames, var dimensions): + let labelsKey = LabelsKey(counter.labels) + guard dimensions[labelsKey] === counter else { return } + dimensions.removeValue(forKey: labelsKey) + store[counter.name] = .counterWithLabels(labelNames, dimensions) + default: + return + } + } + } + + public func destroyGauge(_ gauge: Gauge) { + self.box.withLockedValue { store in + switch store[gauge.name] { + case .gauge(let storedGauge): + guard storedGauge === gauge else { return } + store.removeValue(forKey: gauge.name) + case .gaugeWithLabels(let labelNames, var dimensions): + let dimensionsKey = LabelsKey(gauge.labels) + guard dimensions[dimensionsKey] === gauge else { return } + dimensions.removeValue(forKey: dimensionsKey) + store[gauge.name] = .gaugeWithLabels(labelNames, dimensions) + default: + return + } + } + } + + public func destroyTimeHistogram(_ histogram: DurationHistogram) { + self.box.withLockedValue { store in + switch store[histogram.name] { + case .durationHistogram(let storedHistogram): + guard storedHistogram === histogram else { return } + store.removeValue(forKey: histogram.name) + case .durationHistogramWithLabels(let labelNames, var dimensions, let buckets): + let dimensionsKey = LabelsKey(histogram.labels) + guard dimensions[dimensionsKey] === histogram else { return } + dimensions.removeValue(forKey: dimensionsKey) + store[histogram.name] = .durationHistogramWithLabels(labelNames, dimensions, buckets) + default: + return + } + } + } + + public func destroyValueHistogram(_ histogram: ValueHistogram) { + self.box.withLockedValue { store in + switch store[histogram.name] { + case .valueHistogram(let storedHistogram): + guard storedHistogram === histogram else { return } + store.removeValue(forKey: histogram.name) + case .valueHistogramWithLabels(let labelNames, var dimensions, let buckets): + let dimensionsKey = LabelsKey(histogram.labels) + guard dimensions[dimensionsKey] === histogram else { return } + dimensions.removeValue(forKey: dimensionsKey) + store[histogram.name] = .valueHistogramWithLabels(labelNames, dimensions, buckets) + default: + return + } + } + } + + // MARK: Emitting + + public func emit(into buffer: inout [UInt8]) { + let metrics = self.box.withLockedValue { $0 } + + for (label, metric) in metrics { + switch metric { + case .counter(let counter): + buffer.addTypeLine(label: label, type: "counter") + counter.emit(into: &buffer) + + case .counterWithLabels(_, let counters): + buffer.addTypeLine(label: label, type: "counter") + for counter in counters.values { + counter.emit(into: &buffer) + } + + case .gauge(let gauge): + buffer.addTypeLine(label: label, type: "gauge") + gauge.emit(into: &buffer) + + case .gaugeWithLabels(_, let gauges): + buffer.addTypeLine(label: label, type: "gauge") + for gauge in gauges.values { + gauge.emit(into: &buffer) + } + + case .durationHistogram(let histogram): + buffer.addTypeLine(label: label, type: "histogram") + histogram.emit(into: &buffer) + + case .durationHistogramWithLabels(_, let histograms, _): + buffer.addTypeLine(label: label, type: "histogram") + for histogram in histograms.values { + histogram.emit(into: &buffer) + } + + case .valueHistogram(let histogram): + buffer.addTypeLine(label: label, type: "histogram") + histogram.emit(into: &buffer) + + case .valueHistogramWithLabels(_, let histograms, _): + buffer.addTypeLine(label: label, type: "histogram") + for histogram in histograms.values { + histogram.emit(into: &buffer) + } + } + } + } +} + +extension Array<(String, String)> { + fileprivate var allLabelNames: [String] { + var result = [String]() + result.reserveCapacity(self.count) + for (name, _) in self { + precondition(!result.contains(name)) + result.append(name) + } + result = result.sorted() + return result + } +} + +extension Array { + fileprivate mutating func addTypeLine(label: String, type: String) { + self.append(contentsOf: #"# TYPE "#.utf8) + self.append(contentsOf: label.utf8) + self.append(contentsOf: #" "#.utf8) + self.append(contentsOf: type.utf8) + self.append(contentsOf: #"\#n"#.utf8) + } +} + +protocol PrometheusMetric { + func emit(into buffer: inout [UInt8]) +} + +extension PrometheusMetric { + static func prerenderLabels(_ labels: [(String, String)]) -> [UInt8]? { + guard !labels.isEmpty else { + return nil + } + + var prerendered = [UInt8]() + for (i, (key, value)) in labels.enumerated() { + prerendered.append(contentsOf: key.utf8) + prerendered.append(contentsOf: #"=""#.utf8) + prerendered.append(contentsOf: value.utf8) + prerendered.append(UInt8(ascii: #"""#)) + if i < labels.index(before: labels.endIndex) { + prerendered.append(UInt8(ascii: #","#)) + } + } + return prerendered + } +} diff --git a/Sources/Prometheus/PrometheusMetrics.swift b/Sources/Prometheus/PrometheusMetrics.swift deleted file mode 100644 index cdb9261..0000000 --- a/Sources/Prometheus/PrometheusMetrics.swift +++ /dev/null @@ -1,380 +0,0 @@ -import CoreMetrics - -private class MetricsCounter: CounterHandler { - let counter: PromCounter - let labels: DimensionLabels? - - internal init(counter: PromCounter, dimensions: [(String, String)]) { - self.counter = counter - guard !dimensions.isEmpty else { - labels = nil - return - } - self.labels = DimensionLabels(dimensions) - } - - func increment(by: Int64) { - self.counter.inc(by, labels) - } - - func reset() { } -} - -private class MetricsFloatingPointCounter: FloatingPointCounterHandler { - let counter: PromCounter - let labels: DimensionLabels? - - internal init(counter: PromCounter, dimensions: [(String, String)]) { - self.counter = counter - guard !dimensions.isEmpty else { - labels = nil - return - } - self.labels = DimensionLabels(dimensions) - } - - func increment(by: Double) { - self.counter.inc(by, labels) - } - - func reset() { } -} - -private class MetricsGauge: RecorderHandler { - let gauge: PromGauge - let labels: DimensionLabels? - - internal init(gauge: PromGauge, dimensions: [(String, String)]) { - self.gauge = gauge - guard !dimensions.isEmpty else { - labels = nil - return - } - self.labels = DimensionLabels(dimensions) - } - - func record(_ value: Int64) { - self.record(value.doubleValue) - } - - func record(_ value: Double) { - gauge.set(value, labels) - } -} - -private class MetricsHistogram: RecorderHandler { - let histogram: PromHistogram - let labels: DimensionLabels? - - internal init(histogram: PromHistogram, dimensions: [(String, String)]) { - self.histogram = histogram - guard !dimensions.isEmpty else { - labels = nil - return - } - self.labels = DimensionLabels(dimensions) - } - - func record(_ value: Int64) { - histogram.observe(value.doubleValue, labels) - } - - func record(_ value: Double) { - histogram.observe(value, labels) - } -} - -class MetricsHistogramTimer: TimerHandler { - let histogram: PromHistogram - let labels: DimensionLabels? - - init(histogram: PromHistogram, dimensions: [(String, String)]) { - self.histogram = histogram - if !dimensions.isEmpty { - self.labels = DimensionLabels(dimensions) - } else { - self.labels = nil - } - } - - func recordNanoseconds(_ duration: Int64) { - return histogram.observe(duration, labels) - } -} - -private class MetricsSummary: TimerHandler { - let summary: PromSummary - let labels: DimensionLabels? - - func preferDisplayUnit(_ unit: TimeUnit) { - self.summary.preferDisplayUnit(unit) - } - - internal init(summary: PromSummary, dimensions: [(String, String)]) { - self.summary = summary - guard !dimensions.isEmpty else { - labels = nil - return - } - self.labels = DimensionLabels(dimensions) - } - - func recordNanoseconds(_ duration: Int64) { - return summary.observe(duration, labels) - } -} - -/// Defines the base for a bridge between PrometheusClient and swift-metrics. -/// Used by `SwiftMetrics.prometheus()` to get an instance of `PrometheusClient` from `MetricsSystem` -/// -/// Any custom implementation of `MetricsFactory` using `PrometheusClient` should conform to this implementation. -public protocol PrometheusWrappedMetricsFactory: MetricsFactory { - var client: PrometheusClient { get } -} - -/// A bridge between PrometheusClient and swift-metrics. Prometheus types don't map perfectly on swift-metrics API, -/// which makes bridge implementation non trivial. This class defines how exactly swift-metrics types should be backed -/// with Prometheus types, e.g. how to sanitize labels, what buckets/quantiles to use for recorder/timer, etc. -public struct PrometheusMetricsFactory: PrometheusWrappedMetricsFactory { - - /// Prometheus client to bridge swift-metrics API to. - public let client: PrometheusClient - - /// Bridge configuration. - private let configuration: Configuration - - public init(client: PrometheusClient, - configuration: Configuration = Configuration()) { - self.client = client - self.configuration = configuration - } - - public func destroyCounter(_ handler: CounterHandler) { - guard let handler = handler as? MetricsCounter else { return } - client.removeMetric(handler.counter) - } - - public func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler) { - guard let handler = handler as? MetricsFloatingPointCounter else { return } - client.removeMetric(handler.counter) - } - - public func destroyRecorder(_ handler: RecorderHandler) { - if let handler = handler as? MetricsGauge { - client.removeMetric(handler.gauge) - } - if let handler = handler as? MetricsHistogram { - client.removeMetric(handler.histogram) - } - } - - public func destroyTimer(_ handler: TimerHandler) { - switch self.configuration.timerImplementation._wrapped { - case .summary: - guard let handler = handler as? MetricsSummary else { return } - client.removeMetric(handler.summary) - case .histogram: - guard let handler = handler as? MetricsHistogramTimer else { return } - client.removeMetric(handler.histogram) - } - } - - public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler { - let label = configuration.labelSanitizer.sanitize(label) - let counter = client.createCounter(forType: Int64.self, named: label) - return MetricsCounter(counter: counter, dimensions: dimensions.sanitized()) - } - - public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler { - let label = configuration.labelSanitizer.sanitize(label) - let counter = client.createCounter(forType: Double.self, named: label) - return MetricsFloatingPointCounter(counter: counter, dimensions: dimensions.sanitized()) - } - - public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler { - let label = configuration.labelSanitizer.sanitize(label) - return aggregate ? makeHistogram(label: label, dimensions: dimensions) : makeGauge(label: label, dimensions: dimensions) - } - - private func makeGauge(label: String, dimensions: [(String, String)]) -> RecorderHandler { - let label = configuration.labelSanitizer.sanitize(label) - let gauge = client.createGauge(forType: Double.self, named: label) - return MetricsGauge(gauge: gauge, dimensions: dimensions.sanitized()) - } - - private func makeHistogram(label: String, dimensions: [(String, String)]) -> RecorderHandler { - let label = configuration.labelSanitizer.sanitize(label) - let histogram = client.createHistogram(forType: Double.self, named: label) - return MetricsHistogram(histogram: histogram, dimensions: dimensions.sanitized()) - } - - public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler { - switch configuration.timerImplementation._wrapped { - case .summary(let quantiles): - return self.makeSummaryTimer(label: label, dimensions: dimensions, quantiles: quantiles) - case .histogram(let buckets): - return self.makeHistogramTimer(label: label, dimensions: dimensions, buckets: buckets) - } - } - - /// There's two different ways to back swift-api `Timer` with Prometheus classes. - /// This method creates `Summary` backed timer implementation - private func makeSummaryTimer(label: String, dimensions: [(String, String)], quantiles: [Double]) -> TimerHandler { - let label = configuration.labelSanitizer.sanitize(label) - let summary = client.createSummary(forType: Int64.self, named: label, quantiles: quantiles) - return MetricsSummary(summary: summary, dimensions: dimensions.sanitized()) - } - - /// There's two different ways to back swift-api `Timer` with Prometheus classes. - /// This method creates `Histogram` backed timer implementation - private func makeHistogramTimer(label: String, dimensions: [(String, String)], buckets: Buckets) -> TimerHandler { - let label = configuration.labelSanitizer.sanitize(label) - let histogram = client.createHistogram(forType: Int64.self, named: label, buckets: buckets) - return MetricsHistogramTimer(histogram: histogram, dimensions: dimensions.sanitized()) - } -} - -extension Array where Element == (String, String) { - func sanitized() -> [(String, String)] { - let sanitizer = DimensionsSanitizer() - return self.map { - (sanitizer.sanitize($0.0), $0.1) - } - } -} - -public extension MetricsSystem { - /// Get the bootstrapped `MetricsSystem` as `PrometheusClient` - /// - /// - Returns: `PrometheusClient` used to bootstrap `MetricsSystem` - /// - Throws: `PrometheusError.PrometheusFactoryNotBootstrapped` - /// if no `PrometheusClient` was used to bootstrap `MetricsSystem` - static func prometheus() throws -> PrometheusClient { - guard let prom = self.factory as? PrometheusWrappedMetricsFactory else { - throw PrometheusError.prometheusFactoryNotBootstrapped(bootstrappedWith: "\(self.factory)") - } - return prom.client - } -} - -// MARK: - Labels - -/// A generic `String` based `CodingKey` implementation. -private struct StringCodingKey: CodingKey { - /// `CodingKey` conformance. - public var stringValue: String - - /// `CodingKey` conformance. - public var intValue: Int? { - return Int(self.stringValue) - } - - /// Creates a new `StringCodingKey`. - public init(_ string: String) { - self.stringValue = string - } - - /// `CodingKey` conformance. - public init(stringValue: String) { - self.stringValue = stringValue - } - - /// `CodingKey` conformance. - public init(intValue: Int) { - self.stringValue = intValue.description - } -} - -/// Helper for dimensions -public struct DimensionLabels: Hashable, ExpressibleByArrayLiteral { - let dimensions: [(String, String)] - - public init() { - self.dimensions = [] - } - - public init(_ dimensions: [(String, String)]) { - self.dimensions = dimensions - } - - public init(arrayLiteral elements: (String, String)...) { - self.init(elements) - } - - public func hash(into hasher: inout Hasher) { - for (key, value) in dimensions { - hasher.combine(key) - hasher.combine(value) - } - } - - public static func == (lhs: DimensionLabels, rhs: DimensionLabels) -> Bool { - guard lhs.dimensions.count == rhs.dimensions.count else { return false } - for index in 0.. TimerImplementation { - return TimerImplementation(.summary(defaultQuantiles: defaultQuantiles)) - } - - public static func histogram(defaultBuckets: Buckets = Buckets.defaultBuckets) -> TimerImplementation { - return TimerImplementation(.histogram(defaultBuckets: defaultBuckets)) - } - } - - - /// Configuration for PrometheusClient to swift-metrics api bridge. - public struct Configuration { - /// Sanitizers used to clean up label values provided through - /// swift-metrics. - public var labelSanitizer: LabelSanitizer - - /// This parameter will define what implementation will be used for bridging `swift-metrics` to Prometheus types. - public var timerImplementation: PrometheusMetricsFactory.TimerImplementation - - /// Default buckets for `Recorder` with aggregation. - public var defaultRecorderBuckets: Buckets - - public init(labelSanitizer: LabelSanitizer = PrometheusLabelSanitizer(), - timerImplementation: PrometheusMetricsFactory.TimerImplementation = .summary(), - defaultRecorderBuckets: Buckets = .defaultBuckets) { - self.labelSanitizer = labelSanitizer - self.timerImplementation = timerImplementation - self.defaultRecorderBuckets = defaultRecorderBuckets - } - } -} diff --git a/Sources/Prometheus/PrometheusMetricsFactory.swift b/Sources/Prometheus/PrometheusMetricsFactory.swift new file mode 100644 index 0000000..606ff8c --- /dev/null +++ b/Sources/Prometheus/PrometheusMetricsFactory.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import CoreMetrics + +/// A wrapper around ``PrometheusCollectorRegistry`` to implement the `swift-metrics` `MetricsFactory` protocol +public struct PrometheusMetricsFactory: Sendable { + /// The underlying ``PrometheusCollectorRegistry`` that is used to generate + public var client: PrometheusCollectorRegistry + + /// The default histogram buckets for a ``TimeHistogram``. If there is no explicit overwrite + /// via ``timeHistogramBuckets``, the buckets provided here will be used for any new + /// Swift Metrics `Timer` type. + public var defaultTimeHistogramBuckets: [Duration] + + /// The histogram buckets for a ``TimeHistogram`` per Timer label + public var timeHistogramBuckets: [String: [Duration]] + + /// The default histogram buckets for a ``ValueHistogram``. If there is no explicit overwrite + /// via ``valueHistogramBuckets``, the buckets provided here will be used for any new + /// Swift Metrics `Summary` type. + public var defaultValueHistogramBuckets: [Double] + + /// The histogram buckets for a ``ValueHistogram`` per label + public var valueHistogramBuckets: [String: [Double]] + + /// A closure to modify the label and dimension names used in the Swift Metrics API. This allows users + /// to overwrite the Metric names in third party packages. + public var labelAndDimensionSanitizer: @Sendable (_ label: String, _ dimensions: [(String, String)]) -> (String, [(String, String)]) + + public init(client: PrometheusCollectorRegistry) { + self.client = client + + self.timeHistogramBuckets = [:] + self.defaultTimeHistogramBuckets = [ + .milliseconds(5), + .milliseconds(10), + .milliseconds(25), + .milliseconds(50), + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + .seconds(1), + .milliseconds(2500), + .seconds(5), + .seconds(10), + ] + + self.valueHistogramBuckets = [:] + self.defaultValueHistogramBuckets = [ + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1000, + 2500, + 5000, + 10000, + ] + + self.labelAndDimensionSanitizer = { ($0, $1) } + } +} + +extension PrometheusMetricsFactory: CoreMetrics.MetricsFactory { + public func makeCounter(label: String, dimensions: [(String, String)]) -> CoreMetrics.CounterHandler { + let (label, dimensions) = self.labelAndDimensionSanitizer(label, dimensions) + return self.client.makeCounter(name: label, labels: dimensions) + } + + public func makeFloatingPointCounter(label: String, dimensions: [(String, String)]) -> FloatingPointCounterHandler { + let (label, dimensions) = self.labelAndDimensionSanitizer(label, dimensions) + return self.client.makeCounter(name: label, labels: dimensions) + } + + public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> CoreMetrics.RecorderHandler { + let (label, dimensions) = self.labelAndDimensionSanitizer(label, dimensions) + if aggregate { + let buckets = self.valueHistogramBuckets[label] ?? self.defaultValueHistogramBuckets + return self.client.makeValueHistogram(name: label, labels: dimensions, buckets: buckets) + } else { + return self.client.makeGauge(name: label, labels: dimensions) + } + } + + public func makeTimer(label: String, dimensions: [(String, String)]) -> CoreMetrics.TimerHandler { + let (label, dimensions) = self.labelAndDimensionSanitizer(label, dimensions) + let buckets = self.timeHistogramBuckets[label] ?? self.defaultTimeHistogramBuckets + return self.client.makeDurationHistogram(name: label, labels: dimensions, buckets: buckets) + } + + public func destroyCounter(_ handler: CoreMetrics.CounterHandler) { + guard let counter = handler as? Counter else { + return + } + self.client.destroyCounter(counter) + } + + public func destroyFloatingPointCounter(_ handler: FloatingPointCounterHandler) { + guard let counter = handler as? Counter else { + return + } + self.client.destroyCounter(counter) + } + + public func destroyRecorder(_ handler: CoreMetrics.RecorderHandler) { + switch handler { + case let gauge as Gauge: + self.client.destroyGauge(gauge) + case let histogram as Histogram: + self.client.destroyValueHistogram(histogram) + default: + break + } + } + + public func destroyTimer(_ handler: CoreMetrics.TimerHandler) { + guard let histogram = handler as? Histogram else { + return + } + self.client.destroyTimeHistogram(histogram) + } +} diff --git a/Sources/Prometheus/Sanitizer/DimensionsSanitizer.swift b/Sources/Prometheus/Sanitizer/DimensionsSanitizer.swift deleted file mode 100644 index 3a9422c..0000000 --- a/Sources/Prometheus/Sanitizer/DimensionsSanitizer.swift +++ /dev/null @@ -1,55 +0,0 @@ -struct DimensionsSanitizer: LabelSanitizer { - private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z") - private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z") - private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9") - - public init() { } - - public func sanitize(_ label: String) -> String { - if DimensionsSanitizer.isSanitized(label) { - return label - } else { - return DimensionsSanitizer.sanitizeLabel(label) - } - } - - /// Returns a boolean indicating whether the label is already sanitized. - private static func isSanitized(_ label: String) -> Bool { - return label.utf8.allSatisfy(DimensionsSanitizer.isValidCharacter(_:)) - } - - /// Returns a boolean indicating whether the character may be used in a label. - private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool { - switch codePoint { - case DimensionsSanitizer.lowercaseAThroughZ, - DimensionsSanitizer.uppercaseAThroughZ, - DimensionsSanitizer.zeroThroughNine, - UInt8(ascii: ":"), - UInt8(ascii: "_"): - return true - default: - return false - } - } - - private static func sanitizeLabel(_ label: String) -> String { - let sanitized: [UInt8] = label.utf8.map { character in - if DimensionsSanitizer.isValidCharacter(character) { - return character - } else { - return DimensionsSanitizer.sanitizeCharacter(character) - } - } - - return String(decoding: sanitized, as: UTF8.self) - } - - private static func sanitizeCharacter(_ character: UInt8) -> UInt8 { - if DimensionsSanitizer.uppercaseAThroughZ.contains(character) { - // Uppercase, so shift to lower case. - return character + (UInt8(ascii: "a") - UInt8(ascii: "A")) - } else { - return UInt8(ascii: "_") - } - } -} \ No newline at end of file diff --git a/Sources/Prometheus/Sanitizer/LabelSanitizer.swift b/Sources/Prometheus/Sanitizer/LabelSanitizer.swift deleted file mode 100644 index a9ccf93..0000000 --- a/Sources/Prometheus/Sanitizer/LabelSanitizer.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// Used to sanitize labels into a format compatible with Prometheus label requirements. -/// Useful when using `PrometheusMetrics` via `SwiftMetrics` with clients which do not necessarily know -/// about prometheus label formats, and may be using e.g. `.` or upper-case letters in labels (which Prometheus -/// does not allow). -/// -/// let sanitizer: LabelSanitizer = ... -/// let prometheusLabel = sanitizer.sanitize(nonPrometheusLabel) -/// -/// By default `PrometheusLabelSanitizer` is used by `PrometheusMetricsFactory` -public protocol LabelSanitizer { - /// Sanitize the passed in label to a Prometheus accepted value. - /// - /// - parameters: - /// - label: The created label that needs to be sanitized. - /// - /// - returns: A sanitized string that a Prometheus backend will accept. - func sanitize(_ label: String) -> String -} \ No newline at end of file diff --git a/Sources/Prometheus/Sanitizer/PrometheusLabelSanitizer.swift b/Sources/Prometheus/Sanitizer/PrometheusLabelSanitizer.swift deleted file mode 100644 index e68d020..0000000 --- a/Sources/Prometheus/Sanitizer/PrometheusLabelSanitizer.swift +++ /dev/null @@ -1,58 +0,0 @@ -/// Default implementation of `LabelSanitizer` that sanitizes any characters not -/// allowed by Prometheus to an underscore (`_`). -/// -/// See `https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels` for more info. -public struct PrometheusLabelSanitizer: LabelSanitizer { - private static let uppercaseAThroughZ = UInt8(ascii: "A") ... UInt8(ascii: "Z") - private static let lowercaseAThroughZ = UInt8(ascii: "a") ... UInt8(ascii: "z") - private static let zeroThroughNine = UInt8(ascii: "0") ... UInt8(ascii: "9") - - public init() { } - - public func sanitize(_ label: String) -> String { - if PrometheusLabelSanitizer.isSanitized(label) { - return label - } else { - return PrometheusLabelSanitizer.sanitizeLabel(label) - } - } - - /// Returns a boolean indicating whether the label is already sanitized. - private static func isSanitized(_ label: String) -> Bool { - return label.utf8.allSatisfy(PrometheusLabelSanitizer.isValidCharacter(_:)) - } - - /// Returns a boolean indicating whether the character may be used in a label. - private static func isValidCharacter(_ codePoint: String.UTF8View.Element) -> Bool { - switch codePoint { - case PrometheusLabelSanitizer.lowercaseAThroughZ, - PrometheusLabelSanitizer.zeroThroughNine, - UInt8(ascii: ":"), - UInt8(ascii: "_"): - return true - default: - return false - } - } - - private static func sanitizeLabel(_ label: String) -> String { - let sanitized: [UInt8] = label.utf8.map { character in - if PrometheusLabelSanitizer.isValidCharacter(character) { - return character - } else { - return PrometheusLabelSanitizer.sanitizeCharacter(character) - } - } - - return String(decoding: sanitized, as: UTF8.self) - } - - private static func sanitizeCharacter(_ character: UInt8) -> UInt8 { - if PrometheusLabelSanitizer.uppercaseAThroughZ.contains(character) { - // Uppercase, so shift to lower case. - return character + (UInt8(ascii: "a") - UInt8(ascii: "A")) - } else { - return UInt8(ascii: "_") - } - } -} diff --git a/Sources/Prometheus/Utils.swift b/Sources/Prometheus/Utils.swift deleted file mode 100644 index 3f6d3f3..0000000 --- a/Sources/Prometheus/Utils.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -/// Creates a Prometheus String representation of a `MetricLabels` instance -func encodeLabels(_ labels: Labels, _ excludingKeys: [String] = []) -> String { - do { - let data = try JSONEncoder().encode(labels) - guard var dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { - return "" - } - excludingKeys.forEach { (key) in - dictionary[key] = nil - } - var output = [String]() - dictionary.sorted { $0.key > $1.key }.forEach { (key, value) in - output.append("\(key)=\"\(value)\"") - } - return output.isEmpty ? "" : "{\(output.joined(separator: ", "))}" - } catch { - return "" - } -} - -extension Double { - /// Overwrite for use by Histogram bucketing - var description: String { - if self == Double.greatestFiniteMagnitude { - return "+Inf" - } else if self == Double.leastNonzeroMagnitude { - return "-Inf" - } else { - return "\(self)" - } - } -} - -/// Numbers that can be represented as Double instances -public protocol DoubleRepresentable: Numeric { - /// Double value of the number - var doubleValue: Double {get} - - init(_ double: Double) - - init(_ int: Int) -} - -/// Numbers that convert to other types -public protocol ConvertibleNumberType: DoubleRepresentable {} -public extension ConvertibleNumberType { - /// Number as a Float - var floatValue: Float { return Float(doubleValue) } - /// Number as an Int - var intValue: Int { return lrint(doubleValue) } -} - -/// Double Representable Conformance -extension FixedWidthInteger { - /// Double value of the number - public var doubleValue: Double { - return Double(self) - } -} - -/// Double Representable Conformance -extension Double: ConvertibleNumberType { public var doubleValue: Double { return self }} -/// Double Representable Conformance -extension Float: ConvertibleNumberType { public var doubleValue: Double { return Double(self) }} -/// Double Representable Conformance -extension Int: ConvertibleNumberType { } -/// Double Representable Conformance -extension Int8: ConvertibleNumberType { } -/// Double Representable Conformance -extension Int16: ConvertibleNumberType { } -/// Double Representable Conformance -extension Int32: ConvertibleNumberType { } -/// Double Representable Conformance -extension Int64: ConvertibleNumberType { } -/// Double Representable Conformance -extension UInt: ConvertibleNumberType { } -/// Double Representable Conformance -extension UInt8: ConvertibleNumberType { } -/// Double Representable Conformance -extension UInt16: ConvertibleNumberType { } -/// Double Representable Conformance -extension UInt32: ConvertibleNumberType { } -/// Double Representable Conformance -extension UInt64: ConvertibleNumberType { } diff --git a/Sources/PrometheusExample/main.swift b/Sources/PrometheusExample/main.swift deleted file mode 100644 index fe3aefc..0000000 --- a/Sources/PrometheusExample/main.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Prometheus -import Metrics -import NIO - -let myProm = PrometheusClient() - -MetricsSystem.bootstrap(PrometheusMetricsFactory(client: myProm)) - -//for _ in 0...Int.random(in: 10...100) { -// let c = Counter(label: "test") -// c.increment() -//} - -for _ in 0...Int.random(in: 10...100) { - let c = Counter(label: "test", dimensions: [("abc", "123")]) - c.increment() -} - -//for _ in 0...Int.random(in: 100...500_000) { -// let r = Recorder(label: "recorder") -// r.record(Double.random(in: 0...20)) -//} -// -//for _ in 0...Int.random(in: 100...500_000) { -// let g = Gauge(label: "non_agg_recorder") -// g.record(Double.random(in: 0...20)) -//} -// -//for _ in 0...Int.random(in: 100...500_000) { -// let t = Timer(label: "timer") -// t.recordMicroseconds(Double.random(in: 20...150)) -//} -// -//for _ in 0...Int.random(in: 100...500_000) { -// let r = Recorder(label: "recorder", dimensions: [("abc", "123")]) -// r.record(Double.random(in: 0...20)) -//} -// -//for _ in 0...Int.random(in: 100...500_000) { -// let g = Gauge(label: "non_agg_recorder", dimensions: [("abc", "123")]) -// g.record(Double.random(in: 0...20)) -//} -// -//for _ in 0...Int.random(in: 100...500_000) { -// let t = Timer(label: "timer", dimensions: [("abc", "123")]) -// t.recordMicroseconds(Double.random(in: 20...150)) -//} -// -//let codable1 = MyCodable(thing: "Thing1") -//let codable2 = MyCodable(thing: "Thing2") -// -//let counter = myProm.createCounter(forType: Int.self, named: "my_counter", helpText: "Just a counter", initialValue: 12) -// -//counter.inc(5) -//counter.inc(Int.random(in: 0...100), .init([("thing", "thing2")])) -//counter.inc(Int.random(in: 0...100), .init([("thing", "thing1")])) -// -//let gauge = myProm.createGauge(forType: Int.self, named: "my_gauge", helpText: "Just a gauge", initialValue: 12) -// -//gauge.inc(100) -//gauge.inc(Int.random(in: 0...100), .init([("thing", "thing2")])) -//gauge.inc(Int.random(in: 0...100), .init([("thing", "thing1")])) -// -//let histogram = myProm.createHistogram(forType: Double.self, named: "my_histogram", helpText: "Just a histogram") -// -//for _ in 0...Int.random(in: 10...50) { -// histogram.observe(Double.random(in: 0...1)) -//} -// -//for _ in 0...Int.random(in: 10...50) { -// histogram.observe(Double.random(in: 0...1), .init([("route", "/test")])) -//} -// -//let summary = myProm.createSummary(forType: Double.self, named: "my_summary", helpText: "Just a summary") -// -//for _ in 0...Int.random(in: 100...1000) { -// summary.observe(Double.random(in: 0...10000)) -//} -// -//for _ in 0...Int.random(in: 100...1000) { -// summary.observe(Double.random(in: 0...10000), .init([("route", "/test")])) -//} - -let elg = MultiThreadedEventLoopGroup(numberOfThreads: 1) -let prom = elg.next().makePromise(of: String.self) - -try! MetricsSystem.prometheus().collect(prom.succeed) - -print(try! prom.futureResult.wait()) diff --git a/Tests/PrometheusTests/CounterTests.swift b/Tests/PrometheusTests/CounterTests.swift new file mode 100644 index 0000000..08c948c --- /dev/null +++ b/Tests/PrometheusTests/CounterTests.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import Prometheus + +final class CounterTests: XCTestCase { + func testCounterWithoutLabels() { + let client = PrometheusCollectorRegistry() + let counter = client.makeCounter(name: "foo", labels: []) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 0 + + """) + + // Increment by 1 + buffer.removeAll(keepingCapacity: true) + counter.increment() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 1 + + """ + ) + + // Increment by 1 + buffer.removeAll(keepingCapacity: true) + counter.increment() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 2 + + """) + + // Increment by 2 + buffer.removeAll(keepingCapacity: true) + counter.increment(by: Int64(2)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 4 + + """) + + // Increment by 2.5 + buffer.removeAll(keepingCapacity: true) + counter.increment(by: 2.5) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 6.5 + + """) + + // Reset + buffer.removeAll(keepingCapacity: true) + counter.reset() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 0 + + """) + } + + func testCounterWithLabels() { + let client = PrometheusCollectorRegistry() + let counter = client.makeCounter(name: "foo", labels: [("bar", "baz")]) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 0 + + """) + + // Increment by 1 + buffer.removeAll(keepingCapacity: true) + counter.increment() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 1 + + """) + + // Increment by 1 + buffer.removeAll(keepingCapacity: true) + counter.increment() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 2 + + """) + + // Increment by 2 + buffer.removeAll(keepingCapacity: true) + counter.increment(by: Int64(2)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 4 + + """) + + // Reset + buffer.removeAll(keepingCapacity: true) + counter.reset() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 0 + + """) + } +} + diff --git a/Tests/PrometheusTests/GaugeTests.swift b/Tests/PrometheusTests/GaugeTests.swift new file mode 100644 index 0000000..23a4e9f --- /dev/null +++ b/Tests/PrometheusTests/GaugeTests.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import Prometheus + +final class GaugeTests: XCTestCase { + func testGaugeWithoutLabels() { + let client = PrometheusCollectorRegistry() + let gauge = client.makeGauge(name: "foo", labels: []) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 0.0 + + """) + + // Set to 1 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(1)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 1.0 + + """) + + // Set to 2 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(2)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 2.0 + + """) + + // Set to 4 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(4)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 4.0 + + """) + + // Reset + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(0)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 0.0 + + """) + } + + func testGaugeWithLabels() { + let client = PrometheusCollectorRegistry() + let gauge = client.makeGauge(name: "foo", labels: [("bar", "baz")]) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 0.0 + + """) + + // Set to 1 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(1)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 1.0 + + """) + + // Set to 2 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(2)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 2.0 + + """) + + // Set to 4 + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(4)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 4.0 + + """) + + // Reset + buffer.removeAll(keepingCapacity: true) + gauge.record(Int64(0)) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 0.0 + + """) + } + + func testGaugeSetToFromMultipleTasks() async { + let client = PrometheusCollectorRegistry() + let gauge = client.makeGauge(name: "foo", labels: [("bar", "baz")]) + await withTaskGroup(of: Void.self){ group in + for _ in 0..<100_000 { + group.addTask { + gauge.set(to: Double.random(in: 0..<20)) + } + } + } + } + + func testIncByFromMultipleTasks() async { + let client = PrometheusCollectorRegistry() + let gauge = client.makeGauge(name: "foo", labels: [("bar", "baz")]) + await withTaskGroup(of: Void.self){ group in + for _ in 0..<100_000 { + group.addTask { + gauge.increment(by: Double.random(in: 0..<1)) + } + } + } + } +} + diff --git a/Tests/PrometheusTests/HistogramTests.swift b/Tests/PrometheusTests/HistogramTests.swift new file mode 100644 index 0000000..9928189 --- /dev/null +++ b/Tests/PrometheusTests/HistogramTests.swift @@ -0,0 +1,298 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import Prometheus + +final class HistogramTests: XCTestCase { + func testHistogramWithoutDimensions() { + let client = PrometheusCollectorRegistry() + let histogram = client.makeDurationHistogram(name: "foo", labels: [], buckets: [ + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + .seconds(1), + ]) + + var buffer = [UInt8]() + client.emit(into: &buffer) + + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="0.1"} 0 + foo_bucket{le="0.25"} 0 + foo_bucket{le="0.5"} 0 + foo_bucket{le="1.0"} 0 + foo_bucket{le="+Inf"} 0 + foo_sum 0.0 + foo_count 0 + + """ + ) + + // Record 400ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(400_000_000) // 400ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="0.1"} 0 + foo_bucket{le="0.25"} 0 + foo_bucket{le="0.5"} 1 + foo_bucket{le="1.0"} 1 + foo_bucket{le="+Inf"} 1 + foo_sum 0.4 + foo_count 1 + + """ + ) + + // Record 600ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(600_000_000) // 600ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="0.1"} 0 + foo_bucket{le="0.25"} 0 + foo_bucket{le="0.5"} 1 + foo_bucket{le="1.0"} 2 + foo_bucket{le="+Inf"} 2 + foo_sum 1.0 + foo_count 2 + + """ + ) + + // Record 1200ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(1_200_000_000) // 1200ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="0.1"} 0 + foo_bucket{le="0.25"} 0 + foo_bucket{le="0.5"} 1 + foo_bucket{le="1.0"} 2 + foo_bucket{le="+Inf"} 3 + foo_sum 2.2 + foo_count 3 + + """ + ) + + // Record 80ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(80_000_000) // 80ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="0.1"} 1 + foo_bucket{le="0.25"} 1 + foo_bucket{le="0.5"} 2 + foo_bucket{le="1.0"} 3 + foo_bucket{le="+Inf"} 4 + foo_sum 2.28 + foo_count 4 + + """ + ) + } + + func testHistogramWithOneDimension() { + let client = PrometheusCollectorRegistry() + let histogram = client.makeDurationHistogram(name: "foo", labels: [("bar", "baz")], buckets: [ + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + .seconds(1), + ]) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="0.1"} 0 + foo_bucket{bar="baz",le="0.25"} 0 + foo_bucket{bar="baz",le="0.5"} 0 + foo_bucket{bar="baz",le="1.0"} 0 + foo_bucket{bar="baz",le="+Inf"} 0 + foo_sum{bar="baz"} 0.0 + foo_count{bar="baz"} 0 + + """ + ) + + // Record 400ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(400_000_000) // 400ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="0.1"} 0 + foo_bucket{bar="baz",le="0.25"} 0 + foo_bucket{bar="baz",le="0.5"} 1 + foo_bucket{bar="baz",le="1.0"} 1 + foo_bucket{bar="baz",le="+Inf"} 1 + foo_sum{bar="baz"} 0.4 + foo_count{bar="baz"} 1 + + """ + ) + + // Record 600ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(600_000_000) // 600ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="0.1"} 0 + foo_bucket{bar="baz",le="0.25"} 0 + foo_bucket{bar="baz",le="0.5"} 1 + foo_bucket{bar="baz",le="1.0"} 2 + foo_bucket{bar="baz",le="+Inf"} 2 + foo_sum{bar="baz"} 1.0 + foo_count{bar="baz"} 2 + + """ + ) + + // Record 1200ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(1_200_000_000) // 1200ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="0.1"} 0 + foo_bucket{bar="baz",le="0.25"} 0 + foo_bucket{bar="baz",le="0.5"} 1 + foo_bucket{bar="baz",le="1.0"} 2 + foo_bucket{bar="baz",le="+Inf"} 3 + foo_sum{bar="baz"} 2.2 + foo_count{bar="baz"} 3 + + """ + ) + + // Record 80ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(80_000_000) // 80ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="0.1"} 1 + foo_bucket{bar="baz",le="0.25"} 1 + foo_bucket{bar="baz",le="0.5"} 2 + foo_bucket{bar="baz",le="1.0"} 3 + foo_bucket{bar="baz",le="+Inf"} 4 + foo_sum{bar="baz"} 2.28 + foo_count{bar="baz"} 4 + + """ + ) + } + + func testHistogramWithTwoDimension() { + let client = PrometheusCollectorRegistry() + let histogram = client.makeDurationHistogram(name: "foo", labels: [("bar", "baz"), ("abc", "xyz")], buckets: [ + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + .seconds(1), + ]) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",abc="xyz",le="0.1"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.25"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.5"} 0 + foo_bucket{bar="baz",abc="xyz",le="1.0"} 0 + foo_bucket{bar="baz",abc="xyz",le="+Inf"} 0 + foo_sum{bar="baz",abc="xyz"} 0.0 + foo_count{bar="baz",abc="xyz"} 0 + + """ + ) + + // Record 400ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(400_000_000) // 400ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",abc="xyz",le="0.1"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.25"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.5"} 1 + foo_bucket{bar="baz",abc="xyz",le="1.0"} 1 + foo_bucket{bar="baz",abc="xyz",le="+Inf"} 1 + foo_sum{bar="baz",abc="xyz"} 0.4 + foo_count{bar="baz",abc="xyz"} 1 + + """ + ) + + // Record 600ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(600_000_000) // 600ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",abc="xyz",le="0.1"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.25"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.5"} 1 + foo_bucket{bar="baz",abc="xyz",le="1.0"} 2 + foo_bucket{bar="baz",abc="xyz",le="+Inf"} 2 + foo_sum{bar="baz",abc="xyz"} 1.0 + foo_count{bar="baz",abc="xyz"} 2 + + """ + ) + + // Record 1200ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(1_200_000_000) // 1200ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",abc="xyz",le="0.1"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.25"} 0 + foo_bucket{bar="baz",abc="xyz",le="0.5"} 1 + foo_bucket{bar="baz",abc="xyz",le="1.0"} 2 + foo_bucket{bar="baz",abc="xyz",le="+Inf"} 3 + foo_sum{bar="baz",abc="xyz"} 2.2 + foo_count{bar="baz",abc="xyz"} 3 + + """ + ) + + // Record 80ms + buffer.removeAll(keepingCapacity: true) + histogram.recordNanoseconds(80_000_000) // 80ms + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",abc="xyz",le="0.1"} 1 + foo_bucket{bar="baz",abc="xyz",le="0.25"} 1 + foo_bucket{bar="baz",abc="xyz",le="0.5"} 2 + foo_bucket{bar="baz",abc="xyz",le="1.0"} 3 + foo_bucket{bar="baz",abc="xyz",le="+Inf"} 4 + foo_sum{bar="baz",abc="xyz"} 2.28 + foo_count{bar="baz",abc="xyz"} 4 + + """ + ) + } +} diff --git a/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift b/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift new file mode 100644 index 0000000..6766a5e --- /dev/null +++ b/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import Prometheus + +final class PrometheusCollectorRegistryTests: XCTestCase { + func testAskingForTheSameCounterReturnsTheSameCounter() { + let client = PrometheusCollectorRegistry() + let counter1 = client.makeCounter(name: "foo") + let counter2 = client.makeCounter(name: "foo") + + XCTAssert(counter1 === counter2) + counter1.increment() + counter2.increment(by: Int64(2)) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo 3 + + """ + ) + } + + func testAskingForTheSameCounterWithLabelsReturnsTheSameCounter() { + let client = PrometheusCollectorRegistry() + let counter1 = client.makeCounter(name: "foo", labels: [("bar", "baz")]) + let counter2 = client.makeCounter(name: "foo", labels: [("bar", "baz")]) + + XCTAssert(counter1 === counter2) + counter1.increment() + counter2.increment(by: Int64(2)) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 3 + + """ + ) + } + + func testAskingForTheSameCounterWithDifferentLabelsReturnsTheDifferentCounters() { + let client = PrometheusCollectorRegistry() + let counter1 = client.makeCounter(name: "foo", labels: [("bar", "baz")]) + let counter2 = client.makeCounter(name: "foo", labels: [("bar", "xyz")]) + + XCTAssert(counter1 !== counter2) + counter1.increment() + counter2.increment(by: Int64(2)) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let output = String(decoding: buffer, as: Unicode.UTF8.self) + XCTAssert(output.hasPrefix("# TYPE foo counter\n")) + XCTAssert(output.contains(#"foo{bar="baz"} 1\#n"#)) + XCTAssert(output.contains(#"foo{bar="xyz"} 2\#n"#)) + } + + + func testAskingForTheSameGaugeReturnsTheSameGauge() { + let client = PrometheusCollectorRegistry() + let gauge1 = client.makeGauge(name: "foo") + let gauge2 = client.makeGauge(name: "foo") + + XCTAssert(gauge1 === gauge2) + + gauge1.increment(by: 1) + gauge2.increment(by: 2) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo 3.0 + + """ + ) + } + + func testAskingForTheSameGaugeWithLabelsReturnsTheSameGauge() { + let client = PrometheusCollectorRegistry() + let gauge1 = client.makeGauge(name: "foo", labels: [("bar", "baz")]) + let gauge2 = client.makeGauge(name: "foo", labels: [("bar", "baz")]) + + XCTAssert(gauge1 === gauge2) + + gauge1.increment(by: 1) + gauge2.increment(by: 2) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo gauge + foo{bar="baz"} 3.0 + + """ + ) + } + + + func testAskingForTheSameTimeHistogramReturnsTheSameTimeHistogram() { + let client = PrometheusCollectorRegistry() + let histogram1 = client.makeDurationHistogram(name: "foo", buckets: [.seconds(1), .seconds(2), .seconds(3)]) + let histogram2 = client.makeDurationHistogram(name: "foo", buckets: [.seconds(1), .seconds(2), .seconds(3)]) + + XCTAssert(histogram1 === histogram2) + histogram1.record(.milliseconds(2500)) + histogram2.record(.milliseconds(1500)) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="1.0"} 0 + foo_bucket{le="2.0"} 1 + foo_bucket{le="3.0"} 2 + foo_bucket{le="+Inf"} 2 + foo_sum 4.0 + foo_count 2 + + """ + ) + } + + func testAskingForTheSameTimeHistogramWithLabelsReturnsTheSameTimeHistogram() { + let client = PrometheusCollectorRegistry() + let histogram1 = client.makeDurationHistogram(name: "foo", labels: [("bar", "baz")], buckets: [.seconds(1), .seconds(2), .seconds(3)]) + let histogram2 = client.makeDurationHistogram(name: "foo", labels: [("bar", "baz")], buckets: [.seconds(1), .seconds(2), .seconds(3)]) + + XCTAssert(histogram1 === histogram2) + histogram1.record(.milliseconds(2500)) + histogram2.record(.milliseconds(1500)) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="1.0"} 0 + foo_bucket{bar="baz",le="2.0"} 1 + foo_bucket{bar="baz",le="3.0"} 2 + foo_bucket{bar="baz",le="+Inf"} 2 + foo_sum{bar="baz"} 4.0 + foo_count{bar="baz"} 2 + + """ + ) + } + + + func testAskingForTheSameValueHistogramReturnsTheSameTimeHistogram() { + let client = PrometheusCollectorRegistry() + let histogram1 = client.makeValueHistogram(name: "foo", buckets: [1, 2, 3]) + let histogram2 = client.makeValueHistogram(name: "foo", buckets: [1, 2, 3]) + + XCTAssert(histogram1 === histogram2) + histogram1.record(2.5) + histogram2.record(1.5) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{le="1.0"} 0 + foo_bucket{le="2.0"} 1 + foo_bucket{le="3.0"} 2 + foo_bucket{le="+Inf"} 2 + foo_sum 4.0 + foo_count 2 + + """ + ) + } + + func testAskingForTheSameValueHistogramWithLabelsReturnsTheSameTimeHistogram() { + let client = PrometheusCollectorRegistry() + let histogram1 = client.makeValueHistogram(name: "foo", labels: [("bar", "baz")], buckets: [1, 2, 3]) + let histogram2 = client.makeValueHistogram(name: "foo", labels: [("bar", "baz")], buckets: [1, 2, 3]) + + XCTAssert(histogram1 === histogram2) + histogram1.record(2.5) + histogram2.record(1.5) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo histogram + foo_bucket{bar="baz",le="1.0"} 0 + foo_bucket{bar="baz",le="2.0"} 1 + foo_bucket{bar="baz",le="3.0"} 2 + foo_bucket{bar="baz",le="+Inf"} 2 + foo_sum{bar="baz"} 4.0 + foo_count{bar="baz"} 2 + + """ + ) + } + +} diff --git a/Tests/PrometheusTests/PrometheusMetricsFactoryTests.swift b/Tests/PrometheusTests/PrometheusMetricsFactoryTests.swift new file mode 100644 index 0000000..0982e25 --- /dev/null +++ b/Tests/PrometheusTests/PrometheusMetricsFactoryTests.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2023 SwiftPrometheus project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftPrometheus project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest +import Prometheus + +final class PrometheusMetricsFactoryTests: XCTestCase { + func testMakeTimers() { + let client = PrometheusCollectorRegistry() + let factory = PrometheusMetricsFactory(client: client) + + let timer = factory.makeTimer(label: "foo", dimensions: [("bar", "baz")]) + XCTAssertNotNil(timer as? Histogram) + } + + func testMakeRecorders() { + let client = PrometheusCollectorRegistry() + let factory = PrometheusMetricsFactory(client: client) + + let maybeGauge = factory.makeRecorder(label: "foo", dimensions: [("bar", "baz")], aggregate: false) + XCTAssertNotNil(maybeGauge as? Gauge) + + let maybeRecorder = factory.makeRecorder(label: "bar", dimensions: [], aggregate: true) + XCTAssertNotNil(maybeRecorder as? Histogram) + } + + func testMakeCounters() { + let client = PrometheusCollectorRegistry() + let factory = PrometheusMetricsFactory(client: client) + + let maybeCounter = factory.makeCounter(label: "foo", dimensions: [("bar", "baz")]) + XCTAssertNotNil(maybeCounter as? Counter) + + let maybeFloatingPointCounter = factory.makeFloatingPointCounter(label: "foo", dimensions: [("bar", "baz")]) + XCTAssertNotNil(maybeFloatingPointCounter as? Counter) + + XCTAssert(maybeCounter === maybeFloatingPointCounter) + + maybeCounter.increment(by: 1) + maybeFloatingPointCounter.increment(by: 2.5) + + var buffer = [UInt8]() + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + foo{bar="baz"} 3.5 + + """ + ) + + factory.destroyCounter(maybeCounter) + buffer.removeAll(keepingCapacity: true) + client.emit(into: &buffer) + XCTAssertEqual(String(decoding: buffer, as: Unicode.UTF8.self), """ + # TYPE foo counter + + """ + ) + } +} diff --git a/Tests/SwiftPrometheusTests/BucketsTests.swift b/Tests/SwiftPrometheusTests/BucketsTests.swift deleted file mode 100644 index e00425a..0000000 --- a/Tests/SwiftPrometheusTests/BucketsTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -import Prometheus -class BucketsTests: XCTestCase { - func testExponentialDoesNotThrow() { - let buckets = Buckets.exponential(start: 1, factor: 2, count: 4) - XCTAssertEqual([1.0, 2.0, 4.0, 8.0, Double.greatestFiniteMagnitude], buckets.buckets) - } - - func testLinearDoesNotThrow() { - let buckets = Buckets.linear(start: 1, width: 20, count: 4) - XCTAssertEqual([1, 21, 41, 61, Double.greatestFiniteMagnitude], buckets.buckets) - } -} diff --git a/Tests/SwiftPrometheusTests/GaugeTests.swift b/Tests/SwiftPrometheusTests/GaugeTests.swift deleted file mode 100644 index 4bfa72f..0000000 --- a/Tests/SwiftPrometheusTests/GaugeTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import XCTest -import NIO -@testable import Prometheus -@testable import CoreMetrics - -final class GaugeTests: XCTestCase { - let baseLabels = DimensionLabels([("myValue", "labels")]) - var prom: PrometheusClient! - var group: EventLoopGroup! - var eventLoop: EventLoop { - return group.next() - } - - override func setUp() { - self.prom = PrometheusClient() - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - } - - override func tearDown() { - self.prom = nil - try! self.group.syncShutdownGracefully() - } - - func testGaugeSwiftMetrics() { - let gauge = Gauge(label: "my_gauge") - - gauge.record(10) - gauge.record(12) - gauge.record(20) - - let gaugeTwo = Gauge(label: "my_gauge", dimensions: [("myValue", "labels")]) - gaugeTwo.record(10) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_gauge gauge - my_gauge 20.0 - my_gauge{myValue=\"labels\"} 10.0\n - """) - } - - func testGaugeTime() { - let gauge = prom.createGauge(forType: Double.self, named: "my_gauge") - let delay = 0.05 - gauge.time { - Thread.sleep(forTimeInterval: delay) - } - // Using starts(with:) here since the exact subseconds might differ per-test. - XCTAssert(gauge.collect().starts(with: """ - # TYPE my_gauge gauge - my_gauge \(isCITestRun ? "" : "0.05") - """)) - } - - func testGaugeStandalone() { - let gauge = prom.createGauge(forType: Int.self, named: "my_gauge", helpText: "Gauge for testing", initialValue: 10) - XCTAssertEqual(gauge.get(), 10) - gauge.inc(10) - XCTAssertEqual(gauge.get(), 20) - gauge.dec(12) - XCTAssertEqual(gauge.get(), 8) - gauge.set(20) - gauge.inc(10, baseLabels) - XCTAssertEqual(gauge.get(), 20) - XCTAssertEqual(gauge.get(baseLabels), 20) - - let gaugeTwo = prom.createGauge(forType: Int.self, named: "my_gauge", helpText: "Gauge for testing", initialValue: 10) - XCTAssertEqual(gaugeTwo.get(), 20) - gaugeTwo.inc() - XCTAssertEqual(gauge.get(), 21) - XCTAssertEqual(gaugeTwo.get(), 21) - - XCTAssertEqual(gauge.collect(), """ - # HELP my_gauge Gauge for testing - # TYPE my_gauge gauge - my_gauge 21 - my_gauge{myValue="labels"} 20 - """) - } -} diff --git a/Tests/SwiftPrometheusTests/HistogramTests.swift b/Tests/SwiftPrometheusTests/HistogramTests.swift deleted file mode 100644 index 443f72f..0000000 --- a/Tests/SwiftPrometheusTests/HistogramTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -import XCTest -import NIO -@testable import Prometheus -@testable import CoreMetrics - -final class HistogramTests: XCTestCase { - let baseLabels = DimensionLabels([("myValue", "labels")]) - - var prom: PrometheusClient! - var group: EventLoopGroup! - var eventLoop: EventLoop { - return group.next() - } - - override func setUp() { - self.prom = PrometheusClient() - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - } - - override func tearDown() { - self.prom = nil - try! self.group.syncShutdownGracefully() - } - - func testConcurrent() throws { - let prom = PrometheusClient() - let histogram = prom.createHistogram(forType: Double.self, named: "my_histogram", - helpText: "Histogram for testing", - buckets: Buckets.exponential(start: 1, factor: 2, count: 63)) - let elg = MultiThreadedEventLoopGroup(numberOfThreads: 8) - let semaphore = DispatchSemaphore(value: 0) - _ = elg.next().submit { - for _ in 1...1_000 { - let labels = DimensionLabels([("myValue", "1")]) - let labels2 = DimensionLabels([("myValue", "2")]) - - histogram.observe(1.0, labels) - histogram.observe(1.0, labels2) - } - semaphore.signal() - } - _ = elg.next().submit { - for _ in 1...1_000 { - let labels = DimensionLabels([("myValue", "1")]) - let labels2 = DimensionLabels([("myValue", "2")]) - - histogram.observe(1.0, labels2) - histogram.observe(1.0, labels) - } - semaphore.signal() - } - semaphore.wait() - semaphore.wait() - try elg.syncShutdownGracefully() - - let output = histogram.collect() - XCTAssertTrue(output.contains("my_histogram_count 4000.0")) - XCTAssertTrue(output.contains("my_histogram_sum 4000.0")) - - XCTAssertTrue(output.contains(#"my_histogram_count{myValue="1"} 2000.0"#)) - XCTAssertTrue(output.contains(#"my_histogram_sum{myValue="1"} 2000.0"#)) - - XCTAssertTrue(output.contains(#"my_histogram_count{myValue="2"} 2000.0"#)) - XCTAssertTrue(output.contains(#"my_histogram_sum{myValue="2"} 2000.0"#)) - } - - func testHistogramSwiftMetrics() { - let recorder = Recorder(label: "my_histogram") - recorder.record(1) - recorder.record(2) - recorder.record(3) - - let recorderTwo = Recorder(label: "my_histogram", dimensions: [("myValue", "labels")]) - recorderTwo.record(3) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_histogram histogram - my_histogram_bucket{le="0.005"} 0.0 - my_histogram_bucket{le="0.01"} 0.0 - my_histogram_bucket{le="0.025"} 0.0 - my_histogram_bucket{le="0.05"} 0.0 - my_histogram_bucket{le="0.1"} 0.0 - my_histogram_bucket{le="0.25"} 0.0 - my_histogram_bucket{le="0.5"} 0.0 - my_histogram_bucket{le="1.0"} 1.0 - my_histogram_bucket{le="2.5"} 2.0 - my_histogram_bucket{le="5.0"} 4.0 - my_histogram_bucket{le="10.0"} 4.0 - my_histogram_bucket{le="+Inf"} 4.0 - my_histogram_count 4.0 - my_histogram_sum 9.0 - my_histogram_bucket{myValue="labels", le="0.005"} 0.0 - my_histogram_bucket{myValue="labels", le="0.01"} 0.0 - my_histogram_bucket{myValue="labels", le="0.025"} 0.0 - my_histogram_bucket{myValue="labels", le="0.05"} 0.0 - my_histogram_bucket{myValue="labels", le="0.1"} 0.0 - my_histogram_bucket{myValue="labels", le="0.25"} 0.0 - my_histogram_bucket{myValue="labels", le="0.5"} 0.0 - my_histogram_bucket{myValue="labels", le="1.0"} 0.0 - my_histogram_bucket{myValue="labels", le="2.5"} 0.0 - my_histogram_bucket{myValue="labels", le="5.0"} 1.0 - my_histogram_bucket{myValue="labels", le="10.0"} 1.0 - my_histogram_bucket{myValue="labels", le="+Inf"} 1.0 - my_histogram_count{myValue="labels"} 1.0 - my_histogram_sum{myValue="labels"} 3.0\n - """) - } - - func testHistogramTime() { - let histogram = prom.createHistogram(forType: Double.self, named: "my_histogram") - let delay = 0.05 - histogram.time { - Thread.sleep(forTimeInterval: delay) - } - // Using `contains` here since the exact subseconds might differ per-test, and CI runners can vary even more. - XCTAssert(histogram.collect().contains(""" - my_histogram_bucket{le="1.0"} 1.0 - my_histogram_bucket{le="2.5"} 1.0 - my_histogram_bucket{le="5.0"} 1.0 - my_histogram_bucket{le="10.0"} 1.0 - my_histogram_bucket{le="+Inf"} 1.0 - my_histogram_count 1.0 - my_histogram_sum - """)) - } - - func testHistogramStandalone() { - let histogram = prom.createHistogram(forType: Double.self, named: "my_histogram", helpText: "Histogram for testing", buckets: [0.5, 1, 2, 3, 5, Double.greatestFiniteMagnitude]) - let histogramTwo = prom.createHistogram(forType: Double.self, named: "my_histogram", helpText: "Histogram for testing", buckets: [0.5, 1, 2, 3, 5, Double.greatestFiniteMagnitude]) - - histogram.observe(1) - histogram.observe(2) - histogramTwo.observe(3) - - histogram.observe(3, baseLabels) - - XCTAssertEqual(histogram.collect(), """ - # HELP my_histogram Histogram for testing - # TYPE my_histogram histogram - my_histogram_bucket{le="0.5"} 0.0 - my_histogram_bucket{le="1.0"} 1.0 - my_histogram_bucket{le="2.0"} 2.0 - my_histogram_bucket{le="3.0"} 4.0 - my_histogram_bucket{le="5.0"} 4.0 - my_histogram_bucket{le="+Inf"} 4.0 - my_histogram_count 4.0 - my_histogram_sum 9.0 - my_histogram_bucket{myValue="labels", le="0.5"} 0.0 - my_histogram_bucket{myValue="labels", le="1.0"} 0.0 - my_histogram_bucket{myValue="labels", le="2.0"} 0.0 - my_histogram_bucket{myValue="labels", le="3.0"} 1.0 - my_histogram_bucket{myValue="labels", le="5.0"} 1.0 - my_histogram_bucket{myValue="labels", le="+Inf"} 1.0 - my_histogram_count{myValue="labels"} 1.0 - my_histogram_sum{myValue="labels"} 3.0 - """) - } -} diff --git a/Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift b/Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift deleted file mode 100644 index 598d864..0000000 --- a/Tests/SwiftPrometheusTests/PrometheusMetricsTests.swift +++ /dev/null @@ -1,227 +0,0 @@ -import XCTest -import NIO -@testable import Prometheus -@testable import CoreMetrics - -final class PrometheusMetricsTests: XCTestCase { - - var prom: PrometheusClient! - var group: EventLoopGroup! - var eventLoop: EventLoop { - return group.next() - } - - override func setUp() { - self.prom = PrometheusClient() - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - } - - override func tearDown() { - self.prom = nil - try! self.group.syncShutdownGracefully() - } - - func testGetPrometheus() { - MetricsSystem.bootstrapInternal(NOOPMetricsHandler.instance) - XCTAssertThrowsError(try MetricsSystem.prometheus()) - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: self.prom)) - XCTAssertNoThrow(try MetricsSystem.prometheus()) - } - - func testCounter() { - let counter = Counter(label: "my_counter") - counter.increment(by: 10) - let counterTwo = Counter(label: "my_counter", dimensions: [("myValue", "labels")]) - counterTwo.increment(by: 10) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_counter counter - my_counter 10 - my_counter{myValue=\"labels\"} 10\n - """) - } - - func testFloatingPointCounter() { - let counter = FloatingPointCounter(label: "my_fp_counter") - counter.increment(by: 3.5) - let counterTwo = FloatingPointCounter(label: "my_fp_counter", dimensions: [("myValue", "labels")]) - counterTwo.increment(by: 10.4) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_fp_counter counter - my_fp_counter 3.5 - my_fp_counter{myValue=\"labels\"} 10.4\n - """) - } - - func testMetricDestroying() { - let counter = Counter(label: "my_counter") - counter.increment() - counter.destroy() - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), "") - } - - func testCollectIntoBuffer() { - let counter = Counter(label: "my_counter") - counter.increment(by: 10) - let counterTwo = Counter(label: "my_counter", dimensions: [("myValue", "labels")]) - counterTwo.increment(by: 10) - - let promise = self.eventLoop.makePromise(of: ByteBuffer.self) - prom.collect(promise.succeed) - var buffer = try! promise.futureResult.wait() - - XCTAssertEqual(buffer.readString(length: buffer.readableBytes), """ - # TYPE my_counter counter - my_counter 10 - my_counter{myValue=\"labels\"} 10\n - """) - } - - func testEmptyCollectIsConsistent() throws { - let promise = self.eventLoop.makePromise(of: ByteBuffer.self) - prom.collect(promise.succeed) - var buffer = try promise.futureResult.wait() - - let stringPromise = self.eventLoop.makePromise(of: String.self) - prom.collect(stringPromise.succeed) - let collectedToString = try stringPromise.futureResult.wait() - - let collectedToBuffer = buffer.readString(length: buffer.readableBytes) - XCTAssertEqual(collectedToBuffer, "") - XCTAssertEqual(collectedToBuffer, collectedToString) - } - - func testCollectIsConsistent() throws { - let counter = Counter(label: "my_counter") - counter.increment(by: 10) - let counterTwo = Counter(label: "my_counter", dimensions: [("myValue", "labels")]) - counterTwo.increment(by: 10) - - let promise = self.eventLoop.makePromise(of: ByteBuffer.self) - prom.collect(promise.succeed) - var buffer = try promise.futureResult.wait() - - let stringPromise = self.eventLoop.makePromise(of: String.self) - prom.collect(stringPromise.succeed) - let collectedToString = try stringPromise.futureResult.wait() - - let collectedToBuffer = buffer.readString(length: buffer.readableBytes) - XCTAssertEqual(collectedToBuffer, """ - # TYPE my_counter counter - my_counter 10 - my_counter{myValue=\"labels\"} 10\n - """) - XCTAssertEqual(collectedToBuffer, collectedToString) - } - - func testCollectAFewMetricsIntoBuffer() throws { - let counter = Counter(label: "my_counter") - counter.increment(by: 10) - let counterA = Counter(label: "my_counter", dimensions: [("a", "aaa"), ("x", "x")]) - counterA.increment(by: 4) - let gauge = Gauge(label: "my_gauge") - gauge.record(100) - - let promise = self.eventLoop.makePromise(of: ByteBuffer.self) - prom.collect(promise.succeed) - var buffer = try promise.futureResult.wait() - - let collected = buffer.readString(length: buffer.readableBytes)! - - // We can't guarantee order so check the output contains the expected metrics. - XCTAssertTrue(collected.contains(""" - # TYPE my_counter counter - my_counter 10 - my_counter{x="x", a="aaa"} 4 - """)) - - XCTAssertTrue(collected.contains(""" - # TYPE my_gauge gauge - my_gauge 100.0 - """)) - } - - func testCollectAFewMetricsIntoString() { - let counter = Counter(label: "my_counter") - counter.increment(by: 10) - let counterA = Counter(label: "my_counter", dimensions: [("a", "aaa"), ("x", "x")]) - counterA.increment(by: 4) - let gauge = Gauge(label: "my_gauge") - gauge.record(100) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - let collected = try! promise.futureResult.wait() - - // We can't guarantee order so check the output contains the expected metrics. - XCTAssertTrue(collected.contains(""" - # TYPE my_counter counter - my_counter 10 - my_counter{x="x", a="aaa"} 4 - """)) - - XCTAssertTrue(collected.contains(""" - # TYPE my_gauge gauge - my_gauge 100.0 - """)) - } - - func testHistogramBackedTimer() { - let prom = PrometheusClient() - var config = PrometheusMetricsFactory.Configuration() - config.timerImplementation = .histogram() - let metricsFactory = PrometheusMetricsFactory(client: prom, configuration: config) - metricsFactory.makeTimer(label: "duration_nanos", dimensions: []).recordNanoseconds(1) - guard let histogram: PromHistogram = prom.getMetricInstance(with: "duration_nanos", andType: .histogram) else { - XCTFail("Timer should be backed by Histogram") - return - } - let result = histogram.collect() - let buckets = result.split(separator: "\n").filter { $0.contains("duration_nanos_bucket") } - XCTAssertFalse(buckets.isEmpty, "default histogram backed timer buckets") - } - - func testDestroyHistogramTimer() { - let prom = PrometheusClient() - var config = PrometheusMetricsFactory.Configuration() - config.timerImplementation = .histogram() - let metricsFactory = PrometheusMetricsFactory(client: prom, configuration: config) - let timer = metricsFactory.makeTimer(label: "duration_nanos", dimensions: []) - timer.recordNanoseconds(1) - metricsFactory.destroyTimer(timer) - let histogram: PromHistogram? = prom.getMetricInstance(with: "duration_nanos", andType: .histogram) - XCTAssertNil(histogram) - } - func testDestroySummaryTimer() { - let prom = PrometheusClient() - var config = PrometheusMetricsFactory.Configuration() - config.timerImplementation = .summary() - let metricsFactory = PrometheusMetricsFactory(client: prom) - let timer = metricsFactory.makeTimer(label: "duration_nanos", dimensions: []) - timer.recordNanoseconds(1) - metricsFactory.destroyTimer(timer) - let summary: PromSummary? = prom.getMetricInstance(with: "duration_nanos", andType: .summary) - XCTAssertNil(summary) - } - - func testDimensionLabelEquality() { - let labelsA = DimensionLabels([("a", "a")]) - let labelsB = DimensionLabels([("b", "b")]) - let labelsATwo = DimensionLabels([("a", "a")]) - - XCTAssertEqual(labelsA, labelsATwo) - XCTAssertNotEqual(labelsA, labelsB) - XCTAssertNotEqual(labelsATwo, labelsB) - } -} diff --git a/Tests/SwiftPrometheusTests/SanitizerTests.swift b/Tests/SwiftPrometheusTests/SanitizerTests.swift deleted file mode 100644 index 9fb2965..0000000 --- a/Tests/SwiftPrometheusTests/SanitizerTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import XCTest -import NIO -@testable import Prometheus -@testable import CoreMetrics - -final class SanitizerTests: XCTestCase { - - var group: EventLoopGroup! - var eventLoop: EventLoop { - return group.next() - } - - override func setUp() { - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - } - - override func tearDown() { - try! self.group.syncShutdownGracefully() - } - - func testDefaultSanitizer() throws { - let sanitizer = PrometheusLabelSanitizer() - - XCTAssertEqual(sanitizer.sanitize("MyMetrics.RequestDuration"), "mymetrics_requestduration") - XCTAssertEqual(sanitizer.sanitize("My-Metrics.request-Duration"), "my_metrics_request_duration") - } - - func testCustomSanitizer() throws { - struct Sanitizer: LabelSanitizer { - func sanitize(_ label: String) -> String { - return String(label.reversed()) - } - } - - let sanitizer = Sanitizer() - XCTAssertEqual(sanitizer.sanitize("MyMetrics.RequestDuration"), "noitaruDtseuqeR.scirteMyM") - } - - func testIntegratedSanitizer() throws { - let prom = PrometheusClient() - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - - CoreMetrics.Counter(label: "Test.Counter").increment(by: 10) - - let promise = eventLoop.makePromise(of: String.self) - prom.collect(into: promise) - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE test_counter counter - test_counter 10\n - """) - } - - func testIntegratedSanitizerForDimensions() throws { - let prom = PrometheusClient() - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - - let dimensions: [(String, String)] = [("invalid-service.dimension", "something")] - CoreMetrics.Counter(label: "dimensions_total", dimensions: dimensions).increment() - - let promise = eventLoop.makePromise(of: String.self) - prom.collect(into: promise) - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE dimensions_total counter - dimensions_total 0 - dimensions_total{invalid_service_dimension="something"} 1\n - """) - } -} diff --git a/Tests/SwiftPrometheusTests/SummaryTests.swift b/Tests/SwiftPrometheusTests/SummaryTests.swift deleted file mode 100644 index b93a65a..0000000 --- a/Tests/SwiftPrometheusTests/SummaryTests.swift +++ /dev/null @@ -1,185 +0,0 @@ -import XCTest -import NIO -@testable import Prometheus -@testable import CoreMetrics - -final class SummaryTests: XCTestCase { - let baseLabels = DimensionLabels([("myValue", "labels")]) - var prom: PrometheusClient! - var group: EventLoopGroup! - var eventLoop: EventLoop { - return group.next() - } - - override func setUp() { - self.prom = PrometheusClient() - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - MetricsSystem.bootstrapInternal(PrometheusMetricsFactory(client: prom)) - } - - override func tearDown() { - self.prom = nil - try! self.group.syncShutdownGracefully() - } - - func testSummary() { - let summary = Timer(label: "my_summary") - - summary.recordNanoseconds(1) - summary.recordNanoseconds(2) - summary.recordNanoseconds(4) - summary.recordNanoseconds(10000) - - let summaryTwo = Timer(label: "my_summary", dimensions: [("myValue", "labels")]) - summaryTwo.recordNanoseconds(123) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_summary summary - my_summary{quantile="0.01"} 1.0 - my_summary{quantile="0.05"} 1.0 - my_summary{quantile="0.5"} 4.0 - my_summary{quantile="0.9"} 10000.0 - my_summary{quantile="0.95"} 10000.0 - my_summary{quantile="0.99"} 10000.0 - my_summary{quantile="0.999"} 10000.0 - my_summary_count 5 - my_summary_sum 10130.0 - my_summary{quantile="0.01", myValue="labels"} 123.0 - my_summary{quantile="0.05", myValue="labels"} 123.0 - my_summary{quantile="0.5", myValue="labels"} 123.0 - my_summary{quantile="0.9", myValue="labels"} 123.0 - my_summary{quantile="0.95", myValue="labels"} 123.0 - my_summary{quantile="0.99", myValue="labels"} 123.0 - my_summary{quantile="0.999", myValue="labels"} 123.0 - my_summary_count{myValue="labels"} 1 - my_summary_sum{myValue="labels"} 123.0\n - """) - } - - func testConcurrent() throws { - let prom = PrometheusClient() - let summary = prom.createSummary(forType: Double.self, named: "my_summary", - helpText: "Summary for testing") - let elg = MultiThreadedEventLoopGroup(numberOfThreads: 8) - let semaphore = DispatchSemaphore(value: 2) - _ = elg.next().submit { - for _ in 1...1_000 { - let labels = DimensionLabels([("myValue", "1")]) - let labels2 = DimensionLabels([("myValue", "2")]) - - summary.observe(1.0, labels) - summary.observe(1.0, labels2) - } - semaphore.signal() - } - _ = elg.next().submit { - for _ in 1...1_000 { - let labels = DimensionLabels([("myValue", "1")]) - let labels2 = DimensionLabels([("myValue", "2")]) - - summary.observe(1.0, labels2) - summary.observe(1.0, labels) - } - semaphore.signal() - } - semaphore.wait() - try elg.syncShutdownGracefully() - XCTAssertTrue(summary.collect().contains("my_summary_count 4000.0")) - XCTAssertTrue(summary.collect().contains("my_summary_sum 4000.0")) - } - - func testSummaryWithPreferredDisplayUnit() { - let summary = Timer(label: "my_summary", preferredDisplayUnit: .seconds) - - summary.recordSeconds(1) - summary.recordMilliseconds(2 * 1_000) - summary.recordMicroseconds(3 * 1_000_000) - summary.recordNanoseconds(4 * 1_000_000_000) - summary.recordSeconds(10000) - - let promise = self.eventLoop.makePromise(of: String.self) - prom.collect(promise.succeed) - - XCTAssertEqual(try! promise.futureResult.wait(), """ - # TYPE my_summary summary - my_summary{quantile="0.01"} 1.0 - my_summary{quantile="0.05"} 1.0 - my_summary{quantile="0.5"} 3.0 - my_summary{quantile="0.9"} 10000.0 - my_summary{quantile="0.95"} 10000.0 - my_summary{quantile="0.99"} 10000.0 - my_summary{quantile="0.999"} 10000.0 - my_summary_count 5 - my_summary_sum 10010.0\n - """) - } - - func testSummaryTime() { - let summary = prom.createSummary(forType: Double.self, named: "my_summary", helpText: "Summary for testing", quantiles: [0.5, 0.9, 0.99]) - let delay = 0.05 - summary.time { - Thread.sleep(forTimeInterval: delay) - } - // This setup checks `.startsWith` on a per-line basis - // to prevent issues with subsecond differences per test run - let lines = [ - "# HELP my_summary Summary for testing", - "# TYPE my_summary summary", - #"my_summary{quantile="0.5"} \#(isCITestRun ? "" : "0.05")"#, - #"my_summary{quantile="0.9"} \#(isCITestRun ? "" : "0.05")"#, - #"my_summary{quantile="0.99"} \#(isCITestRun ? "" : "0.05")"#, - #"my_summary_count 1.0"#, - #"my_summary_sum \#(isCITestRun ? "" : "0.05")"# - ] - let collect = summary.collect() - let sections = collect.split(separator: "\n").map(String.init).enumerated().map { i, s in s.starts(with: lines[i]) } - XCTAssert(sections.filter { !$0 }.isEmpty) - } - - func testSummaryStandalone() { - let summary = prom.createSummary(forType: Double.self, named: "my_summary", helpText: "Summary for testing", quantiles: [0.5, 0.9, 0.99]) - let summaryTwo = prom.createSummary(forType: Double.self, named: "my_summary", helpText: "Summary for testing", quantiles: [0.5, 0.9, 0.99]) - - summary.observe(1) - summary.observe(2) - summary.observe(4) - summaryTwo.observe(10000) - - summary.observe(123, baseLabels) - - XCTAssertEqual(summary.collect(), """ - # HELP my_summary Summary for testing - # TYPE my_summary summary - my_summary{quantile=\"0.5\"} 4.0 - my_summary{quantile=\"0.9\"} 10000.0 - my_summary{quantile=\"0.99\"} 10000.0 - my_summary_count 5.0 - my_summary_sum 10130.0 - my_summary{quantile=\"0.5\", myValue=\"labels\"} 123.0 - my_summary{quantile=\"0.9\", myValue=\"labels\"} 123.0 - my_summary{quantile=\"0.99\", myValue=\"labels\"} 123.0 - my_summary_count{myValue=\"labels\"} 1.0 - my_summary_sum{myValue=\"labels\"} 123.0 - """) - } - - func testStandaloneSummaryWithCustomCapacity() { - let capacity = 10 - let summary = prom.createSummary(forType: Double.self, named: "my_summary", helpText: "Summary for testing", capacity: capacity, quantiles: [0.5, 0.99]) - - for i in 0 ..< capacity { summary.observe(Double(i * 1_000)) } - for i in 0 ..< capacity { summary.observe(Double(i)) } - - XCTAssertEqual(summary.collect(), """ - # HELP my_summary Summary for testing - # TYPE my_summary summary - my_summary{quantile="0.5"} 4.5 - my_summary{quantile="0.99"} 9.0 - my_summary_count 20.0 - my_summary_sum 45045.0 - """) - } -} diff --git a/Tests/SwiftPrometheusTests/SwiftPrometheusTests.swift b/Tests/SwiftPrometheusTests/SwiftPrometheusTests.swift deleted file mode 100644 index aec2233..0000000 --- a/Tests/SwiftPrometheusTests/SwiftPrometheusTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -import XCTest -@testable import Prometheus - -var isCITestRun: Bool { - return ProcessInfo.processInfo.environment.contains { k, v in - return k == "CI_RUN" && v == "TRUE" - } -} - -final class SwiftPrometheusTests: XCTestCase { - - let baseLabels = DimensionLabels([("myValue", "labels")]) - - var prom: PrometheusClient! - - override func setUp() { - self.prom = PrometheusClient() - } - - override func tearDown() { - self.prom = nil - } - - func testCounter() { - let counter = prom.createCounter(forType: Int.self, named: "my_counter", helpText: "Counter for testing", initialValue: 10) - XCTAssertEqual(counter.get(), 10) - counter.inc(10) - XCTAssertEqual(counter.get(), 20) - counter.inc(10, baseLabels) - XCTAssertEqual(counter.get(), 20) - XCTAssertEqual(counter.get(baseLabels), 20) - - XCTAssertEqual(counter.collect(), "# HELP my_counter Counter for testing\n# TYPE my_counter counter\nmy_counter 20\nmy_counter{myValue=\"labels\"} 20") - } - - func testMultipleCounter() { - let counter = prom.createCounter(forType: Int.self, named: "my_counter", helpText: "Counter for testing", initialValue: 10) - counter.inc(10) - XCTAssertEqual(counter.get(), 20) - - let counterTwo = prom.createCounter(forType: Int.self, named: "my_counter", helpText: "Counter for testing", initialValue: 10) - counter.inc(10) - XCTAssertEqual(counterTwo.get(), 30) - counterTwo.inc(20, baseLabels) - - XCTAssertEqual(counter.collect(), "# HELP my_counter Counter for testing\n# TYPE my_counter counter\nmy_counter 30\nmy_counter{myValue=\"labels\"} 30") - self.prom.collect { metricsString in - XCTAssertEqual(metricsString, "# HELP my_counter Counter for testing\n# TYPE my_counter counter\nmy_counter 30\nmy_counter{myValue=\"labels\"} 30\n") - } - } -}