Integer-only Counters

Signed-off-by: Not Rob Pike <closeup_oblique.0o@icloud.com>
This commit is contained in:
Not Rob Pike 2024-03-10 12:38:10 -07:00
parent b59cfc9f6e
commit f55a4e3265
2 changed files with 96 additions and 5 deletions

View File

@ -23,6 +23,9 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
// 64-bit float mantissa: https://en.wikipedia.org/wiki/Double-precision_floating-point_format
var float64Mantissa uint64 = 9007199254740992
// Counter is a Metric that represents a single numerical value that only ever // Counter is a Metric that represents a single numerical value that only ever
// goes up. That implies that it cannot be used to count items whose number can // goes up. That implies that it cannot be used to count items whose number can
// also go down, e.g. the number of currently running goroutines. Those // also go down, e.g. the number of currently running goroutines. Those
@ -57,8 +60,52 @@ type ExemplarAdder interface {
AddWithExemplar(value float64, exemplar Labels) AddWithExemplar(value float64, exemplar Labels)
} }
// CounterOpts is an alias for Opts. See there for doc comments. // CounterOpts bundles the options for creating a Histogram metric. It is
type CounterOpts Opts // mandatory to set Name to a non-empty string. All other fields are optional
// and can safely be left at their zero value, although it is strongly
// encouraged to set a Help string.
type CounterOpts struct {
// Namespace, Subsystem, and Name are components of the fully-qualified
// name of the Metric (created by joining these components with
// "_"). Only Name is mandatory, the others merely help structuring the
// name. Note that the fully-qualified name of the metric must be a
// valid Prometheus metric name.
Namespace string
Subsystem string
Name string
// Help provides information about this metric.
//
// Metrics with the same fully-qualified name must have the same Help
// string.
Help string
// ConstLabels are used to attach fixed labels to this metric. Metrics
// with the same fully-qualified name must have the same label names in
// their ConstLabels.
//
// ConstLabels are only used rarely. In particular, do not use them to
// attach the same labels to all your metrics. Those use cases are
// better covered by target labels set by the scraping Prometheus
// server, or by one specific metric (e.g. a build_info or a
// machine_role metric). See also
// https://prometheus.io/docs/instrumenting/writing_exporters/#target-labels-not-static-scraped-labels
ConstLabels Labels
// now is for testing purposes, by default it's time.Now.
now func() time.Time
// Counters are double (float64) values. At values above 2^53, double loses
// the ability to represent discrete integer values precisely. At 2^53 the
// error is just +/-1 and is likely of little consequence. At 2^64 the error
// is +/-1024 (the next smallest number that can be represented is 2^64-2048).
// This may be significant error for long-running counters that reach the
// upper range of uint64. To present large Counter values as integer-only,
// set the IntegerExposition option. This will wrap the Counter twice, once at the
// largest safe integer value, and again when the Counter's uint64 value
// becomes 0. Prometheus will handle this rollover gracefully.
IntegerExposition bool
}
// CounterVecOpts bundles the options to create a CounterVec metric. // CounterVecOpts bundles the options to create a CounterVec metric.
// It is mandatory to set CounterOpts, see there for mandatory fields. VariableLabels // It is mandatory to set CounterOpts, see there for mandatory fields. VariableLabels
@ -94,7 +141,7 @@ func NewCounter(opts CounterOpts) Counter {
if opts.now == nil { if opts.now == nil {
opts.now = time.Now opts.now = time.Now
} }
result := &counter{desc: desc, labelPairs: desc.constLabelPairs, now: opts.now} result := &counter{desc: desc, labelPairs: desc.constLabelPairs, now: opts.now, integerExposition: opts.IntegerExposition}
result.init(result) // Init self-collection. result.init(result) // Init self-collection.
result.createdTs = timestamppb.New(opts.now()) result.createdTs = timestamppb.New(opts.now())
return result return result
@ -115,6 +162,8 @@ type counter struct {
labelPairs []*dto.LabelPair labelPairs []*dto.LabelPair
exemplar atomic.Value // Containing nil or a *dto.Exemplar. exemplar atomic.Value // Containing nil or a *dto.Exemplar.
integerExposition bool
// now is for testing purposes, by default it's time.Now. // now is for testing purposes, by default it's time.Now.
now func() time.Time now func() time.Time
} }
@ -132,6 +181,9 @@ func (c *counter) Add(v float64) {
if float64(ival) == v { if float64(ival) == v {
atomic.AddUint64(&c.valInt, ival) atomic.AddUint64(&c.valInt, ival)
return return
} else if c.integerExposition {
// perhaps there should be an AddInt() method? this is a footgun for callers.
panic(errors.New("cannot add large value with rounding error to integer counter"))
} }
for { for {
@ -153,8 +205,12 @@ func (c *counter) Inc() {
} }
func (c *counter) get() float64 { func (c *counter) get() float64 {
fval := math.Float64frombits(atomic.LoadUint64(&c.valBits))
ival := atomic.LoadUint64(&c.valInt) ival := atomic.LoadUint64(&c.valInt)
if c.integerExposition {
return float64(ival % float64Mantissa)
}
// XXX atomics here are not strictly safe. ival and fval can be incremented elsewhere separately.
fval := math.Float64frombits(atomic.LoadUint64(&c.valBits))
return fval + float64(ival) return fval + float64(ival)
} }
@ -214,7 +270,7 @@ func (v2) NewCounterVec(opts CounterVecOpts) *CounterVec {
if len(lvs) != len(desc.variableLabels.names) { if len(lvs) != len(desc.variableLabels.names) {
panic(makeInconsistentCardinalityError(desc.fqName, desc.variableLabels.names, lvs)) panic(makeInconsistentCardinalityError(desc.fqName, desc.variableLabels.names, lvs))
} }
result := &counter{desc: desc, labelPairs: MakeLabelPairs(desc, lvs), now: opts.now} result := &counter{desc: desc, labelPairs: MakeLabelPairs(desc, lvs), now: opts.now, integerExposition: opts.IntegerExposition}
result.init(result) // Init self-collection. result.init(result) // Init self-collection.
result.createdTs = timestamppb.New(opts.now()) result.createdTs = timestamppb.New(opts.now())
return result return result

View File

@ -386,3 +386,38 @@ func expectCTsForMetricVecValues(t testing.TB, vec *MetricVec, typ dto.MetricTyp
} }
} }
} }
func TestCounterInt(t *testing.T) {
now := time.Now()
counter := NewCounter(CounterOpts{
Name: "test",
Help: "test help",
now: func() time.Time { return now },
IntegerExposition: true,
}).(*counter)
// large is greater than the max safe integer value, but has no rounding error itself and ergo is integer-safe
large := math.Nextafter(float64(float64Mantissa), math.MaxUint64)
counter.Add(large)
if expected, got := 0.0, math.Float64frombits(counter.valBits); expected != got {
t.Errorf("valBits expected %f, got %f.", expected, got)
}
if expected, got := uint64(large), counter.valInt; expected != got {
t.Errorf("valInts expected %d, got %d.", expected, got)
}
m := &dto.Metric{}
counter.Write(m)
expected := &dto.Metric{
Counter: &dto.Counter{
Value: proto.Float64(float64(uint64(large) % float64Mantissa)), // wrapped value!
CreatedTimestamp: timestamppb.New(now),
},
}
if !proto.Equal(expected, m) {
t.Errorf("expected %q, got %q", expected, m)
}
}