diff --git a/Sources/Prometheus/MetricDescriptor.swift b/Sources/Prometheus/MetricDescriptor.swift new file mode 100644 index 0000000..9da5ddc --- /dev/null +++ b/Sources/Prometheus/MetricDescriptor.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftPrometheus open source project +// +// Copyright (c) 2018-2025 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 +// +//===----------------------------------------------------------------------===// + +/// A descriptor that defines the components of a fully qualified Prometheus metric name. +/// +/// The final, underscore-separated metric name is generated by the ``name`` computed property. The ``helpText`` +/// is used to generate the corresponding `# HELP` line in the Prometheus exposition format. +/// - Warning: This initializer will trigger a `preconditionFailure` if ``metricName`` is an empty string. +public struct MetricNameDescriptor { + /// An optional top-level namespace for the metric. + public let namespace: String? + + /// An optional subsystem to group related metrics. + public let subsystem: String? + + /// The required, descriptive base name of the metric. + public let metricName: String + + /// An optional suffix describing the metric's unit (e.g., `total`). + public let unitName: String? + + /// Optional descriptive text for the metric. + public let helpText: String? + + /// Creates a new ``MetricNameDescriptor`` that defines the components of a fully qualified Prometheus metric name. + /// + /// - Parameter namespace: An optional top-level namespace for the metric. + /// - Parameter subsystem: An optional subsystem to group related metrics within a namespace. + /// - Parameter metricName: The required, descriptive base name of the metric. + /// - Parameter unitName: An optional suffix describing the metric's unit (e.g., `total`). + /// - Parameter helpText: Optional descriptive text for the metric. + public init( + namespace: String? = nil, + subsystem: String? = nil, + metricName: String, + unitName: String? = nil, + helpText: String? = nil + ) { + precondition(!metricName.isEmpty, "metricName must not be empty") + self.namespace = namespace + self.subsystem = subsystem + self.metricName = metricName + self.unitName = unitName + self.helpText = helpText + } + + /// The fully qualified metric name, joining non-empty components with underscores. + public var name: String { + [namespace, subsystem, metricName, unitName] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: "_") + } +} diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift index 4975ab2..3e2802d 100644 --- a/Sources/Prometheus/PrometheusCollectorRegistry.swift +++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift @@ -93,6 +93,18 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``Counter`` collector or returns the already existing one with the same name, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Counter`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeCounter(descriptor: MetricNameDescriptor) -> Counter { + return self.makeCounter(name: descriptor.name) + } + /// 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 @@ -150,6 +162,20 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``Counter`` collector or returns the already existing one with the same name, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Counter`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - 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(descriptor: MetricNameDescriptor, labels: [(String, String)]) -> Counter { + return self.makeCounter(name: descriptor.name, labels: labels) + } + /// 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 @@ -178,6 +204,18 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Gauge`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` + public func makeGauge(descriptor: MetricNameDescriptor) -> Gauge { + return self.makeGauge(name: descriptor.name) + } + /// 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 @@ -235,6 +273,20 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name and labels, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``Gauge`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - 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(descriptor: MetricNameDescriptor, labels: [(String, String)]) -> Gauge { + return self.makeGauge(name: descriptor.name, labels: labels) + } + /// 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 @@ -264,6 +316,19 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``DurationHistogram`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the 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(descriptor: MetricNameDescriptor, buckets: [Duration]) -> DurationHistogram { + return self.makeDurationHistogram(name: descriptor.name, buckets: buckets) + } + /// 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 @@ -338,6 +403,25 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name and labels, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``DurationHistogram`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - 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( + descriptor: MetricNameDescriptor, + labels: [(String, String)], + buckets: [Duration] + ) -> DurationHistogram { + return self.makeDurationHistogram(name: descriptor.name, labels: labels, buckets: buckets) + } + /// 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 @@ -362,6 +446,19 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``ValueHistogram`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the 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(descriptor: MetricNameDescriptor, buckets: [Double]) -> ValueHistogram { + return self.makeValueHistogram(name: descriptor.name, buckets: buckets) + } + /// 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 @@ -408,6 +505,27 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name and labels, + /// based on the provided descriptor. + /// + /// When the ``PrometheusCollectorRegistry/emit(into:)`` is called, metrics from the + /// created ``ValueHistogram`` will be part of the export. + /// + /// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric. + /// - 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( + descriptor: MetricNameDescriptor, + labels: [(String, String)], + buckets: [Double] + ) -> ValueHistogram { + return self.makeValueHistogram(name: descriptor.name, labels: labels, buckets: buckets) + } + + // MARK: - Histogram + // MARK: Destroying Metrics /// Unregisters a ``Counter`` from the ``PrometheusCollectorRegistry``. This means that the provided ``Counter`` diff --git a/Tests/PrometheusTests/CounterTests.swift b/Tests/PrometheusTests/CounterTests.swift index ab78914..fc51908 100644 --- a/Tests/PrometheusTests/CounterTests.swift +++ b/Tests/PrometheusTests/CounterTests.swift @@ -164,4 +164,188 @@ final class CounterTests: XCTestCase { """ ) } + + func testWithMetricNameDescriptorWithFullComponentMatrix() { + // --- Test Constants --- + // let helpTextValue = "https://help.url/sub" + let metricName = "foo2" + let incrementValue: Int64 = 2 + let client = PrometheusCollectorRegistry() + + // 1. Define the base naming combinations first. + let baseNameCases: + [( + namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, + description: String + )] = [ + ( + namespace: "myapp", subsystem: "subsystem", unitName: "total", + expectedMetricName: "myapp_subsystem_foo2_total", description: "All components present" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: nil, + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + ), + ( + namespace: "myapp", subsystem: nil, unitName: "total", expectedMetricName: "myapp_foo2_total", + description: "Subsystem is nil" + ), + ( + namespace: "myapp", subsystem: nil, unitName: nil, expectedMetricName: "myapp_foo2", + description: "Subsystem and Unit are nil" + ), + ( + namespace: nil, subsystem: "subsystem", unitName: "total", + expectedMetricName: "subsystem_foo2_total", description: "Namespace is nil" + ), + ( + namespace: nil, subsystem: "subsystem", unitName: nil, expectedMetricName: "subsystem_foo2", + description: "Namespace and Unit are nil" + ), + ( + namespace: nil, subsystem: nil, unitName: "total", expectedMetricName: "foo2_total", + description: "Namespace and Subsystem are nil" + ), + ( + namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", + description: "Only metric name is present" + ), + ( + namespace: "", subsystem: "subsystem", unitName: "total", + expectedMetricName: "subsystem_foo2_total", description: "Namespace is empty string" + ), + ( + namespace: "myapp", subsystem: "", unitName: "total", expectedMetricName: "myapp_foo2_total", + description: "Subsystem is empty string" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: "", + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is empty string" + ), + ( + namespace: "", subsystem: "", unitName: "total", expectedMetricName: "foo2_total", + description: "Namespace and Subsystem are empty strings" + ), + ( + namespace: "myapp", subsystem: "", unitName: "", expectedMetricName: "myapp_foo2", + description: "Subsystem and Unit are empty strings" + ), + ( + namespace: "", subsystem: "subsystem", unitName: "", expectedMetricName: "subsystem_foo2", + description: "Namespace and Unit are empty strings" + ), + ( + namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", + description: "All optional components are empty strings" + ), + ] + + // 2. Define the label combinations to test. + let labelCases: [(labels: [(String, String)], expectedLabelString: String, description: String)] = [ + (labels: [], expectedLabelString: "", description: "without labels"), + (labels: [("method", "get")], expectedLabelString: "{method=\"get\"}", description: "with one label"), + ( + labels: [("status", "200"), ("path", "/api/v1")], + expectedLabelString: "{status=\"200\",path=\"/api/v1\"}", description: "with two labels" + ), + ] + + // 3. Programmatically generate the final, full matrix by crossing name cases with label cases. + var allTestCases: + [( + descriptor: MetricNameDescriptor, labels: [(String, String)], expectedOutput: String, + failureDescription: String + )] = [] + + for nameCase in baseNameCases { + for labelCase in labelCases { + let expectedMetricLine = + "\(nameCase.expectedMetricName)\(labelCase.expectedLabelString) \(incrementValue)" + + // Case: Without help text (helpText is nil) + allTestCases.append( + ( + descriptor: MetricNameDescriptor( + namespace: nameCase.namespace, + subsystem: nameCase.subsystem, + metricName: metricName, + unitName: nameCase.unitName, + helpText: nil + ), + labels: labelCase.labels, + expectedOutput: """ + # TYPE \(nameCase.expectedMetricName) counter + \(expectedMetricLine) + + """, + failureDescription: "\(nameCase.description), \(labelCase.description)" + ) + ) + } + } + + let expectedTestCaseCount = baseNameCases.count * labelCases.count + XCTAssertEqual( + allTestCases.count, + expectedTestCaseCount, + "Test setup failed: Did not generate the correct number of test cases." + ) + + // 4. Loop through the complete, generated test matrix. + for testCase in allTestCases { + // --- Test 1: The primary `makeCounter` overload with a `labels` parameter --- + // This is tested for all cases, including where the labels array is empty. + let counterWithLabels = client.makeCounter(descriptor: testCase.descriptor, labels: testCase.labels) + counterWithLabels.increment(by: incrementValue) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let actualOutput = String(decoding: buffer, as: Unicode.UTF8.self) + + var failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeCounter(descriptor:labels:) + - Descriptor: \(testCase.descriptor) + - Labels: \(testCase.labels) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput) + --- + """ + XCTAssertEqual(actualOutput, testCase.expectedOutput, failureMessage) + client.unregisterCounter(counterWithLabels) + + // --- Test 2: The convenience `makeCounter` overload without a `labels` parameter --- + // This should only be tested when the label set is empty. + if testCase.labels.isEmpty { + let counterWithoutLabels = client.makeCounter(descriptor: testCase.descriptor) + counterWithoutLabels.increment(by: incrementValue) + + var buffer2 = [UInt8]() + client.emit(into: &buffer2) + let actualOutput2 = String(decoding: buffer2, as: Unicode.UTF8.self) + + failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeCounter(descriptor:) + - Descriptor: \(testCase.descriptor) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput2) + --- + """ + XCTAssertEqual(actualOutput2, testCase.expectedOutput, failureMessage) + client.unregisterCounter(counterWithoutLabels) + } + } + } + } diff --git a/Tests/PrometheusTests/GaugeTests.swift b/Tests/PrometheusTests/GaugeTests.swift index aef6d0d..3dfc5d7 100644 --- a/Tests/PrometheusTests/GaugeTests.swift +++ b/Tests/PrometheusTests/GaugeTests.swift @@ -175,4 +175,187 @@ final class GaugeTests: XCTestCase { } } } + + func testWithMetricNameDescriptorWithFullComponentMatrix() { + // --- Test Constants --- + // let helpTextValue = "https://help.url/sub" + let metricName = "foo2" + let incrementValue: Double = 1.0 + let client = PrometheusCollectorRegistry() + + // 1. Define the base naming combinations first. + let baseNameCases: + [( + namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, + description: String + )] = [ + ( + namespace: "myapp", subsystem: "subsystem", unitName: "bytes", + expectedMetricName: "myapp_subsystem_foo2_bytes", description: "All components present" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: nil, + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + ), + ( + namespace: "myapp", subsystem: nil, unitName: "bytes", expectedMetricName: "myapp_foo2_bytes", + description: "Subsystem is nil" + ), + ( + namespace: "myapp", subsystem: nil, unitName: nil, expectedMetricName: "myapp_foo2", + description: "Subsystem and Unit are nil" + ), + ( + namespace: nil, subsystem: "subsystem", unitName: "bytes", + expectedMetricName: "subsystem_foo2_bytes", description: "Namespace is nil" + ), + ( + namespace: nil, subsystem: "subsystem", unitName: nil, expectedMetricName: "subsystem_foo2", + description: "Namespace and Unit are nil" + ), + ( + namespace: nil, subsystem: nil, unitName: "bytes", expectedMetricName: "foo2_bytes", + description: "Namespace and Subsystem are nil" + ), + ( + namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", + description: "Only metric name is present" + ), + ( + namespace: "", subsystem: "subsystem", unitName: "bytes", + expectedMetricName: "subsystem_foo2_bytes", description: "Namespace is empty string" + ), + ( + namespace: "myapp", subsystem: "", unitName: "bytes", expectedMetricName: "myapp_foo2_bytes", + description: "Subsystem is empty string" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: "", + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is empty string" + ), + ( + namespace: "", subsystem: "", unitName: "bytes", expectedMetricName: "foo2_bytes", + description: "Namespace and Subsystem are empty strings" + ), + ( + namespace: "myapp", subsystem: "", unitName: "", expectedMetricName: "myapp_foo2", + description: "Subsystem and Unit are empty strings" + ), + ( + namespace: "", subsystem: "subsystem", unitName: "", expectedMetricName: "subsystem_foo2", + description: "Namespace and Unit are empty strings" + ), + ( + namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", + description: "All optional components are empty strings" + ), + ] + + // 2. Define the label combinations to test. + let labelCases: [(labels: [(String, String)], expectedLabelString: String, description: String)] = [ + (labels: [], expectedLabelString: "", description: "without labels"), + (labels: [("method", "get")], expectedLabelString: "{method=\"get\"}", description: "with one label"), + ( + labels: [("status", "200"), ("path", "/api/v1")], + expectedLabelString: "{status=\"200\",path=\"/api/v1\"}", description: "with two labels" + ), + ] + + // 3. Programmatically generate the final, full matrix by crossing name cases with label cases. + var allTestCases: + [( + descriptor: MetricNameDescriptor, labels: [(String, String)], expectedOutput: String, + failureDescription: String + )] = [] + + for nameCase in baseNameCases { + for labelCase in labelCases { + let expectedMetricLine = + "\(nameCase.expectedMetricName)\(labelCase.expectedLabelString) \(incrementValue)" + + // Case: Without help text (helpText is nil) + allTestCases.append( + ( + descriptor: MetricNameDescriptor( + namespace: nameCase.namespace, + subsystem: nameCase.subsystem, + metricName: metricName, + unitName: nameCase.unitName, + helpText: nil + ), + labels: labelCase.labels, + expectedOutput: """ + # TYPE \(nameCase.expectedMetricName) gauge + \(expectedMetricLine) + + """, + failureDescription: "\(nameCase.description), \(labelCase.description)" + ) + ) + } + } + + let expectedTestCaseCount = baseNameCases.count * labelCases.count + XCTAssertEqual( + allTestCases.count, + expectedTestCaseCount, + "Test setup failed: Did not generate the correct number of test cases." + ) + + // 4. Loop through the complete, generated test matrix. + for testCase in allTestCases { + // --- Test 1: The primary `makeGauge` overload with a `labels` parameter --- + // This is tested for all cases, including where the labels array is empty. + let gaugeWithLabels = client.makeGauge(descriptor: testCase.descriptor, labels: testCase.labels) + gaugeWithLabels.increment(by: incrementValue) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let actualOutput = String(decoding: buffer, as: Unicode.UTF8.self) + + var failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeGauge(descriptor:labels:) + - Descriptor: \(testCase.descriptor) + - Labels: \(testCase.labels) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput) + --- + """ + XCTAssertEqual(actualOutput, testCase.expectedOutput, failureMessage) + client.unregisterGauge(gaugeWithLabels) + + // --- Test 2: The convenience `makeGauge` overload without a `labels` parameter --- + // This should only be tested when the label set is empty. + if testCase.labels.isEmpty { + let gaugeWithoutLabels = client.makeGauge(descriptor: testCase.descriptor) + gaugeWithoutLabels.increment(by: incrementValue) + + var buffer2 = [UInt8]() + client.emit(into: &buffer2) + let actualOutput2 = String(decoding: buffer2, as: Unicode.UTF8.self) + + failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeGauge(descriptor:) + - Descriptor: \(testCase.descriptor) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput2) + --- + """ + XCTAssertEqual(actualOutput2, testCase.expectedOutput, failureMessage) + client.unregisterGauge(gaugeWithoutLabels) + } + } + } } diff --git a/Tests/PrometheusTests/HistogramTests.swift b/Tests/PrometheusTests/HistogramTests.swift index 8c5705a..a48ae7e 100644 --- a/Tests/PrometheusTests/HistogramTests.swift +++ b/Tests/PrometheusTests/HistogramTests.swift @@ -340,4 +340,348 @@ final class HistogramTests: XCTestCase { """ ) } + + // MARK: - MetricNameDescriptor Histogram Tests + + func testValueHistogramWithMetricNameDescriptorWithFullComponentMatrix() { + // --- Test Constants --- + let metricName = "foo2" + let observeValue: Double = 0.8 + let buckets: [Double] = [0.5, 1.0, 2.5] + let client = PrometheusCollectorRegistry() + + // 1. Define the base naming combinations first. + let baseNameCases: + [( + namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, + description: String + )] = [ + ( + namespace: "myapp", subsystem: "subsystem", unitName: "values", + expectedMetricName: "myapp_subsystem_foo2_values", description: "All components present" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: nil, + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + ), + ( + namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", + description: "Only metric name is present" + ), + ( + namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", + description: "All optional components are empty strings" + ), + ] + + // 2. Define the label combinations to test. + let labelCases: [(labels: [(String, String)], expectedLabelString: String, description: String)] = [ + (labels: [], expectedLabelString: "", description: "without labels"), + (labels: [("method", "get")], expectedLabelString: "{method=\"get\"}", description: "with one label"), + ( + labels: [("status", "200"), ("path", "/api/v1")], + expectedLabelString: "{status=\"200\",path=\"/api/v1\"}", description: "with two labels" + ), + ] + + // 3. Programmatically generate the final, full matrix by crossing name cases with label cases. + var allTestCases: + [( + descriptor: MetricNameDescriptor, labels: [(String, String)], expectedOutput: String, + failureDescription: String + )] = [] + + for nameCase in baseNameCases { + for labelCase in labelCases { + let descriptor = MetricNameDescriptor( + namespace: nameCase.namespace, + subsystem: nameCase.subsystem, + metricName: metricName, + unitName: nameCase.unitName, + helpText: nil + ) + + let expectedOutput = self.generateHistogramOutput( + metricName: nameCase.expectedMetricName, + labelString: labelCase.expectedLabelString, + buckets: buckets, + observedValue: observeValue + ) + + let failureDescription = "ValueHistogram: \(nameCase.description), \(labelCase.description)" + + allTestCases.append( + ( + descriptor: descriptor, + labels: labelCase.labels, + expectedOutput: expectedOutput, + failureDescription: failureDescription + ) + ) + } + } + + let expectedTestCaseCount = baseNameCases.count * labelCases.count + XCTAssertEqual( + allTestCases.count, + expectedTestCaseCount, + "Test setup failed: Did not generate the correct number of test cases." + ) + + // 4. Loop through the complete, generated test matrix. + for testCase in allTestCases { + // --- Test 1: The `makeValueHistogram` overload with a `labels` parameter --- + let histogramWithLabels = client.makeValueHistogram( + descriptor: testCase.descriptor, + labels: testCase.labels, + buckets: buckets + ) + histogramWithLabels.record(observeValue) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let actualOutput = String(decoding: buffer, as: Unicode.UTF8.self) + + var failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeValueHistogram(descriptor:labels:buckets:) + - Descriptor: \(testCase.descriptor) + - Labels: \(testCase.labels) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput) + --- + """ + XCTAssertEqual(actualOutput, testCase.expectedOutput, failureMessage) + client.unregisterValueHistogram(histogramWithLabels) + + // --- Test 2: The `makeValueHistogram` overload without a `labels` parameter --- + if testCase.labels.isEmpty { + let histogramWithoutLabels = client.makeValueHistogram( + descriptor: testCase.descriptor, + buckets: buckets + ) + histogramWithoutLabels.record(observeValue) + + var buffer2 = [UInt8]() + client.emit(into: &buffer2) + let actualOutput2 = String(decoding: buffer2, as: Unicode.UTF8.self) + + failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeValueHistogram(descriptor:buckets:) + - Descriptor: \(testCase.descriptor) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput2) + --- + """ + XCTAssertEqual(actualOutput2, testCase.expectedOutput, failureMessage) + client.unregisterValueHistogram(histogramWithoutLabels) + } + } + } + + func testDurationHistogramWithMetricNameDescriptorWithFullComponentMatrix() { + // --- Test Constants --- + let metricName = "foo2" + let observeValue = Duration.milliseconds(400) + let buckets: [Duration] = [ + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + .seconds(1), + ] + let client = PrometheusCollectorRegistry() + + // 1. Define the base naming combinations first. + let baseNameCases: + [( + namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, + description: String + )] = [ + ( + namespace: "myapp", subsystem: "subsystem", unitName: "seconds", + expectedMetricName: "myapp_subsystem_foo2_seconds", description: "All components present" + ), + ( + namespace: "myapp", subsystem: "subsystem", unitName: nil, + expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + ), + ( + namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", + description: "Only metric name is present" + ), + ( + namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", + description: "All optional components are empty strings" + ), + ] + + // 2. Define the label combinations to test. + let labelCases: [(labels: [(String, String)], expectedLabelString: String, description: String)] = [ + (labels: [], expectedLabelString: "", description: "without labels"), + (labels: [("method", "get")], expectedLabelString: "{method=\"get\"}", description: "with one label"), + ( + labels: [("status", "200"), ("path", "/api/v1")], + expectedLabelString: "{status=\"200\",path=\"/api/v1\"}", description: "with two labels" + ), + ] + + // 3. Programmatically generate the final, full matrix by crossing name cases with label cases. + var allTestCases: + [( + descriptor: MetricNameDescriptor, labels: [(String, String)], expectedOutput: String, + failureDescription: String + )] = [] + + for nameCase in baseNameCases { + for labelCase in labelCases { + let descriptor = MetricNameDescriptor( + namespace: nameCase.namespace, + subsystem: nameCase.subsystem, + metricName: metricName, + unitName: nameCase.unitName, + helpText: nil + ) + + // Convert Durations to Doubles (seconds) for expected output generation + let bucketsInSeconds = buckets.map { + Double($0.components.seconds) + Double($0.components.attoseconds) / 1_000_000_000_000_000_000.0 + } + let observedValueInSeconds = + Double(observeValue.components.seconds) + Double(observeValue.components.attoseconds) + / 1_000_000_000_000_000_000.0 + + let expectedOutput = self.generateHistogramOutput( + metricName: nameCase.expectedMetricName, + labelString: labelCase.expectedLabelString, + buckets: bucketsInSeconds, + observedValue: observedValueInSeconds + ) + + let failureDescription = "DurationHistogram: \(nameCase.description), \(labelCase.description)" + + allTestCases.append( + ( + descriptor: descriptor, + labels: labelCase.labels, + expectedOutput: expectedOutput, + failureDescription: failureDescription + ) + ) + } + } + + let expectedTestCaseCount = baseNameCases.count * labelCases.count + XCTAssertEqual( + allTestCases.count, + expectedTestCaseCount, + "Test setup failed: Did not generate the correct number of test cases." + ) + + // 4. Loop through the complete, generated test matrix. + for testCase in allTestCases { + // --- Test 1: The `makeDurationHistogram` overload with a `labels` parameter --- + let histogramWithLabels = client.makeDurationHistogram( + descriptor: testCase.descriptor, + labels: testCase.labels, + buckets: buckets + ) + histogramWithLabels.record(observeValue) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let actualOutput = String(decoding: buffer, as: Unicode.UTF8.self) + + var failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeDurationHistogram(descriptor:labels:buckets:) + - Descriptor: \(testCase.descriptor) + - Labels: \(testCase.labels) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput) + --- + """ + XCTAssertEqual(actualOutput, testCase.expectedOutput, failureMessage) + client.unregisterDurationHistogram(histogramWithLabels) + + // --- Test 2: The `makeDurationHistogram` overload without a `labels` parameter --- + if testCase.labels.isEmpty { + let histogramWithoutLabels = client.makeDurationHistogram( + descriptor: testCase.descriptor, + buckets: buckets + ) + histogramWithoutLabels.record(observeValue) + + var buffer2 = [UInt8]() + client.emit(into: &buffer2) + let actualOutput2 = String(decoding: buffer2, as: Unicode.UTF8.self) + + failureMessage = """ + Failed on test case: '\(testCase.failureDescription)' + Overload: makeDurationHistogram(descriptor:buckets:) + - Descriptor: \(testCase.descriptor) + - Expected Output: + --- + \(testCase.expectedOutput) + --- + - Actual Output: + --- + \(actualOutput2) + --- + """ + XCTAssertEqual(actualOutput2, testCase.expectedOutput, failureMessage) + client.unregisterDurationHistogram(histogramWithoutLabels) + } + } + } + + // MARK: - Helpers + + /// Generates the expected Prometheus exposition format string for a histogram with a single observation. + private func generateHistogramOutput( + metricName: String, + labelString: String, + buckets: [Double], + observedValue: Double + ) -> String { + var output = "# TYPE \(metricName) histogram\n" + let labelsWithLe = { (le: String) -> String in + guard labelString.isEmpty else { + // Insert 'le' at the end of the existing labels + return "{\(labelString.dropFirst().dropLast()),le=\"\(le)\"}" + } + return "{le=\"\(le)\"}" + } + + var cumulativeCount = 0 + for bucket in buckets { + if observedValue <= bucket { + cumulativeCount = 1 + } + output += "\(metricName)_bucket\(labelsWithLe("\(bucket)")) \(cumulativeCount)\n" + } + + let totalObservations = 1 + output += "\(metricName)_bucket\(labelsWithLe("+Inf")) \(totalObservations)\n" + output += "\(metricName)_sum\(labelString) \(observedValue)\n" + + output += "\(metricName)_count\(labelString) \(totalObservations)\n" + + return output + } }