Skip to content

Commit a3bb1e5

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 a3bb1e5

File tree

2 files changed

+182
-85
lines changed

2 files changed

+182
-85
lines changed

Sources/Prometheus/PrometheusCollectorRegistry.swift

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

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

9995
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -104,7 +100,7 @@ public final class PrometheusCollectorRegistry: Sendable {
104100
/// - Parameter name: A name to identify ``Counter``'s value.
105101
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
106102
public func makeCounter(name: String) -> Counter {
107-
return self.makeCounter(name: name, help: "")
103+
return self.makeCounter(name: name, labels: [], help: "")
108104
}
109105

110106
/// Creates a new ``Counter`` collector or returns the already existing one with the same name,
@@ -116,7 +112,7 @@ public final class PrometheusCollectorRegistry: Sendable {
116112
/// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric.
117113
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
118114
public func makeCounter(descriptor: MetricNameDescriptor) -> Counter {
119-
return self.makeCounter(name: descriptor.name, help: descriptor.helpText ?? "")
115+
return self.makeCounter(name: descriptor.name, labels: [], help: descriptor.helpText ?? "")
120116
}
121117

122118
/// Creates a new ``Counter`` collector or returns the already existing one with the same name.
@@ -131,51 +127,49 @@ public final class PrometheusCollectorRegistry: Sendable {
131127
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
132128
/// - Returns: A ``Counter`` that is registered with this ``PrometheusCollectorRegistry``
133129
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-
138130
let name = name.ensureValidMetricName()
139131
let labels = labels.ensureValidLabelNames()
140132
let help = help.ensureValidHelpText()
133+
let key = LabelsKey(labels)
141134

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

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

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

@@ -703,17 +697,19 @@ public final class PrometheusCollectorRegistry: Sendable {
703697
public func unregisterCounter(_ counter: Counter) {
704698
self.box.withLockedValue { store in
705699
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)
700+
case .counter(let name, var counterGroup):
701+
let key = LabelsKey(counter.labels)
702+
guard let existingCounterGroup = counterGroup.countersByLabelSets[key],
703+
existingCounterGroup.counter === counter
704+
else {
705+
return
706+
}
707+
counterGroup.countersByLabelSets.removeValue(forKey: key)
708+
709+
if counterGroup.countersByLabelSets.isEmpty {
710+
store.removeValue(forKey: name)
715711
} else {
716-
store[counter.name] = .counterWithLabels(labelNames, dimensions, help: help)
712+
store[name] = .counter(name: name, counterGroup)
717713
}
718714
default:
719715
return
@@ -806,52 +802,52 @@ public final class PrometheusCollectorRegistry: Sendable {
806802
let prefixHelp = "HELP"
807803
let prefixType = "TYPE"
808804

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

823819
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")
820+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
821+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
826822
gauge.emit(into: &buffer)
827823

828824
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")
825+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
826+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
831827
for gauge in gauges.values {
832828
gauge.emit(into: &buffer)
833829
}
834830

835831
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")
832+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
833+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
838834
histogram.emit(into: &buffer)
839835

840836
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")
837+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
838+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
843839
for histogram in histograms.values {
844840
histogram.emit(into: &buffer)
845841
}
846842

847843
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")
844+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
845+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
850846
histogram.emit(into: &buffer)
851847

852848
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")
849+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
850+
buffer.addLine(prefix: prefixType, name: name, value: "histogram")
855851
for histogram in histograms.values {
856852
histogram.emit(into: &buffer)
857853
}

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)