Skip to content

Commit 118a3c1

Browse files
committed
By default, return an error if metrics collide when escaped to underscores
Signed-off-by: Owen Williams <[email protected]>
1 parent 93c851f commit 118a3c1

File tree

3 files changed

+269
-10
lines changed

3 files changed

+269
-10
lines changed

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 is a hash of all the metric name 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

prometheus/registry.go

+73-9
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,23 @@ 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+
// AllowEscapedCollisions determines whether the Registry should reject
78+
// Collectors that would collide when escaped to underscores for compatibility
79+
// with older systems. You may set this option to Allow if you know your metrics
80+
// will never be scraped by an older system.
81+
func (r *Registry) AllowEscapedCollisions(allow bool) *Registry {
82+
r.allowEscapedCollision = allow
83+
return r
84+
}
85+
7586
// NewPedanticRegistry returns a registry that checks during collection if each
7687
// collected Metric is consistent with its reported Desc, and if the Desc has
7788
// actually been registered with the registry. Unchecked Collectors (those whose
@@ -258,21 +269,30 @@ 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+
allowEscapedCollision bool
267285
}
268286

269287
// Register implements Registerer.
270288
func (r *Registry) Register(c Collector) error {
271289
var (
272290
descChan = make(chan *Desc, capDescChan)
273291
newDescIDs = map[uint64]struct{}{}
292+
newEscapedIDs = map[uint64]struct{}{}
274293
newDimHashesByName = map[string]uint64{}
275294
collectorID uint64 // All desc IDs XOR'd together.
295+
escapedID uint64
276296
duplicateDescErr error
277297
)
278298
go func() {
@@ -307,6 +327,24 @@ func (r *Registry) Register(c Collector) error {
307327
collectorID ^= desc.id
308328
}
309329

330+
// Unless we are in pure UTF-8 mode, also check to see if the descID is
331+
// unique when all the names are escaped to underscores.
332+
if !r.allowEscapedCollision {
333+
// First check the primary map, then check the secondary map.
334+
if _, exists := r.descIDs[desc.escapedID]; exists {
335+
duplicateDescErr = fmt.Errorf("descriptor %s will collide with an existing descriptor when escaped for compatibility with non-UTF8 systems", desc)
336+
}
337+
if _, exists := r.escapedDescIDs[desc.escapedID]; exists {
338+
duplicateDescErr = fmt.Errorf("descriptor %s will collide with an existing descriptor when escaped for compatibility with non-UTF8 systems", desc)
339+
}
340+
}
341+
if _, exists := newEscapedIDs[desc.escapedID]; !exists {
342+
if desc.escapedID != desc.id {
343+
newEscapedIDs[desc.escapedID] = struct{}{}
344+
}
345+
escapedID ^= desc.escapedID
346+
}
347+
310348
// Are all the label names and the help string consistent with
311349
// previous descriptors of the same name?
312350
// First check existing descriptors...
@@ -331,7 +369,18 @@ func (r *Registry) Register(c Collector) error {
331369
r.uncheckedCollectors = append(r.uncheckedCollectors, c)
332370
return nil
333371
}
334-
if existing, exists := r.collectorsByID[collectorID]; exists {
372+
373+
existing, collision := r.collectorsByID[collectorID]
374+
// Unless we are in pure UTF-8 mode, we also need to check that the
375+
// underscore-escaped versions of the IDs don't match.
376+
if !collision && !r.allowEscapedCollision {
377+
existing, collision = r.collectorsByID[escapedID]
378+
if !collision {
379+
existing, collision = r.collectorsByEscapedID[escapedID]
380+
}
381+
}
382+
383+
if collision {
335384
switch e := existing.(type) {
336385
case *wrappingCollector:
337386
return AlreadyRegisteredError{
@@ -353,21 +402,30 @@ func (r *Registry) Register(c Collector) error {
353402

354403
// Only after all tests have passed, actually register.
355404
r.collectorsByID[collectorID] = c
405+
// We only need to store the escapedID if it doesn't match the unescaped one.
406+
if escapedID != collectorID {
407+
r.collectorsByEscapedID[escapedID] = c
408+
}
356409
for hash := range newDescIDs {
357410
r.descIDs[hash] = struct{}{}
358411
}
359412
for name, dimHash := range newDimHashesByName {
360413
r.dimHashesByName[name] = dimHash
361414
}
415+
for hash := range newEscapedIDs {
416+
r.escapedDescIDs[hash] = struct{}{}
417+
}
362418
return nil
363419
}
364420

365421
// Unregister implements Registerer.
366422
func (r *Registry) Unregister(c Collector) bool {
367423
var (
368-
descChan = make(chan *Desc, capDescChan)
369-
descIDs = map[uint64]struct{}{}
370-
collectorID uint64 // All desc IDs XOR'd together.
424+
descChan = make(chan *Desc, capDescChan)
425+
descIDs = map[uint64]struct{}{}
426+
escpaedDescIDs = map[uint64]struct{}{}
427+
collectorID uint64 // All desc IDs XOR'd together.
428+
collectorEscapedID uint64
371429
)
372430
go func() {
373431
c.Describe(descChan)
@@ -377,6 +435,8 @@ func (r *Registry) Unregister(c Collector) bool {
377435
if _, exists := descIDs[desc.id]; !exists {
378436
collectorID ^= desc.id
379437
descIDs[desc.id] = struct{}{}
438+
collectorEscapedID ^= desc.escapedID
439+
escpaedDescIDs[desc.escapedID] = struct{}{}
380440
}
381441
}
382442

@@ -391,9 +451,13 @@ func (r *Registry) Unregister(c Collector) bool {
391451
defer r.mtx.Unlock()
392452

393453
delete(r.collectorsByID, collectorID)
454+
delete(r.collectorsByEscapedID, collectorEscapedID)
394455
for id := range descIDs {
395456
delete(r.descIDs, id)
396457
}
458+
for id := range escpaedDescIDs {
459+
delete(r.escapedDescIDs, id)
460+
}
397461
// dimHashesByName is left untouched as those must be consistent
398462
// throughout the lifetime of a program.
399463
return true

prometheus/registry_test.go

+185
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636

3737
dto "github.com/prometheus/client_model/go"
3838
"github.com/prometheus/common/expfmt"
39+
"github.com/prometheus/common/model"
3940
"google.golang.org/protobuf/proto"
4041
"google.golang.org/protobuf/types/known/timestamppb"
4142
)
@@ -1181,6 +1182,190 @@ func TestAlreadyRegisteredCollision(t *testing.T) {
11811182
}
11821183
}
11831184

1185+
func TestAlreadyRegisteredEscapingCollision(t *testing.T) {
1186+
oldValidation := model.NameValidationScheme
1187+
model.NameValidationScheme = model.UTF8Validation
1188+
defer func() {
1189+
model.NameValidationScheme = oldValidation
1190+
}()
1191+
1192+
tests := []struct {
1193+
name string
1194+
// These are functions because hashes that determine collision are created
1195+
// at metric creation time.
1196+
counterA func() prometheus.Counter
1197+
counterB func() prometheus.Counter
1198+
utf8Collision bool
1199+
expectErr bool
1200+
// postInitFlagFlip tests for the case where metrics are created in an
1201+
// init() function, when the mode will be to disallow legacy collisions, and
1202+
// then the user later selects to allow them.
1203+
postInitFlagFlip bool
1204+
}{
1205+
{
1206+
name: "no metric name collision",
1207+
counterA: func() prometheus.Counter {
1208+
return prometheus.NewCounter(prometheus.CounterOpts{
1209+
Name: "my_counter_a",
1210+
ConstLabels: prometheus.Labels{
1211+
"name": "label",
1212+
"type": "test",
1213+
},
1214+
})
1215+
},
1216+
counterB: func() prometheus.Counter {
1217+
return prometheus.NewCounter(prometheus.CounterOpts{
1218+
Name: "myAcounterAa",
1219+
ConstLabels: prometheus.Labels{
1220+
"name": "label",
1221+
"type": "test",
1222+
},
1223+
})
1224+
},
1225+
},
1226+
{
1227+
name: "compatibility metric name collision",
1228+
counterA: func() prometheus.Counter {
1229+
return prometheus.NewCounter(prometheus.CounterOpts{
1230+
Name: "my_counter_a",
1231+
ConstLabels: prometheus.Labels{
1232+
"name": "label",
1233+
"type": "test",
1234+
},
1235+
})
1236+
},
1237+
counterB: func() prometheus.Counter {
1238+
return prometheus.NewCounter(prometheus.CounterOpts{
1239+
Name: "my.counter.a",
1240+
ConstLabels: prometheus.Labels{
1241+
"name": "label",
1242+
"type": "test",
1243+
},
1244+
})
1245+
},
1246+
expectErr: true,
1247+
},
1248+
{
1249+
// This is a regression test to make sure we are not accidentally
1250+
// reporting collisions when label values are different.
1251+
name: "no label value collision",
1252+
counterA: func() prometheus.Counter {
1253+
return prometheus.NewCounter(prometheus.CounterOpts{
1254+
Name: "my_counter_a",
1255+
ConstLabels: prometheus.Labels{
1256+
"name": "label.value",
1257+
"type": "test",
1258+
},
1259+
})
1260+
},
1261+
counterB: func() prometheus.Counter {
1262+
return prometheus.NewCounter(prometheus.CounterOpts{
1263+
Name: "my_counter_a",
1264+
ConstLabels: prometheus.Labels{
1265+
"name": "label_value",
1266+
"type": "test",
1267+
},
1268+
})
1269+
},
1270+
},
1271+
{
1272+
name: "compatibility label name collision",
1273+
counterA: func() prometheus.Counter {
1274+
return prometheus.NewCounter(prometheus.CounterOpts{
1275+
Name: "my_counter_a",
1276+
ConstLabels: prometheus.Labels{
1277+
"label.name": "name",
1278+
"type": "test",
1279+
},
1280+
})
1281+
},
1282+
counterB: func() prometheus.Counter {
1283+
return prometheus.NewCounter(prometheus.CounterOpts{
1284+
Name: "my_counter_a",
1285+
ConstLabels: prometheus.Labels{
1286+
"label_name": "name",
1287+
"type": "test",
1288+
},
1289+
})
1290+
},
1291+
expectErr: true,
1292+
},
1293+
{
1294+
name: "no utf8 metric name collision",
1295+
counterA: func() prometheus.Counter {
1296+
return prometheus.NewCounter(prometheus.CounterOpts{
1297+
Name: "my_counter_a",
1298+
ConstLabels: prometheus.Labels{
1299+
"name": "label",
1300+
"type": "test",
1301+
},
1302+
})
1303+
},
1304+
counterB: func() prometheus.Counter {
1305+
return prometheus.NewCounter(prometheus.CounterOpts{
1306+
Name: "my.counter.a",
1307+
ConstLabels: prometheus.Labels{
1308+
"name": "label",
1309+
"type": "test",
1310+
},
1311+
})
1312+
},
1313+
utf8Collision: true,
1314+
},
1315+
{
1316+
name: "post init flag flip, should collide",
1317+
counterA: func() prometheus.Counter {
1318+
return prometheus.NewCounter(prometheus.CounterOpts{
1319+
Name: "my.counter.a",
1320+
ConstLabels: prometheus.Labels{
1321+
"name": "label",
1322+
"type": "test",
1323+
},
1324+
})
1325+
},
1326+
counterB: func() prometheus.Counter {
1327+
return prometheus.NewCounter(prometheus.CounterOpts{
1328+
Name: "my.counter.a",
1329+
ConstLabels: prometheus.Labels{
1330+
"name": "label",
1331+
"type": "test",
1332+
},
1333+
})
1334+
},
1335+
postInitFlagFlip: true,
1336+
expectErr: true,
1337+
},
1338+
}
1339+
1340+
for _, tc := range tests {
1341+
t.Run(tc.name, func(t *testing.T) {
1342+
reg := prometheus.NewRegistry()
1343+
if tc.postInitFlagFlip {
1344+
reg.AllowEscapedCollisions(false)
1345+
} else {
1346+
reg.AllowEscapedCollisions(tc.utf8Collision)
1347+
}
1348+
err := reg.Register(tc.counterA())
1349+
if err != nil {
1350+
t.Errorf("expected no error, got: %v", err)
1351+
}
1352+
if tc.postInitFlagFlip {
1353+
reg.AllowEscapedCollisions(false)
1354+
}
1355+
err = reg.Register(tc.counterB())
1356+
if !tc.expectErr {
1357+
if err != nil {
1358+
t.Errorf("expected no error, got %T", err)
1359+
}
1360+
} else {
1361+
if err == nil {
1362+
t.Error("expected AlreadyRegisteredError, got none")
1363+
}
1364+
}
1365+
})
1366+
}
1367+
}
1368+
11841369
type tGatherer struct {
11851370
done bool
11861371
err error

0 commit comments

Comments
 (0)