1// Copyright 2020 The Prometheus Authors
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//
6//     http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14// Package promlint provides a linter for Prometheus metrics.
15package promlint
16
17import (
18	"fmt"
19	"io"
20	"regexp"
21	"sort"
22	"strings"
23
24	"github.com/prometheus/common/expfmt"
25
26	dto "github.com/prometheus/client_model/go"
27)
28
29// A Linter is a Prometheus metrics linter.  It identifies issues with metric
30// names, types, and metadata, and reports them to the caller.
31type Linter struct {
32	// The linter will read metrics in the Prometheus text format from r and
33	// then lint it, _and_ it will lint the metrics provided directly as
34	// MetricFamily proto messages in mfs. Note, however, that the current
35	// constructor functions New and NewWithMetricFamilies only ever set one
36	// of them.
37	r   io.Reader
38	mfs []*dto.MetricFamily
39}
40
41// A Problem is an issue detected by a Linter.
42type Problem struct {
43	// The name of the metric indicated by this Problem.
44	Metric string
45
46	// A description of the issue for this Problem.
47	Text string
48}
49
50// newProblem is helper function to create a Problem.
51func newProblem(mf *dto.MetricFamily, text string) Problem {
52	return Problem{
53		Metric: mf.GetName(),
54		Text:   text,
55	}
56}
57
58// New creates a new Linter that reads an input stream of Prometheus metrics in
59// the Prometheus text exposition format.
60func New(r io.Reader) *Linter {
61	return &Linter{
62		r: r,
63	}
64}
65
66// NewWithMetricFamilies creates a new Linter that reads from a slice of
67// MetricFamily protobuf messages.
68func NewWithMetricFamilies(mfs []*dto.MetricFamily) *Linter {
69	return &Linter{
70		mfs: mfs,
71	}
72}
73
74// Lint performs a linting pass, returning a slice of Problems indicating any
75// issues found in the metrics stream. The slice is sorted by metric name
76// and issue description.
77func (l *Linter) Lint() ([]Problem, error) {
78	var problems []Problem
79
80	if l.r != nil {
81		d := expfmt.NewDecoder(l.r, expfmt.FmtText)
82
83		mf := &dto.MetricFamily{}
84		for {
85			if err := d.Decode(mf); err != nil {
86				if err == io.EOF {
87					break
88				}
89
90				return nil, err
91			}
92
93			problems = append(problems, lint(mf)...)
94		}
95	}
96	for _, mf := range l.mfs {
97		problems = append(problems, lint(mf)...)
98	}
99
100	// Ensure deterministic output.
101	sort.SliceStable(problems, func(i, j int) bool {
102		if problems[i].Metric == problems[j].Metric {
103			return problems[i].Text < problems[j].Text
104		}
105		return problems[i].Metric < problems[j].Metric
106	})
107
108	return problems, nil
109}
110
111// lint is the entry point for linting a single metric.
112func lint(mf *dto.MetricFamily) []Problem {
113	fns := []func(mf *dto.MetricFamily) []Problem{
114		lintHelp,
115		lintMetricUnits,
116		lintCounter,
117		lintHistogramSummaryReserved,
118		lintMetricTypeInName,
119		lintReservedChars,
120		lintCamelCase,
121		lintUnitAbbreviations,
122	}
123
124	var problems []Problem
125	for _, fn := range fns {
126		problems = append(problems, fn(mf)...)
127	}
128
129	// TODO(mdlayher): lint rules for specific metrics types.
130	return problems
131}
132
133// lintHelp detects issues related to the help text for a metric.
134func lintHelp(mf *dto.MetricFamily) []Problem {
135	var problems []Problem
136
137	// Expect all metrics to have help text available.
138	if mf.Help == nil {
139		problems = append(problems, newProblem(mf, "no help text"))
140	}
141
142	return problems
143}
144
145// lintMetricUnits detects issues with metric unit names.
146func lintMetricUnits(mf *dto.MetricFamily) []Problem {
147	var problems []Problem
148
149	unit, base, ok := metricUnits(*mf.Name)
150	if !ok {
151		// No known units detected.
152		return nil
153	}
154
155	// Unit is already a base unit.
156	if unit == base {
157		return nil
158	}
159
160	problems = append(problems, newProblem(mf, fmt.Sprintf("use base unit %q instead of %q", base, unit)))
161
162	return problems
163}
164
165// lintCounter detects issues specific to counters, as well as patterns that should
166// only be used with counters.
167func lintCounter(mf *dto.MetricFamily) []Problem {
168	var problems []Problem
169
170	isCounter := mf.GetType() == dto.MetricType_COUNTER
171	isUntyped := mf.GetType() == dto.MetricType_UNTYPED
172	hasTotalSuffix := strings.HasSuffix(mf.GetName(), "_total")
173
174	switch {
175	case isCounter && !hasTotalSuffix:
176		problems = append(problems, newProblem(mf, `counter metrics should have "_total" suffix`))
177	case !isUntyped && !isCounter && hasTotalSuffix:
178		problems = append(problems, newProblem(mf, `non-counter metrics should not have "_total" suffix`))
179	}
180
181	return problems
182}
183
184// lintHistogramSummaryReserved detects when other types of metrics use names or labels
185// reserved for use by histograms and/or summaries.
186func lintHistogramSummaryReserved(mf *dto.MetricFamily) []Problem {
187	// These rules do not apply to untyped metrics.
188	t := mf.GetType()
189	if t == dto.MetricType_UNTYPED {
190		return nil
191	}
192
193	var problems []Problem
194
195	isHistogram := t == dto.MetricType_HISTOGRAM
196	isSummary := t == dto.MetricType_SUMMARY
197
198	n := mf.GetName()
199
200	if !isHistogram && strings.HasSuffix(n, "_bucket") {
201		problems = append(problems, newProblem(mf, `non-histogram metrics should not have "_bucket" suffix`))
202	}
203	if !isHistogram && !isSummary && strings.HasSuffix(n, "_count") {
204		problems = append(problems, newProblem(mf, `non-histogram and non-summary metrics should not have "_count" suffix`))
205	}
206	if !isHistogram && !isSummary && strings.HasSuffix(n, "_sum") {
207		problems = append(problems, newProblem(mf, `non-histogram and non-summary metrics should not have "_sum" suffix`))
208	}
209
210	for _, m := range mf.GetMetric() {
211		for _, l := range m.GetLabel() {
212			ln := l.GetName()
213
214			if !isHistogram && ln == "le" {
215				problems = append(problems, newProblem(mf, `non-histogram metrics should not have "le" label`))
216			}
217			if !isSummary && ln == "quantile" {
218				problems = append(problems, newProblem(mf, `non-summary metrics should not have "quantile" label`))
219			}
220		}
221	}
222
223	return problems
224}
225
226// lintMetricTypeInName detects when metric types are included in the metric name.
227func lintMetricTypeInName(mf *dto.MetricFamily) []Problem {
228	var problems []Problem
229	n := strings.ToLower(mf.GetName())
230
231	for i, t := range dto.MetricType_name {
232		if i == int32(dto.MetricType_UNTYPED) {
233			continue
234		}
235
236		typename := strings.ToLower(t)
237		if strings.Contains(n, "_"+typename+"_") || strings.HasSuffix(n, "_"+typename) {
238			problems = append(problems, newProblem(mf, fmt.Sprintf(`metric name should not include type '%s'`, typename)))
239		}
240	}
241	return problems
242}
243
244// lintReservedChars detects colons in metric names.
245func lintReservedChars(mf *dto.MetricFamily) []Problem {
246	var problems []Problem
247	if strings.Contains(mf.GetName(), ":") {
248		problems = append(problems, newProblem(mf, "metric names should not contain ':'"))
249	}
250	return problems
251}
252
253var camelCase = regexp.MustCompile(`[a-z][A-Z]`)
254
255// lintCamelCase detects metric names and label names written in camelCase.
256func lintCamelCase(mf *dto.MetricFamily) []Problem {
257	var problems []Problem
258	if camelCase.FindString(mf.GetName()) != "" {
259		problems = append(problems, newProblem(mf, "metric names should be written in 'snake_case' not 'camelCase'"))
260	}
261
262	for _, m := range mf.GetMetric() {
263		for _, l := range m.GetLabel() {
264			if camelCase.FindString(l.GetName()) != "" {
265				problems = append(problems, newProblem(mf, "label names should be written in 'snake_case' not 'camelCase'"))
266			}
267		}
268	}
269	return problems
270}
271
272// lintUnitAbbreviations detects abbreviated units in the metric name.
273func lintUnitAbbreviations(mf *dto.MetricFamily) []Problem {
274	var problems []Problem
275	n := strings.ToLower(mf.GetName())
276	for _, s := range unitAbbreviations {
277		if strings.Contains(n, "_"+s+"_") || strings.HasSuffix(n, "_"+s) {
278			problems = append(problems, newProblem(mf, "metric names should not contain abbreviated units"))
279		}
280	}
281	return problems
282}
283
284// metricUnits attempts to detect known unit types used as part of a metric name,
285// e.g. "foo_bytes_total" or "bar_baz_milligrams".
286func metricUnits(m string) (unit string, base string, ok bool) {
287	ss := strings.Split(m, "_")
288
289	for unit, base := range units {
290		// Also check for "no prefix".
291		for _, p := range append(unitPrefixes, "") {
292			for _, s := range ss {
293				// Attempt to explicitly match a known unit with a known prefix,
294				// as some words may look like "units" when matching suffix.
295				//
296				// As an example, "thermometers" should not match "meters", but
297				// "kilometers" should.
298				if s == p+unit {
299					return p + unit, base, true
300				}
301			}
302		}
303	}
304
305	return "", "", false
306}
307
308// Units and their possible prefixes recognized by this library.  More can be
309// added over time as needed.
310var (
311	// map a unit to the appropriate base unit.
312	units = map[string]string{
313		// Base units.
314		"amperes": "amperes",
315		"bytes":   "bytes",
316		"celsius": "celsius", // Also allow Celsius because it is common in typical Prometheus use cases.
317		"grams":   "grams",
318		"joules":  "joules",
319		"kelvin":  "kelvin", // SI base unit, used in special cases (e.g. color temperature, scientific measurements).
320		"meters":  "meters", // Both American and international spelling permitted.
321		"metres":  "metres",
322		"seconds": "seconds",
323		"volts":   "volts",
324
325		// Non base units.
326		// Time.
327		"minutes": "seconds",
328		"hours":   "seconds",
329		"days":    "seconds",
330		"weeks":   "seconds",
331		// Temperature.
332		"kelvins":    "kelvin",
333		"fahrenheit": "celsius",
334		"rankine":    "celsius",
335		// Length.
336		"inches": "meters",
337		"yards":  "meters",
338		"miles":  "meters",
339		// Bytes.
340		"bits": "bytes",
341		// Energy.
342		"calories": "joules",
343		// Mass.
344		"pounds": "grams",
345		"ounces": "grams",
346	}
347
348	unitPrefixes = []string{
349		"pico",
350		"nano",
351		"micro",
352		"milli",
353		"centi",
354		"deci",
355		"deca",
356		"hecto",
357		"kilo",
358		"kibi",
359		"mega",
360		"mibi",
361		"giga",
362		"gibi",
363		"tera",
364		"tebi",
365		"peta",
366		"pebi",
367	}
368
369	// Common abbreviations that we'd like to discourage.
370	unitAbbreviations = []string{
371		"s",
372		"ms",
373		"us",
374		"ns",
375		"sec",
376		"b",
377		"kb",
378		"mb",
379		"gb",
380		"tb",
381		"pb",
382		"m",
383		"h",
384		"d",
385	}
386)
387