Skip to content

Commit 3195d72

Browse files
committed
refactor: uniquely identify Gaugess by name and label sets
Previously, gauges could only be identified by their name. This change allows multiple gauges 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 c697ee8 commit 3195d72

File tree

2 files changed

+163
-71
lines changed

2 files changed

+163
-71
lines changed

Sources/Prometheus/PrometheusCollectorRegistry.swift

Lines changed: 62 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,17 @@ public final class PrometheusCollectorRegistry: Sendable {
4747
}
4848
}
4949

50+
// Note: In order to support Sendable, need to explicitely define the types.
5051
private struct CounterWithHelp {
5152
var counter: Counter
5253
let help: String
5354
}
5455

56+
private struct GaugeWithHelp {
57+
var gauge: Gauge
58+
let help: String
59+
}
60+
5561
private struct CounterGroup {
5662
// A collection of Counter metrics, each with a unique label set, that share the same metric name.
5763
// Distinct help strings for the same metric name are permitted, but Prometheus retains only the
@@ -61,10 +67,13 @@ public final class PrometheusCollectorRegistry: Sendable {
6167
var countersByLabelSets: [LabelsKey: CounterWithHelp]
6268
}
6369

70+
private struct GaugeGroup {
71+
var gaugesByLabelSets: [LabelsKey: GaugeWithHelp]
72+
}
73+
6474
private enum Metric {
6575
case counter(CounterGroup)
66-
case gauge(Gauge, help: String)
67-
case gaugeWithLabels([String], [LabelsKey: Gauge], help: String)
76+
case gauge(GaugeGroup)
6877
case durationHistogram(DurationHistogram, help: String)
6978
case durationHistogramWithLabels([String], [LabelsKey: DurationHistogram], [Duration], help: String)
7079
case valueHistogram(ValueHistogram, help: String)
@@ -209,25 +218,7 @@ public final class PrometheusCollectorRegistry: Sendable {
209218
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
210219
/// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry``
211220
public func makeGauge(name: String, help: String) -> Gauge {
212-
let name = name.ensureValidMetricName()
213-
let help = help.ensureValidHelpText()
214-
return self.box.withLockedValue { store -> Gauge in
215-
guard let value = store[name] else {
216-
let gauge = Gauge(name: name, labels: [])
217-
store[name] = .gauge(gauge, help: help)
218-
return gauge
219-
}
220-
guard case .gauge(let gauge, _) = value else {
221-
fatalError(
222-
"""
223-
Could not make Gauge with name: \(name), since another metric type already
224-
exists for the same name.
225-
"""
226-
)
227-
}
228-
229-
return gauge
230-
}
221+
return self.makeGauge(name: name, labels: [], help: help)
231222
}
232223

233224
/// Creates a new ``Gauge`` collector or returns the already existing one with the same name.
@@ -238,7 +229,7 @@ public final class PrometheusCollectorRegistry: Sendable {
238229
/// - Parameter name: A name to identify ``Gauge``'s value.
239230
/// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry``
240231
public func makeGauge(name: String) -> Gauge {
241-
return self.makeGauge(name: name, help: "")
232+
return self.makeGauge(name: name, labels: [], help: "")
242233
}
243234

244235
/// Creates a new ``Gauge`` collector or returns the already existing one with the same name,
@@ -250,7 +241,7 @@ public final class PrometheusCollectorRegistry: Sendable {
250241
/// - Parameter descriptor: An ``MetricNameDescriptor`` that provides the fully qualified name for the metric.
251242
/// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry``
252243
public func makeGauge(descriptor: MetricNameDescriptor) -> Gauge {
253-
return self.makeGauge(name: descriptor.name, help: descriptor.helpText ?? "")
244+
return self.makeGauge(name: descriptor.name, labels: [], help: descriptor.helpText ?? "")
254245
}
255246

256247
/// Creates a new ``Gauge`` collector or returns the already existing one with the same name.
@@ -265,51 +256,49 @@ public final class PrometheusCollectorRegistry: Sendable {
265256
/// If the parameter is omitted or an empty string is passed, the `# HELP` line will not be generated for this metric.
266257
/// - Returns: A ``Gauge`` that is registered with this ``PrometheusCollectorRegistry``
267258
public func makeGauge(name: String, labels: [(String, String)], help: String) -> Gauge {
268-
guard !labels.isEmpty else {
269-
return self.makeGauge(name: name, help: help)
270-
}
271-
272259
let name = name.ensureValidMetricName()
273260
let labels = labels.ensureValidLabelNames()
274261
let help = help.ensureValidHelpText()
262+
let key = LabelsKey(labels)
275263

276264
return self.box.withLockedValue { store -> Gauge in
277-
guard let value = store[name] else {
278-
let labelNames = labels.allLabelNames
265+
guard let entry = store[name] else {
266+
// First time a Gauge is registered with this name.
279267
let gauge = Gauge(name: name, labels: labels)
280-
281-
store[name] = .gaugeWithLabels(labelNames, [LabelsKey(labels): gauge], help: help)
282-
return gauge
283-
}
284-
guard case .gaugeWithLabels(let labelNames, var dimensionLookup, let help) = value else {
285-
fatalError(
286-
"""
287-
Could not make Gauge with name: \(name) and labels: \(labels), since another
288-
metric type already exists for the same name.
289-
"""
268+
let gaugeWithHelp = GaugeWithHelp(gauge: gauge, help: help)
269+
let gaugeGroup = GaugeGroup(
270+
gaugesByLabelSets: [key: gaugeWithHelp]
290271
)
272+
store[name] = .gauge(gaugeGroup)
273+
return gauge
291274
}
275+
switch entry {
276+
case .gauge(var existingGaugeGroup):
277+
if let existingGaugeWithHelp = existingGaugeGroup.gaugesByLabelSets[key] {
278+
return existingGaugeWithHelp.gauge
279+
}
280+
281+
// Even if the metric name is identical, each label set defines a unique time series.
282+
let gauge = Gauge(name: name, labels: labels)
283+
let gaugeWithHelp = GaugeWithHelp(gauge: gauge, help: help)
284+
existingGaugeGroup.gaugesByLabelSets[key] = gaugeWithHelp
285+
286+
// Write the modified entry back to the store.
287+
store[name] = .gauge(existingGaugeGroup)
292288

293-
let key = LabelsKey(labels)
294-
if let gauge = dimensionLookup[key] {
295289
return gauge
296-
}
297290

298-
// check if all labels match the already existing ones.
299-
if labelNames != labels.allLabelNames {
291+
default:
292+
// A metric with this name exists, but it's not a Gauge. This is a programming error.
293+
// While Prometheus wouldn't stop you, it may result in unpredictable behavior with tools like Grafana or PromQL.
300294
fatalError(
301295
"""
302-
Could not make Gauge with name: \(name) and labels: \(labels), since the
303-
label names don't match the label names of previously registered Gauges with
304-
the same name.
296+
Metric type mismatch:
297+
Could not register a Gauge with name '\(name)',
298+
since a different metric type (\(entry.self)) was already registered with this name.
305299
"""
306300
)
307301
}
308-
309-
let gauge = Gauge(name: name, labels: labels)
310-
dimensionLookup[key] = gauge
311-
store[name] = .gaugeWithLabels(labelNames, dimensionLookup, help: help)
312-
return gauge
313302
}
314303
}
315304

@@ -724,17 +713,19 @@ public final class PrometheusCollectorRegistry: Sendable {
724713
public func unregisterGauge(_ gauge: Gauge) {
725714
self.box.withLockedValue { store in
726715
switch store[gauge.name] {
727-
case .gauge(let storedGauge, _):
728-
guard storedGauge === gauge else { return }
729-
store.removeValue(forKey: gauge.name)
730-
case .gaugeWithLabels(let labelNames, var dimensions, let help):
731-
let dimensionsKey = LabelsKey(gauge.labels)
732-
guard dimensions[dimensionsKey] === gauge else { return }
733-
dimensions.removeValue(forKey: dimensionsKey)
734-
if dimensions.isEmpty {
716+
case .gauge(var gaugeGroup):
717+
let key = LabelsKey(gauge.labels)
718+
guard let existingGaugeGroup = gaugeGroup.gaugesByLabelSets[key],
719+
existingGaugeGroup.gauge === gauge
720+
else {
721+
return
722+
}
723+
gaugeGroup.gaugesByLabelSets.removeValue(forKey: key)
724+
725+
if gaugeGroup.gaugesByLabelSets.isEmpty {
735726
store.removeValue(forKey: gauge.name)
736727
} else {
737-
store[gauge.name] = .gaugeWithLabels(labelNames, dimensions, help: help)
728+
store[gauge.name] = .gauge(gaugeGroup)
738729
}
739730
default:
740731
return
@@ -815,16 +806,16 @@ public final class PrometheusCollectorRegistry: Sendable {
815806
counterWithHelp.counter.emit(into: &buffer)
816807
}
817808

818-
case .gauge(let gauge, let help):
819-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
820-
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
821-
gauge.emit(into: &buffer)
822-
823-
case .gaugeWithLabels(_, let gauges, let help):
824-
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
825-
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
826-
for gauge in gauges.values {
827-
gauge.emit(into: &buffer)
809+
case .gauge(let gaugeGroup):
810+
// Should not be empty, as a safeguard skip if it is.
811+
guard let _ = gaugeGroup.gaugesByLabelSets.first?.value else {
812+
continue
813+
}
814+
for gaugeWithHelp in gaugeGroup.gaugesByLabelSets.values {
815+
let help = gaugeWithHelp.help
816+
help.isEmpty ? () : buffer.addLine(prefix: prefixHelp, name: name, value: help)
817+
buffer.addLine(prefix: prefixType, name: name, value: "gauge")
818+
gaugeWithHelp.gauge.emit(into: &buffer)
828819
}
829820

830821
case .durationHistogram(let histogram, let help):

Tests/PrometheusTests/GaugeTests.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,107 @@ final class GaugeTests: XCTestCase {
152152
)
153153
}
154154

155+
func testGaugeWithSharedMetricNameDistinctLabelSets() {
156+
let client = PrometheusCollectorRegistry()
157+
158+
let gauge0 = client.makeGauge(
159+
name: "foo",
160+
labels: [],
161+
help: "Base metric name with no labels"
162+
)
163+
164+
let gauge1 = client.makeGauge(
165+
name: "foo",
166+
labels: [("bar", "baz")],
167+
help: "Base metric name with one label set variant"
168+
)
169+
170+
let gauge2 = client.makeGauge(
171+
name: "foo",
172+
labels: [("bar", "newBaz"), ("newKey1", "newValue1")],
173+
help: "Base metric name with a different label set variant"
174+
)
175+
176+
var buffer = [UInt8]()
177+
gauge0.set(to: 1.0)
178+
gauge1.set(to: 9.0)
179+
gauge2.set(to: 4.0)
180+
gauge1.decrement(by: 12.0)
181+
gauge0.record(2.0)
182+
gauge2.increment(by: 24.0)
183+
client.emit(into: &buffer)
184+
var outputString = String(decoding: buffer, as: Unicode.UTF8.self)
185+
var actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
186+
var expectedLines = Set([
187+
"# HELP foo Base metric name with no labels",
188+
"# TYPE foo gauge",
189+
"foo 2.0",
190+
191+
"# HELP foo Base metric name with one label set variant",
192+
"# TYPE foo gauge",
193+
#"foo{bar="baz"} -3.0"#,
194+
195+
"# HELP foo Base metric name with a different label set variant",
196+
"# TYPE foo gauge",
197+
#"foo{bar="newBaz",newKey1="newValue1"} 28.0"#,
198+
])
199+
XCTAssertEqual(actualLines, expectedLines)
200+
201+
// Gauges are unregistered in a cascade.
202+
client.unregisterGauge(gauge0)
203+
buffer.removeAll(keepingCapacity: true)
204+
client.emit(into: &buffer)
205+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
206+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
207+
expectedLines = Set([
208+
"# HELP foo Base metric name with one label set variant",
209+
"# TYPE foo gauge",
210+
#"foo{bar="baz"} -3.0"#,
211+
212+
"# HELP foo Base metric name with a different label set variant",
213+
"# TYPE foo gauge",
214+
#"foo{bar="newBaz",newKey1="newValue1"} 28.0"#,
215+
])
216+
XCTAssertEqual(actualLines, expectedLines)
217+
218+
client.unregisterGauge(gauge1)
219+
buffer.removeAll(keepingCapacity: true)
220+
client.emit(into: &buffer)
221+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
222+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
223+
expectedLines = Set([
224+
"# HELP foo Base metric name with a different label set variant",
225+
"# TYPE foo gauge",
226+
#"foo{bar="newBaz",newKey1="newValue1"} 28.0"#,
227+
])
228+
XCTAssertEqual(actualLines, expectedLines)
229+
230+
client.unregisterGauge(gauge2)
231+
buffer.removeAll(keepingCapacity: true)
232+
client.emit(into: &buffer)
233+
outputString = String(decoding: buffer, as: Unicode.UTF8.self)
234+
actualLines = Set(outputString.components(separatedBy: .newlines).filter { !$0.isEmpty })
235+
expectedLines = Set([])
236+
XCTAssertEqual(actualLines, expectedLines)
237+
238+
let _ = client.makeCounter(
239+
name: "foo",
240+
labels: [],
241+
help: "Base metric name used for new metric of type counter"
242+
)
243+
buffer.removeAll(keepingCapacity: true)
244+
client.emit(into: &buffer)
245+
XCTAssertEqual(
246+
String(decoding: buffer, as: Unicode.UTF8.self),
247+
"""
248+
# HELP foo Base metric name used for new metric of type counter
249+
# TYPE foo counter
250+
foo 0
251+
252+
"""
253+
)
254+
}
255+
155256
func testGaugeSetToFromMultipleTasks() async {
156257
let client = PrometheusCollectorRegistry()
157258
let gauge = client.makeGauge(name: "foo", labels: [("bar", "baz")])

0 commit comments

Comments
 (0)