Skip to content

Commit 364d3cf

Browse files
committed
feat(metrics): per-family label union and dedup in prometheus bridge
1 parent a12cc0b commit 364d3cf

2 files changed

Lines changed: 82 additions & 31 deletions

File tree

internal/metrics/exporters/prometheus.go

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -93,59 +93,81 @@ type forgeCollector struct {
9393
// Describe implements prometheus.Collector. Intentionally emits no descriptors.
9494
func (c *forgeCollector) Describe(chan<- *prometheus.Desc) {}
9595

96+
type series struct {
97+
value any
98+
labels map[string]string
99+
}
100+
96101
// Collect implements prometheus.Collector.
97102
func (c *forgeCollector) Collect(ch chan<- prometheus.Metric) {
98103
if c.snapshot == nil {
99104
return
100105
}
101106

107+
families := make(map[string][]series) // fqName -> series
102108
for key, value := range c.snapshot() {
103109
name, labels := parseMetricKey(key)
104110
fqName := buildFQName(c.namespace, name)
111+
families[fqName] = append(families[fqName], series{value: value, labels: labels})
112+
}
105113

106-
switch v := value.(type) {
107-
case float64:
108-
c.emitScalar(ch, fqName, prometheus.GaugeValue, "gauge", v, labels)
109-
case int64:
110-
c.emitScalar(ch, fqName, prometheus.GaugeValue, "gauge", float64(v), labels)
111-
case uint64:
112-
c.emitScalar(ch, fqName, prometheus.GaugeValue, "gauge", float64(v), labels)
113-
case map[string]any:
114-
c.emitComplex(ch, fqName, v, labels)
114+
for fqName, list := range families {
115+
keys := unionKeys(list) // sanitized, sorted union of label keys
116+
seen := make(map[string]bool) // dedup by joined label values
117+
for _, s := range list {
118+
vals := alignValues(keys, s.labels)
119+
sig := strings.Join(vals, "\x1f")
120+
if seen[sig] {
121+
continue
122+
}
123+
seen[sig] = true
124+
c.emit(ch, fqName, keys, vals, s.value)
115125
}
116-
// Unknown shapes are skipped.
126+
}
127+
}
128+
129+
func (c *forgeCollector) emit(ch chan<- prometheus.Metric, fqName string,
130+
keys, vals []string, value any) {
131+
switch v := value.(type) {
132+
case float64:
133+
c.emitScalar(ch, fqName, keys, vals, prometheus.GaugeValue, "gauge", v)
134+
case int64:
135+
c.emitScalar(ch, fqName, keys, vals, prometheus.GaugeValue, "gauge", float64(v))
136+
case uint64:
137+
c.emitScalar(ch, fqName, keys, vals, prometheus.GaugeValue, "gauge", float64(v))
138+
case map[string]any:
139+
c.emitComplex(ch, fqName, keys, vals, v)
117140
}
118141
}
119142

120143
func (c *forgeCollector) emitScalar(ch chan<- prometheus.Metric, fqName string,
121-
vt prometheus.ValueType, kind string, value float64, labels map[string]string) {
122-
keys, vals := sortedLabels(labels)
144+
keys, vals []string, vt prometheus.ValueType, kind string, value float64) {
123145
desc := prometheus.NewDesc(fqName, helpFor(kind, fqName), keys, nil)
124146
ch <- prometheus.MustNewConstMetric(desc, vt, value, vals...)
125147
}
126148

127149
func (c *forgeCollector) emitComplex(ch chan<- prometheus.Metric, fqName string,
128-
v map[string]any, labels map[string]string) {
150+
keys, vals []string, v map[string]any) {
129151
if t, _ := v["_type"].(string); t == "counter" {
130152
if val, ok := toFloat(v["value"]); ok {
131-
c.emitScalar(ch, fqName, prometheus.CounterValue, "counter", val, labels)
153+
c.emitScalar(ch, fqName, keys, vals, prometheus.CounterValue, "counter", val)
132154
}
133155
return
134156
}
135157

136158
if raw, ok := v["buckets"].(map[float64]uint64); ok {
137-
c.emitHistogram(ch, fqName, v, raw, labels)
159+
c.emitHistogram(ch, fqName, keys, vals, v, raw)
138160
return
139161
}
140162

141163
if _, ok := v["count"]; ok {
142-
c.emitTimer(ch, fqName, v, labels)
164+
c.emitTimer(ch, fqName, keys, vals, v)
143165
return
144166
}
145167
}
146168

147169
func (c *forgeCollector) emitTimer(ch chan<- prometheus.Metric, fqName string,
148-
v map[string]any, labels map[string]string) {
170+
keys, vals []string, v map[string]any) {
149171
count, _ := toUint64(v["count"])
150172

151173
quantiles := make(map[float64]float64)
@@ -160,7 +182,6 @@ func (c *forgeCollector) emitTimer(ch chan<- prometheus.Metric, fqName string,
160182
sum = mean * float64(count)
161183
}
162184

163-
keys, vals := sortedLabels(labels)
164185
desc := prometheus.NewDesc(fqName, helpFor("summary", fqName), keys, nil)
165186
ch <- prometheus.MustNewConstSummary(desc, count, sum, quantiles, vals...)
166187
}
@@ -179,7 +200,7 @@ func durationSeconds(v any) (float64, bool) {
179200
}
180201

181202
func (c *forgeCollector) emitHistogram(ch chan<- prometheus.Metric, fqName string,
182-
v map[string]any, perBucket map[float64]uint64, labels map[string]string) {
203+
keys, vals []string, v map[string]any, perBucket map[float64]uint64) {
183204
bounds := make([]float64, 0, len(perBucket))
184205
for b := range perBucket {
185206
bounds = append(bounds, b)
@@ -199,7 +220,6 @@ func (c *forgeCollector) emitHistogram(ch chan<- prometheus.Metric, fqName strin
199220
}
200221
sum, _ := toFloat(v["sum"])
201222

202-
keys, vals := sortedLabels(labels)
203223
desc := prometheus.NewDesc(fqName, helpFor("histogram", fqName), keys, nil)
204224
ch <- prometheus.MustNewConstHistogram(desc, count, sum, cumulative, vals...)
205225
}
@@ -276,24 +296,33 @@ func sanitizeName(s string) string {
276296
return b.String()
277297
}
278298

279-
// sortedLabels returns label keys (sanitized, sorted) and values in matching order.
280-
func sortedLabels(labels map[string]string) ([]string, []string) {
281-
if len(labels) == 0 {
282-
return nil, nil
299+
// unionKeys returns the sanitized, sorted union of label keys across all series.
300+
func unionKeys(list []series) []string {
301+
set := make(map[string]struct{})
302+
for _, s := range list {
303+
for k := range s.labels {
304+
set[sanitizeName(k)] = struct{}{}
305+
}
283306
}
284-
keys := make([]string, 0, len(labels))
285-
for k := range labels {
307+
keys := make([]string, 0, len(set))
308+
for k := range set {
286309
keys = append(keys, k)
287310
}
288311
sort.Strings(keys)
312+
return keys
313+
}
289314

290-
outKeys := make([]string, len(keys))
291-
outVals := make([]string, len(keys))
315+
// alignValues returns label values ordered to match keys, "" for absent keys.
316+
func alignValues(keys []string, labels map[string]string) []string {
317+
sanitized := make(map[string]string, len(labels))
318+
for k, v := range labels {
319+
sanitized[sanitizeName(k)] = v
320+
}
321+
vals := make([]string, len(keys))
292322
for i, k := range keys {
293-
outKeys[i] = sanitizeName(k)
294-
outVals[i] = labels[k]
323+
vals[i] = sanitized[k]
295324
}
296-
return outKeys, outVals
325+
return vals
297326
}
298327

299328
func helpFor(kind, fqName string) string {

internal/metrics/exporters/prometheus_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,25 @@ op_duration_seconds_count 10
131131
t.Fatalf("unexpected timer exposition: %v", err)
132132
}
133133
}
134+
135+
func TestBridge_LabelUnion(t *testing.T) {
136+
snapshot := func() map[string]any {
137+
return map[string]any{
138+
`hits_total{a="1"}`: map[string]any{"value": float64(1), "_type": "counter"},
139+
`hits_total{b="2"}`: map[string]any{"value": float64(2), "_type": "counter"},
140+
}
141+
}
142+
b := NewPrometheusBridge(snapshot, PrometheusConfig{})
143+
144+
// Both series get the union {a,b}; the missing key is filled with "".
145+
expected := `
146+
# HELP hits_total Forge counter hits_total
147+
# TYPE hits_total counter
148+
hits_total{a="1",b=""} 1
149+
hits_total{a="",b="2"} 2
150+
`
151+
if err := testutil.CollectAndCompare(b.collector, strings.NewReader(expected),
152+
"hits_total"); err != nil {
153+
t.Fatalf("unexpected label-union exposition: %v", err)
154+
}
155+
}

0 commit comments

Comments
 (0)