From a85a8b2aea91d22a011ed30166db98f9cf4e274a Mon Sep 17 00:00:00 2001 From: Bartlomiej Plotka Date: Wed, 16 Mar 2022 14:36:58 +0000 Subject: [PATCH] Added MustNewMetricWithExemplars that wraps metrics with exemplar Changes: * Make sure to not "leak" dto.Metric * Reused upper bounds we already have for histogram * Common code for all types. Signed-off-by: Bartlomiej Plotka --- prometheus/examples_test.go | 90 ++++++++++++++++++++++++++++--------- prometheus/histogram.go | 63 +------------------------- prometheus/metric.go | 89 ++++++++++++++++++++++++++++++++++++ prometheus/metric_test.go | 45 ++++++++++++++++++- 4 files changed, 204 insertions(+), 83 deletions(-) diff --git a/prometheus/examples_test.go b/prometheus/examples_test.go index e39b879ff..e028f53b6 100644 --- a/prometheus/examples_test.go +++ b/prometheus/examples_test.go @@ -22,13 +22,10 @@ import ( "strings" "time" - "google.golang.org/protobuf/types/known/timestamppb" - //nolint:staticcheck // Ignore SA1019. Need to keep deprecated package for compatibility. "github.com/golang/protobuf/proto" - "github.com/prometheus/common/expfmt" - dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -551,30 +548,83 @@ func ExampleNewConstHistogram() { prometheus.Labels{"owner": "example"}, ) - var exemplars []*dto.Exemplar - n := "testName" - v := "testVal" - lp := dto.LabelPair{Name: &n, Value: &v} - var labelPairs []*dto.LabelPair - labelPairs = append(labelPairs, &lp) - vals := []float64{24.0, 42.0, 89.0, 157.0} - t, _ := time.Parse("unix", "Mon Jan _2 15:04:05 MST 2006") - ts := timestamppb.New(t) - - for i := 0; i < 4; i++ { - e := dto.Exemplar{Label: labelPairs, Value: &vals[i], Timestamp: ts} - exemplars = append(exemplars, &e) - } + // Create a constant histogram from values we got from a 3rd party telemetry system. + h := prometheus.MustNewConstHistogram( + desc, + 4711, 403.34, + map[float64]uint64{25: 121, 50: 2403, 100: 3221, 200: 4233}, + "200", "get", + ) + + // Just for demonstration, let's check the state of the histogram by + // (ab)using its Write method (which is usually only used by Prometheus + // internally). + metric := &dto.Metric{} + h.Write(metric) + fmt.Println(proto.MarshalTextString(metric)) + + // Output: + // label: < + // name: "code" + // value: "200" + // > + // label: < + // name: "method" + // value: "get" + // > + // label: < + // name: "owner" + // value: "example" + // > + // histogram: < + // sample_count: 4711 + // sample_sum: 403.34 + // bucket: < + // cumulative_count: 121 + // upper_bound: 25 + // > + // bucket: < + // cumulative_count: 2403 + // upper_bound: 50 + // > + // bucket: < + // cumulative_count: 3221 + // upper_bound: 100 + // > + // bucket: < + // cumulative_count: 4233 + // upper_bound: 200 + // > + // > +} + +func ExampleNewConstHistogram_WithExemplar() { + desc := prometheus.NewDesc( + "http_request_duration_seconds", + "A histogram of the HTTP request durations.", + []string{"code", "method"}, + prometheus.Labels{"owner": "example"}, + ) // Create a constant histogram from values we got from a 3rd party telemetry system. - h := prometheus.MustNewConstHistogramWithExemplar( + h := prometheus.MustNewConstHistogram( desc, 4711, 403.34, map[float64]uint64{25: 121, 50: 2403, 100: 3221, 200: 4233}, - exemplars, "200", "get", ) + // Wrap const histogram with exemplars for each bucket. + exemplarTs, _ := time.Parse("unix", "Mon Jan _2 15:04:05 MST 2006") + exemplarLabels := prometheus.Labels{"testName": "testVal"} + h = prometheus.MustNewMetricWithExemplars( + h, + prometheus.Exemplar{Labels: exemplarLabels, Timestamp: exemplarTs, Value: 24.0}, + prometheus.Exemplar{Labels: exemplarLabels, Timestamp: exemplarTs, Value: 42.0}, + prometheus.Exemplar{Labels: exemplarLabels, Timestamp: exemplarTs, Value: 89.0}, + prometheus.Exemplar{Labels: exemplarLabels, Timestamp: exemplarTs, Value: 157.0}, + ) + // Just for demonstration, let's check the state of the histogram by // (ab)using its Write method (which is usually only used by Prometheus // internally). diff --git a/prometheus/histogram.go b/prometheus/histogram.go index 9bcbf4610..0d47fecdc 100644 --- a/prometheus/histogram.go +++ b/prometheus/histogram.go @@ -573,7 +573,6 @@ type constHistogram struct { sum float64 buckets map[float64]uint64 labelPairs []*dto.LabelPair - exemplars []*dto.Exemplar } func (h *constHistogram) Desc() *Desc { @@ -582,9 +581,8 @@ func (h *constHistogram) Desc() *Desc { func (h *constHistogram) Write(out *dto.Metric) error { his := &dto.Histogram{} - // h.buckets, buckets and bounds are all the same length + buckets := make([]*dto.Bucket, 0, len(h.buckets)) - bounds := make([]float64, 0, len(h.buckets)) his.SampleCount = proto.Uint64(h.count) his.SampleSum = proto.Float64(h.sum) @@ -593,28 +591,11 @@ func (h *constHistogram) Write(out *dto.Metric) error { CumulativeCount: proto.Uint64(count), UpperBound: proto.Float64(upperBound), }) - bounds = append(bounds, upperBound) } - // make sure that both bounds and buckets have the same ordering if len(buckets) > 0 { sort.Sort(buckSort(buckets)) - sort.Float64s(bounds) - } - - if len(h.exemplars) > 0 { - r := len(buckets) - l := len(h.exemplars) - for i := 0; i < r && i < l; i++ { - bound := sort.SearchFloat64s(bounds, *h.exemplars[i].Value) - // Only append the exemplar if it's within the bounds defined in the - // buckets. - if bound < r { - buckets[bound].Exemplar = h.exemplars[i] - } - } } - his.Bucket = buckets out.Histogram = his @@ -636,7 +617,6 @@ func (h *constHistogram) Write(out *dto.Metric) error { // // NewConstHistogram returns an error if the length of labelValues is not // consistent with the variable labels in Desc or if Desc is invalid. - func NewConstHistogram( desc *Desc, count uint64, @@ -675,47 +655,6 @@ func MustNewConstHistogram( return m } -func NewConstHistogramWithExemplar( - desc *Desc, - count uint64, - sum float64, - buckets map[float64]uint64, - exemplars []*dto.Exemplar, - labelValues ...string, - -) (Metric, error) { - if desc.err != nil { - return nil, desc.err - } - if err := validateLabelValues(labelValues, len(desc.variableLabels)); err != nil { - return nil, err - } - - h, err := NewConstHistogram(desc, count, sum, buckets, labelValues...) - if err != nil { - return nil, err - } - - h.(*constHistogram).exemplars = exemplars - - return h, nil -} - -// MustNewConstHistogram is a version of NewConstHistogram that panics where -// NewConstHistogram would have returned an error. -func MustNewConstHistogramWithExemplar( - desc *Desc, - count uint64, - sum float64, - buckets map[float64]uint64, - exemplars []*dto.Exemplar, - labelValues ...string, -) Metric { - h := MustNewConstHistogram(desc, count, sum, buckets, labelValues...) - h.(*constHistogram).exemplars = exemplars - return h -} - type buckSort []*dto.Bucket func (s buckSort) Len() int { diff --git a/prometheus/metric.go b/prometheus/metric.go index 118a54e84..79ab4b867 100644 --- a/prometheus/metric.go +++ b/prometheus/metric.go @@ -14,6 +14,8 @@ package prometheus import ( + "errors" + "sort" "strings" "time" @@ -158,3 +160,90 @@ func (m timestampedMetric) Write(pb *dto.Metric) error { func NewMetricWithTimestamp(t time.Time, m Metric) Metric { return timestampedMetric{Metric: m, t: t} } + +type withExemplarsMetric struct { + Metric + + exemplars []*dto.Exemplar +} + +func (m *withExemplarsMetric) Write(pb *dto.Metric) error { + if err := m.Metric.Write(pb); err != nil { + return err + } + + switch { + case pb.Counter != nil: + pb.Counter.Exemplar = m.exemplars[len(m.exemplars)-1] + case pb.Histogram != nil: + for _, e := range m.exemplars { + // pb.Histogram.Bucket are sorted by UpperBound. + i := sort.Search(len(pb.Histogram.Bucket), func(i int) bool { + return pb.Histogram.Bucket[i].GetUpperBound() <= e.GetValue() + }) + if i < len(pb.Histogram.Bucket) { + pb.Histogram.Bucket[i].Exemplar = e + } else { + panic("yolo") // TODO: Broken, fix + } + } + default: + // TODO(bwplotka): Implement Gauge? + return errors.New("cannot inject exemplar into Gauge, Summary or Untyped") + } + + return nil +} + +// Exemplar is easier to use, user-facing representation of *dto.Exemplar. +type Exemplar struct { + Value float64 + Labels Labels + // Optional. + // Default value (time.Time{}) indicates its empty, which should be + // understood as time.Now() time at the moment of creation of metric. + Timestamp time.Time +} + +// NewMetricWithExemplars returns a new Metric wrapping the provided Metric with given +// exemplars. Exemplars are validated. +// +// Only last applicable exemplar is injected from the list. +// For example for Counter it means last exemplar is injected. +// For Histogram, it means last applicable exemplar for each bucket is injected. +// +// NewMetricWithExemplars works best with MustNewConstMetric and +// MustNewConstHistogram, see example. +func NewMetricWithExemplars(m Metric, exemplars ...Exemplar) (Metric, error) { + if len(exemplars) == 0 { + return nil, errors.New("no exemplar was passed for NewMetricWithExemplars") + } + + var ( + now = time.Now() + exs = make([]*dto.Exemplar, len(exemplars)) + err error + ) + for i, e := range exemplars { + ts := e.Timestamp + if ts == (time.Time{}) { + ts = now + } + exs[i], err = newExemplar(e.Value, ts, e.Labels) + if err != nil { + return nil, err + } + } + + return &withExemplarsMetric{Metric: m, exemplars: exs}, nil +} + +// MustNewMetricWithExemplars is a version of NewMetricWithExemplars that panics where +// NewMetricWithExemplars would have returned an error. +func MustNewMetricWithExemplars(m Metric, exemplars ...Exemplar) Metric { + ret, err := NewMetricWithExemplars(m, exemplars...) + if err != nil { + panic(err) + } + return ret +} diff --git a/prometheus/metric_test.go b/prometheus/metric_test.go index 7145f5e53..56ff05fd3 100644 --- a/prometheus/metric_test.go +++ b/prometheus/metric_test.go @@ -13,7 +13,12 @@ package prometheus -import "testing" +import ( + "testing" + + "github.com/golang/protobuf/proto" + dto "github.com/prometheus/client_model/go" +) func TestBuildFQName(t *testing.T) { scenarios := []struct{ namespace, subsystem, name, result string }{ @@ -33,3 +38,41 @@ func TestBuildFQName(t *testing.T) { } } } + +func TestWithExemplarsMetric(t *testing.T) { + t.Run("histogram", func(t *testing.T) { + // Create a constant histogram from values we got from a 3rd party telemetry system. + h := MustNewConstHistogram( + nil, + 4711, 403.34, + map[float64]uint64{25: 121, 50: 2403, 100: 3221, 200: 4233}, + ) + + m := &withExemplarsMetric{Metric: h, exemplars: []*dto.Exemplar{ + {Value: proto.Float64(24.0)}, + {Value: proto.Float64(25.1)}, + {Value: proto.Float64(42.0)}, + {Value: proto.Float64(89.0)}, + {Value: proto.Float64(100.0)}, + {Value: proto.Float64(157.0)}, + }} + metric := dto.Metric{} + if err := m.Write(&metric); err != nil { + t.Fatal(err) + } + if want, got := 4, len(metric.GetHistogram().Bucket); want != got { + t.Errorf("want %v, got %v", want, got) + } + + expectedExemplarVals := []float64{24.0, 42.0, 100.0, 157.0} + for i, b := range metric.GetHistogram().Bucket { + if b.Exemplar == nil { + t.Errorf("Expected exemplar for bucket %v, got nil", i) + } + if want, got := expectedExemplarVals[i], *metric.GetHistogram().Bucket[i].Exemplar.Value; want != got { + t.Errorf("%v: want %v, got %v", i, want, got) + } + } + }) + +}