Skip to content

Commit df7fa49

Browse files
Arthur Silva Sensbwplotka
Arthur Silva Sens
andauthored
Extend Counters, Summaries and Histograms with creation timestamp (#1313)
* Extend Counters, Summaries and Histograms with creation timestamp Signed-off-by: Arthur Silva Sens <[email protected]> * Backport created timestamp to existing tests Signed-off-by: Arthur Silva Sens <[email protected]> * Last touches (readability and consistency) Changes: * Comments for "now" are more explicit and not inlined. * populateMetrics is simpler and bit more efficient without timestamp to time to timestamp conversionts for more common code. * Test consistency and simplicity - the fewer variables the better. * Fixed inconsistency for v2 and MetricVec - let's pass opt.now consistently. * We don't need TestCounterXXXTimestamp - we test CT in many other places already. * Added more involved test for counter vectors with created timestamp. * Refactored normalization for simplicity. * Make histogram, summaries now consistent. * Simplified histograms CT flow and implemented proper CT on reset. TODO for next PRs: * NewConstSummary and NewConstHistogram - ability to specify CTs there. Signed-off-by: bwplotka <[email protected]> * Update prometheus/counter_test.go Co-authored-by: Arthur Silva Sens <[email protected]> Signed-off-by: Bartlomiej Plotka <[email protected]> --------- Signed-off-by: Arthur Silva Sens <[email protected]> Signed-off-by: bwplotka <[email protected]> Signed-off-by: Bartlomiej Plotka <[email protected]> Co-authored-by: bwplotka <[email protected]>
1 parent 74cc262 commit df7fa49

18 files changed

+531
-91
lines changed

Diff for: go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/cespare/xxhash/v2 v2.2.0
88
github.com/davecgh/go-spew v1.1.1
99
github.com/json-iterator/go v1.1.12
10-
github.com/prometheus/client_model v0.4.0
10+
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16
1111
github.com/prometheus/common v0.44.0
1212
github.com/prometheus/procfs v0.11.1
1313
golang.org/x/sys v0.11.0

Diff for: go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW
3434
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
3535
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3636
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
37-
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
38-
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
37+
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
38+
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
3939
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
4040
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
4141
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=

Diff for: prometheus/counter.go

+15-5
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"time"
2121

2222
dto "github.com/prometheus/client_model/go"
23+
"google.golang.org/protobuf/types/known/timestamppb"
2324
)
2425

2526
// Counter is a Metric that represents a single numerical value that only ever
@@ -90,8 +91,12 @@ func NewCounter(opts CounterOpts) Counter {
9091
nil,
9192
opts.ConstLabels,
9293
)
93-
result := &counter{desc: desc, labelPairs: desc.constLabelPairs, now: time.Now}
94+
if opts.now == nil {
95+
opts.now = time.Now
96+
}
97+
result := &counter{desc: desc, labelPairs: desc.constLabelPairs, now: opts.now}
9498
result.init(result) // Init self-collection.
99+
result.createdTs = timestamppb.New(opts.now())
95100
return result
96101
}
97102

@@ -106,10 +111,12 @@ type counter struct {
106111
selfCollector
107112
desc *Desc
108113

114+
createdTs *timestamppb.Timestamp
109115
labelPairs []*dto.LabelPair
110116
exemplar atomic.Value // Containing nil or a *dto.Exemplar.
111117

112-
now func() time.Time // To mock out time.Now() for testing.
118+
// now is for testing purposes, by default it's time.Now.
119+
now func() time.Time
113120
}
114121

115122
func (c *counter) Desc() *Desc {
@@ -159,8 +166,7 @@ func (c *counter) Write(out *dto.Metric) error {
159166
exemplar = e.(*dto.Exemplar)
160167
}
161168
val := c.get()
162-
163-
return populateMetric(CounterValue, val, c.labelPairs, exemplar, out)
169+
return populateMetric(CounterValue, val, c.labelPairs, exemplar, out, c.createdTs)
164170
}
165171

166172
func (c *counter) updateExemplar(v float64, l Labels) {
@@ -200,13 +206,17 @@ func (v2) NewCounterVec(opts CounterVecOpts) *CounterVec {
200206
opts.VariableLabels,
201207
opts.ConstLabels,
202208
)
209+
if opts.now == nil {
210+
opts.now = time.Now
211+
}
203212
return &CounterVec{
204213
MetricVec: NewMetricVec(desc, func(lvs ...string) Metric {
205214
if len(lvs) != len(desc.variableLabels.names) {
206215
panic(makeInconsistentCardinalityError(desc.fqName, desc.variableLabels.names, lvs))
207216
}
208-
result := &counter{desc: desc, labelPairs: MakeLabelPairs(desc, lvs), now: time.Now}
217+
result := &counter{desc: desc, labelPairs: MakeLabelPairs(desc, lvs), now: opts.now}
209218
result.init(result) // Init self-collection.
219+
result.createdTs = timestamppb.New(opts.now())
210220
return result
211221
}),
212222
}

Diff for: prometheus/counter_test.go

+93-5
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ import (
2626
)
2727

2828
func TestCounterAdd(t *testing.T) {
29+
now := time.Now()
30+
2931
counter := NewCounter(CounterOpts{
3032
Name: "test",
3133
Help: "test help",
3234
ConstLabels: Labels{"a": "1", "b": "2"},
35+
now: func() time.Time { return now },
3336
}).(*counter)
3437
counter.Inc()
3538
if expected, got := 0.0, math.Float64frombits(counter.valBits); expected != got {
@@ -66,7 +69,10 @@ func TestCounterAdd(t *testing.T) {
6669
{Name: proto.String("a"), Value: proto.String("1")},
6770
{Name: proto.String("b"), Value: proto.String("2")},
6871
},
69-
Counter: &dto.Counter{Value: proto.Float64(67.42)},
72+
Counter: &dto.Counter{
73+
Value: proto.Float64(67.42),
74+
CreatedTimestamp: timestamppb.New(now),
75+
},
7076
}
7177
if !proto.Equal(expected, m) {
7278
t.Errorf("expected %q, got %q", expected, m)
@@ -139,9 +145,12 @@ func expectPanic(t *testing.T, op func(), errorMsg string) {
139145
}
140146

141147
func TestCounterAddInf(t *testing.T) {
148+
now := time.Now()
149+
142150
counter := NewCounter(CounterOpts{
143151
Name: "test",
144152
Help: "test help",
153+
now: func() time.Time { return now },
145154
}).(*counter)
146155

147156
counter.Inc()
@@ -173,7 +182,8 @@ func TestCounterAddInf(t *testing.T) {
173182

174183
expected := &dto.Metric{
175184
Counter: &dto.Counter{
176-
Value: proto.Float64(math.Inf(1)),
185+
Value: proto.Float64(math.Inf(1)),
186+
CreatedTimestamp: timestamppb.New(now),
177187
},
178188
}
179189

@@ -183,9 +193,12 @@ func TestCounterAddInf(t *testing.T) {
183193
}
184194

185195
func TestCounterAddLarge(t *testing.T) {
196+
now := time.Now()
197+
186198
counter := NewCounter(CounterOpts{
187199
Name: "test",
188200
Help: "test help",
201+
now: func() time.Time { return now },
189202
}).(*counter)
190203

191204
// large overflows the underlying type and should therefore be stored in valBits.
@@ -203,7 +216,8 @@ func TestCounterAddLarge(t *testing.T) {
203216

204217
expected := &dto.Metric{
205218
Counter: &dto.Counter{
206-
Value: proto.Float64(large),
219+
Value: proto.Float64(large),
220+
CreatedTimestamp: timestamppb.New(now),
207221
},
208222
}
209223

@@ -213,10 +227,14 @@ func TestCounterAddLarge(t *testing.T) {
213227
}
214228

215229
func TestCounterAddSmall(t *testing.T) {
230+
now := time.Now()
231+
216232
counter := NewCounter(CounterOpts{
217233
Name: "test",
218234
Help: "test help",
235+
now: func() time.Time { return now },
219236
}).(*counter)
237+
220238
small := 0.000000000001
221239
counter.Add(small)
222240
if expected, got := small, math.Float64frombits(counter.valBits); expected != got {
@@ -231,7 +249,8 @@ func TestCounterAddSmall(t *testing.T) {
231249

232250
expected := &dto.Metric{
233251
Counter: &dto.Counter{
234-
Value: proto.Float64(small),
252+
Value: proto.Float64(small),
253+
CreatedTimestamp: timestamppb.New(now),
235254
},
236255
}
237256

@@ -246,8 +265,8 @@ func TestCounterExemplar(t *testing.T) {
246265
counter := NewCounter(CounterOpts{
247266
Name: "test",
248267
Help: "test help",
268+
now: func() time.Time { return now },
249269
}).(*counter)
250-
counter.now = func() time.Time { return now }
251270

252271
ts := timestamppb.New(now)
253272
if err := ts.CheckValid(); err != nil {
@@ -298,3 +317,72 @@ func TestCounterExemplar(t *testing.T) {
298317
t.Error("adding exemplar with oversized labels succeeded")
299318
}
300319
}
320+
321+
func TestCounterVecCreatedTimestampWithDeletes(t *testing.T) {
322+
now := time.Now()
323+
324+
counterVec := NewCounterVec(CounterOpts{
325+
Name: "test",
326+
Help: "test help",
327+
now: func() time.Time { return now },
328+
}, []string{"label"})
329+
330+
// First use of "With" should populate CT.
331+
counterVec.WithLabelValues("1")
332+
expected := map[string]time.Time{"1": now}
333+
334+
now = now.Add(1 * time.Hour)
335+
expectCTsForMetricVecValues(t, counterVec.MetricVec, dto.MetricType_COUNTER, expected)
336+
337+
// Two more labels at different times.
338+
counterVec.WithLabelValues("2")
339+
expected["2"] = now
340+
341+
now = now.Add(1 * time.Hour)
342+
343+
counterVec.WithLabelValues("3")
344+
expected["3"] = now
345+
346+
now = now.Add(1 * time.Hour)
347+
expectCTsForMetricVecValues(t, counterVec.MetricVec, dto.MetricType_COUNTER, expected)
348+
349+
// Recreate metric instance should reset created timestamp to now.
350+
counterVec.DeleteLabelValues("1")
351+
counterVec.WithLabelValues("1")
352+
expected["1"] = now
353+
354+
now = now.Add(1 * time.Hour)
355+
expectCTsForMetricVecValues(t, counterVec.MetricVec, dto.MetricType_COUNTER, expected)
356+
}
357+
358+
func expectCTsForMetricVecValues(t testing.TB, vec *MetricVec, typ dto.MetricType, ctsPerLabelValue map[string]time.Time) {
359+
t.Helper()
360+
361+
for val, ct := range ctsPerLabelValue {
362+
var metric dto.Metric
363+
m, err := vec.GetMetricWithLabelValues(val)
364+
if err != nil {
365+
t.Fatal(err)
366+
}
367+
368+
if err := m.Write(&metric); err != nil {
369+
t.Fatal(err)
370+
}
371+
372+
var gotTs time.Time
373+
switch typ {
374+
case dto.MetricType_COUNTER:
375+
gotTs = metric.Counter.CreatedTimestamp.AsTime()
376+
case dto.MetricType_HISTOGRAM:
377+
gotTs = metric.Histogram.CreatedTimestamp.AsTime()
378+
case dto.MetricType_SUMMARY:
379+
gotTs = metric.Summary.CreatedTimestamp.AsTime()
380+
default:
381+
t.Fatalf("unknown metric type %v", typ)
382+
}
383+
384+
if !gotTs.Equal(ct) {
385+
t.Errorf("expected created timestamp for %s with label value %q: %s, got %s", typ, val, ct, gotTs)
386+
}
387+
}
388+
}

Diff for: prometheus/example_metricvec_test.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
package prometheus_test
1515

1616
import (
17+
"fmt"
18+
1719
"google.golang.org/protobuf/proto"
1820

1921
dto "github.com/prometheus/client_model/go"
@@ -124,7 +126,7 @@ func ExampleMetricVec() {
124126
if err != nil || len(metricFamilies) != 1 {
125127
panic("unexpected behavior of custom test registry")
126128
}
127-
printlnNormalized(metricFamilies[0])
129+
fmt.Println(toNormalizedJSON(metricFamilies[0]))
128130

129131
// Output:
130132
// {"name":"library_version_info","help":"Versions of the libraries used in this binary.","type":"GAUGE","metric":[{"label":[{"name":"library","value":"k8s.io/client-go"},{"name":"version","value":"0.18.8"}],"gauge":{"value":1}},{"label":[{"name":"library","value":"prometheus/client_golang"},{"name":"version","value":"1.7.1"}],"gauge":{"value":1}}]}

Diff for: prometheus/examples_test.go

+28-10
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,22 @@ func ExampleCounterVec() {
153153
httpReqs.DeleteLabelValues("200", "GET")
154154
// Same thing with the more verbose Labels syntax.
155155
httpReqs.Delete(prometheus.Labels{"method": "GET", "code": "200"})
156+
157+
// Just for demonstration, let's check the state of the counter vector
158+
// by registering it with a custom registry and then let it collect the
159+
// metrics.
160+
reg := prometheus.NewRegistry()
161+
reg.MustRegister(httpReqs)
162+
163+
metricFamilies, err := reg.Gather()
164+
if err != nil || len(metricFamilies) != 1 {
165+
panic("unexpected behavior of custom test registry")
166+
}
167+
168+
fmt.Println(toNormalizedJSON(sanitizeMetricFamily(metricFamilies[0])))
169+
170+
// Output:
171+
// {"name":"http_requests_total","help":"How many HTTP requests processed, partitioned by status code and HTTP method.","type":"COUNTER","metric":[{"label":[{"name":"code","value":"404"},{"name":"method","value":"POST"}],"counter":{"value":42,"createdTimestamp":"1970-01-01T00:00:10Z"}}]}
156172
}
157173

158174
func ExampleRegister() {
@@ -320,10 +336,10 @@ func ExampleSummary() {
320336
metric := &dto.Metric{}
321337
temps.Write(metric)
322338

323-
printlnNormalized(metric)
339+
fmt.Println(toNormalizedJSON(sanitizeMetric(metric)))
324340

325341
// Output:
326-
// {"summary":{"sampleCount":"1000","sampleSum":29969.50000000001,"quantile":[{"quantile":0.5,"value":31.1},{"quantile":0.9,"value":41.3},{"quantile":0.99,"value":41.9}]}}
342+
// {"summary":{"sampleCount":"1000","sampleSum":29969.50000000001,"quantile":[{"quantile":0.5,"value":31.1},{"quantile":0.9,"value":41.3},{"quantile":0.99,"value":41.9}],"createdTimestamp":"1970-01-01T00:00:10Z"}}
327343
}
328344

329345
func ExampleSummaryVec() {
@@ -355,10 +371,11 @@ func ExampleSummaryVec() {
355371
if err != nil || len(metricFamilies) != 1 {
356372
panic("unexpected behavior of custom test registry")
357373
}
358-
printlnNormalized(metricFamilies[0])
374+
375+
fmt.Println(toNormalizedJSON(sanitizeMetricFamily(metricFamilies[0])))
359376

360377
// Output:
361-
// {"name":"pond_temperature_celsius","help":"The temperature of the frog pond.","type":"SUMMARY","metric":[{"label":[{"name":"species","value":"leiopelma-hochstetteri"}],"summary":{"sampleCount":"0","sampleSum":0,"quantile":[{"quantile":0.5,"value":"NaN"},{"quantile":0.9,"value":"NaN"},{"quantile":0.99,"value":"NaN"}]}},{"label":[{"name":"species","value":"lithobates-catesbeianus"}],"summary":{"sampleCount":"1000","sampleSum":31956.100000000017,"quantile":[{"quantile":0.5,"value":32.4},{"quantile":0.9,"value":41.4},{"quantile":0.99,"value":41.9}]}},{"label":[{"name":"species","value":"litoria-caerulea"}],"summary":{"sampleCount":"1000","sampleSum":29969.50000000001,"quantile":[{"quantile":0.5,"value":31.1},{"quantile":0.9,"value":41.3},{"quantile":0.99,"value":41.9}]}}]}
378+
// {"name":"pond_temperature_celsius","help":"The temperature of the frog pond.","type":"SUMMARY","metric":[{"label":[{"name":"species","value":"leiopelma-hochstetteri"}],"summary":{"sampleCount":"0","sampleSum":0,"quantile":[{"quantile":0.5,"value":"NaN"},{"quantile":0.9,"value":"NaN"},{"quantile":0.99,"value":"NaN"}],"createdTimestamp":"1970-01-01T00:00:10Z"}},{"label":[{"name":"species","value":"lithobates-catesbeianus"}],"summary":{"sampleCount":"1000","sampleSum":31956.100000000017,"quantile":[{"quantile":0.5,"value":32.4},{"quantile":0.9,"value":41.4},{"quantile":0.99,"value":41.9}],"createdTimestamp":"1970-01-01T00:00:10Z"}},{"label":[{"name":"species","value":"litoria-caerulea"}],"summary":{"sampleCount":"1000","sampleSum":29969.50000000001,"quantile":[{"quantile":0.5,"value":31.1},{"quantile":0.9,"value":41.3},{"quantile":0.99,"value":41.9}],"createdTimestamp":"1970-01-01T00:00:10Z"}}]}
362379
}
363380

364381
func ExampleNewConstSummary() {
@@ -382,7 +399,7 @@ func ExampleNewConstSummary() {
382399
// internally).
383400
metric := &dto.Metric{}
384401
s.Write(metric)
385-
printlnNormalized(metric)
402+
fmt.Println(toNormalizedJSON(metric))
386403

387404
// Output:
388405
// {"label":[{"name":"code","value":"200"},{"name":"method","value":"get"},{"name":"owner","value":"example"}],"summary":{"sampleCount":"4711","sampleSum":403.34,"quantile":[{"quantile":0.5,"value":42.3},{"quantile":0.9,"value":323.3}]}}
@@ -405,10 +422,11 @@ func ExampleHistogram() {
405422
// internally).
406423
metric := &dto.Metric{}
407424
temps.Write(metric)
408-
printlnNormalized(metric)
425+
426+
fmt.Println(toNormalizedJSON(sanitizeMetric(metric)))
409427

410428
// Output:
411-
// {"histogram":{"sampleCount":"1000","sampleSum":29969.50000000001,"bucket":[{"cumulativeCount":"192","upperBound":20},{"cumulativeCount":"366","upperBound":25},{"cumulativeCount":"501","upperBound":30},{"cumulativeCount":"638","upperBound":35},{"cumulativeCount":"816","upperBound":40}]}}
429+
// {"histogram":{"sampleCount":"1000","sampleSum":29969.50000000001,"bucket":[{"cumulativeCount":"192","upperBound":20},{"cumulativeCount":"366","upperBound":25},{"cumulativeCount":"501","upperBound":30},{"cumulativeCount":"638","upperBound":35},{"cumulativeCount":"816","upperBound":40}],"createdTimestamp":"1970-01-01T00:00:10Z"}}
412430
}
413431

414432
func ExampleNewConstHistogram() {
@@ -432,7 +450,7 @@ func ExampleNewConstHistogram() {
432450
// internally).
433451
metric := &dto.Metric{}
434452
h.Write(metric)
435-
printlnNormalized(metric)
453+
fmt.Println(toNormalizedJSON(metric))
436454

437455
// Output:
438456
// {"label":[{"name":"code","value":"200"},{"name":"method","value":"get"},{"name":"owner","value":"example"}],"histogram":{"sampleCount":"4711","sampleSum":403.34,"bucket":[{"cumulativeCount":"121","upperBound":25},{"cumulativeCount":"2403","upperBound":50},{"cumulativeCount":"3221","upperBound":100},{"cumulativeCount":"4233","upperBound":200}]}}
@@ -470,7 +488,7 @@ func ExampleNewConstHistogram_WithExemplar() {
470488
// internally).
471489
metric := &dto.Metric{}
472490
h.Write(metric)
473-
printlnNormalized(metric)
491+
fmt.Println(toNormalizedJSON(metric))
474492

475493
// Output:
476494
// {"label":[{"name":"code","value":"200"},{"name":"method","value":"get"},{"name":"owner","value":"example"}],"histogram":{"sampleCount":"4711","sampleSum":403.34,"bucket":[{"cumulativeCount":"121","upperBound":25,"exemplar":{"label":[{"name":"testName","value":"testVal"}],"value":24,"timestamp":"2006-01-02T15:04:05Z"}},{"cumulativeCount":"2403","upperBound":50,"exemplar":{"label":[{"name":"testName","value":"testVal"}],"value":42,"timestamp":"2006-01-02T15:04:05Z"}},{"cumulativeCount":"3221","upperBound":100,"exemplar":{"label":[{"name":"testName","value":"testVal"}],"value":89,"timestamp":"2006-01-02T15:04:05Z"}},{"cumulativeCount":"4233","upperBound":200,"exemplar":{"label":[{"name":"testName","value":"testVal"}],"value":157,"timestamp":"2006-01-02T15:04:05Z"}}]}}
@@ -632,7 +650,7 @@ func ExampleNewMetricWithTimestamp() {
632650
// internally).
633651
metric := &dto.Metric{}
634652
s.Write(metric)
635-
printlnNormalized(metric)
653+
fmt.Println(toNormalizedJSON(metric))
636654

637655
// Output:
638656
// {"gauge":{"value":298.15},"timestampMs":"1257894000012"}

Diff for: prometheus/expvar_collector_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func ExampleNewExpvarCollector() {
8181
if !strings.Contains(m.Desc().String(), "expvar_memstats") {
8282
metric.Reset()
8383
m.Write(&metric)
84-
metricStrings = append(metricStrings, protoToNormalizedJSON(&metric))
84+
metricStrings = append(metricStrings, toNormalizedJSON(&metric))
8585
}
8686
}
8787
sort.Strings(metricStrings)

0 commit comments

Comments
 (0)