From 1ea08dd80f1d35b12f2620fab53757c1f4c6316d Mon Sep 17 00:00:00 2001 From: Melissa Kilby Date: Thu, 31 Jul 2025 16:12:32 -0700 Subject: [PATCH 1/2] new: add optional HELP line support Signed-off-by: Melissa Kilby --- Sources/Prometheus/MetricDescriptor.swift | 6 +- .../PrometheusCollectorRegistry.swift | 246 ++++++++++++------ Tests/PrometheusTests/CounterTests.swift | 121 ++++++--- Tests/PrometheusTests/GaugeTests.swift | 123 ++++++--- Tests/PrometheusTests/HistogramTests.swift | 207 ++++++++++++--- Tests/PrometheusTests/ValidNamesTests.swift | 21 ++ 6 files changed, 526 insertions(+), 198 deletions(-) diff --git a/Sources/Prometheus/MetricDescriptor.swift b/Sources/Prometheus/MetricDescriptor.swift index 9da5ddc..f72d8ea 100644 --- a/Sources/Prometheus/MetricDescriptor.swift +++ b/Sources/Prometheus/MetricDescriptor.swift @@ -30,7 +30,7 @@ public struct MetricNameDescriptor { /// An optional suffix describing the metric's unit (e.g., `total`). public let unitName: String? - /// Optional descriptive text for the metric. + /// Optional help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. public let helpText: String? /// Creates a new ``MetricNameDescriptor`` that defines the components of a fully qualified Prometheus metric name. @@ -39,7 +39,7 @@ public struct MetricNameDescriptor { /// - 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. + /// - Parameter helpText: Optional help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. public init( namespace: String? = nil, subsystem: String? = nil, @@ -55,7 +55,7 @@ public struct MetricNameDescriptor { self.helpText = helpText } - /// The fully qualified metric name, joining non-empty components with underscores. + /// The fully qualified metric name, joining non-empty components with underscores (e.g. `namespace_subsytem_metricName_unitName`). public var name: String { [namespace, subsystem, metricName, unitName] .compactMap { $0?.isEmpty == false ? $0 : nil } diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift index 3e2802d..c34e6f9 100644 --- a/Sources/Prometheus/PrometheusCollectorRegistry.swift +++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift @@ -48,14 +48,14 @@ public final class PrometheusCollectorRegistry: Sendable { } 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]) + case counter(Counter, String) + case counterWithLabels([String], [LabelsKey: Counter], String) + case gauge(Gauge, String) + case gaugeWithLabels([String], [LabelsKey: Gauge], String) + case durationHistogram(DurationHistogram, String) + case durationHistogramWithLabels([String], [LabelsKey: DurationHistogram], [Duration], String) + case valueHistogram(ValueHistogram, String) + case valueHistogramWithLabels([String], [LabelsKey: ValueHistogram], [Double], String) } private let box = NIOLockedValueBox([String: Metric]()) @@ -71,16 +71,18 @@ public final class PrometheusCollectorRegistry: Sendable { /// created ``Counter`` will be part of the export. /// /// - Parameter name: A name to identify ``Counter``'s value. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeCounter(name: String) -> Counter { + public func makeCounter(name: String, help: String = "") -> Counter { let name = name.ensureValidMetricName() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Counter in guard let value = store[name] else { let counter = Counter(name: name, labels: []) - store[name] = .counter(counter) + store[name] = .counter(counter, help) return counter } - guard case .counter(let counter) = value else { + guard case .counter(let counter, _) = value else { fatalError( """ Could not make Counter with name: \(name), since another metric type @@ -102,7 +104,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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) + return self.makeCounter(name: descriptor.name, help: descriptor.helpText ?? "") } /// Creates a new ``Counter`` collector or returns the already existing one with the same name. @@ -113,24 +115,26 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeCounter(name: String, labels: [(String, String)]) -> Counter { + public func makeCounter(name: String, labels: [(String, String)], help: String = "") -> Counter { guard !labels.isEmpty else { - return self.makeCounter(name: name) + return self.makeCounter(name: name, help: help) } let name = name.ensureValidMetricName() let labels = labels.ensureValidLabelNames() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Counter in guard let value = store[name] else { let labelNames = labels.allLabelNames let counter = Counter(name: name, labels: labels) - store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter]) + store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter], help) return counter } - guard case .counterWithLabels(let labelNames, var dimensionLookup) = value else { + guard case .counterWithLabels(let labelNames, var dimensionLookup, let help) = value else { fatalError( """ Could not make Counter with name: \(name) and labels: \(labels), since another @@ -157,7 +161,7 @@ public final class PrometheusCollectorRegistry: Sendable { let counter = Counter(name: name, labels: labels) dimensionLookup[key] = counter - store[name] = .counterWithLabels(labelNames, dimensionLookup) + store[name] = .counterWithLabels(labelNames, dimensionLookup, help) return counter } } @@ -173,7 +177,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// 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) + return self.makeCounter(name: descriptor.name, labels: labels, help: descriptor.helpText ?? "") } /// Creates a new ``Gauge`` collector or returns the already existing one with the same name. @@ -182,16 +186,18 @@ public final class PrometheusCollectorRegistry: Sendable { /// created ``Gauge`` will be part of the export. /// /// - Parameter name: A name to identify ``Gauge``'s value. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeGauge(name: String) -> Gauge { + public func makeGauge(name: String, help: String = "") -> Gauge { let name = name.ensureValidMetricName() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Gauge in guard let value = store[name] else { let gauge = Gauge(name: name, labels: []) - store[name] = .gauge(gauge) + store[name] = .gauge(gauge, help) return gauge } - guard case .gauge(let gauge) = value else { + guard case .gauge(let gauge, _) = value else { fatalError( """ Could not make Gauge with name: \(name), since another metric type already @@ -213,7 +219,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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) + return self.makeGauge(name: descriptor.name, help: descriptor.helpText ?? "") } /// Creates a new ``Gauge`` collector or returns the already existing one with the same name. @@ -224,24 +230,26 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeGauge(name: String, labels: [(String, String)]) -> Gauge { + public func makeGauge(name: String, labels: [(String, String)], help: String = "") -> Gauge { guard !labels.isEmpty else { - return self.makeGauge(name: name) + return self.makeGauge(name: name, help: help) } let name = name.ensureValidMetricName() let labels = labels.ensureValidLabelNames() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Gauge in guard let value = store[name] else { let labelNames = labels.allLabelNames let gauge = Gauge(name: name, labels: labels) - store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge]) + store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge], help) return gauge } - guard case .gaugeWithLabels(let labelNames, var dimensionLookup) = value else { + guard case .gaugeWithLabels(let labelNames, var dimensionLookup, let help) = value else { fatalError( """ Could not make Gauge with name: \(name) and labels: \(labels), since another @@ -268,7 +276,7 @@ public final class PrometheusCollectorRegistry: Sendable { let gauge = Gauge(name: name, labels: labels) dimensionLookup[key] = gauge - store[name] = .gaugeWithLabels(labelNames, dimensionLookup) + store[name] = .gaugeWithLabels(labelNames, dimensionLookup, help) return gauge } } @@ -284,7 +292,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// 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) + return self.makeGauge(name: descriptor.name, labels: labels, help: descriptor.helpText ?? "") } /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name. @@ -294,16 +302,18 @@ public final class PrometheusCollectorRegistry: Sendable { /// /// - Parameter name: A name to identify ``DurationHistogram``'s value. /// - Parameter buckets: Define the buckets that shall be used within the ``DurationHistogram`` + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeDurationHistogram(name: String, buckets: [Duration]) -> DurationHistogram { + public func makeDurationHistogram(name: String, buckets: [Duration], help: String = "") -> DurationHistogram { let name = name.ensureValidMetricName() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> DurationHistogram in guard let value = store[name] else { let gauge = DurationHistogram(name: name, labels: [], buckets: buckets) - store[name] = .durationHistogram(gauge) + store[name] = .durationHistogram(gauge, help) return gauge } - guard case .durationHistogram(let histogram) = value else { + guard case .durationHistogram(let histogram, _) = value else { fatalError( """ Could not make DurationHistogram with name: \(name), since another @@ -326,7 +336,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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) + return self.makeDurationHistogram(name: descriptor.name, buckets: buckets, help: descriptor.helpText ?? "") } /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name. @@ -338,28 +348,33 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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`` + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` public func makeDurationHistogram( name: String, labels: [(String, String)], - buckets: [Duration] + buckets: [Duration], + help: String = "" ) -> DurationHistogram { guard !labels.isEmpty else { - return self.makeDurationHistogram(name: name, buckets: buckets) + return self.makeDurationHistogram(name: name, buckets: buckets, help: help) } let name = name.ensureValidMetricName() let labels = labels.ensureValidLabelNames() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> DurationHistogram in guard let value = store[name] else { let labelNames = labels.allLabelNames let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets) - store[name] = .durationHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets) + store[name] = .durationHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets, help) return histogram } - guard case .durationHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets) = value + guard + case .durationHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets, let help) = + value else { fatalError( """ @@ -398,7 +413,7 @@ public final class PrometheusCollectorRegistry: Sendable { let histogram = DurationHistogram(name: name, labels: labels, buckets: storedBuckets) dimensionLookup[key] = histogram - store[name] = .durationHistogramWithLabels(labelNames, dimensionLookup, storedBuckets) + store[name] = .durationHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help) return histogram } } @@ -419,7 +434,12 @@ public final class PrometheusCollectorRegistry: Sendable { labels: [(String, String)], buckets: [Duration] ) -> DurationHistogram { - return self.makeDurationHistogram(name: descriptor.name, labels: labels, buckets: buckets) + return self.makeDurationHistogram( + name: descriptor.name, + labels: labels, + buckets: buckets, + help: descriptor.helpText ?? "" + ) } /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name. @@ -429,16 +449,18 @@ public final class PrometheusCollectorRegistry: Sendable { /// /// - Parameter name: A name to identify ``ValueHistogram``'s value. /// - Parameter buckets: Define the buckets that shall be used within the ``ValueHistogram`` + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeValueHistogram(name: String, buckets: [Double]) -> ValueHistogram { + public func makeValueHistogram(name: String, buckets: [Double], help: String = "") -> ValueHistogram { let name = name.ensureValidMetricName() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> ValueHistogram in guard let value = store[name] else { let gauge = ValueHistogram(name: name, labels: [], buckets: buckets) - store[name] = .valueHistogram(gauge) + store[name] = .valueHistogram(gauge, help) return gauge } - guard case .valueHistogram(let histogram) = value else { + guard case .valueHistogram(let histogram, _) = value else { fatalError() } @@ -456,7 +478,7 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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) + return self.makeValueHistogram(name: descriptor.name, buckets: buckets, help: descriptor.helpText ?? "") } /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name. @@ -468,24 +490,33 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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`` + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeValueHistogram(name: String, labels: [(String, String)], buckets: [Double]) -> ValueHistogram { + public func makeValueHistogram( + name: String, + labels: [(String, String)], + buckets: [Double], + help: String = "" + ) -> ValueHistogram { guard !labels.isEmpty else { - return self.makeValueHistogram(name: name, buckets: buckets) + return self.makeValueHistogram(name: name, buckets: buckets, help: help) } let name = name.ensureValidMetricName() let labels = labels.ensureValidLabelNames() + let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> ValueHistogram in guard let value = store[name] else { let labelNames = labels.allLabelNames let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets) - store[name] = .valueHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets) + store[name] = .valueHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets, help) return histogram } - guard case .valueHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets) = value else { + guard + case .valueHistogramWithLabels(let labelNames, var dimensionLookup, let storedBuckets, let help) = value + else { fatalError() } @@ -500,7 +531,7 @@ public final class PrometheusCollectorRegistry: Sendable { let histogram = ValueHistogram(name: name, labels: labels, buckets: storedBuckets) dimensionLookup[key] = histogram - store[name] = .valueHistogramWithLabels(labelNames, dimensionLookup, storedBuckets) + store[name] = .valueHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help) return histogram } } @@ -521,7 +552,12 @@ public final class PrometheusCollectorRegistry: Sendable { labels: [(String, String)], buckets: [Double] ) -> ValueHistogram { - return self.makeValueHistogram(name: descriptor.name, labels: labels, buckets: buckets) + return self.makeValueHistogram( + name: descriptor.name, + labels: labels, + buckets: buckets, + help: descriptor.helpText ?? "" + ) } // MARK: - Histogram @@ -536,17 +572,17 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterCounter(_ counter: Counter) { self.box.withLockedValue { store in switch store[counter.name] { - case .counter(let storedCounter): + case .counter(let storedCounter, _): guard storedCounter === counter else { return } store.removeValue(forKey: counter.name) - case .counterWithLabels(let labelNames, var dimensions): + case .counterWithLabels(let labelNames, var dimensions, let help): let labelsKey = LabelsKey(counter.labels) guard dimensions[labelsKey] === counter else { return } dimensions.removeValue(forKey: labelsKey) if dimensions.isEmpty { store.removeValue(forKey: counter.name) } else { - store[counter.name] = .counterWithLabels(labelNames, dimensions) + store[counter.name] = .counterWithLabels(labelNames, dimensions, help) } default: return @@ -562,17 +598,17 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterGauge(_ gauge: Gauge) { self.box.withLockedValue { store in switch store[gauge.name] { - case .gauge(let storedGauge): + case .gauge(let storedGauge, _): guard storedGauge === gauge else { return } store.removeValue(forKey: gauge.name) - case .gaugeWithLabels(let labelNames, var dimensions): + case .gaugeWithLabels(let labelNames, var dimensions, let help): let dimensionsKey = LabelsKey(gauge.labels) guard dimensions[dimensionsKey] === gauge else { return } dimensions.removeValue(forKey: dimensionsKey) if dimensions.isEmpty { store.removeValue(forKey: gauge.name) } else { - store[gauge.name] = .gaugeWithLabels(labelNames, dimensions) + store[gauge.name] = .gaugeWithLabels(labelNames, dimensions, help) } default: return @@ -588,17 +624,17 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterDurationHistogram(_ histogram: DurationHistogram) { self.box.withLockedValue { store in switch store[histogram.name] { - case .durationHistogram(let storedHistogram): + case .durationHistogram(let storedHistogram, _): guard storedHistogram === histogram else { return } store.removeValue(forKey: histogram.name) - case .durationHistogramWithLabels(let labelNames, var dimensions, let buckets): + case .durationHistogramWithLabels(let labelNames, var dimensions, let buckets, let help): let dimensionsKey = LabelsKey(histogram.labels) guard dimensions[dimensionsKey] === histogram else { return } dimensions.removeValue(forKey: dimensionsKey) if dimensions.isEmpty { store.removeValue(forKey: histogram.name) } else { - store[histogram.name] = .durationHistogramWithLabels(labelNames, dimensions, buckets) + store[histogram.name] = .durationHistogramWithLabels(labelNames, dimensions, buckets, help) } default: return @@ -614,17 +650,17 @@ public final class PrometheusCollectorRegistry: Sendable { public func unregisterValueHistogram(_ histogram: ValueHistogram) { self.box.withLockedValue { store in switch store[histogram.name] { - case .valueHistogram(let storedHistogram): + case .valueHistogram(let storedHistogram, _): guard storedHistogram === histogram else { return } store.removeValue(forKey: histogram.name) - case .valueHistogramWithLabels(let labelNames, var dimensions, let buckets): + case .valueHistogramWithLabels(let labelNames, var dimensions, let buckets, let help): let dimensionsKey = LabelsKey(histogram.labels) guard dimensions[dimensionsKey] === histogram else { return } dimensions.removeValue(forKey: dimensionsKey) if dimensions.isEmpty { store.removeValue(forKey: histogram.name) } else { - store[histogram.name] = .valueHistogramWithLabels(labelNames, dimensions, buckets) + store[histogram.name] = .valueHistogramWithLabels(labelNames, dimensions, buckets, help) } default: return @@ -636,45 +672,55 @@ public final class PrometheusCollectorRegistry: Sendable { public func emit(into buffer: inout [UInt8]) { let metrics = self.box.withLockedValue { $0 } + let prefixHelp = "HELP" + let prefixType = "TYPE" for (label, metric) in metrics { switch metric { - case .counter(let counter): - buffer.addTypeLine(label: label, type: "counter") + case .counter(let counter, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "counter") counter.emit(into: &buffer) - case .counterWithLabels(_, let counters): - buffer.addTypeLine(label: label, type: "counter") + case .counterWithLabels(_, let counters, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "counter") for counter in counters.values { counter.emit(into: &buffer) } - case .gauge(let gauge): - buffer.addTypeLine(label: label, type: "gauge") + case .gauge(let gauge, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "gauge") gauge.emit(into: &buffer) - case .gaugeWithLabels(_, let gauges): - buffer.addTypeLine(label: label, type: "gauge") + case .gaugeWithLabels(_, let gauges, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "gauge") for gauge in gauges.values { gauge.emit(into: &buffer) } - case .durationHistogram(let histogram): - buffer.addTypeLine(label: label, type: "histogram") + case .durationHistogram(let histogram, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "histogram") histogram.emit(into: &buffer) - case .durationHistogramWithLabels(_, let histograms, _): - buffer.addTypeLine(label: label, type: "histogram") + case .durationHistogramWithLabels(_, let histograms, _, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "histogram") for histogram in histograms.values { histogram.emit(into: &buffer) } - case .valueHistogram(let histogram): - buffer.addTypeLine(label: label, type: "histogram") + case .valueHistogram(let histogram, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "histogram") histogram.emit(into: &buffer) - case .valueHistogramWithLabels(_, let histograms, _): - buffer.addTypeLine(label: label, type: "histogram") + case .valueHistogramWithLabels(_, let histograms, _, let help): + help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help) + buffer.addLine(prefix: prefixType, name: label, value: "histogram") for histogram in histograms.values { histogram.emit(into: &buffer) } @@ -704,11 +750,13 @@ extension [(String, String)] { } extension [UInt8] { - fileprivate mutating func addTypeLine(label: String, type: String) { - self.append(contentsOf: #"# TYPE "#.utf8) - self.append(contentsOf: label.utf8) + fileprivate mutating func addLine(prefix: String, name: String, value: String) { + self.append(contentsOf: #"# "#.utf8) + self.append(contentsOf: prefix.utf8) + self.append(contentsOf: #" "#.utf8) + self.append(contentsOf: name.utf8) self.append(contentsOf: #" "#.utf8) - self.append(contentsOf: type.utf8) + self.append(contentsOf: value.utf8) self.append(contentsOf: #"\#n"#.utf8) } } @@ -780,6 +828,22 @@ extension String { return true } + fileprivate func isValidHelpText() -> Bool { + guard !self.isEmpty else { return true } + let containsInvalidCharacter = self.contains { character in + switch character { + case "\u{0000}"..."\u{001F}", // C0 Controls + "\u{007F}"..."\u{009F}", // C1 Controls + "\u{2028}", "\u{2029}": // Extra security, invisible but troublesome Unicode characters + return true // Remove + default: + return false + } + } + + return !containsInvalidCharacter + } + fileprivate func ensureValidMetricName() -> String { guard self.isValidMetricName() else { var new = self @@ -798,6 +862,15 @@ extension String { return self } + fileprivate func ensureValidHelpText() -> String { + guard self.isValidHelpText() else { + var new = self + new.fixPrometheusHelpText() + return new + } + return self + } + fileprivate mutating func fixPrometheusName(allowColon: Bool) { var startIndex = self.startIndex var isFirstCharacter = true @@ -821,4 +894,17 @@ extension String { } } } + + fileprivate mutating func fixPrometheusHelpText() { + self.removeAll { character in + switch character { + case "\u{0000}"..."\u{001F}", // C0 Controls + "\u{007F}"..."\u{009F}", // C1 Controls + "\u{2028}", "\u{2029}": // Extra security, invisible but troublesome Unicode characters + return true // Remove + default: + return false + } + } + } } diff --git a/Tests/PrometheusTests/CounterTests.swift b/Tests/PrometheusTests/CounterTests.swift index fc51908..535799f 100644 --- a/Tests/PrometheusTests/CounterTests.swift +++ b/Tests/PrometheusTests/CounterTests.swift @@ -167,76 +167,100 @@ final class CounterTests: XCTestCase { func testWithMetricNameDescriptorWithFullComponentMatrix() { // --- Test Constants --- - // let helpTextValue = "https://help.url/sub" - let metricName = "foo2" + let helpTextValue = "https://help.url/sub" + let metricNameWithHelp = "metric_with_help" + let metricNameWithoutHelp = "metric_without_help" 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: String?, subsystem: String?, metricName: String, unitName: String?, + expectedMetricName: String, help: String?, description: String )] = [ + // --- Test 1: Cases with help text (using metricNameWithHelp) ( - namespace: "myapp", subsystem: "subsystem", unitName: "total", - expectedMetricName: "myapp_subsystem_foo2_total", description: "All components present" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_with_help_total", help: helpTextValue, + description: "All components present, with help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: nil, - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_subsystem_metric_with_help", help: helpTextValue, + description: "Unit is nil, with help text" ), ( - namespace: "myapp", subsystem: nil, unitName: "total", expectedMetricName: "myapp_foo2_total", - description: "Subsystem is nil" + namespace: "myapp", subsystem: "", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_metric_with_help_total", help: helpTextValue, + description: "Subsystem is empty string, with help text" ), ( - namespace: "myapp", subsystem: nil, unitName: nil, expectedMetricName: "myapp_foo2", - description: "Subsystem and Unit are nil" + namespace: "myapp", subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_metric_with_help", help: helpTextValue, + description: "Subsystem and Unit are nil, with help text" ), ( - namespace: nil, subsystem: "subsystem", unitName: "total", - expectedMetricName: "subsystem_foo2_total", description: "Namespace is nil" + namespace: "", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "subsystem_metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, with help text" ), ( - namespace: nil, subsystem: "subsystem", unitName: nil, expectedMetricName: "subsystem_foo2", - description: "Namespace and Unit are nil" + namespace: nil, subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "", + expectedMetricName: "subsystem_metric_with_help", help: helpTextValue, + description: "Namespace is nil, Unit is empty string, with help text" ), ( - namespace: nil, subsystem: nil, unitName: "total", expectedMetricName: "foo2_total", - description: "Namespace and Subsystem are nil" + namespace: "", subsystem: nil, metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, Subsystem is nil, with help text" ), ( - namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", - description: "Only metric name is present" + namespace: nil, subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "metric_with_help", help: helpTextValue, + description: "Only metric name is present (all nil), with help text" + ), + + // --- Test 2: Cases without help text (using metricNameWithoutHelp) + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_without_help_total", help: nil, + description: "All components present, no help text" ), ( - namespace: "", subsystem: "subsystem", unitName: "total", - expectedMetricName: "subsystem_foo2_total", description: "Namespace is empty string" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "myapp_subsystem_metric_without_help", help: nil, + description: "Unit is empty string, no help text" ), ( - namespace: "myapp", subsystem: "", unitName: "total", expectedMetricName: "myapp_foo2_total", - description: "Subsystem is empty string" + namespace: "myapp", subsystem: nil, metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_metric_without_help_total", help: nil, + description: "Subsystem is nil, no help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: "", - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is empty string" + namespace: "myapp", subsystem: "", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "myapp_metric_without_help", help: nil, + description: "Subsystem is empty string, Unit is nil, no help text" ), ( - namespace: "", subsystem: "", unitName: "total", expectedMetricName: "foo2_total", - description: "Namespace and Subsystem are empty strings" + namespace: nil, subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "subsystem_metric_without_help_total", help: nil, + description: "Namespace is nil, no help text" ), ( - namespace: "myapp", subsystem: "", unitName: "", expectedMetricName: "myapp_foo2", - description: "Subsystem and Unit are empty strings" + namespace: "", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "subsystem_metric_without_help", help: nil, + description: "Namespace is empty string, Unit is nil, no help text" ), ( - namespace: "", subsystem: "subsystem", unitName: "", expectedMetricName: "subsystem_foo2", - description: "Namespace and Unit are empty strings" + namespace: nil, subsystem: "", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "metric_without_help_total", help: nil, + description: "Namespace is nil, Subsystem is empty string, no help text" ), ( - namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", - description: "All optional components are empty strings" + namespace: "", subsystem: "", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "metric_without_help", help: nil, + description: "Only metric name is present (all empty strings), no help text" ), ] @@ -262,22 +286,35 @@ final class CounterTests: XCTestCase { let expectedMetricLine = "\(nameCase.expectedMetricName)\(labelCase.expectedLabelString) \(incrementValue)" - // Case: Without help text (helpText is nil) + var expectedOutput: String + if let helpText = nameCase.help, !helpText.isEmpty { + // If help text exists and is not empty, include the # HELP line. + expectedOutput = """ + # HELP \(nameCase.expectedMetricName) \(helpText) + # TYPE \(nameCase.expectedMetricName) counter + \(expectedMetricLine) + + """ + } else { + // Otherwise, use the original format without the # HELP line. + expectedOutput = """ + # TYPE \(nameCase.expectedMetricName) counter + \(expectedMetricLine) + + """ + } + allTestCases.append( ( descriptor: MetricNameDescriptor( namespace: nameCase.namespace, subsystem: nameCase.subsystem, - metricName: metricName, + metricName: nameCase.metricName, unitName: nameCase.unitName, - helpText: nil + helpText: nameCase.help ), labels: labelCase.labels, - expectedOutput: """ - # TYPE \(nameCase.expectedMetricName) counter - \(expectedMetricLine) - - """, + expectedOutput: expectedOutput, // Use the pre-calculated string failureDescription: "\(nameCase.description), \(labelCase.description)" ) ) diff --git a/Tests/PrometheusTests/GaugeTests.swift b/Tests/PrometheusTests/GaugeTests.swift index 3dfc5d7..1f7376e 100644 --- a/Tests/PrometheusTests/GaugeTests.swift +++ b/Tests/PrometheusTests/GaugeTests.swift @@ -178,76 +178,100 @@ final class GaugeTests: XCTestCase { func testWithMetricNameDescriptorWithFullComponentMatrix() { // --- Test Constants --- - // let helpTextValue = "https://help.url/sub" - let metricName = "foo2" - let incrementValue: Double = 1.0 + let helpTextValue = "https://help.url/sub" + let metricNameWithHelp = "metric_with_help" + let metricNameWithoutHelp = "metric_without_help" + let incrementValue: Double = 2.0 let client = PrometheusCollectorRegistry() // 1. Define the base naming combinations first. let baseNameCases: [( - namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, - description: String + namespace: String?, subsystem: String?, metricName: String, unitName: String?, + expectedMetricName: String, help: String?, description: String )] = [ + // --- Test 1: Cases with help text (using metricNameWithHelp) ( - namespace: "myapp", subsystem: "subsystem", unitName: "bytes", - expectedMetricName: "myapp_subsystem_foo2_bytes", description: "All components present" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_with_help_total", help: helpTextValue, + description: "All components present, with help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: nil, - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_subsystem_metric_with_help", help: helpTextValue, + description: "Unit is nil, with help text" ), ( - namespace: "myapp", subsystem: nil, unitName: "bytes", expectedMetricName: "myapp_foo2_bytes", - description: "Subsystem is nil" + namespace: "myapp", subsystem: "", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_metric_with_help_total", help: helpTextValue, + description: "Subsystem is empty string, with help text" ), ( - namespace: "myapp", subsystem: nil, unitName: nil, expectedMetricName: "myapp_foo2", - description: "Subsystem and Unit are nil" + namespace: "myapp", subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_metric_with_help", help: helpTextValue, + description: "Subsystem and Unit are nil, with help text" ), ( - namespace: nil, subsystem: "subsystem", unitName: "bytes", - expectedMetricName: "subsystem_foo2_bytes", description: "Namespace is nil" + namespace: "", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "subsystem_metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, with help text" ), ( - namespace: nil, subsystem: "subsystem", unitName: nil, expectedMetricName: "subsystem_foo2", - description: "Namespace and Unit are nil" + namespace: nil, subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "", + expectedMetricName: "subsystem_metric_with_help", help: helpTextValue, + description: "Namespace is nil, Unit is empty string, with help text" ), ( - namespace: nil, subsystem: nil, unitName: "bytes", expectedMetricName: "foo2_bytes", - description: "Namespace and Subsystem are nil" + namespace: "", subsystem: nil, metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, Subsystem is nil, with help text" ), ( - namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", - description: "Only metric name is present" + namespace: nil, subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "metric_with_help", help: helpTextValue, + description: "Only metric name is present (all nil), with help text" + ), + + // --- Test 2: Cases without help text (using metricNameWithoutHelp) + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_without_help_total", help: nil, + description: "All components present, no help text" ), ( - namespace: "", subsystem: "subsystem", unitName: "bytes", - expectedMetricName: "subsystem_foo2_bytes", description: "Namespace is empty string" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "myapp_subsystem_metric_without_help", help: nil, + description: "Unit is empty string, no help text" ), ( - namespace: "myapp", subsystem: "", unitName: "bytes", expectedMetricName: "myapp_foo2_bytes", - description: "Subsystem is empty string" + namespace: "myapp", subsystem: nil, metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_metric_without_help_total", help: nil, + description: "Subsystem is nil, no help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: "", - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is empty string" + namespace: "myapp", subsystem: "", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "myapp_metric_without_help", help: nil, + description: "Subsystem is empty string, Unit is nil, no help text" ), ( - namespace: "", subsystem: "", unitName: "bytes", expectedMetricName: "foo2_bytes", - description: "Namespace and Subsystem are empty strings" + namespace: nil, subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "subsystem_metric_without_help_total", help: nil, + description: "Namespace is nil, no help text" ), ( - namespace: "myapp", subsystem: "", unitName: "", expectedMetricName: "myapp_foo2", - description: "Subsystem and Unit are empty strings" + namespace: "", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "subsystem_metric_without_help", help: nil, + description: "Namespace is empty string, Unit is nil, no help text" ), ( - namespace: "", subsystem: "subsystem", unitName: "", expectedMetricName: "subsystem_foo2", - description: "Namespace and Unit are empty strings" + namespace: nil, subsystem: "", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "metric_without_help_total", help: nil, + description: "Namespace is nil, Subsystem is empty string, no help text" ), ( - namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", - description: "All optional components are empty strings" + namespace: "", subsystem: "", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "metric_without_help", help: nil, + description: "Only metric name is present (all empty strings), no help text" ), ] @@ -273,22 +297,35 @@ final class GaugeTests: XCTestCase { let expectedMetricLine = "\(nameCase.expectedMetricName)\(labelCase.expectedLabelString) \(incrementValue)" - // Case: Without help text (helpText is nil) + var expectedOutput: String + if let helpText = nameCase.help, !helpText.isEmpty { + // If help text exists and is not empty, include the # HELP line. + expectedOutput = """ + # HELP \(nameCase.expectedMetricName) \(helpText) + # TYPE \(nameCase.expectedMetricName) gauge + \(expectedMetricLine) + + """ + } else { + // Otherwise, use the original format without the # HELP line. + expectedOutput = """ + # TYPE \(nameCase.expectedMetricName) gauge + \(expectedMetricLine) + + """ + } + allTestCases.append( ( descriptor: MetricNameDescriptor( namespace: nameCase.namespace, subsystem: nameCase.subsystem, - metricName: metricName, + metricName: nameCase.metricName, unitName: nameCase.unitName, - helpText: nil + helpText: nameCase.help ), labels: labelCase.labels, - expectedOutput: """ - # TYPE \(nameCase.expectedMetricName) gauge - \(expectedMetricLine) - - """, + expectedOutput: expectedOutput, // Use the pre-calculated string failureDescription: "\(nameCase.description), \(labelCase.description)" ) ) diff --git a/Tests/PrometheusTests/HistogramTests.swift b/Tests/PrometheusTests/HistogramTests.swift index a48ae7e..fc5d60b 100644 --- a/Tests/PrometheusTests/HistogramTests.swift +++ b/Tests/PrometheusTests/HistogramTests.swift @@ -345,7 +345,9 @@ final class HistogramTests: XCTestCase { func testValueHistogramWithMetricNameDescriptorWithFullComponentMatrix() { // --- Test Constants --- - let metricName = "foo2" + let helpTextValue = "https://help.url/sub" + let metricNameWithHelp = "metric_with_help" + let metricNameWithoutHelp = "metric_without_help" let observeValue: Double = 0.8 let buckets: [Double] = [0.5, 1.0, 2.5] let client = PrometheusCollectorRegistry() @@ -353,24 +355,91 @@ final class HistogramTests: XCTestCase { // 1. Define the base naming combinations first. let baseNameCases: [( - namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, - description: String + namespace: String?, subsystem: String?, metricName: String, unitName: String?, + expectedMetricName: String, help: String?, description: String )] = [ + // --- Test 1: Cases with help text (using metricNameWithHelp) ( - namespace: "myapp", subsystem: "subsystem", unitName: "values", - expectedMetricName: "myapp_subsystem_foo2_values", description: "All components present" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_with_help_total", help: helpTextValue, + description: "All components present, with help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: nil, - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_subsystem_metric_with_help", help: helpTextValue, + description: "Unit is nil, with help text" ), ( - namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", - description: "Only metric name is present" + namespace: "myapp", subsystem: "", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_metric_with_help_total", help: helpTextValue, + description: "Subsystem is empty string, with help text" ), ( - namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", - description: "All optional components are empty strings" + namespace: "myapp", subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_metric_with_help", help: helpTextValue, + description: "Subsystem and Unit are nil, with help text" + ), + ( + namespace: "", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "subsystem_metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, with help text" + ), + ( + namespace: nil, subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "", + expectedMetricName: "subsystem_metric_with_help", help: helpTextValue, + description: "Namespace is nil, Unit is empty string, with help text" + ), + ( + namespace: "", subsystem: nil, metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, Subsystem is nil, with help text" + ), + ( + namespace: nil, subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "metric_with_help", help: helpTextValue, + description: "Only metric name is present (all nil), with help text" + ), + + // --- Test 2: Cases without help text (using metricNameWithoutHelp) + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_without_help_total", help: nil, + description: "All components present, no help text" + ), + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "myapp_subsystem_metric_without_help", help: nil, + description: "Unit is empty string, no help text" + ), + ( + namespace: "myapp", subsystem: nil, metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_metric_without_help_total", help: nil, + description: "Subsystem is nil, no help text" + ), + ( + namespace: "myapp", subsystem: "", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "myapp_metric_without_help", help: nil, + description: "Subsystem is empty string, Unit is nil, no help text" + ), + ( + namespace: nil, subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "subsystem_metric_without_help_total", help: nil, + description: "Namespace is nil, no help text" + ), + ( + namespace: "", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "subsystem_metric_without_help", help: nil, + description: "Namespace is empty string, Unit is nil, no help text" + ), + ( + namespace: nil, subsystem: "", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "metric_without_help_total", help: nil, + description: "Namespace is nil, Subsystem is empty string, no help text" + ), + ( + namespace: "", subsystem: "", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "metric_without_help", help: nil, + description: "Only metric name is present (all empty strings), no help text" ), ] @@ -396,16 +465,17 @@ final class HistogramTests: XCTestCase { let descriptor = MetricNameDescriptor( namespace: nameCase.namespace, subsystem: nameCase.subsystem, - metricName: metricName, + metricName: nameCase.metricName, unitName: nameCase.unitName, - helpText: nil + helpText: nameCase.help ) let expectedOutput = self.generateHistogramOutput( metricName: nameCase.expectedMetricName, labelString: labelCase.expectedLabelString, buckets: buckets, - observedValue: observeValue + observedValue: observeValue, + helpText: nameCase.help ?? "" ) let failureDescription = "ValueHistogram: \(nameCase.description), \(labelCase.description)" @@ -492,7 +562,9 @@ final class HistogramTests: XCTestCase { func testDurationHistogramWithMetricNameDescriptorWithFullComponentMatrix() { // --- Test Constants --- - let metricName = "foo2" + let helpTextValue = "https://help.url/sub" + let metricNameWithHelp = "metric_with_help" + let metricNameWithoutHelp = "metric_without_help" let observeValue = Duration.milliseconds(400) let buckets: [Duration] = [ .milliseconds(100), @@ -505,24 +577,91 @@ final class HistogramTests: XCTestCase { // 1. Define the base naming combinations first. let baseNameCases: [( - namespace: String?, subsystem: String?, unitName: String?, expectedMetricName: String, - description: String + namespace: String?, subsystem: String?, metricName: String, unitName: String?, + expectedMetricName: String, help: String?, description: String )] = [ + // --- Test 1: Cases with help text (using metricNameWithHelp) + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_with_help_total", help: helpTextValue, + description: "All components present, with help text" + ), + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_subsystem_metric_with_help", help: helpTextValue, + description: "Unit is nil, with help text" + ), + ( + namespace: "myapp", subsystem: "", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "myapp_metric_with_help_total", help: helpTextValue, + description: "Subsystem is empty string, with help text" + ), + ( + namespace: "myapp", subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "myapp_metric_with_help", help: helpTextValue, + description: "Subsystem and Unit are nil, with help text" + ), + ( + namespace: "", subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "subsystem_metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, with help text" + ), + ( + namespace: nil, subsystem: "subsystem", metricName: metricNameWithHelp, unitName: "", + expectedMetricName: "subsystem_metric_with_help", help: helpTextValue, + description: "Namespace is nil, Unit is empty string, with help text" + ), + ( + namespace: "", subsystem: nil, metricName: metricNameWithHelp, unitName: "total", + expectedMetricName: "metric_with_help_total", help: helpTextValue, + description: "Namespace is empty string, Subsystem is nil, with help text" + ), ( - namespace: "myapp", subsystem: "subsystem", unitName: "seconds", - expectedMetricName: "myapp_subsystem_foo2_seconds", description: "All components present" + namespace: nil, subsystem: nil, metricName: metricNameWithHelp, unitName: nil, + expectedMetricName: "metric_with_help", help: helpTextValue, + description: "Only metric name is present (all nil), with help text" + ), + + // --- Test 2: Cases without help text (using metricNameWithoutHelp) + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_subsystem_metric_without_help_total", help: nil, + description: "All components present, no help text" + ), + ( + namespace: "myapp", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "myapp_subsystem_metric_without_help", help: nil, + description: "Unit is empty string, no help text" + ), + ( + namespace: "myapp", subsystem: nil, metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "myapp_metric_without_help_total", help: nil, + description: "Subsystem is nil, no help text" ), ( - namespace: "myapp", subsystem: "subsystem", unitName: nil, - expectedMetricName: "myapp_subsystem_foo2", description: "Unit is nil" + namespace: "myapp", subsystem: "", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "myapp_metric_without_help", help: nil, + description: "Subsystem is empty string, Unit is nil, no help text" ), ( - namespace: nil, subsystem: nil, unitName: nil, expectedMetricName: "foo2", - description: "Only metric name is present" + namespace: nil, subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "subsystem_metric_without_help_total", help: nil, + description: "Namespace is nil, no help text" ), ( - namespace: "", subsystem: "", unitName: "", expectedMetricName: "foo2", - description: "All optional components are empty strings" + namespace: "", subsystem: "subsystem", metricName: metricNameWithoutHelp, unitName: nil, + expectedMetricName: "subsystem_metric_without_help", help: nil, + description: "Namespace is empty string, Unit is nil, no help text" + ), + ( + namespace: nil, subsystem: "", metricName: metricNameWithoutHelp, unitName: "total", + expectedMetricName: "metric_without_help_total", help: nil, + description: "Namespace is nil, Subsystem is empty string, no help text" + ), + ( + namespace: "", subsystem: "", metricName: metricNameWithoutHelp, unitName: "", + expectedMetricName: "metric_without_help", help: nil, + description: "Only metric name is present (all empty strings), no help text" ), ] @@ -548,9 +687,9 @@ final class HistogramTests: XCTestCase { let descriptor = MetricNameDescriptor( namespace: nameCase.namespace, subsystem: nameCase.subsystem, - metricName: metricName, + metricName: nameCase.metricName, unitName: nameCase.unitName, - helpText: nil + helpText: nameCase.help ) // Convert Durations to Doubles (seconds) for expected output generation @@ -565,7 +704,8 @@ final class HistogramTests: XCTestCase { metricName: nameCase.expectedMetricName, labelString: labelCase.expectedLabelString, buckets: bucketsInSeconds, - observedValue: observedValueInSeconds + observedValue: observedValueInSeconds, + helpText: nameCase.help ?? "" ) let failureDescription = "DurationHistogram: \(nameCase.description), \(labelCase.description)" @@ -657,9 +797,16 @@ final class HistogramTests: XCTestCase { metricName: String, labelString: String, buckets: [Double], - observedValue: Double + observedValue: Double, + helpText: String = "" ) -> String { - var output = "# TYPE \(metricName) histogram\n" + + var output: String = "" + if !helpText.isEmpty { + // If help text is not empty, include the # HELP line. + output += "# HELP \(metricName) \(helpText)\n" + } + output += "# TYPE \(metricName) histogram\n" let labelsWithLe = { (le: String) -> String in guard labelString.isEmpty else { // Insert 'le' at the end of the existing labels diff --git a/Tests/PrometheusTests/ValidNamesTests.swift b/Tests/PrometheusTests/ValidNamesTests.swift index ca099e5..a65960f 100644 --- a/Tests/PrometheusTests/ValidNamesTests.swift +++ b/Tests/PrometheusTests/ValidNamesTests.swift @@ -92,4 +92,25 @@ final class ValidNamesTests: XCTestCase { """ ) } + + func testIllegalHelpText() async throws { + let registry = PrometheusCollectorRegistry() + + registry.makeCounter( + name: "metric", + labels: [("key", "value")], + help: "T\0his# is an_ \u{001B}example\u{001B} (help-\r\nt\u{2028}ext), link: https://help.url/sub" + ).increment() + + var buffer = [UInt8]() + registry.emit(into: &buffer) + XCTAssertEqual( + String(decoding: buffer, as: Unicode.UTF8.self).split(separator: "\n").sorted().joined(separator: "\n"), + """ + # HELP metric This# is an_ example (help-text), link: https://help.url/sub + # TYPE metric counter + metric{key="value"} 1 + """ + ) + } } From ddc3cf410a3958b6fbfa50fa7a99ffc640d18387 Mon Sep 17 00:00:00 2001 From: Melissa Kilby Date: Fri, 1 Aug 2025 11:49:19 -0700 Subject: [PATCH 2/2] update: reviewers suggestions re optional HELP line support Notably, add consistent overloads to maintain ABI stability Plus adjust `ValidHelpText` approach for broader robustness Signed-off-by: Melissa Kilby Co-authored-by: Konrad `ktoso` Malawski --- Sources/Prometheus/MetricDescriptor.swift | 6 +- .../PrometheusCollectorRegistry.swift | 249 +++++++++++++----- Tests/PrometheusTests/ValidNamesTests.swift | 3 +- 3 files changed, 195 insertions(+), 63 deletions(-) diff --git a/Sources/Prometheus/MetricDescriptor.swift b/Sources/Prometheus/MetricDescriptor.swift index f72d8ea..577c1fa 100644 --- a/Sources/Prometheus/MetricDescriptor.swift +++ b/Sources/Prometheus/MetricDescriptor.swift @@ -30,7 +30,8 @@ public struct MetricNameDescriptor { /// An optional suffix describing the metric's unit (e.g., `total`). public let unitName: String? - /// Optional help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// Optional help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. public let helpText: String? /// Creates a new ``MetricNameDescriptor`` that defines the components of a fully qualified Prometheus metric name. @@ -39,7 +40,8 @@ public struct MetricNameDescriptor { /// - 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 help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter helpText: Optional help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. public init( namespace: String? = nil, subsystem: String? = nil, diff --git a/Sources/Prometheus/PrometheusCollectorRegistry.swift b/Sources/Prometheus/PrometheusCollectorRegistry.swift index c34e6f9..ca4faf6 100644 --- a/Sources/Prometheus/PrometheusCollectorRegistry.swift +++ b/Sources/Prometheus/PrometheusCollectorRegistry.swift @@ -48,14 +48,14 @@ public final class PrometheusCollectorRegistry: Sendable { } private enum Metric { - case counter(Counter, String) - case counterWithLabels([String], [LabelsKey: Counter], String) - case gauge(Gauge, String) - case gaugeWithLabels([String], [LabelsKey: Gauge], String) - case durationHistogram(DurationHistogram, String) - case durationHistogramWithLabels([String], [LabelsKey: DurationHistogram], [Duration], String) - case valueHistogram(ValueHistogram, String) - case valueHistogramWithLabels([String], [LabelsKey: ValueHistogram], [Double], String) + case counter(Counter, help: String) + case counterWithLabels([String], [LabelsKey: Counter], help: String) + case gauge(Gauge, help: String) + case gaugeWithLabels([String], [LabelsKey: Gauge], help: String) + case durationHistogram(DurationHistogram, help: String) + case durationHistogramWithLabels([String], [LabelsKey: DurationHistogram], [Duration], help: String) + case valueHistogram(ValueHistogram, help: String) + case valueHistogramWithLabels([String], [LabelsKey: ValueHistogram], [Double], help: String) } private let box = NIOLockedValueBox([String: Metric]()) @@ -71,15 +71,16 @@ public final class PrometheusCollectorRegistry: Sendable { /// created ``Counter`` will be part of the export. /// /// - Parameter name: A name to identify ``Counter``'s value. - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeCounter(name: String, help: String = "") -> Counter { + public func makeCounter(name: String, help: String) -> Counter { let name = name.ensureValidMetricName() let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Counter in guard let value = store[name] else { let counter = Counter(name: name, labels: []) - store[name] = .counter(counter, help) + store[name] = .counter(counter, help: help) return counter } guard case .counter(let counter, _) = value else { @@ -95,6 +96,17 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// 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 { + return self.makeCounter(name: name, help: "") + } + /// Creates a new ``Counter`` collector or returns the already existing one with the same name, /// based on the provided descriptor. /// @@ -115,9 +127,10 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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. - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeCounter(name: String, labels: [(String, String)], help: String = "") -> Counter { + public func makeCounter(name: String, labels: [(String, String)], help: String) -> Counter { guard !labels.isEmpty else { return self.makeCounter(name: name, help: help) } @@ -131,7 +144,7 @@ public final class PrometheusCollectorRegistry: Sendable { let labelNames = labels.allLabelNames let counter = Counter(name: name, labels: labels) - store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter], help) + store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter], help: help) return counter } guard case .counterWithLabels(let labelNames, var dimensionLookup, let help) = value else { @@ -161,11 +174,24 @@ public final class PrometheusCollectorRegistry: Sendable { let counter = Counter(name: name, labels: labels) dimensionLookup[key] = counter - store[name] = .counterWithLabels(labelNames, dimensionLookup, help) + store[name] = .counterWithLabels(labelNames, dimensionLookup, help: help) 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 { + return self.makeCounter(name: name, labels: labels, help: "") + } + /// Creates a new ``Counter`` collector or returns the already existing one with the same name, /// based on the provided descriptor. /// @@ -186,15 +212,16 @@ public final class PrometheusCollectorRegistry: Sendable { /// created ``Gauge`` will be part of the export. /// /// - Parameter name: A name to identify ``Gauge``'s value. - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeGauge(name: String, help: String = "") -> Gauge { + public func makeGauge(name: String, help: String) -> Gauge { let name = name.ensureValidMetricName() let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> Gauge in guard let value = store[name] else { let gauge = Gauge(name: name, labels: []) - store[name] = .gauge(gauge, help) + store[name] = .gauge(gauge, help: help) return gauge } guard case .gauge(let gauge, _) = value else { @@ -210,6 +237,17 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// 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 { + return self.makeGauge(name: name, help: "") + } + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name, /// based on the provided descriptor. /// @@ -230,9 +268,10 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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. - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeGauge(name: String, labels: [(String, String)], help: String = "") -> Gauge { + public func makeGauge(name: String, labels: [(String, String)], help: String) -> Gauge { guard !labels.isEmpty else { return self.makeGauge(name: name, help: help) } @@ -246,7 +285,7 @@ public final class PrometheusCollectorRegistry: Sendable { let labelNames = labels.allLabelNames let gauge = Gauge(name: name, labels: labels) - store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge], help) + store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge], help: help) return gauge } guard case .gaugeWithLabels(let labelNames, var dimensionLookup, let help) = value else { @@ -276,11 +315,24 @@ public final class PrometheusCollectorRegistry: Sendable { let gauge = Gauge(name: name, labels: labels) dimensionLookup[key] = gauge - store[name] = .gaugeWithLabels(labelNames, dimensionLookup, help) + store[name] = .gaugeWithLabels(labelNames, dimensionLookup, help: help) 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 { + return self.makeGauge(name: name, labels: labels, help: "") + } + /// Creates a new ``Gauge`` collector or returns the already existing one with the same name and labels, /// based on the provided descriptor. /// @@ -302,15 +354,16 @@ public final class PrometheusCollectorRegistry: Sendable { /// /// - Parameter name: A name to identify ``DurationHistogram``'s value. /// - Parameter buckets: Define the buckets that shall be used within the ``DurationHistogram`` - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeDurationHistogram(name: String, buckets: [Duration], help: String = "") -> DurationHistogram { + public func makeDurationHistogram(name: String, buckets: [Duration], help: String) -> DurationHistogram { let name = name.ensureValidMetricName() let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> DurationHistogram in guard let value = store[name] else { let gauge = DurationHistogram(name: name, labels: [], buckets: buckets) - store[name] = .durationHistogram(gauge, help) + store[name] = .durationHistogram(gauge, help: help) return gauge } guard case .durationHistogram(let histogram, _) = value else { @@ -326,6 +379,18 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// 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 { + return self.makeDurationHistogram(name: name, buckets: buckets, help: "") + } + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name, /// based on the provided descriptor. /// @@ -348,13 +413,14 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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`` - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``DurationHistogram`` that is registered with this ``PrometheusCollectorRegistry`` public func makeDurationHistogram( name: String, labels: [(String, String)], buckets: [Duration], - help: String = "" + help: String ) -> DurationHistogram { guard !labels.isEmpty else { return self.makeDurationHistogram(name: name, buckets: buckets, help: help) @@ -369,7 +435,12 @@ public final class PrometheusCollectorRegistry: Sendable { let labelNames = labels.allLabelNames let histogram = DurationHistogram(name: name, labels: labels, buckets: buckets) - store[name] = .durationHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets, help) + store[name] = .durationHistogramWithLabels( + labelNames, + [LabelsKey(labels): histogram], + buckets, + help: help + ) return histogram } guard @@ -413,11 +484,34 @@ public final class PrometheusCollectorRegistry: Sendable { let histogram = DurationHistogram(name: name, labels: labels, buckets: storedBuckets) dimensionLookup[key] = histogram - store[name] = .durationHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help) + store[name] = .durationHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help: help) return histogram } } + /// 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 { + return self.makeDurationHistogram( + name: name, + labels: labels, + buckets: buckets, + help: "" + ) + } + /// Creates a new ``DurationHistogram`` collector or returns the already existing one with the same name and labels, /// based on the provided descriptor. /// @@ -449,15 +543,16 @@ public final class PrometheusCollectorRegistry: Sendable { /// /// - Parameter name: A name to identify ``ValueHistogram``'s value. /// - Parameter buckets: Define the buckets that shall be used within the ``ValueHistogram`` - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` - public func makeValueHistogram(name: String, buckets: [Double], help: String = "") -> ValueHistogram { + public func makeValueHistogram(name: String, buckets: [Double], help: String) -> ValueHistogram { let name = name.ensureValidMetricName() let help = help.ensureValidHelpText() return self.box.withLockedValue { store -> ValueHistogram in guard let value = store[name] else { let gauge = ValueHistogram(name: name, labels: [], buckets: buckets) - store[name] = .valueHistogram(gauge, help) + store[name] = .valueHistogram(gauge, help: help) return gauge } guard case .valueHistogram(let histogram, _) = value else { @@ -468,6 +563,18 @@ public final class PrometheusCollectorRegistry: Sendable { } } + /// 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 { + return self.makeValueHistogram(name: name, buckets: buckets, help: "") + } + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name, /// based on the provided descriptor. /// @@ -490,13 +597,14 @@ public final class PrometheusCollectorRegistry: Sendable { /// - 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`` - /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. + /// - Parameter help: Help text for the metric. If a non-empty string is provided, it will be emitted as a `# HELP` line in the exposition format. + /// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric. /// - Returns: A ``ValueHistogram`` that is registered with this ``PrometheusCollectorRegistry`` public func makeValueHistogram( name: String, labels: [(String, String)], buckets: [Double], - help: String = "" + help: String ) -> ValueHistogram { guard !labels.isEmpty else { return self.makeValueHistogram(name: name, buckets: buckets, help: help) @@ -511,7 +619,7 @@ public final class PrometheusCollectorRegistry: Sendable { let labelNames = labels.allLabelNames let histogram = ValueHistogram(name: name, labels: labels, buckets: buckets) - store[name] = .valueHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets, help) + store[name] = .valueHistogramWithLabels(labelNames, [LabelsKey(labels): histogram], buckets, help: help) return histogram } guard @@ -531,11 +639,34 @@ public final class PrometheusCollectorRegistry: Sendable { let histogram = ValueHistogram(name: name, labels: labels, buckets: storedBuckets) dimensionLookup[key] = histogram - store[name] = .valueHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help) + store[name] = .valueHistogramWithLabels(labelNames, dimensionLookup, storedBuckets, help: help) 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 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 { + return self.makeValueHistogram( + name: name, + labels: labels, + buckets: buckets, + help: "" + ) + } + /// Creates a new ``ValueHistogram`` collector or returns the already existing one with the same name and labels, /// based on the provided descriptor. /// @@ -582,7 +713,7 @@ public final class PrometheusCollectorRegistry: Sendable { if dimensions.isEmpty { store.removeValue(forKey: counter.name) } else { - store[counter.name] = .counterWithLabels(labelNames, dimensions, help) + store[counter.name] = .counterWithLabels(labelNames, dimensions, help: help) } default: return @@ -608,7 +739,7 @@ public final class PrometheusCollectorRegistry: Sendable { if dimensions.isEmpty { store.removeValue(forKey: gauge.name) } else { - store[gauge.name] = .gaugeWithLabels(labelNames, dimensions, help) + store[gauge.name] = .gaugeWithLabels(labelNames, dimensions, help: help) } default: return @@ -634,7 +765,7 @@ public final class PrometheusCollectorRegistry: Sendable { if dimensions.isEmpty { store.removeValue(forKey: histogram.name) } else { - store[histogram.name] = .durationHistogramWithLabels(labelNames, dimensions, buckets, help) + store[histogram.name] = .durationHistogramWithLabels(labelNames, dimensions, buckets, help: help) } default: return @@ -660,7 +791,7 @@ public final class PrometheusCollectorRegistry: Sendable { if dimensions.isEmpty { store.removeValue(forKey: histogram.name) } else { - store[histogram.name] = .valueHistogramWithLabels(labelNames, dimensions, buckets, help) + store[histogram.name] = .valueHistogramWithLabels(labelNames, dimensions, buckets, help: help) } default: return @@ -828,19 +959,22 @@ extension String { return true } - fileprivate func isValidHelpText() -> Bool { - guard !self.isEmpty else { return true } - let containsInvalidCharacter = self.contains { character in - switch character { - case "\u{0000}"..."\u{001F}", // C0 Controls - "\u{007F}"..."\u{009F}", // C1 Controls - "\u{2028}", "\u{2029}": // Extra security, invisible but troublesome Unicode characters - return true // Remove - default: - return false - } + fileprivate func isDisallowedHelpTextUnicdeScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x00...0x1F, // C0 Controls + 0x7F...0x9F, // C1 Controls + 0x2028, 0x2029, 0x200B, 0x200C, 0x200D, 0x2060, 0x00AD, // Extra security + 0x202A...0x202E, // BiDi Controls + 0x2066...0x2069: // Isolate formatting characters + return true // Remove + default: + return false } + } + fileprivate func isValidHelpText() -> Bool { + guard !self.isEmpty else { return true } + let containsInvalidCharacter = self.unicodeScalars.contains(where: isDisallowedHelpTextUnicdeScalar) return !containsInvalidCharacter } @@ -896,15 +1030,10 @@ extension String { } fileprivate mutating func fixPrometheusHelpText() { - self.removeAll { character in - switch character { - case "\u{0000}"..."\u{001F}", // C0 Controls - "\u{007F}"..."\u{009F}", // C1 Controls - "\u{2028}", "\u{2029}": // Extra security, invisible but troublesome Unicode characters - return true // Remove - default: - return false - } + var result = self + result.removeAll { character in + character.unicodeScalars.contains(where: isDisallowedHelpTextUnicdeScalar) } + self = result } } diff --git a/Tests/PrometheusTests/ValidNamesTests.swift b/Tests/PrometheusTests/ValidNamesTests.swift index a65960f..4783d9e 100644 --- a/Tests/PrometheusTests/ValidNamesTests.swift +++ b/Tests/PrometheusTests/ValidNamesTests.swift @@ -99,7 +99,8 @@ final class ValidNamesTests: XCTestCase { registry.makeCounter( name: "metric", labels: [("key", "value")], - help: "T\0his# is an_ \u{001B}example\u{001B} (help-\r\nt\u{2028}ext), link: https://help.url/sub" + help: + "\u{007F}T\0his# is\u{200B} an_ \u{001B}ex\u{00AD}ample\u{001B} \u{202A}(help-\r\nt\u{2028}ext),\u{2029} \u{2066}link: https://help.url/sub" ).increment() var buffer = [UInt8]()