Skip to content

Commit aad8c01

Browse files
committed
Return an http error during scraping if metrics collide when escaped to underscores
Signed-off-by: Owen Williams <[email protected]>
1 parent e1675ce commit aad8c01

File tree

7 files changed

+330
-15
lines changed

7 files changed

+330
-15
lines changed

Diff for: go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,25 @@ require (
1212
github.com/prometheus/client_model v0.6.1
1313
github.com/prometheus/common v0.59.1
1414
github.com/prometheus/procfs v0.15.1
15+
github.com/stretchr/testify v1.9.0
1516
golang.org/x/sys v0.25.0
1617
google.golang.org/protobuf v1.34.2
1718
)
1819

1920
require (
21+
github.com/davecgh/go-spew v1.1.1 // indirect
2022
github.com/jpillora/backoff v1.0.0 // indirect
2123
github.com/kr/pretty v0.3.1 // indirect
2224
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
2325
github.com/modern-go/reflect2 v1.0.2 // indirect
2426
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
2527
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
28+
github.com/pmezard/go-difflib v1.0.0 // indirect
2629
golang.org/x/net v0.28.0 // indirect
2730
golang.org/x/oauth2 v0.22.0 // indirect
2831
golang.org/x/text v0.17.0 // indirect
2932
gopkg.in/yaml.v2 v2.4.0 // indirect
33+
gopkg.in/yaml.v3 v3.0.1 // indirect
3034
)
3135

3236
exclude github.com/prometheus/client_golang v1.12.1

Diff for: prometheus/desc.go

+11-1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ type Desc struct {
5757
// must be unique among all registered descriptors and can therefore be
5858
// used as an identifier of the descriptor.
5959
id uint64
60+
// escapedID is similar to id, but with the metric and label names escaped
61+
// with underscores.
62+
escapedID uint64
6063
// dimHash is a hash of the label names (preset and variable) and the
6164
// Help string. Each Desc with the same fqName must have the same
6265
// dimHash.
@@ -142,11 +145,18 @@ func (v2) NewDesc(fqName, help string, variableLabels ConstrainableLabels, const
142145
}
143146

144147
xxh := xxhash.New()
145-
for _, val := range labelValues {
148+
escapedXXH := xxhash.New()
149+
for i, val := range labelValues {
146150
xxh.WriteString(val)
147151
xxh.Write(separatorByteSlice)
152+
if i == 0 {
153+
val = model.EscapeName(val, model.UnderscoreEscaping)
154+
}
155+
escapedXXH.WriteString(val)
156+
escapedXXH.Write(separatorByteSlice)
148157
}
149158
d.id = xxh.Sum64()
159+
d.escapedID = escapedXXH.Sum64()
150160
// Sort labelNames so that order doesn't matter for the hash.
151161
sort.Strings(labelNames)
152162
// Now hash together (in this order) the help string and the sorted

Diff for: prometheus/promhttp/http.go

+16
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343

4444
"github.com/klauspost/compress/zstd"
4545
"github.com/prometheus/common/expfmt"
46+
"github.com/prometheus/common/model"
4647

4748
"github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil"
4849
"github.com/prometheus/client_golang/prometheus"
@@ -121,6 +122,7 @@ func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerO
121122
if opts.MaxRequestsInFlight > 0 {
122123
inFlightSem = make(chan struct{}, opts.MaxRequestsInFlight)
123124
}
125+
var hasEscapedCollisions bool
124126
if opts.Registry != nil {
125127
// Initialize all possibilities that can occur below.
126128
errCnt.WithLabelValues("gathering")
@@ -133,6 +135,7 @@ func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerO
133135
panic(err)
134136
}
135137
}
138+
hasEscapedCollisions = opts.Registry.HasEscapedCollision()
136139
}
137140

138141
// Select compression formats to offer based on default or user choice.
@@ -190,6 +193,19 @@ func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerO
190193
} else {
191194
contentType = expfmt.Negotiate(req.Header)
192195
}
196+
197+
if hasEscapedCollisions {
198+
switch contentType.ToEscapingScheme() {
199+
case model.UnderscoreEscaping, model.DotsEscaping:
200+
if opts.ErrorLog != nil {
201+
opts.ErrorLog.Println("error: one or more metrics collide when escaped")
202+
}
203+
httpError(rsp, fmt.Errorf("one or more metrics collide when escaped"))
204+
return
205+
default:
206+
}
207+
}
208+
193209
rsp.Header().Set(contentTypeHeader, string(contentType))
194210

195211
w, encodingHeader, closeWriter, err := negotiateEncodingWriter(req, rsp, compressions)

Diff for: prometheus/promhttp/http_test.go

+42
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import (
2828

2929
"github.com/klauspost/compress/zstd"
3030
dto "github.com/prometheus/client_model/go"
31+
"github.com/prometheus/common/expfmt"
32+
"github.com/prometheus/common/model"
33+
"github.com/stretchr/testify/require"
3134

3235
"github.com/prometheus/client_golang/prometheus"
3336
)
@@ -548,6 +551,45 @@ func TestNegotiateEncodingWriter(t *testing.T) {
548551
}
549552
}
550553

554+
func TestEscapedCollisions(t *testing.T) {
555+
oldScheme := model.NameValidationScheme
556+
defer func() {
557+
model.NameValidationScheme = oldScheme
558+
}()
559+
model.NameValidationScheme = model.UTF8Validation
560+
561+
reg := prometheus.NewRegistry()
562+
reg.MustRegister(prometheus.NewCounter(prometheus.CounterOpts{
563+
Name: "test_metric",
564+
Help: "A test metric with underscores",
565+
}))
566+
reg.MustRegister(prometheus.NewCounter(prometheus.CounterOpts{
567+
Name: "test.metric",
568+
Help: "A test metric with dots",
569+
}))
570+
571+
handler := HandlerFor(reg, HandlerOpts{
572+
Registry: reg,
573+
})
574+
575+
t.Run("fail case", func(t *testing.T) {
576+
writer := httptest.NewRecorder()
577+
request, _ := http.NewRequest("GET", "/metrics", nil)
578+
request.Header.Add(acceptHeader, string(expfmt.NewFormat(expfmt.TypeTextPlain)))
579+
handler.ServeHTTP(writer, request)
580+
require.Equal(t, 500, writer.Code)
581+
require.Equal(t, "An error has occurred while serving metrics:\n\none or more metrics collide when escaped\n", writer.Body.String())
582+
})
583+
584+
t.Run("success case", func(t *testing.T) {
585+
writer := httptest.NewRecorder()
586+
request, _ := http.NewRequest("GET", "/metrics", nil)
587+
request.Header.Add(acceptHeader, string(expfmt.NewFormat(expfmt.TypeTextPlain).WithEscapingScheme(model.NoEscaping)))
588+
handler.ServeHTTP(writer, request)
589+
require.Equal(t, 200, writer.Code)
590+
})
591+
}
592+
551593
func BenchmarkCompression(b *testing.B) {
552594
benchmarks := []struct {
553595
name string

Diff for: prometheus/registry.go

+84-14
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,18 @@ func init() {
6666
// pre-registered.
6767
func NewRegistry() *Registry {
6868
return &Registry{
69-
collectorsByID: map[uint64]Collector{},
70-
descIDs: map[uint64]struct{}{},
71-
dimHashesByName: map[string]uint64{},
69+
collectorsByID: map[uint64]Collector{},
70+
collectorsByEscapedID: map[uint64]Collector{},
71+
descIDs: map[uint64]struct{}{},
72+
escapedDescIDs: map[uint64]struct{}{},
73+
dimHashesByName: map[string]uint64{},
7274
}
7375
}
7476

77+
func (r *Registry) HasEscapedCollision() bool {
78+
return r.hasEscapedCollision
79+
}
80+
7581
// NewPedanticRegistry returns a registry that checks during collection if each
7682
// collected Metric is consistent with its reported Desc, and if the Desc has
7783
// actually been registered with the registry. Unchecked Collectors (those whose
@@ -131,6 +137,11 @@ type Registerer interface {
131137
// instance must only collect consistent metrics throughout its
132138
// lifetime.
133139
Unregister(Collector) bool
140+
141+
// HasEscapedCollision returns true if any two of the registered metrics would
142+
// be the same when escaped to underscores. This is needed to prevent
143+
// duplicate metric issues when being scraped by a legacy system.
144+
HasEscapedCollision() bool
134145
}
135146

136147
// Gatherer is the interface for the part of a registry in charge of gathering
@@ -258,22 +269,36 @@ func (errs MultiError) MaybeUnwrap() error {
258269
// Registry implements Collector to allow it to be used for creating groups of
259270
// metrics. See the Grouping example for how this can be done.
260271
type Registry struct {
261-
mtx sync.RWMutex
262-
collectorsByID map[uint64]Collector // ID is a hash of the descIDs.
272+
mtx sync.RWMutex
273+
collectorsByID map[uint64]Collector // ID is a hash of the descIDs.
274+
// collectorsByEscapedID stores colletors by escapedID, only if escaped id is
275+
// different (otherwise we can just do the lookup in the regular map).
276+
collectorsByEscapedID map[uint64]Collector
263277
descIDs map[uint64]struct{}
278+
// escapedDescIDs records desc ids of the escaped version of the metric, only
279+
// if different from the regular name.
280+
escapedDescIDs map[uint64]struct{}
264281
dimHashesByName map[string]uint64
265282
uncheckedCollectors []Collector
266283
pedanticChecksEnabled bool
284+
285+
// hasEscapedCollision is set to true if any two metrics that were not
286+
// identical under UTF-8 would collide if scraped by a system that requires
287+
// names to be escaped to legacy underscore replacement.
288+
hasEscapedCollision bool
267289
}
268290

269291
// Register implements Registerer.
270292
func (r *Registry) Register(c Collector) error {
271293
var (
272-
descChan = make(chan *Desc, capDescChan)
273-
newDescIDs = map[uint64]struct{}{}
274-
newDimHashesByName = map[string]uint64{}
275-
collectorID uint64 // All desc IDs XOR'd together.
276-
duplicateDescErr error
294+
descChan = make(chan *Desc, capDescChan)
295+
newDescIDs = map[uint64]struct{}{}
296+
newEscapedIDs = map[uint64]struct{}{}
297+
newDimHashesByName = map[string]uint64{}
298+
collectorID uint64 // All desc IDs XOR'd together.
299+
escapedID uint64
300+
duplicateDescErr error
301+
duplicateEscapedDesc bool
277302
)
278303
go func() {
279304
c.Describe(descChan)
@@ -307,6 +332,22 @@ func (r *Registry) Register(c Collector) error {
307332
collectorID ^= desc.id
308333
}
309334

335+
// Also check to see if the descID is unique when all the names are escaped
336+
// to underscores. First check the primary map, then check the secondary
337+
// map. We only officially log a collision later.
338+
if _, exists := r.descIDs[desc.escapedID]; exists {
339+
duplicateEscapedDesc = true
340+
}
341+
if _, exists := r.escapedDescIDs[desc.escapedID]; exists {
342+
duplicateEscapedDesc = true
343+
}
344+
if _, exists := newEscapedIDs[desc.escapedID]; !exists {
345+
if desc.escapedID != desc.id {
346+
newEscapedIDs[desc.escapedID] = struct{}{}
347+
}
348+
escapedID ^= desc.escapedID
349+
}
350+
310351
// Are all the label names and the help string consistent with
311352
// previous descriptors of the same name?
312353
// First check existing descriptors...
@@ -331,7 +372,17 @@ func (r *Registry) Register(c Collector) error {
331372
r.uncheckedCollectors = append(r.uncheckedCollectors, c)
332373
return nil
333374
}
334-
if existing, exists := r.collectorsByID[collectorID]; exists {
375+
376+
existing, collision := r.collectorsByID[collectorID]
377+
// Also check whether the underscore-escaped versions of the IDs match.
378+
if !collision {
379+
_, escapedCollision := r.collectorsByID[escapedID]
380+
r.hasEscapedCollision = r.hasEscapedCollision || escapedCollision
381+
_, escapedCollision = r.collectorsByEscapedID[escapedID]
382+
r.hasEscapedCollision = r.hasEscapedCollision || escapedCollision
383+
}
384+
385+
if collision {
335386
switch e := existing.(type) {
336387
case *wrappingCollector:
337388
return AlreadyRegisteredError{
@@ -351,23 +402,36 @@ func (r *Registry) Register(c Collector) error {
351402
return duplicateDescErr
352403
}
353404

405+
if duplicateEscapedDesc {
406+
r.hasEscapedCollision = true
407+
}
408+
354409
// Only after all tests have passed, actually register.
355410
r.collectorsByID[collectorID] = c
411+
// We only need to store the escapedID if it doesn't match the unescaped one.
412+
if escapedID != collectorID {
413+
r.collectorsByEscapedID[escapedID] = c
414+
}
356415
for hash := range newDescIDs {
357416
r.descIDs[hash] = struct{}{}
358417
}
359418
for name, dimHash := range newDimHashesByName {
360419
r.dimHashesByName[name] = dimHash
361420
}
421+
for hash := range newEscapedIDs {
422+
r.escapedDescIDs[hash] = struct{}{}
423+
}
362424
return nil
363425
}
364426

365427
// Unregister implements Registerer.
366428
func (r *Registry) Unregister(c Collector) bool {
367429
var (
368-
descChan = make(chan *Desc, capDescChan)
369-
descIDs = map[uint64]struct{}{}
370-
collectorID uint64 // All desc IDs XOR'd together.
430+
descChan = make(chan *Desc, capDescChan)
431+
descIDs = map[uint64]struct{}{}
432+
escpaedDescIDs = map[uint64]struct{}{}
433+
collectorID uint64 // All desc IDs XOR'd together.
434+
collectorEscapedID uint64
371435
)
372436
go func() {
373437
c.Describe(descChan)
@@ -377,6 +441,8 @@ func (r *Registry) Unregister(c Collector) bool {
377441
if _, exists := descIDs[desc.id]; !exists {
378442
collectorID ^= desc.id
379443
descIDs[desc.id] = struct{}{}
444+
collectorEscapedID ^= desc.escapedID
445+
escpaedDescIDs[desc.escapedID] = struct{}{}
380446
}
381447
}
382448

@@ -391,9 +457,13 @@ func (r *Registry) Unregister(c Collector) bool {
391457
defer r.mtx.Unlock()
392458

393459
delete(r.collectorsByID, collectorID)
460+
delete(r.collectorsByEscapedID, collectorEscapedID)
394461
for id := range descIDs {
395462
delete(r.descIDs, id)
396463
}
464+
for id := range escpaedDescIDs {
465+
delete(r.escapedDescIDs, id)
466+
}
397467
// dimHashesByName is left untouched as those must be consistent
398468
// throughout the lifetime of a program.
399469
return true

0 commit comments

Comments
 (0)