diff --git a/prometheus/examples_test.go b/prometheus/examples_test.go index a73ed184c..a7a8d0c14 100644 --- a/prometheus/examples_test.go +++ b/prometheus/examples_test.go @@ -24,9 +24,8 @@ import ( //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" @@ -599,6 +598,115 @@ func ExampleNewConstHistogram() { // > } +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.MustNewConstHistogram( + desc, + 4711, 403.34, + map[float64]uint64{25: 121, 50: 2403, 100: 3221, 200: 4233}, + "200", "get", + ) + + // Wrap const histogram with exemplars for each bucket. + exemplarTs, _ := time.Parse(time.RFC850, "Monday, 02-Jan-06 15:04:05 GMT") + 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). + 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 + // exemplar: < + // label: < + // name: "testName" + // value: "testVal" + // > + // value: 24 + // timestamp: < + // seconds: 1136214245 + // > + // > + // > + // bucket: < + // cumulative_count: 2403 + // upper_bound: 50 + // exemplar: < + // label: < + // name: "testName" + // value: "testVal" + // > + // value: 42 + // timestamp: < + // seconds: 1136214245 + // > + // > + // > + // bucket: < + // cumulative_count: 3221 + // upper_bound: 100 + // exemplar: < + // label: < + // name: "testName" + // value: "testVal" + // > + // value: 89 + // timestamp: < + // seconds: 1136214245 + // > + // > + // > + // bucket: < + // cumulative_count: 4233 + // upper_bound: 200 + // exemplar: < + // label: < + // name: "testName" + // value: "testVal" + // > + // value: 157 + // timestamp: < + // seconds: 1136214245 + // > + // > + // > + // > +} + func ExampleAlreadyRegisteredError() { reqCounter := prometheus.NewCounter(prometheus.CounterOpts{ Name: "requests_total", diff --git a/prometheus/histogram.go b/prometheus/histogram.go index 893802fd6..0d47fecdc 100644 --- a/prometheus/histogram.go +++ b/prometheus/histogram.go @@ -581,11 +581,11 @@ func (h *constHistogram) Desc() *Desc { func (h *constHistogram) Write(out *dto.Metric) error { his := &dto.Histogram{} + buckets := make([]*dto.Bucket, 0, len(h.buckets)) his.SampleCount = proto.Uint64(h.count) his.SampleSum = proto.Float64(h.sum) - for upperBound, count := range h.buckets { buckets = append(buckets, &dto.Bucket{ CumulativeCount: proto.Uint64(count), diff --git a/prometheus/histogram_test.go b/prometheus/histogram_test.go index da5771544..b96eff9e1 100644 --- a/prometheus/histogram_test.go +++ b/prometheus/histogram_test.go @@ -424,24 +424,24 @@ func TestHistogramExemplar(t *testing.T) { } expectedExemplars := []*dto.Exemplar{ nil, - &dto.Exemplar{ + { Label: []*dto.LabelPair{ - &dto.LabelPair{Name: proto.String("id"), Value: proto.String("2")}, + {Name: proto.String("id"), Value: proto.String("2")}, }, Value: proto.Float64(1.6), Timestamp: ts, }, nil, - &dto.Exemplar{ + { Label: []*dto.LabelPair{ - &dto.LabelPair{Name: proto.String("id"), Value: proto.String("3")}, + {Name: proto.String("id"), Value: proto.String("3")}, }, Value: proto.Float64(4), Timestamp: ts, }, - &dto.Exemplar{ + { Label: []*dto.LabelPair{ - &dto.LabelPair{Name: proto.String("id"), Value: proto.String("4")}, + {Name: proto.String("id"), Value: proto.String("4")}, }, Value: proto.Float64(4.5), Timestamp: ts, diff --git a/prometheus/metric.go b/prometheus/metric.go index 118a54e84..48d4a5d50 100644 --- a/prometheus/metric.go +++ b/prometheus/metric.go @@ -14,6 +14,8 @@ package prometheus import ( + "errors" + "sort" "strings" "time" @@ -158,3 +160,91 @@ 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 { + // This is not possible as last bucket is Inf. + panic("no bucket was found for given exemplar value") + } + } + 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..6100e0d8a 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( + NewDesc("http_request_duration_seconds", "A histogram of the HTTP request durations.", nil, 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) + } + } + }) + +}