Skip to content

Commit 3db20ca

Browse files
committed
refactor: uniquely identify Counters by name and label sets
Previously, counters could only be identified by their name. This change allows multiple counters to share the same name, with each unique set of labels defining a distinct time series. Additionally, the internal data structure for a stored metric has been refactored, providing a more robust and programmatic representation. Signed-off-by: Melissa Kilby <[email protected]>
1 parent fb9f489 commit 3db20ca

File tree

2 files changed

+176
-88
lines changed

2 files changed

+176
-88
lines changed

Sources/Prometheus/PrometheusCollectorRegistry.swift

Lines changed: 75 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ public final class PrometheusCollectorRegistry: Sendable {
4747
}
4848
}
4949

50-
private enum Metric {
51-
case counter(Counter, help: String)
52-
case counterWithLabels([String], [LabelsKey: Counter], help: String)
50+
private typealias Metric<T> = (metric: T, help: String)
51+
52+
private struct MetricContainer<T> {
53+
var metrics: [LabelsKey: Metric<T>]
54+
}
55+
56+
private enum MetricType {
57+
case counterMetrics(String, MetricContainer<Counter>)
5358
case gauge(Gauge, help: String)
5459
case gaugeWithLabels([String], [LabelsKey: Gauge], help: String)
5560
case durationHistogram(DurationHistogram, help: String)
@@ -58,7 +63,7 @@ public final class PrometheusCollectorRegistry: Sendable {
5863
case valueHistogramWithLabels([String], [LabelsKey: ValueHistogram], [Double], help: String)
5964
}
6065

61-
private let box = NIOLockedValueBox([String: Metric]())
66+
private let box = NIOLockedValueBox([String: MetricType]())
6267

6368
/// Create a new collector registry
6469
public init() {}
@@ -75,25 +80,7 @@ public final class PrometheusCollectorRegistry: Sendable {
7580
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
7681
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
7782
public func makeCounter(name: String, help: String) -> Counter {
78-
let name = name.ensureValidMetricName()
79-
let help = help.ensureValidHelpText()
80-
return self.box.withLockedValue { store -> Counter in
81-
guard let value = store[name] else {
82-
let counter = Counter(name: name, labels: [])
83-
store[name] = .counter(counter, help: help)
84-
return counter
85-
}
86-
guard case .counter(let counter, _) = value else {
87-
fatalError(
88-
"""
89-
Could not make Counter with name: \(name), since another metric type
90-
already exists for the same name.
91-
"""
92-
)
93-
}
94-
95-
return counter
96-
}
83+
return self.makeCounter(name: name, labels: [], help: help)
9784
}
9885

9986
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -104,7 +91,7 @@ public final class PrometheusCollectorRegistry: Sendable {
10491
/// - Parameter name: A name to identify ``Counter``'s value.
10592
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
10693
public func makeCounter(name: String) -> Counter {
107-
return self.makeCounter(name: name, help: "")
94+
return self.makeCounter(name: name, labels: [], help: "")
10895
}
10996

11097
/// Creates a new ``Counter`` collector or returns the already existing one with the same name,
@@ -116,7 +103,7 @@ public final class PrometheusCollectorRegistry: Sendable {
116103
/// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric.
117104
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
118105
public func makeCounter(descriptor: MetricNameDescriptor) -> Counter {
119-
return self.makeCounter(name: descriptor.name, help: descriptor.helpText ?? "")
106+
return self.makeCounter(name: descriptor.name, labels: [], help: descriptor.helpText ?? "")
120107
}
121108

122109
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -131,51 +118,49 @@ public final class PrometheusCollectorRegistry: Sendable {
131118
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
132119
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
133120
public func makeCounter(name: String, labels: [(String, String)], help: String) -> Counter {
134-
guard !labels.isEmpty else {
135-
return self.makeCounter(name: name, help: help)
136-
}
137-
138121
let name = name.ensureValidMetricName()
139122
let labels = labels.ensureValidLabelNames()
140123
let help = help.ensureValidHelpText()
124+
let key = LabelsKey(labels)
141125

142126
return self.box.withLockedValue { store -> Counter in
143-
guard let value = store[name] else {
144-
let labelNames = labels.allLabelNames
127+
guard let entry = store[name] else {
128+
// First time a Counter is registered with this name.
145129
let counter = Counter(name: name, labels: labels)
146-
147-
store[name] = .counterWithLabels(labelNames, [LabelsKey(labels): counter], help: help)
148-
return counter
149-
}
150-
guard case .counterWithLabels(let labelNames, var dimensionLookup, let help) = value else {
151-
fatalError(
152-
"""
153-
Could not make Counter with name: \(name) and labels: \(labels), since another
154-
metric type already exists for the same name.
155-
"""
130+
let newMetric: Metric<Counter> = (metric: counter, help: help)
131+
let newContainer = MetricContainer<Counter>(
132+
metrics: [key: newMetric]
156133
)
157-
}
158-
159-
let key = LabelsKey(labels)
160-
if let counter = dimensionLookup[key] {
134+
store[name] = .counterMetrics(name, newContainer)
161135
return counter
162136
}
137+
switch entry {
138+
case .counterMetrics(let existingName, var MetricContainer):
139+
if let existingMetric = MetricContainer.metrics[key] {
140+
return existingMetric.metric
141+
}
163142

164-
// check if all labels match the already existing ones.
165-
if labelNames != labels.allLabelNames {
143+
// Even if the metric name is identical, each label set defines a unique time series
144+
let newCounter = Counter(name: name, labels: labels)
145+
let netNewMetric: Metric<Counter> = (metric: newCounter, help: help)
146+
MetricContainer.metrics[key] = netNewMetric
147+
148+
// Write the modified entry back to the store.
149+
store[name] = .counterMetrics(existingName, MetricContainer)
150+
151+
return newCounter
152+
153+
default:
154+
// A metric with this name exists, but it's not a Counter. This is a programming error.
155+
// While Prometheus wouldn't stop you, it may result in unpredictable behavior with tools like Grafana or PromQL.
166156
fatalError(
167157
"""
168-
Could not make Counter with name: \(name) and labels: \(labels), since the
169-
label names don't match the label names of previously registered Counters with
170-
the same name.
158+
Metric type mismatch:
159+
Could not register a Counter with name '\(name)',
160+
since a different metric type (\(entry.self)) was already registered with this name.
171161
"""
172162
)
173163
}
174-
175-
let counter = Counter(name: name, labels: labels)
176-
dimensionLookup[key] = counter
177-
store[name] = .counterWithLabels(labelNames, dimensionLookup, help: help)
178-
return counter
179164
}
180165
}
181166

@@ -703,17 +688,19 @@ public final class PrometheusCollectorRegistry: Sendable {
703688
public func unregisterCounter(_ counter: Counter) {
704689
self.box.withLockedValue { store in
705690
switch store[counter.name] {
706-
case .counter(let storedCounter, _):
707-
guard storedCounter === counter else { return }
708-
store.removeValue(forKey: counter.name)
709-
case .counterWithLabels(let labelNames, var dimensions, let help):
710-
let labelsKey = LabelsKey(counter.labels)
711-
guard dimensions[labelsKey] === counter else { return }
712-
dimensions.removeValue(forKey: labelsKey)
713-
if dimensions.isEmpty {
714-
store.removeValue(forKey: counter.name)
691+
case .counterMetrics(let name, var MetricContainer):
692+
let key = LabelsKey(counter.labels)
693+
guard let existingMetric = MetricContainer.metrics[key],
694+
existingMetric.metric === counter
695+
else {
696+
return
697+
}
698+
MetricContainer.metrics.removeValue(forKey: key)
699+
700+
if MetricContainer.metrics.isEmpty {
701+
store.removeValue(forKey: name)
715702
} else {
716-
store[counter.name] = .counterWithLabels(labelNames, dimensions, help: help)
703+
store[name] = .counterMetrics(name, MetricContainer)
717704
}
718705
default:
719706
return
@@ -806,52 +793,52 @@ public final class PrometheusCollectorRegistry: Sendable {
806793
let prefixHelp = "HELP"
807794
let prefixType = "TYPE"
808795

809-
for (label, metric) in metrics {
796+
for (name, metric) in metrics {
810797
switch metric {
811-
case .counter(let counter, let help):
812-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
813-
buffer.addLine(prefix: prefixType, name: label, value: "counter")
814-
counter.emit(into: &buffer)
815-
816-
case .counterWithLabels(_, let counters, let help):
817-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
818-
buffer.addLine(prefix: prefixType, name: label, value: "counter")
819-
for counter in counters.values {
820-
counter.emit(into: &buffer)
798+
case .counterMetrics(_, let MetricContainer):
799+
// An entry should not be empty, as a safeguard skip if it is.
800+
guard let _ = MetricContainer.metrics.first?.value else {
801+
continue
802+
}
803+
for Metric in MetricContainer.metrics.values {
804+
let help = Metric.help
805+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
806+
buffer.addLine(prefix: prefixType, name: name, value: "counter")
807+
Metric.metric.emit(into: &buffer)
821808
}
822809

823810
case .gauge(let gauge, let help):
824-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
825-
buffer.addLine(prefix: prefixType, name: label, value: "gauge")
811+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
812+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
826813
gauge.emit(into: &buffer)
827814

828815
case .gaugeWithLabels(_, let gauges, let help):
829-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
830-
buffer.addLine(prefix: prefixType, name: label, value: "gauge")
816+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
817+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
831818
for gauge in gauges.values {
832819
gauge.emit(into: &buffer)
833820
}
834821

835822
case .durationHistogram(let histogram, let help):
836-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
837-
buffer.addLine(prefix: prefixType, name: label, value: "histogram")
823+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
824+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
838825
histogram.emit(into: &buffer)
839826

840827
case .durationHistogramWithLabels(_, let histograms, _, let help):
841-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
842-
buffer.addLine(prefix: prefixType, name: label, value: "histogram")
828+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
829+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
843830
for histogram in histograms.values {
844831
histogram.emit(into: &buffer)
845832
}
846833

847834
case .valueHistogram(let histogram, let help):
848-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
849-
buffer.addLine(prefix: prefixType, name: label, value: "histogram")
835+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
836+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
850837
histogram.emit(into: &buffer)
851838

852839
case .valueHistogramWithLabels(_, let histograms, _, let help):
853-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: label, value: help)
854-
buffer.addLine(prefix: prefixType, name: label, value: "histogram")
840+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
841+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
855842
for histogram in histograms.values {
856843
histogram.emit(into: &buffer)
857844
}

Tests/PrometheusTests/CounterTests.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,107 @@ final class CounterTests: XCTestCase {
165165
)
166166
}
167167

168+
func testCounterWithSharedMetricNamDistinctLabelSets() {
169+
let client = PrometheusCollectorRegistry()
170+
171+
let counter0 = client.makeCounter(
172+
name: "foo",
173+
labels: [],
174+
help: "Base metric name with no labels"
175+
)
176+
177+
let counter1 = client.makeCounter(
178+
name: "foo",
179+
labels: [("bar", "baz")],
180+
help: "Base metric name with one label set variant"
181+
)
182+
183+
let counter2 = client.makeCounter(
184+
name: "foo",
185+
labels: [("bar", "newBaz"), ("newKey1", "newValue1")],
186+
help: "Base metric name with a different label set variant"
187+
)
188+
189+
var buffer = [UInt8]()
190+
counter0.increment()
191+
counter1.increment(by: Int64(9))
192+
counter2.increment(by: Int64(4))
193+
counter1.increment(by: Int64(3))
194+
counter0.increment()
195+
counter2.increment(by: Int64(20))
196+
client.emit(into: &buffer)
197+
var outputString = String(decoding: buffer, as: Unicode.UTF8.self)
198+
var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
199+
var expectedLines = Set([
200+
"# HELP foo Base metric name with no labels",
201+
"# TYPE foo counter",
202+
"foo 2",
203+
204+
"# HELP foo Base metric name with one label set variant",
205+
"# TYPE foo counter",
206+
#"foo{bar="baz"} 12"#,
207+
208+
"# HELP foo Base metric name with a different label set variant",
209+
"# TYPE foo counter",
210+
#"foo{bar="newBaz",newKey1="newValue1"} 24"#,
211+
])
212+
XCTAssertEqual(actualLines, expectedLines)
213+
214+
// Counters are unregistered in a cascade.
215+
client.unregisterCounter(counter0)
216+
buffer.removeAll(keepingCapacity: true)
217+
client.emit(into: &buffer)
218+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
219+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
220+
expectedLines = Set([
221+
"# HELP foo Base metric name with one label set variant",
222+
"# TYPE foo counter",
223+
#"foo{bar="baz"} 12"#,
224+
225+
"# HELP foo Base metric name with a different label set variant",
226+
"# TYPE foo counter",
227+
#"foo{bar="newBaz",newKey1="newValue1"} 24"#,
228+
])
229+
XCTAssertEqual(actualLines, expectedLines)
230+
231+
client.unregisterCounter(counter1)
232+
buffer.removeAll(keepingCapacity: true)
233+
client.emit(into: &buffer)
234+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
235+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
236+
expectedLines = Set([
237+
"# HELP foo Base metric name with a different label set variant",
238+
"# TYPE foo counter",
239+
#"foo{bar="newBaz",newKey1="newValue1"} 24"#,
240+
])
241+
XCTAssertEqual(actualLines, expectedLines)
242+
243+
client.unregisterCounter(counter2)
244+
buffer.removeAll(keepingCapacity: true)
245+
client.emit(into: &buffer)
246+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
247+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
248+
expectedLines = Set([])
249+
XCTAssertEqual(actualLines, expectedLines)
250+
251+
let counterGaugeSameName = client.makeGauge(
252+
name: "foo",
253+
labels: [],
254+
help: "Base metric name used for new metric of type gauge"
255+
)
256+
buffer.removeAll(keepingCapacity: true)
257+
client.emit(into: &buffer)
258+
XCTAssertEqual(
259+
String(decoding: buffer, as: Unicode.UTF8.self),
260+
"""
261+
# HELP foo Base metric name used for new metric of type gauge
262+
# TYPE foo gauge
263+
foo 0.0
264+
265+
"""
266+
)
267+
}
268+
168269
func testWithMetricNameDescriptorWithFullComponentMatrix() {
169270
// --- Test Constants ---
170271
let helpTextValue = "https://help.url/sub"

0 commit comments

Comments
 (0)