Skip to content

Commit c697ee8

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 c697ee8

File tree

2 files changed

+180
-84
lines changed

2 files changed

+180
-84
lines changed

Sources/Prometheus/PrometheusCollectorRegistry.swift

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

50+
private struct CounterWithHelp {
51+
var counter: Counter
52+
let help: String
53+
}
54+
55+
private struct CounterGroup {
56+
// A collection of Counter metrics, each with a unique label set, that share the same metric name.
57+
// Distinct help strings for the same metric name are permitted, but Prometheus retains only the
58+
// most recent one. For an unlabelled Counter, the empty label set is used as the key, and the
59+
// collection contains only one entry. Finally, for clarification, the same Counter metric name can
60+
// simultaneously be labeled and unlabeled.
61+
var countersByLabelSets: [LabelsKey: CounterWithHelp]
62+
}
63+
5064
private enum Metric {
51-
case counter(Counter, help: String)
52-
case counterWithLabels([String], [LabelsKey: Counter], help: String)
65+
case counter(CounterGroup)
5366
case gauge(Gauge, help: String)
5467
case gaugeWithLabels([String], [LabelsKey: Gauge], help: String)
5568
case durationHistogram(DurationHistogram, help: String)
@@ -75,25 +88,7 @@ public final class PrometheusCollectorRegistry: Sendable {
7588
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
7689
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
7790
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-
}
91+
return self.makeCounter(name: name, labels: [], help: help)
9792
}
9893

9994
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -104,7 +99,7 @@ public final class PrometheusCollectorRegistry: Sendable {
10499
/// - Parameter name: A name to identify ``Counter``'s value.
105100
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
106101
public func makeCounter(name: String) -> Counter {
107-
return self.makeCounter(name: name, help: "")
102+
return self.makeCounter(name: name, labels: [], help: "")
108103
}
109104

110105
/// Creates a new ``Counter`` collector or returns the already existing one with the same name,
@@ -116,7 +111,7 @@ public final class PrometheusCollectorRegistry: Sendable {
116111
/// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric.
117112
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
118113
public func makeCounter(descriptor: MetricNameDescriptor) -> Counter {
119-
return self.makeCounter(name: descriptor.name, help: descriptor.helpText ?? "")
114+
return self.makeCounter(name: descriptor.name, labels: [], help: descriptor.helpText ?? "")
120115
}
121116

122117
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -131,51 +126,49 @@ public final class PrometheusCollectorRegistry: Sendable {
131126
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
132127
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
133128
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-
138129
let name = name.ensureValidMetricName()
139130
let labels = labels.ensureValidLabelNames()
140131
let help = help.ensureValidHelpText()
132+
let key = LabelsKey(labels)
141133

142134
return self.box.withLockedValue { store -> Counter in
143-
guard let value = store[name] else {
144-
let labelNames = labels.allLabelNames
135+
guard let entry = store[name] else {
136+
// First time a Counter is registered with this name.
145137
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-
"""
138+
let counterWithHelp = CounterWithHelp(counter: counter, help: help)
139+
let counterGroup = CounterGroup(
140+
countersByLabelSets: [key: counterWithHelp]
156141
)
142+
store[name] = .counter(counterGroup)
143+
return counter
157144
}
145+
switch entry {
146+
case .counter(var existingCounterGroup):
147+
if let existingCounterWithHelp = existingCounterGroup.countersByLabelSets[key] {
148+
return existingCounterWithHelp.counter
149+
}
150+
151+
// Even if the metric name is identical, each label set defines a unique time series.
152+
let counter = Counter(name: name, labels: labels)
153+
let counterWithHelp = CounterWithHelp(counter: counter, help: help)
154+
existingCounterGroup.countersByLabelSets[key] = counterWithHelp
155+
156+
// Write the modified entry back to the store.
157+
store[name] = .counter(existingCounterGroup)
158158

159-
let key = LabelsKey(labels)
160-
if let counter = dimensionLookup[key] {
161159
return counter
162-
}
163160

164-
// check if all labels match the already existing ones.
165-
if labelNames != labels.allLabelNames {
161+
default:
162+
// A metric with this name exists, but it's not a Counter. This is a programming error.
163+
// While Prometheus wouldn't stop you, it may result in unpredictable behavior with tools like Grafana or PromQL.
166164
fatalError(
167165
"""
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.
166+
Metric type mismatch:
167+
Could not register a Counter with name '\(name)',
168+
since a different metric type (\(entry.self)) was already registered with this name.
171169
"""
172170
)
173171
}
174-
175-
let counter = Counter(name: name, labels: labels)
176-
dimensionLookup[key] = counter
177-
store[name] = .counterWithLabels(labelNames, dimensionLookup, help: help)
178-
return counter
179172
}
180173
}
181174

@@ -703,17 +696,19 @@ public final class PrometheusCollectorRegistry: Sendable {
703696
public func unregisterCounter(_ counter: Counter) {
704697
self.box.withLockedValue { store in
705698
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 {
699+
case .counter(var counterGroup):
700+
let key = LabelsKey(counter.labels)
701+
guard let existingCounterGroup = counterGroup.countersByLabelSets[key],
702+
existingCounterGroup.counter === counter
703+
else {
704+
return
705+
}
706+
counterGroup.countersByLabelSets.removeValue(forKey: key)
707+
708+
if counterGroup.countersByLabelSets.isEmpty {
714709
store.removeValue(forKey: counter.name)
715710
} else {
716-
store[counter.name] = .counterWithLabels(labelNames, dimensions, help: help)
711+
store[counter.name] = .counter(counterGroup)
717712
}
718713
default:
719714
return
@@ -806,52 +801,52 @@ public final class PrometheusCollectorRegistry: Sendable {
806801
let prefixHelp = "HELP"
807802
let prefixType = "TYPE"
808803

809-
for (label, metric) in metrics {
804+
for (name, metric) in metrics {
810805
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)
806+
case .counter(let counterGroup):
807+
// Should not be empty, as a safeguard skip if it is.
808+
guard let _ = counterGroup.countersByLabelSets.first?.value else {
809+
continue
810+
}
811+
for counterWithHelp in counterGroup.countersByLabelSets.values {
812+
let help = counterWithHelp.help
813+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
814+
buffer.addLine(prefix: prefixType, name: name, value: "counter")
815+
counterWithHelp.counter.emit(into: &buffer)
821816
}
822817

823818
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")
819+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
820+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
826821
gauge.emit(into: &buffer)
827822

828823
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")
824+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
825+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
831826
for gauge in gauges.values {
832827
gauge.emit(into: &buffer)
833828
}
834829

835830
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")
831+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
832+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
838833
histogram.emit(into: &buffer)
839834

840835
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")
836+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
837+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
843838
for histogram in histograms.values {
844839
histogram.emit(into: &buffer)
845840
}
846841

847842
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")
843+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
844+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
850845
histogram.emit(into: &buffer)
851846

852847
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")
848+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
849+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
855850
for histogram in histograms.values {
856851
histogram.emit(into: &buffer)
857852
}

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 _ = 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)