diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift index a573f4c..86f2990 100644 --- a/Sources/Prometheus/PrometheusCollectorRegistry.swift +++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift @@ -47,43 +47,159 @@ public final class PrometheusCollectorRegistry: Sendable { } } - private struct MetricWithHelp: Sendable { - var metric: Metric - let help: String - } - private enum HistogramBuckets: Sendable, Hashable { case duration([Duration]) case value([Double]) } - /// A collection of metrics, each with a unique label set, that share the same metric name. - /// Distinct help strings for the same metric name are permitted, but Prometheus retains only the - /// first one. For an unlabelled metric, the empty label set is used as the key, and the - /// collection contains only one entry. Finally, for clarification, the same metric name can - /// simultaneously be labeled and unlabeled. - /// For histograms, the buckets are immutable for a MetricGroup once initialized with the first + /// A MetricFamily. + /// + /// A metric name can either map to multiple labeled metrics OR a single unlabeled metric, + /// but not both simultaneously to ensure proper Prometheus aggregations. + /// All metrics with the same name must have identical help text for consistency. + /// For histograms, the buckets are immutable for a MetricFamily once initialized with the first /// metric. See also https://github.com/prometheus/OpenMetrics/issues/197. - private struct MetricGroup: Sendable { - var metricsByLabelSets: [LabelsKey: MetricWithHelp] + private struct MetricFamily: Sendable { + private enum State { + case labeled([LabelsKey: Metric]) + case unlabeled(Metric) + case empty + } + let buckets: HistogramBuckets? + let help: String? + private let state: State + + init( + metricsByLabelSets: [LabelsKey: Metric] = [:], + buckets: HistogramBuckets? = nil, + help: String? = nil, + metricUnlabeled: Metric? = nil + ) { + // Validate mutual exclusivity on creation. + if metricUnlabeled != nil && !metricsByLabelSets.isEmpty { + fatalError("Cannot have both labeled and unlabeled metrics in the same family.") + } - init(metricsByLabelSets: [LabelsKey: MetricWithHelp] = [:], buckets: HistogramBuckets? = nil) { - self.metricsByLabelSets = metricsByLabelSets self.buckets = buckets + self.help = help + + // Set internal state based on inputs. + if let unlabeled = metricUnlabeled { + self.state = .unlabeled(unlabeled) + } else if !metricsByLabelSets.isEmpty { + self.state = .labeled(metricsByLabelSets) + } else { + self.state = .empty + } + } + + func adding(metric: Metric, for labels: [(String, String)]) -> MetricFamily { + guard !labels.isEmpty else { + fatalError("Use initializer for unlabeled metrics, not adding method") + } + + switch state { + case .unlabeled: + fatalError("Cannot register a labeled metric when an unlabeled metric already exists.") + case .labeled(let existingMetrics): + let labelsKey = LabelsKey(labels) + guard existingMetrics[labelsKey] == nil else { + return self + } + + var newMetricsByLabelSets = existingMetrics + newMetricsByLabelSets[labelsKey] = metric + + return MetricFamily( + metricsByLabelSets: newMetricsByLabelSets, + buckets: buckets, + help: help, + metricUnlabeled: nil + ) + case .empty: + let labelsKey = LabelsKey(labels) + return MetricFamily( + metricsByLabelSets: [labelsKey: metric], + buckets: buckets, + help: help, + metricUnlabeled: nil + ) + } + } + + func removing(metric: Metric, for labels: [(String, String)]) -> MetricFamily? { + switch state { + case .unlabeled(let unlabeledMetric): + if labels.isEmpty && unlabeledMetric === metric { + return nil // Remove entire family. + } + return self // Metric not found, return unchanged. + + case .labeled(let labeledMetrics): + let key = LabelsKey(labels) + guard let existingMetric = labeledMetrics[key], + existingMetric === metric + else { + return self // Metric not found, return unchanged. + } + + var newMetricsByLabelSets = labeledMetrics + newMetricsByLabelSets.removeValue(forKey: key) + + guard newMetricsByLabelSets.isEmpty else { + return MetricFamily( + metricsByLabelSets: newMetricsByLabelSets, + buckets: buckets, + help: help, + metricUnlabeled: nil + ) + } + return nil // Remove entire family. + + case .empty: + return self // Nothing to remove. + } + } + + func getMetric(for labels: [(String, String)]) -> Metric? { + switch state { + case .labeled(let metrics): + return metrics[LabelsKey(labels)] + case .unlabeled(let metric): + return labels.isEmpty ? metric : nil + case .empty: + return nil + } + } + + func forEachMetric(_ closure: (Metric) -> Void) { + switch state { + case .unlabeled(let metric): + closure(metric) + case .labeled(let metrics): + for metric in metrics.values { + closure(metric) + } + case .empty: + break + } } } private enum Metric { - case counter(MetricGroup) - case gauge(MetricGroup) - case durationHistogram(MetricGroup) - case valueHistogram(MetricGroup) + case counter(MetricFamily) + case gauge(MetricFamily) + case durationHistogram(MetricFamily) + case valueHistogram(MetricFamily) } private let box = NIOLockedValueBox([String: Metric]()) - /// Create a new collector registry + /// Creates a new PrometheusCollectorRegistry with default configuration. + /// + /// Uses deduplication for TYPE and HELP lines according to Prometheus specifications, + /// where only one TYPE and HELP line is emitted per metric name regardless of label sets. public init() {} // MARK: Creating Metrics @@ -145,26 +261,41 @@ public final class PrometheusCollectorRegistry: Sendable { guard let entry = store[name] else { // First time a Counter is registered with this name. let counter = Counter(name: name, labels: labels) - let counterWithHelp = MetricWithHelp(metric: counter, help: help) - let counterGroup = MetricGroup( - metricsByLabelSets: [key: counterWithHelp] + let counterFamily = MetricFamily( + metricsByLabelSets: labels.isEmpty ? [:] : [key: counter], + help: help, + metricUnlabeled: labels.isEmpty ? counter : nil ) - store[name] = .counter(counterGroup) + store[name] = .counter(counterFamily) return counter } + switch entry { - case .counter(var existingCounterGroup): - if let existingCounterWithHelp = existingCounterGroup.metricsByLabelSets[key] { - return existingCounterWithHelp.metric + case .counter(let existingCounterFamily): + + // Validate help text consistency. While Prometheus wouldn't break with duplicate and distinct + // HELP lines, the client enforces uniqueness and consistency. + if let existingHelp = existingCounterFamily.help, existingHelp != help { + fatalError( + """ + Help text mismatch for metric '\(name)': + Existing help: '\(existingHelp)' + New help: '\(help)' + All metrics with the same name must have identical help text. + """ + ) + } + + if let existingCounter = existingCounterFamily.getMetric(for: labels) { + return existingCounter } // Even if the metric name is identical, each label set defines a unique time series. let counter = Counter(name: name, labels: labels) - let counterWithHelp = MetricWithHelp(metric: counter, help: help) - existingCounterGroup.metricsByLabelSets[key] = counterWithHelp + let updatedFamily = existingCounterFamily.adding(metric: counter, for: labels) // Write the modified entry back to the store. - store[name] = .counter(existingCounterGroup) + store[name] = .counter(updatedFamily) return counter @@ -266,26 +397,41 @@ public final class PrometheusCollectorRegistry: Sendable { guard let entry = store[name] else { // First time a Gauge is registered with this name. let gauge = Gauge(name: name, labels: labels) - let gaugeWithHelp = MetricWithHelp(metric: gauge, help: help) - let gaugeGroup = MetricGroup( - metricsByLabelSets: [key: gaugeWithHelp] + let gaugeFamily = MetricFamily( + metricsByLabelSets: labels.isEmpty ? [:] : [key: gauge], + help: help, + metricUnlabeled: labels.isEmpty ? gauge : nil ) - store[name] = .gauge(gaugeGroup) + store[name] = .gauge(gaugeFamily) return gauge } + switch entry { - case .gauge(var existingGaugeGroup): - if let existingGaugeWithHelp = existingGaugeGroup.metricsByLabelSets[key] { - return existingGaugeWithHelp.metric + case .gauge(let existingGaugeFamily): + + // Validate help text consistency. While Prometheus wouldn't break with duplicate and distinct + // HELP lines, the client enforces uniqueness and consistency. + if let existingHelp = existingGaugeFamily.help, existingHelp != help { + fatalError( + """ + Help text mismatch for metric '\(name)': + Existing help: '\(existingHelp)' + New help: '\(help)' + All metrics with the same name must have identical help text. + """ + ) + } + + if let existingGauge = existingGaugeFamily.getMetric(for: labels) { + return existingGauge } // Even if the metric name is identical, each label set defines a unique time series. let gauge = Gauge(name: name, labels: labels) - let gaugeWithHelp = MetricWithHelp(metric: gauge, help: help) - existingGaugeGroup.metricsByLabelSets[key] = gaugeWithHelp + let updatedFamily = existingGaugeFamily.adding(metric: gauge, for: labels) // Write the modified entry back to the store. - store[name] = .gauge(existingGaugeGroup) + store[name] = .gauge(updatedFamily) return gauge @@ -401,19 +547,34 @@ public final class PrometheusCollectorRegistry: Sendable { guard let entry = store[name] else { // First time a DurationHistogram is registered with this name. This defines the buckets. let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets) - let histogramWithHelp = MetricWithHelp(metric: histogram, help: help) - let histogramGroup = MetricGroup( - metricsByLabelSets: [key: histogramWithHelp], - buckets: .duration(buckets) + let histogramFamily = MetricFamily( + metricsByLabelSets: labels.isEmpty ? [:] : [key: histogram], + buckets: .duration(buckets), + help: help, + metricUnlabeled: labels.isEmpty ? histogram : nil ) - store[name] = .durationHistogram(histogramGroup) + store[name] = .durationHistogram(histogramFamily) return histogram } switch entry { - case .durationHistogram(var existingHistogramGroup): + case .durationHistogram(let existingHistogramFamily): + + // Validate help text consistency. While Prometheus wouldn't break with duplicate and distinct + // HELP lines, the client enforces uniqueness and consistency. + if let existingHelp = existingHistogramFamily.help, existingHelp != help { + fatalError( + """ + Help text mismatch for metric '\(name)': + Existing help: '\(existingHelp)' + New help: '\(help)' + All metrics with the same name must have identical help text. + """ + ) + } + // Validate buckets match the stored ones. - if case .duration(let storedBuckets) = existingHistogramGroup.buckets { + if case .duration(let storedBuckets) = existingHistogramFamily.buckets { guard storedBuckets == buckets else { fatalError( """ @@ -426,17 +587,16 @@ public final class PrometheusCollectorRegistry: Sendable { } } - if let existingHistogramWithHelp = existingHistogramGroup.metricsByLabelSets[key] { - return existingHistogramWithHelp.metric + if let existingHistogram = existingHistogramFamily.getMetric(for: labels) { + return existingHistogram } // Even if the metric name is identical, each label set defines a unique time series. let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets) - let histogramWithHelp = MetricWithHelp(metric: histogram, help: help) - existingHistogramGroup.metricsByLabelSets[key] = histogramWithHelp + let updatedFamily = existingHistogramFamily.adding(metric: histogram, for: labels) // Write the modified entry back to the store. - store[name] = .durationHistogram(existingHistogramGroup) + store[name] = .durationHistogram(updatedFamily) return histogram @@ -572,19 +732,33 @@ public final class PrometheusCollectorRegistry: Sendable { guard let entry = store[name] else { // First time a ValueHistogram is registered with this name. This defines the buckets. let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets) - let histogramWithHelp = MetricWithHelp(metric: histogram, help: help) - let histogramGroup = MetricGroup( - metricsByLabelSets: [key: histogramWithHelp], - buckets: .value(buckets) + let histogramFamily = MetricFamily( + metricsByLabelSets: labels.isEmpty ? [:] : [key: histogram], + buckets: .value(buckets), + help: help, + metricUnlabeled: labels.isEmpty ? histogram : nil ) - store[name] = .valueHistogram(histogramGroup) + store[name] = .valueHistogram(histogramFamily) return histogram } switch entry { - case .valueHistogram(var existingHistogramGroup): + case .valueHistogram(let existingHistogramFamily): + + // Validate help text consistency. While Prometheus wouldn't break with duplicate and distinct + // HELP lines, the client enforces uniqueness and consistency. + if let existingHelp = existingHistogramFamily.help, existingHelp != help { + fatalError( + """ + Help text mismatch for metric '\(name)': + Existing help: '\(existingHelp)' + New help: '\(help)' + All metrics with the same name must have identical help text. + """ + ) + } // Validate buckets match the stored ones. - if case .value(let storedBuckets) = existingHistogramGroup.buckets { + if case .value(let storedBuckets) = existingHistogramFamily.buckets { guard storedBuckets == buckets else { fatalError( """ @@ -597,17 +771,16 @@ public final class PrometheusCollectorRegistry: Sendable { } } - if let existingHistogramWithHelp = existingHistogramGroup.metricsByLabelSets[key] { - return existingHistogramWithHelp.metric + if let existingHistogram = existingHistogramFamily.getMetric(for: labels) { + return existingHistogram } // Even if the metric name is identical, each label set defines a unique time series. let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets) - let histogramWithHelp = MetricWithHelp(metric: histogram, help: help) - existingHistogramGroup.metricsByLabelSets[key] = histogramWithHelp + let updatedFamily = existingHistogramFamily.adding(metric: histogram, for: labels) // Write the modified entry back to the store. - store[name] = .valueHistogram(existingHistogramGroup) + store[name] = .valueHistogram(updatedFamily) return histogram @@ -684,19 +857,12 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterCounter(_ counter: Counter) { self.box.withLockedValue { store in switch store[counter.name] { - case .counter(var counterGroup): - let key = LabelsKey(counter.labels) - guard let existingCounterGroup = counterGroup.metricsByLabelSets[key], - existingCounterGroup.metric === counter - else { - return - } - counterGroup.metricsByLabelSets.removeValue(forKey: key) - - if counterGroup.metricsByLabelSets.isEmpty { - store.removeValue(forKey: counter.name) + case .counter(let counterFamily): + if let updatedFamily = counterFamily.removing(metric: counter, for: counter.labels) { + store[counter.name] = .counter(updatedFamily) } else { - store[counter.name] = .counter(counterGroup) + // Remove the entire family. + store.removeValue(forKey: counter.name) } default: return @@ -712,19 +878,12 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterGauge(_ gauge: Gauge) { self.box.withLockedValue { store in switch store[gauge.name] { - case .gauge(var gaugeGroup): - let key = LabelsKey(gauge.labels) - guard let existingGaugeGroup = gaugeGroup.metricsByLabelSets[key], - existingGaugeGroup.metric === gauge - else { - return - } - gaugeGroup.metricsByLabelSets.removeValue(forKey: key) - - if gaugeGroup.metricsByLabelSets.isEmpty { - store.removeValue(forKey: gauge.name) + case .gauge(let gaugeFamily): + if let updatedFamily = gaugeFamily.removing(metric: gauge, for: gauge.labels) { + store[gauge.name] = .gauge(updatedFamily) } else { - store[gauge.name] = .gauge(gaugeGroup) + // Remove the entire family. + store.removeValue(forKey: gauge.name) } default: return @@ -740,19 +899,12 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterDurationHistogram(_ histogram: DurationHistogram) { self.box.withLockedValue { store in switch store[histogram.name] { - case .durationHistogram(var histogramGroup): - let key = LabelsKey(histogram.labels) - guard let existingHistogramGroup = histogramGroup.metricsByLabelSets[key], - existingHistogramGroup.metric === histogram - else { - return - } - histogramGroup.metricsByLabelSets.removeValue(forKey: key) - - if histogramGroup.metricsByLabelSets.isEmpty { - store.removeValue(forKey: histogram.name) + case .durationHistogram(let histogramFamily): + if let updatedFamily = histogramFamily.removing(metric: histogram, for: histogram.labels) { + store[histogram.name] = .durationHistogram(updatedFamily) } else { - store[histogram.name] = .durationHistogram(histogramGroup) + // Remove the entire family. + store.removeValue(forKey: histogram.name) } default: return @@ -768,19 +920,12 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterValueHistogram(_ histogram: ValueHistogram) { self.box.withLockedValue { store in switch store[histogram.name] { - case .valueHistogram(var histogramGroup): - let key = LabelsKey(histogram.labels) - guard let existingHistogramGroup = histogramGroup.metricsByLabelSets[key], - existingHistogramGroup.metric === histogram - else { - return - } - histogramGroup.metricsByLabelSets.removeValue(forKey: key) - - if histogramGroup.metricsByLabelSets.isEmpty { - store.removeValue(forKey: histogram.name) + case .valueHistogram(let histogramFamily): + if let updatedFamily = histogramFamily.removing(metric: histogram, for: histogram.labels) { + store[histogram.name] = .valueHistogram(updatedFamily) } else { - store[histogram.name] = .valueHistogram(histogramGroup) + // Remove the entire family. + store.removeValue(forKey: histogram.name) } default: return @@ -790,6 +935,90 @@ public final class PrometheusCollectorRegistry: Sendable { // MARK: Emitting + private let bufferBox = NIOLockedValueBox([UInt8]()) + + /// Resets the internal buffer used by ``emitToString()`` and ``emitToBuffer()``. + /// + /// Forces the buffer capacity back to 0, which will trigger re-calibration on the next emission call. + /// This is useful when the registry's metric composition has changed significantly and you want to + /// optimize buffer size for the new workload. + /// + /// - Note: Thread-safe. Does not affect ``emit(into:)`` calls which use external buffers + public func resetInternalBuffer() { + bufferBox.withLockedValue { buffer in + // Resets capacity to 0, forcing re-calibration + buffer.removeAll() + } + } + + /// Returns the current capacity of the internal buffer used by ``emitToString()`` and ``emitToBuffer()``. + /// + /// The capacity represents the allocated memory size, not the current content length. A capacity of 0 + /// indicates the buffer will auto-size on the next emission call. The capacity may grow over time as + /// the registry's output requirements increase. + /// + /// - Returns: The current buffer capacity in bytes + /// - Note: Thread-safe. Primarily useful for testing and monitoring buffer behavior + public func internalBufferCapacity() -> Int { + return bufferBox.withLockedValue { buffer in + buffer.capacity + } + } + + /// Emits all registered metrics in Prometheus text format as a String. + /// + /// Uses an internal buffer that auto-sizes on first call to find optimal initial capacity. The buffer + /// may resize during the registry's lifetime if output grows significantly. Subsequent calls reuse the + /// established capacity, clearing content but preserving the initially allocated memory. + /// + /// - Returns: A String containing all registered metrics in Prometheus text format + /// - Note: Thread-safe. Use ``resetInternalBuffer()`` to force recalibration + /// - SeeAlso: ``emitToBuffer()`` for raw UTF-8 bytes, ``emit(into:)`` for custom buffer + public func emitToString() -> String { + return bufferBox.withLockedValue { buffer in + guard buffer.capacity == 0 else { + // Subsequent times: clear content but keep the capacity + buffer.removeAll(keepingCapacity: true) + emit(into: &buffer) + return String(decoding: buffer, as: UTF8.self) + } + // First time: emit and let buffer auto-resize to find the initial optimal size + emit(into: &buffer) + return String(decoding: buffer, as: UTF8.self) + } + } + + /// Emits all registered metrics in Prometheus text format as a UTF-8 byte array. + /// + /// Uses an internal buffer that auto-sizes on first call to find optimal initial capacity. The buffer + /// may resize during the registry's lifetime if output grows significantly. Subsequent calls reuse the + /// established capacity, clearing content but preserving the initially allocated memory. Returns a copy. + /// + /// - Returns: A copy of the UTF-8 encoded byte array containing all registered metrics + /// - Note: Thread-safe. Use ``resetInternalBuffer()`` to force recalibration + /// - SeeAlso: ``emitToString()`` for String output, ``emit(into:)`` for custom buffer + public func emitToBuffer() -> [UInt8] { + return bufferBox.withLockedValue { buffer in + guard buffer.capacity == 0 else { + buffer.removeAll(keepingCapacity: true) + emit(into: &buffer) + return Array(buffer) // Creates a copy + } + emit(into: &buffer) + return Array(buffer) // Creates a copy + } + } + + /// Emits all registered metrics in Prometheus text format into the provided buffer. + /// + /// Writes directly into the supplied buffer without any internal buffer management or thread synchronization. + /// The caller is responsible for buffer sizing, clearing, and thread safety. This method provides maximum + /// performance and control but requires manual buffer lifecycle management. + /// + /// - Parameter buffer: The buffer to write metrics data into. Content will be appended to existing data + /// - Note: Not thread-safe. Caller must handle synchronization and may optimize buffer capacity for + /// maximum performance by reducing reallocations + /// - SeeAlso: ``emitToString()`` and ``emitToBuffer()`` for automatic buffer management public func emit(into buffer: inout [UInt8]) { let metrics = self.box.withLockedValue { $0 } let prefixHelp = "HELP" @@ -797,52 +1026,40 @@ public final class PrometheusCollectorRegistry: Sendable { for (name, metric) in metrics { switch metric { - case .counter(let counterGroup): - // Should not be empty, as a safeguard skip if it is. - guard let _ = counterGroup.metricsByLabelSets.first?.value else { - continue + case .counter(let counterFamily): + if let help = counterFamily.help, !help.isEmpty { + buffer.addLine(prefix: prefixHelp, name: name, value: help) } - for counterWithHelp in counterGroup.metricsByLabelSets.values { - let help = counterWithHelp.help - help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help) - buffer.addLine(prefix: prefixType, name: name, value: "counter") - counterWithHelp.metric.emit(into: &buffer) + buffer.addLine(prefix: prefixType, name: name, value: "counter") + counterFamily.forEachMetric { counter in + counter.emit(into: &buffer) } - case .gauge(let gaugeGroup): - // Should not be empty, as a safeguard skip if it is. - guard let _ = gaugeGroup.metricsByLabelSets.first?.value else { - continue + case .gauge(let gaugeFamily): + if let help = gaugeFamily.help, !help.isEmpty { + buffer.addLine(prefix: prefixHelp, name: name, value: help) } - for gaugeWithHelp in gaugeGroup.metricsByLabelSets.values { - let help = gaugeWithHelp.help - help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help) - buffer.addLine(prefix: prefixType, name: name, value: "gauge") - gaugeWithHelp.metric.emit(into: &buffer) + buffer.addLine(prefix: prefixType, name: name, value: "gauge") + gaugeFamily.forEachMetric { gauge in + gauge.emit(into: &buffer) } - case .durationHistogram(let histogramGroup): - // Should not be empty, as a safeguard skip if it is. - guard let _ = histogramGroup.metricsByLabelSets.first?.value else { - continue + case .durationHistogram(let histogramFamily): + if let help = histogramFamily.help, !help.isEmpty { + buffer.addLine(prefix: prefixHelp, name: name, value: help) } - for histogramWithHelp in histogramGroup.metricsByLabelSets.values { - let help = histogramWithHelp.help - help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help) - buffer.addLine(prefix: prefixType, name: name, value: "histogram") - histogramWithHelp.metric.emit(into: &buffer) + buffer.addLine(prefix: prefixType, name: name, value: "histogram") + histogramFamily.forEachMetric { histogram in + histogram.emit(into: &buffer) } - case .valueHistogram(let histogramGroup): - // Should not be empty, as a safeguard skip if it is. - guard let _ = histogramGroup.metricsByLabelSets.first?.value else { - continue + case .valueHistogram(let histogramFamily): + if let help = histogramFamily.help, !help.isEmpty { + buffer.addLine(prefix: prefixHelp, name: name, value: help) } - for histogramWithHelp in histogramGroup.metricsByLabelSets.values { - let help = histogramWithHelp.help - help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help) - buffer.addLine(prefix: prefixType, name: name, value: "histogram") - histogramWithHelp.metric.emit(into: &buffer) + buffer.addLine(prefix: prefixType, name: name, value: "histogram") + histogramFamily.forEachMetric { histogram in + histogram.emit(into: &buffer) } } } diff --git a/Tests/PrometheusTests/CounterTests.swift b/Tests/PrometheusTests/CounterTests.swift index bef97b9..572aa8f 100644 --- a/Tests/PrometheusTests/CounterTests.swift +++ b/Tests/PrometheusTests/CounterTests.swift @@ -168,65 +168,37 @@ final class CounterTests: XCTestCase { func testCounterWithSharedMetricNamDistinctLabelSets() { let client = PrometheusCollectorRegistry() - let counter0 = client.makeCounter( - name: "foo", - labels: [], - help: "Base metric name with no labels" - ) - let counter1 = client.makeCounter( name: "foo", labels: [("bar", "baz")], - help: "Base metric name with one label set variant" + help: "Shared help text" ) let counter2 = client.makeCounter( name: "foo", labels: [("bar", "newBaz"), ("newKey1", "newValue1")], - help: "Base metric name with a different label set variant" + help: "Shared help text" ) var buffer = [UInt8]() - counter0.increment() counter1.increment(by: Int64(9)) counter2.increment(by: Int64(4)) counter1.increment(by: Int64(3)) - counter0.increment() counter2.increment(by: Int64(20)) client.emit(into: &buffer) var outputString = String(decoding: buffer, as: Unicode.UTF8.self) var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) var expectedLines = Set([ - "# HELP foo Base metric name with no labels", + "# HELP foo Shared help text", "# TYPE foo counter", - "foo 2", - "# HELP foo Base metric name with one label set variant", - "# TYPE foo counter", #"foo{bar="baz"} 12"#, - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo counter", #"foo{bar="newBaz",newKey1="newValue1"} 24"#, ]) XCTAssertEqual(actualLines, expectedLines) // Counters are unregistered in a cascade. - client.unregisterCounter(counter0) - buffer.removeAll(keepingCapacity: true) - client.emit(into: &buffer) - outputString = String(decoding: buffer, as: Unicode.UTF8.self) - actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) - expectedLines = Set([ - "# HELP foo Base metric name with one label set variant", - "# TYPE foo counter", - #"foo{bar="baz"} 12"#, - - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo counter", - #"foo{bar="newBaz",newKey1="newValue1"} 24"#, - ]) - XCTAssertEqual(actualLines, expectedLines) client.unregisterCounter(counter1) buffer.removeAll(keepingCapacity: true) @@ -234,7 +206,7 @@ final class CounterTests: XCTestCase { outputString = String(decoding: buffer, as: Unicode.UTF8.self) actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) expectedLines = Set([ - "# HELP foo Base metric name with a different label set variant", + "# HELP foo Shared help text", "# TYPE foo counter", #"foo{bar="newBaz",newKey1="newValue1"} 24"#, ]) @@ -251,14 +223,14 @@ final class CounterTests: XCTestCase { let _ = client.makeGauge( name: "foo", labels: [], - help: "Base metric name used for new metric of type gauge" + help: "Shared help text" ) buffer.removeAll(keepingCapacity: true) client.emit(into: &buffer) XCTAssertEqual( String(decoding: buffer, as: Unicode.UTF8.self), """ - # HELP foo Base metric name used for new metric of type gauge + # HELP foo Shared help text # TYPE foo gauge foo 0.0 @@ -367,7 +339,6 @@ final class CounterTests: XCTestCase { // 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")], diff --git a/Tests/PrometheusTests/GaugeTests.swift b/Tests/PrometheusTests/GaugeTests.swift index 36ab213..81e8762 100644 --- a/Tests/PrometheusTests/GaugeTests.swift +++ b/Tests/PrometheusTests/GaugeTests.swift @@ -155,65 +155,37 @@ final class GaugeTests: XCTestCase { func testGaugeWithSharedMetricNameDistinctLabelSets() { let client = PrometheusCollectorRegistry() - let gauge0 = client.makeGauge( - name: "foo", - labels: [], - help: "Base metric name with no labels" - ) - let gauge1 = client.makeGauge( name: "foo", labels: [("bar", "baz")], - help: "Base metric name with one label set variant" + help: "Shared help text" ) let gauge2 = client.makeGauge( name: "foo", labels: [("bar", "newBaz"), ("newKey1", "newValue1")], - help: "Base metric name with a different label set variant" + help: "Shared help text" ) var buffer = [UInt8]() - gauge0.set(to: 1.0) gauge1.set(to: 9.0) gauge2.set(to: 4.0) gauge1.decrement(by: 12.0) - gauge0.record(2.0) gauge2.increment(by: 24.0) client.emit(into: &buffer) var outputString = String(decoding: buffer, as: Unicode.UTF8.self) var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) var expectedLines = Set([ - "# HELP foo Base metric name with no labels", + "# HELP foo Shared help text", "# TYPE foo gauge", - "foo 2.0", - "# HELP foo Base metric name with one label set variant", - "# TYPE foo gauge", #"foo{bar="baz"} -3.0"#, - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo gauge", #"foo{bar="newBaz",newKey1="newValue1"} 28.0"#, ]) XCTAssertEqual(actualLines, expectedLines) // Gauges are unregistered in a cascade. - client.unregisterGauge(gauge0) - buffer.removeAll(keepingCapacity: true) - client.emit(into: &buffer) - outputString = String(decoding: buffer, as: Unicode.UTF8.self) - actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) - expectedLines = Set([ - "# HELP foo Base metric name with one label set variant", - "# TYPE foo gauge", - #"foo{bar="baz"} -3.0"#, - - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo gauge", - #"foo{bar="newBaz",newKey1="newValue1"} 28.0"#, - ]) - XCTAssertEqual(actualLines, expectedLines) client.unregisterGauge(gauge1) buffer.removeAll(keepingCapacity: true) @@ -221,7 +193,7 @@ final class GaugeTests: XCTestCase { outputString = String(decoding: buffer, as: Unicode.UTF8.self) actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) expectedLines = Set([ - "# HELP foo Base metric name with a different label set variant", + "# HELP foo Shared help text", "# TYPE foo gauge", #"foo{bar="newBaz",newKey1="newValue1"} 28.0"#, ]) @@ -238,14 +210,14 @@ final class GaugeTests: XCTestCase { let _ = client.makeCounter( name: "foo", labels: [], - help: "Base metric name used for new metric of type counter" + help: "Shared help text" ) buffer.removeAll(keepingCapacity: true) client.emit(into: &buffer) XCTAssertEqual( String(decoding: buffer, as: Unicode.UTF8.self), """ - # HELP foo Base metric name used for new metric of type counter + # HELP foo Shared help text # TYPE foo counter foo 0 @@ -378,7 +350,6 @@ final class GaugeTests: XCTestCase { // 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")], diff --git a/Tests/PrometheusTests/HistogramTests.swift b/Tests/PrometheusTests/HistogramTests.swift index db7bcfe..a15c080 100644 --- a/Tests/PrometheusTests/HistogramTests.swift +++ b/Tests/PrometheusTests/HistogramTests.swift @@ -381,50 +381,33 @@ final class HistogramTests: XCTestCase { .seconds(1), ] - let histogram0 = client.makeDurationHistogram( - name: "foo", - labels: [], - buckets: sharedBuckets, - help: "Base metric name with no labels" - ) - let histogram1 = client.makeDurationHistogram( name: "foo", labels: [("bar", "baz")], buckets: sharedBuckets, // Must match the first registration - help: "Base metric name with one label set variant" + help: "Shared help text" ) let histogram2 = client.makeDurationHistogram( name: "foo", labels: [("bar", "newBaz"), ("newKey1", "newValue1")], buckets: sharedBuckets, // Must match the first registration - help: "Base metric name with a different label set variant" + help: "Shared help text" ) var buffer = [UInt8]() - histogram0.recordNanoseconds(300_000_000) // 300ms histogram1.recordNanoseconds(600_000_000) // 600ms histogram2.recordNanoseconds(150_000_000) // 150ms histogram1.recordNanoseconds(1_500_000_000) // 1500ms - histogram0.recordNanoseconds(800_000_000) // 800ms histogram2.recordNanoseconds(100_000_000) // 100ms client.emit(into: &buffer) var outputString = String(decoding: buffer, as: Unicode.UTF8.self) var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) var expectedLines = Set([ - "# HELP foo Base metric name with no labels", - "# TYPE foo histogram", - "foo_bucket{le=\"0.1\"} 0", - "foo_bucket{le=\"0.5\"} 1", - "foo_bucket{le=\"1.0\"} 2", - "foo_bucket{le=\"+Inf\"} 2", - "foo_sum 1.1", - "foo_count 2", - - "# HELP foo Base metric name with one label set variant", + "# HELP foo Shared help text", "# TYPE foo histogram", + #"foo_bucket{bar="baz",le="0.1"} 0"#, #"foo_bucket{bar="baz",le="0.5"} 0"#, #"foo_bucket{bar="baz",le="1.0"} 1"#, @@ -432,8 +415,6 @@ final class HistogramTests: XCTestCase { #"foo_sum{bar="baz"} 2.1"#, #"foo_count{bar="baz"} 2"#, - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo histogram", #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.1"} 1"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.5"} 2"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="1.0"} 2"#, @@ -444,31 +425,6 @@ final class HistogramTests: XCTestCase { XCTAssertEqual(actualLines, expectedLines) // Histograms are unregistered in a cascade. - client.unregisterDurationHistogram(histogram0) - buffer.removeAll(keepingCapacity: true) - client.emit(into: &buffer) - outputString = String(decoding: buffer, as: Unicode.UTF8.self) - actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) - expectedLines = Set([ - "# HELP foo Base metric name with one label set variant", - "# TYPE foo histogram", - #"foo_bucket{bar="baz",le="0.1"} 0"#, - #"foo_bucket{bar="baz",le="0.5"} 0"#, - #"foo_bucket{bar="baz",le="1.0"} 1"#, - #"foo_bucket{bar="baz",le="+Inf"} 2"#, - #"foo_sum{bar="baz"} 2.1"#, - #"foo_count{bar="baz"} 2"#, - - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo histogram", - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.1"} 1"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.5"} 2"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="1.0"} 2"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="+Inf"} 2"#, - #"foo_sum{bar="newBaz",newKey1="newValue1"} 0.25"#, - #"foo_count{bar="newBaz",newKey1="newValue1"} 2"#, - ]) - XCTAssertEqual(actualLines, expectedLines) client.unregisterDurationHistogram(histogram1) buffer.removeAll(keepingCapacity: true) @@ -476,7 +432,7 @@ final class HistogramTests: XCTestCase { outputString = String(decoding: buffer, as: Unicode.UTF8.self) actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) expectedLines = Set([ - "# HELP foo Base metric name with a different label set variant", + "# HELP foo Shared help text", "# TYPE foo histogram", #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.1"} 1"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="0.5"} 2"#, @@ -498,14 +454,14 @@ final class HistogramTests: XCTestCase { let _ = client.makeGauge( name: "foo", labels: [], - help: "Base metric name used for new metric of type gauge" + help: "Shared help text" ) buffer.removeAll(keepingCapacity: true) client.emit(into: &buffer) XCTAssertEqual( String(decoding: buffer, as: Unicode.UTF8.self), """ - # HELP foo Base metric name used for new metric of type gauge + # HELP foo Shared help text # TYPE foo gauge foo 0.0 @@ -521,77 +477,33 @@ final class HistogramTests: XCTestCase { 1.0, 5.0, 10.0, ] - let histogram0 = client.makeValueHistogram( - name: "foo", - labels: [], - buckets: sharedBuckets, - help: "Base metric name with no labels" - ) - let histogram1 = client.makeValueHistogram( name: "foo", labels: [("bar", "baz")], buckets: sharedBuckets, // Must match the first registration - help: "Base metric name with one label set variant" + help: "Shared help text" ) let histogram2 = client.makeValueHistogram( name: "foo", labels: [("bar", "newBaz"), ("newKey1", "newValue1")], buckets: sharedBuckets, // Must match the first registration - help: "Base metric name with a different label set variant" + help: "Shared help text" ) var buffer = [UInt8]() - histogram0.record(3.0) histogram1.record(6.0) histogram2.record(2.0) histogram1.record(12.0) - histogram0.record(8.0) histogram2.record(1.5) client.emit(into: &buffer) var outputString = String(decoding: buffer, as: Unicode.UTF8.self) var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) var expectedLines = Set([ - "# HELP foo Base metric name with no labels", - "# TYPE foo histogram", - "foo_bucket{le=\"1.0\"} 0", - "foo_bucket{le=\"5.0\"} 1", - "foo_bucket{le=\"10.0\"} 2", - "foo_bucket{le=\"+Inf\"} 2", - "foo_sum 11.0", - "foo_count 2", - - "# HELP foo Base metric name with one label set variant", + "# HELP foo Shared help text", "# TYPE foo histogram", - #"foo_bucket{bar="baz",le="1.0"} 0"#, - #"foo_bucket{bar="baz",le="5.0"} 0"#, - #"foo_bucket{bar="baz",le="10.0"} 1"#, - #"foo_bucket{bar="baz",le="+Inf"} 2"#, - #"foo_sum{bar="baz"} 18.0"#, - #"foo_count{bar="baz"} 2"#, - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo histogram", - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="1.0"} 0"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="5.0"} 2"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="10.0"} 2"#, - #"foo_bucket{bar="newBaz",newKey1="newValue1",le="+Inf"} 2"#, - #"foo_sum{bar="newBaz",newKey1="newValue1"} 3.5"#, - #"foo_count{bar="newBaz",newKey1="newValue1"} 2"#, - ]) - XCTAssertEqual(actualLines, expectedLines) - - // Histograms are unregistered in a cascade. - client.unregisterValueHistogram(histogram0) - buffer.removeAll(keepingCapacity: true) - client.emit(into: &buffer) - outputString = String(decoding: buffer, as: Unicode.UTF8.self) - actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) - expectedLines = Set([ - "# HELP foo Base metric name with one label set variant", - "# TYPE foo histogram", #"foo_bucket{bar="baz",le="1.0"} 0"#, #"foo_bucket{bar="baz",le="5.0"} 0"#, #"foo_bucket{bar="baz",le="10.0"} 1"#, @@ -599,8 +511,6 @@ final class HistogramTests: XCTestCase { #"foo_sum{bar="baz"} 18.0"#, #"foo_count{bar="baz"} 2"#, - "# HELP foo Base metric name with a different label set variant", - "# TYPE foo histogram", #"foo_bucket{bar="newBaz",newKey1="newValue1",le="1.0"} 0"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="5.0"} 2"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="10.0"} 2"#, @@ -610,13 +520,14 @@ final class HistogramTests: XCTestCase { ]) XCTAssertEqual(actualLines, expectedLines) + // Histograms are unregistered in a cascade. client.unregisterValueHistogram(histogram1) buffer.removeAll(keepingCapacity: true) client.emit(into: &buffer) outputString = String(decoding: buffer, as: Unicode.UTF8.self) actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty }) expectedLines = Set([ - "# HELP foo Base metric name with a different label set variant", + "# HELP foo Shared help text", "# TYPE foo histogram", #"foo_bucket{bar="newBaz",newKey1="newValue1",le="1.0"} 0"#, #"foo_bucket{bar="newBaz",newKey1="newValue1",le="5.0"} 2"#, @@ -638,14 +549,14 @@ final class HistogramTests: XCTestCase { let _ = client.makeGauge( name: "foo", labels: [], - help: "Base metric name used for new metric of type gauge" + help: "Shared help text" ) buffer.removeAll(keepingCapacity: true) client.emit(into: &buffer) XCTAssertEqual( String(decoding: buffer, as: Unicode.UTF8.self), """ - # HELP foo Base metric name used for new metric of type gauge + # HELP foo Shared help text # TYPE foo gauge foo 0.0 @@ -757,7 +668,6 @@ final class HistogramTests: XCTestCase { // 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")], @@ -979,7 +889,6 @@ final class HistogramTests: XCTestCase { // 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")], diff --git a/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift b/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift index c883f81..c70d641 100644 --- a/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift +++ b/Tests/PrometheusTests/PrometheusCollectorRegistryTests.swift @@ -261,4 +261,230 @@ final class PrometheusCollectorRegistryTests: XCTestCase { _ = registry.makeCounter(name: "name", labels: [("a", "1")]) } + + func testInternalBufferEmitToStringEmitToBuffer() { + let client = PrometheusCollectorRegistry() + + // Initially, buffer should have no capacity + XCTAssertEqual(client.internalBufferCapacity(), 0) + + // Create some metrics to establish buffer size. + let gauge1 = client.makeGauge(name: "test_gauge_1", labels: []) + let gauge2 = client.makeGauge(name: "test_gauge_2", labels: [("label", "value")]) + let counter = client.makeCounter(name: "test_counter", labels: []) + + gauge1.set(42.0) + gauge2.set(100.0) + counter.increment(by: 5.0) + + // First emission - start with emitToBuffer (this will auto-size the internal buffer). + let output1Buffer = client.emitToBuffer() + let output1String = client.emitToString() + + XCTAssertFalse(output1String.isEmpty) + XCTAssertFalse(output1Buffer.isEmpty) + + // Verify both outputs represent the same data. + XCTAssertEqual(output1String, String(decoding: output1Buffer, as: UTF8.self)) + + // Buffer should now have some capacity. + let initialCapacity = client.internalBufferCapacity() + XCTAssertGreaterThan(initialCapacity, 0) + + // Second emission - start with emitToString this time. + let output2String = client.emitToString() + let output2Buffer = client.emitToBuffer() + + // Same content regardless of method used. + XCTAssertEqual(output1String, output2String) + XCTAssertEqual(output1Buffer, output2Buffer) + XCTAssertEqual(output2String, String(decoding: output2Buffer, as: UTF8.self)) + XCTAssertEqual(client.internalBufferCapacity(), initialCapacity) // Same capacity + + // Reset the internal buffer. + client.resetInternalBuffer() + XCTAssertEqual(client.internalBufferCapacity(), 0) // Capacity should be reset to 0 + + // Add more metrics to change the required buffer size. + let histogram = client.makeValueHistogram( + name: "test_histogram", + labels: [("method", "GET"), ("status", "200")], + buckets: [0.1, 0.5, 1.0, 5.0, 10.0] + ) + histogram.record(2.5) + + // This emission should re-calibrate the buffer size - start with emitToString. + let output3String = client.emitToString() + let output3Buffer = client.emitToBuffer() + + XCTAssertTrue(output3String.contains("test_histogram")) + XCTAssertTrue(output3String.contains("test_gauge_1")) + XCTAssertEqual(output3String, String(decoding: output3Buffer, as: UTF8.self)) + + // Buffer should have a new capacity after re-calibration. + let recalibratedCapacity = client.internalBufferCapacity() + XCTAssertGreaterThan(recalibratedCapacity, 0) + XCTAssertGreaterThan(recalibratedCapacity, initialCapacity) + + // Add many more metrics after re-calibration to force buffer resizing. + // Add multiple gauges with long names and labels to increase buffer requirements. + var additionalGauges: [Gauge] = [] + for i in 0..<10 { + let gauge = client.makeGauge( + name: "test_gauge_with_very_long_name_to_increase_buffer_size_\(i)", + labels: [ + ("environment", "production_environment_with_long_value"), + ("service", "microservice_with_extremely_long_service_name"), + ("region", "us-west-2-availability-zone-1a-with-long-suffix"), + ("version", "v1.2.3-build-12345-commit-abcdef123456789"), + ] + ) + gauge.set(Double(i * 100)) + additionalGauges.append(gauge) + } + + // Add multiple counters with extensive labels. + var additionalCounters: [Counter] = [] + for i in 0..<5 { + let counter = client.makeCounter( + name: "http_requests_total_with_very_descriptive_name_\(i)", + labels: [ + ("method", "POST"), + ("endpoint", "/api/v1/users/profile/settings/notifications/preferences"), + ("status_code", "200"), + ("user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"), + ("client_ip", "192.168.1.100"), + ("request_id", "req-\(UUID().uuidString)"), + ] + ) + counter.increment(by: Double(i + 1) * 50) + additionalCounters.append(counter) + } + + // Add multiple histograms with many buckets. + var additionalHistograms: [ValueHistogram] = [] + for i in 0..<3 { + let histogram = client.makeValueHistogram( + name: "request_duration_seconds_detailed_histogram_\(i)", + labels: [ + ("service", "authentication-service-with-long-name"), + ("operation", "validate-user-credentials-and-permissions"), + ("database", "postgresql-primary-read-write-instance"), + ], + buckets: [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, 15.0, 20.0, + 30.0, + ] + ) + for j in 0..<20 { + histogram.record(Double(j) * 0.1 + Double(i)) + } + additionalHistograms.append(histogram) + } + + // This should definitely trigger buffer resizing due to massive amount of new content. + let output4String = client.emitToString() + let output4Buffer = client.emitToBuffer() + + XCTAssertTrue(output4String.contains("test_histogram")) + XCTAssertTrue(output4String.contains("test_gauge_with_very_long_name")) + XCTAssertTrue(output4String.contains("http_requests_total_with_very_descriptive_name")) + XCTAssertTrue(output4String.contains("request_duration_seconds_detailed_histogram")) + XCTAssertEqual(output4String, String(decoding: output4Buffer, as: UTF8.self)) + + // Buffer capacity should have grown significantly to accommodate all the additional metrics. + let newCapacity = client.internalBufferCapacity() + XCTAssertGreaterThan(newCapacity, 0) + XCTAssertGreaterThan(newCapacity, recalibratedCapacity) // Should be much larger now + + // Subsequent emission should reuse the new buffer size - start with emitToBuffer. + let output5Buffer = client.emitToBuffer() + let output5String = client.emitToString() + + XCTAssertEqual(output4String, output5String) + XCTAssertEqual(output4Buffer, output5Buffer) + XCTAssertEqual(output5String, String(decoding: output5Buffer, as: UTF8.self)) + XCTAssertEqual(client.internalBufferCapacity(), newCapacity) // Capacity unchanged + + // Verify buffer reset works with empty registry - unregister all metrics + client.unregisterGauge(gauge1) + client.unregisterGauge(gauge2) + client.unregisterCounter(counter) + client.unregisterValueHistogram(histogram) + + // Unregister all additional metrics. + for gauge in additionalGauges { + client.unregisterGauge(gauge) + } + for counter in additionalCounters { + client.unregisterCounter(counter) + } + for histogram in additionalHistograms { + client.unregisterValueHistogram(histogram) + } + + client.resetInternalBuffer() + XCTAssertEqual(client.internalBufferCapacity(), 0) // Reset to 0 again + + // Test empty output with both methods - start with emitToString. + let emptyOutputString = client.emitToString() + let emptyOutputBuffer = client.emitToBuffer() + + XCTAssertTrue(emptyOutputString.isEmpty) + XCTAssertTrue(emptyOutputBuffer.isEmpty) + XCTAssertEqual(emptyOutputString, String(decoding: emptyOutputBuffer, as: UTF8.self)) + + // With empty output, buffer capacity should remain 0. + let emptyCapacity = client.internalBufferCapacity() + XCTAssertEqual(emptyCapacity, 0) + } + + func testDefaultRegistryDedupTypeHelpPerMetricNameOnEmitWhenMetricNameSharedInMetricFamily() { + let client = PrometheusCollectorRegistry() + + let gauge1 = client.makeGauge( + name: "foo", + labels: [("bar", "baz")], + help: "Shared help text for all variants" + ) + + let gauge2 = client.makeGauge( + name: "foo", + labels: [("bar", "newBaz"), ("newKey1", "newValue1")], + help: "Shared help text for all variants" + ) + + gauge1.set(to: 9.0) + gauge2.set(to: 4.0) + + var buffer = [UInt8]() + client.emit(into: &buffer) + let outputString = String(decoding: buffer, as: Unicode.UTF8.self) + let lines = outputString.components(separatedBy: .newlines).filter { !$0.isEmpty } + + // Should have exactly one HELP and one TYPE line. + let helpLines = lines.filter { $0.hasPrefix("# HELP foo") } + let typeLines = lines.filter { $0.hasPrefix("# TYPE foo") } + let metricLines = lines.filter { $0.hasPrefix("foo") && !$0.hasPrefix("# ") } + + XCTAssertEqual(helpLines.count, 1, "Should have exactly one HELP line") + XCTAssertEqual(typeLines.count, 1, "Should have exactly one TYPE line") + XCTAssertEqual(metricLines.count, 2, "Should have three metric value lines") + + XCTAssertEqual(helpLines.first, "# HELP foo Shared help text for all variants") + XCTAssertEqual(typeLines.first, "# TYPE foo gauge") + + // Verify HELP and TYPE appear before any metric values. + let helpIndex = lines.firstIndex { $0.hasPrefix("# HELP foo") }! + let typeIndex = lines.firstIndex { $0.hasPrefix("# TYPE foo") }! + let firstMetricIndex = lines.firstIndex { $0.hasPrefix("foo") && !$0.hasPrefix("# ") }! + + XCTAssertLessThan(helpIndex, firstMetricIndex, "HELP should appear before metric values") + XCTAssertLessThan(typeIndex, firstMetricIndex, "TYPE should appear before metric values") + + // Verify all three metric values are present (order doesn't matter). + XCTAssertTrue(metricLines.contains(#"foo{bar="baz"} 9.0"#)) + XCTAssertTrue(metricLines.contains(#"foo{bar="newBaz",newKey1="newValue1"} 4.0"#)) + } + }