package promassert import ( "errors" "fmt" "math" "regexp" "testing" promi "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" ) // Verbose is provided as a var so that logging can be turned on/off explicitly. var Verbose = true // ErrEmptyMetric is returned whenever an function expects a non-empty metric. var ErrEmptyMetric = errors.New("metric is empty") // ParsedMetrics contains all the combinations of metric labels/values associated with a named Counter or Gauge. type ParsedMetrics []ParsedMetric // ParsedMetric represents an individual line within the prometheus metrics exposition format for Counters and Gauges. type ParsedMetric struct { Value float64 Labels map[string]string } // Counter parses the prometheus exposition format scraped from promhttp.Handler(), // and gathers all of the metrics data that matches the provided name. // // This function panics if the metric is not a Counter. Your test will fail, so this is a good thing. // // Counter(...) returns an empty slice if the metric is not present in the scrape data. // // NOTE: Counter metrics must be set using the `Add(...)`, or `Inc()` functions in order for it to be found. // Declaring a metric using `prometheus.NewCounter(...)` and `prometheus.NewCounterVec(...)` does not initialize the metric. func Counter(name string) ParsedMetrics { return parseMetric(name, promi.MetricType_COUNTER) } // Gauge parses the prometheus exposition format scraped from promhttp.Handler(), // and gathers all of the metrics data that matches the provided name. // // This function panics if the metric is not a Gauge. Your test will fail, so this is a good thing. // // Gauge(...) returns an empty slice if the metric was not found. // // NOTE: Gauge metrics must be set using the `Add(...)`, `Set(...)` or `Inc()` functions in order for it to be found. // Declaring a metric using `prometheus.NewGauge(...)` and `prometheus.NewGaugeVec(...)` does not initialize the metric. func Gauge(name string) ParsedMetrics { return parseMetric(name, promi.MetricType_GAUGE) } // parseMetric parses the prometheus exposition format scraped from promhttp.Handler(), and gathers all of the metrics data that matches the provided name. // // NOTE: This function only works with Counters and Gauges, and panics if the metric is any other metric type. func parseMetric(name string, mtype promi.MetricType) ParsedMetrics { var ret []ParsedMetric var prombody, err = ScrapePrometheusMetrics() if err != nil { panic(fmt.Sprintf("got error when scraping metrics: %v", err)) } var tp expfmt.TextParser mf, err := tp.TextToMetricFamilies(prombody) if err != nil { // Should not happen, but need to know if it does. panic(fmt.Sprintf("could not parse metrics from prometheus handler: %v", err)) } metf, found := mf[name] if !found { // Exit early and return the empty set. // The assert will fail later, or pass if they are asserting empty! return ret } else if mtype != metf.GetType() { panic(fmt.Sprintf("expected metric %q to be type %q but got type %q", name, mtype, metf.GetType())) } for _, met := range metf.GetMetric() { var pm = ParsedMetric{Labels: make(map[string]string)} switch mtype { case promi.MetricType_COUNTER: pm.Value = met.GetCounter().GetValue() case promi.MetricType_GAUGE: pm.Value = met.GetGauge().GetValue() } // metrics can omit labels for _, lp := range met.GetLabel() { pm.Labels[lp.GetName()] = lp.GetValue() } ret = append(ret, pm) } return ret } var ErrFoldedNaN = errors.New("folded float values cannot contain NaN") var ErrFoldedInf = errors.New("folded float values cannot contain Inf") // Fold combines the values of the parsed metric and returns the sum. // // If the metric is empty, then fold returns ErrEmptyMetric. // // If the metric contains NaN or Inf floats, then ErrFoldedNan or ErrFoldedInf is returned. // // Because float operations are imprecise, it's recommended to only fold metrics whose values represent integers. func (p ParsedMetrics) Fold() (float64, error) { if len(p) == 0 { return 0, ErrEmptyMetric } var sum float64 for _, pm := range p { if math.IsNaN(pm.Value) { return 0, ErrFoldedNaN } else if math.IsInf(pm.Value, 0) { return 0, ErrFoldedInf } sum += pm.Value } return sum, nil } // TryFold returns the folded value, or `0` if p.Fold returned an error. func (p ParsedMetrics) TryFold() float64 { value, err := p.Fold() if err != nil { return 0 } return value } // IsNaN asserts whether the values within `p` are NaN. // // This assert fails if `p` is empty. func (p ParsedMetrics) IsNaN(t *testing.T) bool { if len(p) == 0 { if t != nil { t.Error(ErrEmptyMetric) } return false } var passed = true for _, pm := range p { if !math.IsNaN(pm.Value) { passed = false if t != nil { t.Errorf("value %f is not NaN", pm.Value) } } } if t != nil && Verbose && passed { t.Logf("assertion passed: the values of the metric are NaN") } return passed } // IsInf asserts whether the values within `p` are infinity, according to sign. // If sign > 0, IsInf asserts whether the values in `p` are positive infinity. // If sign < 0, IsInf asserts whether the values within `p` are negative infinity. // If sign == 0, IsInf asserts whether the values in `p` are either infinity. // // This assert fails if `p` is empty. func (p ParsedMetrics) IsInf(t *testing.T, sign int) bool { if len(p) == 0 { if t != nil { t.Error(ErrEmptyMetric) } return false } // Used for pretty printing the sign. var s string if sign > 0 { s = "+" } else if sign < 0 { s = "-" } var passed = true for _, pm := range p { if !math.IsInf(pm.Value, sign) { passed = false if t != nil { t.Errorf("value %f is not %sInf", pm.Value, s) } } } if t != nil && Verbose && passed { t.Logf("assertion passed: the values of the metric are %sInf", s) } return passed } // Exists asserts the ParsedMetrics are not empty, aka the metric exists, or the filtered labels were found. // // // checks if the "foo_total" metric exists in the scraped prometheus exposition format. // promassert.Metric("foo_total").Exists(t) // // // checks if "foo_total" contains the label "bar" with the value "baz" // var labels = map[string]string{"bar":"baz"} // promassert.Metric("foo_total").With(labels).Exists(t) func (p ParsedMetrics) Exists(t *testing.T) bool { if len(p) != 0 { if t != nil && Verbose { t.Logf("assertion passed. the metric exists") } return true } if t != nil { t.Errorf("the metric does not exist") } return false } // NotExists asserts the ParsedMetrics are empty, aka the metric does not exist, or the filtered labels do not exist. func (p ParsedMetrics) NotExists(t *testing.T) bool { if len(p) == 0 { if t != nil && Verbose { t.Logf("assertion passed: the metric does not exist") } return true } if t != nil { t.Errorf("the metric exists") } return false } // Equals asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) Equals(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result != total { if t != nil { t.Errorf("folded metric value %f does not equal expected total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f equals expected total %f", result, total) } return true } // NotEquals asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) NotEquals(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result == total { if t != nil { t.Errorf("folded metric value %f equals provided total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f does not equal provided total %f", result, total) } return true } // GreaterThan asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) GreaterThan(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result <= total { if t != nil { t.Errorf("folded metric value %f is not greater than expected total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f is greater than provided total %f", result, total) } return true } // GreaterThanOrEquals asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) GreaterThanOrEquals(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result < total { if t != nil { t.Errorf("folded metric value %f is not greater than or equal to expected total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f is greater than or equal to provided total %f", result, total) } return true } // LessThan asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) LessThan(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result >= total { if t != nil { t.Errorf("folded metric value %f is not less than expected total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f is less than provided total %f", result, total) } return true } // LessThanOrEquals asserts the folded value of metrics within `p`. See the #Fold method for more information on folding. // // If the metrics are empty, then the assert fails func (p ParsedMetrics) LessThanOrEquals(t *testing.T, total float64) bool { result, err := p.Fold() if err != nil { if t != nil { t.Error(err) } return false } else if result > total { if t != nil { t.Errorf("folded metric value %f is not less than or equal to expected total %f", result, total) } return false } if t != nil && Verbose { t.Logf("assertion passed. folded metric value %f is less than or equal to provided total %f", result, total) } return true } // LabelKeysExist is used to assert the metric contains each of the provided label names. func (p ParsedMetrics) LabelKeysExist(t *testing.T, keys ...string) bool { if len(p) == 0 { if t != nil { t.Error(ErrEmptyMetric) } return false } var missing = make(map[string]bool) for _, pm := range p { for _, k := range keys { if _, found := pm.Labels[k]; !found { missing[k] = true } } } if len(missing) != 0 { // mk is used to pretty print the missing keys. var mk []string for k := range missing { mk = append(mk, k) } if t != nil { t.Errorf("the metric is missing the following keys: %v", mk) } } else if t != nil && Verbose { t.Logf("assertion passed: the keys %v are present in the metric", keys) } return len(missing) == 0 } // WithRationalValues drops any parsed metric value containing Nan, +Inf, or -Inf. func (p ParsedMetrics) WithRationalValues() ParsedMetrics { var filtered []ParsedMetric for _, pm := range p { if !math.IsNaN(pm.Value) && !math.IsInf(pm.Value, 0) { filtered = append(filtered, pm) } } return filtered } // With filters the metric by matching the keys/values provided in `l`. // // If none match, the empty set is returned. func (p ParsedMetrics) With(labels map[string]string) ParsedMetrics { var filtered []ParsedMetric for _, pm := range p { var didNotMatch bool for k, v := range labels { if pmv, found := pm.Labels[k]; !found || pmv != v { didNotMatch = true break } } if !didNotMatch { filtered = append(filtered, pm) } } return filtered } // WithValues is like With, except you can provide more than one label value to match against. // // WithValues panics when a label's value slice is empty. // // WithValues is useful when checking specific HTTP status codes, and/or methods // // var lvs = map[string][]string{ // "code": []string{"200", "204"}, // "method": []string{"GET", "POST"}, // } // promassert.Metric("requests_total").WithValues(lvs).Equals(t, 42) func (p ParsedMetrics) WithValues(labelValues map[string][]string) ParsedMetrics { var filtered []ParsedMetric // Convert input to map for quicker asymptotic lookup. var lm = make(map[string]map[string]bool) for k, vs := range labelValues { if 0 == len(vs) { panic("the label values must not be an empty slice") } lm[k] = make(map[string]bool) for _, v := range vs { lm[k][v] = true } } for _, pm := range p { var didNotMatch bool for k, vm := range lm { if pmv, found := pm.Labels[k]; !found || !vm[pmv] { didNotMatch = true break } } if !didNotMatch { filtered = append(filtered, pm) } } return filtered } // WithRegexp filters the metric by matching the key and then checking if the `lrxp[key].MatchString(...)` function passes. // // WithRegexp is useful when testing highly orthogonal labels, such as hostnames, clusters, pods, etc. // // var mrxp = map[string]*regexp.Regexp{ // "hostname": regexp.MustCompile("foo-[0-9]+[.]local"), // } // promassert.Metric("requests_total").WithRegexp(mrxp).Equals(t, 3) func (p ParsedMetrics) WithRegexp(lrxp map[string]*regexp.Regexp) ParsedMetrics { var filtered []ParsedMetric for _, pm := range p { var didNotMatch bool for k, rxpv := range lrxp { if pmv, found := pm.Labels[k]; !found || !rxpv.MatchString(pmv) { didNotMatch = true break } } if !didNotMatch { filtered = append(filtered, pm) } } return filtered }