From 283756c503537466f8ca4b19ba93de1527a68dd9 Mon Sep 17 00:00:00 2001 From: Matt Layher Date: Thu, 13 Apr 2017 17:53:41 -0400 Subject: [PATCH] Initial commit of 'promtool check-metrics', promlint package (#2605) --- cmd/promtool/main.go | 42 ++++++ util/promlint/promlint.go | 198 ++++++++++++++++++++++++++ util/promlint/promlint_test.go | 253 +++++++++++++++++++++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 util/promlint/promlint.go create mode 100644 util/promlint/promlint_test.go diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go index 4095a1ea1..431d38135 100644 --- a/cmd/promtool/main.go +++ b/cmd/promtool/main.go @@ -24,6 +24,7 @@ import ( "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/promql" "github.com/prometheus/prometheus/util/cli" + "github.com/prometheus/prometheus/util/promlint" ) // CheckConfigCmd validates configuration files. @@ -182,6 +183,42 @@ func checkRules(t cli.Term, filename string) (int, error) { return len(rules), nil } +var checkMetricsUsage = strings.TrimSpace(` +usage: promtool check-metrics + +Pass Prometheus metrics over stdin to lint them for consistency and correctness. + +examples: + +$ cat metrics.prom | promtool check-metrics +$ curl -s http://localhost:9090/metrics | promtool check-metrics +`) + +// CheckMetricsCmd performs a linting pass on input metrics. +func CheckMetricsCmd(t cli.Term, args ...string) int { + if len(args) != 0 { + t.Infof(checkMetricsUsage) + return 2 + } + + l := promlint.New(os.Stdin) + problems, err := l.Lint() + if err != nil { + t.Errorf("error while linting: %v", err) + return 1 + } + + for _, p := range problems { + t.Errorf("%s: %s", p.Metric, p.Text) + } + + if len(problems) > 0 { + return 3 + } + + return 0 +} + // VersionCmd prints the binaries version information. func VersionCmd(t cli.Term, _ ...string) int { fmt.Fprintln(os.Stdout, version.Print("promtool")) @@ -201,6 +238,11 @@ func main() { Run: CheckRulesCmd, }) + app.Register("check-metrics", &cli.Command{ + Desc: "validate metrics for correctness", + Run: CheckMetricsCmd, + }) + app.Register("version", &cli.Command{ Desc: "print the version of this binary", Run: VersionCmd, diff --git a/util/promlint/promlint.go b/util/promlint/promlint.go new file mode 100644 index 000000000..69e80627e --- /dev/null +++ b/util/promlint/promlint.go @@ -0,0 +1,198 @@ +// Copyright 2017 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package promlint provides a linter for Prometheus metrics. +package promlint + +import ( + "fmt" + "io" + "sort" + "strings" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" +) + +// A Linter is a Prometheus metrics linter. It identifies issues with metric +// names, types, and metadata, and reports them to the caller. +type Linter struct { + r io.Reader +} + +// A Problem is an issue detected by a Linter. +type Problem struct { + // The name of the metric indicated by this Problem. + Metric string + + // A description of the issue for this Problem. + Text string +} + +// New creates a new Linter that reads an input stream of Prometheus metrics. +// Only the text exposition format is supported. +func New(r io.Reader) *Linter { + return &Linter{ + r: r, + } +} + +// Lint performs a linting pass, returning a slice of Problems indicating any +// issues found in the metrics stream. The slice is sorted by metric name +// and issue description. +func (l *Linter) Lint() ([]Problem, error) { + // TODO(mdlayher): support for protobuf exposition format? + d := expfmt.NewDecoder(l.r, expfmt.FmtText) + + var problems []Problem + + var mf dto.MetricFamily + for { + if err := d.Decode(&mf); err != nil { + if err == io.EOF { + break + } + + return nil, err + } + + problems = append(problems, lint(mf)...) + } + + // Ensure deterministic output. + sort.SliceStable(problems, func(i, j int) bool { + if problems[i].Metric < problems[j].Metric { + return true + } + + return problems[i].Text < problems[j].Text + }) + + return problems, nil +} + +// lint is the entry point for linting a single metric. +func lint(mf dto.MetricFamily) []Problem { + fns := []func(mf dto.MetricFamily) []Problem{ + lintHelp, + lintMetricUnits, + } + + var problems []Problem + for _, fn := range fns { + problems = append(problems, fn(mf)...) + } + + // TODO(mdlayher): lint rules for specific metrics types. + return problems +} + +// lintHelp detects issues related to the help text for a metric. +func lintHelp(mf dto.MetricFamily) []Problem { + var problems []Problem + + // Expect all metrics to have help text available. + if mf.Help == nil { + problems = append(problems, Problem{ + Metric: *mf.Name, + Text: "no help text", + }) + } + + return problems +} + +// lintMetricUnits detects issues with metric unit names. +func lintMetricUnits(mf dto.MetricFamily) []Problem { + var problems []Problem + + unit, base, ok := metricUnits(*mf.Name) + if !ok { + // No known units detected. + return nil + } + + // Unit is already a base unit. + if unit == base { + return nil + } + + problems = append(problems, Problem{ + Metric: *mf.Name, + Text: fmt.Sprintf("use base unit %q instead of %q", base, unit), + }) + + return problems +} + +// metricUnits attempts to detect known unit types used as part of a metric name, +// e.g. "foo_bytes_total" or "bar_baz_milligrams". +func metricUnits(m string) (unit string, base string, ok bool) { + ss := strings.Split(m, "_") + + for _, u := range baseUnits { + // Also check for "no prefix". + for _, p := range append(unitPrefixes, "") { + for _, s := range ss { + // Attempt to explicitly match a known unit with a known prefix, + // as some words may look like "units" when matching suffix. + // + // As an example, "thermometers" should not match "meters", but + // "kilometers" should. + if s == p+u { + return p + u, u, true + } + } + } + } + + return "", "", false +} + +// Units and their possible prefixes recognized by this library. More can be +// added over time as needed. +var ( + baseUnits = []string{ + "amperes", + "bytes", + "candela", + "grams", + "kelvin", // Both plural and non-plural form allowed. + "kelvins", + "meters", // Both American and international spelling permitted. + "metres", + "moles", + "seconds", + } + + unitPrefixes = []string{ + "pico", + "nano", + "micro", + "milli", + "centi", + "deci", + "deca", + "hecto", + "kilo", + "kibi", + "mega", + "mibi", + "giga", + "gibi", + "tera", + "tebi", + "peta", + "pebi", + } +) diff --git a/util/promlint/promlint_test.go b/util/promlint/promlint_test.go new file mode 100644 index 000000000..b26a42667 --- /dev/null +++ b/util/promlint/promlint_test.go @@ -0,0 +1,253 @@ +// Copyright 2017 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package promlint_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/prometheus/prometheus/util/promlint" +) + +func TestLintNoHelpText(t *testing.T) { + const msg = "no help text" + + tests := []struct { + name string + in string + problems []promlint.Problem + }{ + { + name: "no help", + in: ` +# TYPE go_goroutines gauge +go_goroutines 24 +`, + problems: []promlint.Problem{{ + Metric: "go_goroutines", + Text: msg, + }}, + }, + { + name: "empty help", + in: ` +# HELP go_goroutines +# TYPE go_goroutines gauge +go_goroutines 24 +`, + problems: []promlint.Problem{{ + Metric: "go_goroutines", + Text: msg, + }}, + }, + { + name: "no help and empty help", + in: ` +# HELP go_goroutines +# TYPE go_goroutines gauge +go_goroutines 24 +# TYPE go_threads gauge +go_threads 10 +`, + problems: []promlint.Problem{ + { + Metric: "go_goroutines", + Text: msg, + }, + { + Metric: "go_threads", + Text: msg, + }, + }, + }, + { + name: "OK", + in: ` +# HELP go_goroutines Number of goroutines that currently exist. +# TYPE go_goroutines gauge +go_goroutines 24 +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := promlint.New(strings.NewReader(tt.in)) + + problems, err := l.Lint() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if want, got := tt.problems, problems; !reflect.DeepEqual(want, got) { + t.Fatalf("unexpected problems:\n- want: %v\n- got: %v", + want, got) + } + }) + } +} + +func TestLintMetricUnits(t *testing.T) { + tests := []struct { + name string + in string + problems []promlint.Problem + }{ + { + name: "amperes", + in: ` +# HELP x_milliamperes Test metric. +# TYPE x_milliamperes counter +x_milliamperes 10 +`, + problems: []promlint.Problem{{ + Metric: "x_milliamperes", + Text: `use base unit "amperes" instead of "milliamperes"`, + }}, + }, + { + name: "bytes", + in: ` +# HELP x_gigabytes Test metric. +# TYPE x_gigabytes counter +x_gigabytes 10 +`, + problems: []promlint.Problem{{ + Metric: "x_gigabytes", + Text: `use base unit "bytes" instead of "gigabytes"`, + }}, + }, + { + name: "candela", + in: ` +# HELP x_kilocandela Test metric. +# TYPE x_kilocandela counter +x_kilocandela 10 +`, + problems: []promlint.Problem{{ + Metric: "x_kilocandela", + Text: `use base unit "candela" instead of "kilocandela"`, + }}, + }, + { + name: "grams", + in: ` +# HELP x_kilograms Test metric. +# TYPE x_kilograms counter +x_kilograms 10 +`, + problems: []promlint.Problem{{ + Metric: "x_kilograms", + Text: `use base unit "grams" instead of "kilograms"`, + }}, + }, + { + name: "kelvin", + in: ` +# HELP x_nanokelvin Test metric. +# TYPE x_nanokelvin counter +x_nanokelvin 10 +`, + problems: []promlint.Problem{{ + Metric: "x_nanokelvin", + Text: `use base unit "kelvin" instead of "nanokelvin"`, + }}, + }, + { + name: "kelvins", + in: ` +# HELP x_nanokelvins Test metric. +# TYPE x_nanokelvins counter +x_nanokelvins 10 +`, + problems: []promlint.Problem{{ + Metric: "x_nanokelvins", + Text: `use base unit "kelvins" instead of "nanokelvins"`, + }}, + }, + { + name: "meters", + in: ` +# HELP x_kilometers Test metric. +# TYPE x_kilometers counter +x_kilometers 10 +`, + problems: []promlint.Problem{{ + Metric: "x_kilometers", + Text: `use base unit "meters" instead of "kilometers"`, + }}, + }, + { + name: "metres", + in: ` +# HELP x_kilometres Test metric. +# TYPE x_kilometres counter +x_kilometres 10 +`, + problems: []promlint.Problem{{ + Metric: "x_kilometres", + Text: `use base unit "metres" instead of "kilometres"`, + }}, + }, + { + name: "moles", + in: ` +# HELP x_picomoles Test metric. +# TYPE x_picomoles counter +x_picomoles 10 +`, + problems: []promlint.Problem{{ + Metric: "x_picomoles", + Text: `use base unit "moles" instead of "picomoles"`, + }}, + }, + { + name: "seconds", + in: ` +# HELP x_microseconds Test metric. +# TYPE x_microseconds counter +x_microseconds 10 +`, + problems: []promlint.Problem{{ + Metric: "x_microseconds", + Text: `use base unit "seconds" instead of "microseconds"`, + }}, + }, + { + name: "OK", + in: ` +# HELP thermometers_degrees_kelvin Test metric with name that looks like "meters". +# TYPE thermometers_degrees_kelvin counter +thermometers_degrees_kelvin 0 +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := promlint.New(strings.NewReader(tt.in)) + + problems, err := l.Lint() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if want, got := tt.problems, problems; !reflect.DeepEqual(want, got) { + t.Fatalf("unexpected problems:\n- want: %v\n- got: %v", + want, got) + } + }) + } +}