package promlinter import ( "fmt" "go/ast" "go/token" "sort" "strconv" "strings" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil/promlint" dto "github.com/prometheus/client_model/go" ) var ( metricsType map[string]dto.MetricType constMetricArgNum map[string]int validOptsFields map[string]bool lintFuncText map[string][]string LintFuncNames []string ) func init() { metricsType = map[string]dto.MetricType{ "Counter": dto.MetricType_COUNTER, "NewCounter": dto.MetricType_COUNTER, "NewCounterVec": dto.MetricType_COUNTER, "Gauge": dto.MetricType_GAUGE, "NewGauge": dto.MetricType_GAUGE, "NewGaugeVec": dto.MetricType_GAUGE, "NewHistogram": dto.MetricType_HISTOGRAM, "NewHistogramVec": dto.MetricType_HISTOGRAM, "NewSummary": dto.MetricType_SUMMARY, "NewSummaryVec": dto.MetricType_SUMMARY, } constMetricArgNum = map[string]int{ "MustNewConstMetric": 3, "MustNewHistogram": 4, "MustNewSummary": 4, "NewLazyConstMetric": 3, } // Doesn't contain ConstLabels since we don't need this field here. validOptsFields = map[string]bool{ "Name": true, "Namespace": true, "Subsystem": true, "Help": true, } lintFuncText = map[string][]string{ "Help": {"no help text"}, "MetricUnits": {"use base unit"}, "Counter": {"counter metrics should"}, "HistogramSummaryReserved": {"non-histogram", "non-summary"}, "MetricTypeInName": {"metric name should not include type"}, "ReservedChars": {"metric names should not contain ':'"}, "CamelCase": {"'snake_case' not 'camelCase'"}, "lintUnitAbbreviations": {"metric names should not contain abbreviated units"}, } LintFuncNames = []string{"Help", "MetricUnits", "Counter", "HistogramSummaryReserved", "MetricTypeInName", "ReservedChars", "CamelCase", "lintUnitAbbreviations"} } type Setting struct { Strict bool DisabledLintFuncs []string } // Issue contains metric name, error text and metric position. type Issue struct { Text string Metric string Pos token.Position } type MetricFamilyWithPos struct { MetricFamily *dto.MetricFamily Pos token.Position } type visitor struct { fs *token.FileSet metrics []MetricFamilyWithPos issues []Issue strict bool } type opt struct { namespace string subsystem string name string } func RunList(fs *token.FileSet, files []*ast.File, strict bool) []MetricFamilyWithPos { v := &visitor{ fs: fs, metrics: make([]MetricFamilyWithPos, 0), issues: make([]Issue, 0), strict: strict, } for _, file := range files { ast.Walk(v, file) } sort.Slice(v.metrics, func(i, j int) bool { return v.metrics[i].Pos.String() < v.metrics[j].Pos.String() }) return v.metrics } func RunLint(fs *token.FileSet, files []*ast.File, s Setting) []Issue { v := &visitor{ fs: fs, metrics: make([]MetricFamilyWithPos, 0), issues: make([]Issue, 0), strict: s.Strict, } for _, file := range files { ast.Walk(v, file) } // lint metrics for _, mfp := range v.metrics { problems, err := promlint.NewWithMetricFamilies([]*dto.MetricFamily{mfp.MetricFamily}).Lint() if err != nil { panic(err) } for _, p := range problems { for _, disabledFunc := range s.DisabledLintFuncs { for _, pattern := range lintFuncText[disabledFunc] { if strings.Contains(p.Text, pattern) { goto END } } } v.issues = append(v.issues, Issue{ Pos: mfp.Pos, Metric: p.Metric, Text: p.Text, }) END: } } sort.Slice(v.issues, func(i, j int) bool { return v.issues[i].Pos.String() < v.issues[j].Pos.String() }) return v.issues } func (v *visitor) Visit(n ast.Node) ast.Visitor { if n == nil { return v } switch t := n.(type) { case *ast.CallExpr: return v.parseCallerExpr(t) case *ast.SendStmt: return v.parseSendMetricChanExpr(t) } return v } func (v *visitor) parseCallerExpr(call *ast.CallExpr) ast.Visitor { var ( metricType dto.MetricType methodName string ok bool ) switch stmt := call.Fun.(type) { /* That's the case of setting alias . to client_golang/prometheus or promauto package. import . "github.com/prometheus/client_golang/prometheus" metric := NewCounter(CounterOpts{}) */ case *ast.Ident: if stmt.Name == "NewCounterFunc" { return v.parseOpts(call.Args[0], dto.MetricType_COUNTER) } if stmt.Name == "NewGaugeFunc" { return v.parseOpts(call.Args[0], dto.MetricType_GAUGE) } if metricType, ok = metricsType[stmt.Name]; !ok { return v } methodName = stmt.Name /* This case covers the most of cases to initialize metrics. prometheus.NewCounter(CounterOpts{}) promauto.With(nil).NewCounter(CounterOpts{}) factory := promauto.With(nil) factory.NewCounter(CounterOpts{}) prometheus.NewCounterFunc() */ case *ast.SelectorExpr: if stmt.Sel.Name == "NewCounterFunc" { return v.parseOpts(call.Args[0], dto.MetricType_COUNTER) } if stmt.Sel.Name == "NewGaugeFunc" { return v.parseOpts(call.Args[0], dto.MetricType_GAUGE) } if stmt.Sel.Name == "NewFamilyGenerator" && len(call.Args) == 5 { return v.parseKSMMetrics(call.Args[0], call.Args[1], call.Args[2]) } if metricType, ok = metricsType[stmt.Sel.Name]; !ok { return v } methodName = stmt.Sel.Name default: return v } argNum := 1 if strings.HasSuffix(methodName, "Vec") { argNum = 2 } // The methods used to initialize metrics should have at least one arg. if len(call.Args) < argNum && v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(call.Pos()), Metric: "", Text: fmt.Sprintf("%s should have at least %d arguments", methodName, argNum), }) return v } return v.parseOpts(call.Args[0], metricType) } func (v *visitor) parseOpts(optArg ast.Node, metricType dto.MetricType) ast.Visitor { // position for the first arg of the CallExpr optsPosition := v.fs.Position(optArg.Pos()) opts, help := v.parseOptsExpr(optArg) if opts == nil { return v } currentMetric := dto.MetricFamily{ Type: &metricType, Help: help, } metricName := prometheus.BuildFQName(opts.namespace, opts.subsystem, opts.name) // We skip the invalid metric if the name is an empty string. // This kind of metric declaration might be used as a stud metric // https://github.com/thanos-io/thanos/blob/main/cmd/thanos/tools_bucket.go#L538. if metricName == "" { return v } currentMetric.Name = &metricName v.metrics = append(v.metrics, MetricFamilyWithPos{MetricFamily: ¤tMetric, Pos: optsPosition}) return v } // Parser for kube-state-metrics generators. func (v *visitor) parseKSMMetrics(nameArg ast.Node, helpArg ast.Node, metricTypeArg ast.Node) ast.Visitor { optsPosition := v.fs.Position(nameArg.Pos()) currentMetric := dto.MetricFamily{} name, ok := v.parseValue("name", nameArg) if !ok { return v } currentMetric.Name = &name help, ok := v.parseValue("help", helpArg) if !ok { return v } currentMetric.Help = &help switch stmt := metricTypeArg.(type) { case *ast.SelectorExpr: if metricType, ok := metricsType[stmt.Sel.Name]; !ok { return v } else { currentMetric.Type = &metricType } } v.metrics = append(v.metrics, MetricFamilyWithPos{MetricFamily: ¤tMetric, Pos: optsPosition}) return v } func (v *visitor) parseSendMetricChanExpr(chExpr *ast.SendStmt) ast.Visitor { var ( ok bool requiredArgNum int methodName string metricType dto.MetricType ) call, ok := chExpr.Value.(*ast.CallExpr) if !ok { return v } switch stmt := call.Fun.(type) { case *ast.Ident: if requiredArgNum, ok = constMetricArgNum[stmt.Name]; !ok { return v } methodName = stmt.Name case *ast.SelectorExpr: if requiredArgNum, ok = constMetricArgNum[stmt.Sel.Name]; !ok { return v } methodName = stmt.Sel.Name } if len(call.Args) < requiredArgNum && v.strict { v.issues = append(v.issues, Issue{ Metric: "", Pos: v.fs.Position(call.Pos()), Text: fmt.Sprintf("%s should have at least %d arguments", methodName, requiredArgNum), }) return v } name, help := v.parseConstMetricOptsExpr(call.Args[0]) if name == nil { return v } metric := &dto.MetricFamily{ Name: name, Help: help, } switch methodName { case "MustNewConstMetric", "NewLazyConstMetric": switch t := call.Args[1].(type) { case *ast.Ident: metric.Type = getConstMetricType(t.Name) case *ast.SelectorExpr: metric.Type = getConstMetricType(t.Sel.Name) } case "MustNewHistogram": metricType = dto.MetricType_HISTOGRAM metric.Type = &metricType case "MustNewSummary": metricType = dto.MetricType_SUMMARY metric.Type = &metricType } v.metrics = append(v.metrics, MetricFamilyWithPos{MetricFamily: metric, Pos: v.fs.Position(call.Pos())}) return v } func (v *visitor) parseOptsExpr(n ast.Node) (*opt, *string) { switch stmt := n.(type) { case *ast.CompositeLit: return v.parseCompositeOpts(stmt) case *ast.Ident: if stmt.Obj != nil { if decl, ok := stmt.Obj.Decl.(*ast.AssignStmt); ok && len(decl.Rhs) > 0 { if t, ok := decl.Rhs[0].(*ast.CompositeLit); ok { return v.parseCompositeOpts(t) } } } case *ast.UnaryExpr: return v.parseOptsExpr(stmt.X) } return nil, nil } func (v *visitor) parseCompositeOpts(stmt *ast.CompositeLit) (*opt, *string) { metricOption := &opt{} var help *string for _, elt := range stmt.Elts { kvExpr, ok := elt.(*ast.KeyValueExpr) if !ok { continue } object, ok := kvExpr.Key.(*ast.Ident) if !ok { continue } if _, ok := validOptsFields[object.Name]; !ok { continue } // If failed to parse field value, stop parsing. stringLiteral, ok := v.parseValue(object.Name, kvExpr.Value) if !ok { return nil, nil } switch object.Name { case "Namespace": metricOption.namespace = stringLiteral case "Subsystem": metricOption.subsystem = stringLiteral case "Name": metricOption.name = stringLiteral case "Help": help = &stringLiteral } } return metricOption, help } func (v *visitor) parseValue(object string, n ast.Node) (string, bool) { switch t := n.(type) { // make sure it is string literal value case *ast.BasicLit: if t.Kind == token.STRING { return mustUnquote(t.Value), true } return "", false case *ast.Ident: if t.Obj == nil { return "", false } if vs, ok := t.Obj.Decl.(*ast.ValueSpec); ok { return v.parseValue(object, vs) } case *ast.ValueSpec: if len(t.Values) == 0 { return "", false } return v.parseValue(object, t.Values[0]) // For binary expr, we only support adding two strings like `foo` + `bar`. case *ast.BinaryExpr: if t.Op == token.ADD { x, ok := v.parseValue(object, t.X) if !ok { return "", false } y, ok := v.parseValue(object, t.Y) if !ok { return "", false } return x + y, true } // We can only cover some basic cases here case *ast.CallExpr: return v.parseValueCallExpr(object, t) default: if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(n.Pos()), Metric: "", Text: fmt.Sprintf("parsing %s with type %T is not supported", object, t), }) } } return "", false } func (v *visitor) parseValueCallExpr(object string, call *ast.CallExpr) (string, bool) { var ( methodName string namespace string subsystem string name string ok bool ) switch expr := call.Fun.(type) { case *ast.SelectorExpr: methodName = expr.Sel.Name case *ast.Ident: methodName = expr.Name default: return "", false } if methodName == "BuildFQName" && len(call.Args) == 3 { namespace, ok = v.parseValue("namespace", call.Args[0]) if !ok { return "", false } subsystem, ok = v.parseValue("subsystem", call.Args[1]) if !ok { return "", false } name, ok = v.parseValue("name", call.Args[2]) if !ok { return "", false } return prometheus.BuildFQName(namespace, subsystem, name), true } if v.strict { v.issues = append(v.issues, Issue{ Metric: "", Pos: v.fs.Position(call.Pos()), Text: fmt.Sprintf("parsing %s with function %s is not supported", object, methodName), }) } return "", false } func (v *visitor) parseConstMetricOptsExpr(n ast.Node) (*string, *string) { switch stmt := n.(type) { case *ast.CallExpr: return v.parseNewDescCallExpr(stmt) case *ast.Ident: if stmt.Obj != nil { switch t := stmt.Obj.Decl.(type) { case *ast.AssignStmt: if len(t.Rhs) > 0 { if call, ok := t.Rhs[0].(*ast.CallExpr); ok { return v.parseNewDescCallExpr(call) } } case *ast.ValueSpec: if len(t.Values) > 0 { if call, ok := t.Values[0].(*ast.CallExpr); ok { return v.parseNewDescCallExpr(call) } } } if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(stmt.Pos()), Metric: "", Text: fmt.Sprintf("parsing desc of type %T is not supported", stmt.Obj.Decl), }) } } default: if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(stmt.Pos()), Metric: "", Text: fmt.Sprintf("parsing desc of type %T is not supported", stmt), }) } } return nil, nil } func (v *visitor) parseNewDescCallExpr(call *ast.CallExpr) (*string, *string) { var ( help string name string ok bool ) switch expr := call.Fun.(type) { case *ast.Ident: if expr.Name != "NewDesc" { if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(expr.Pos()), Metric: "", Text: fmt.Sprintf("parsing desc with function %s is not supported", expr.Name), }) } return nil, nil } case *ast.SelectorExpr: if expr.Sel.Name != "NewDesc" { if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(expr.Sel.Pos()), Metric: "", Text: fmt.Sprintf("parsing desc with function %s is not supported", expr.Sel.Name), }) } return nil, nil } default: if v.strict { v.issues = append(v.issues, Issue{ Pos: v.fs.Position(expr.Pos()), Metric: "", Text: fmt.Sprintf("parsing desc of %T is not supported", expr), }) } return nil, nil } // k8s.io/component-base/metrics.NewDesc has 6 args // while prometheus.NewDesc has 4 args if len(call.Args) < 4 && v.strict { v.issues = append(v.issues, Issue{ Metric: "", Pos: v.fs.Position(call.Pos()), Text: "NewDesc should have at least 4 args", }) return nil, nil } name, ok = v.parseValue("fqName", call.Args[0]) if !ok { return nil, nil } help, ok = v.parseValue("help", call.Args[1]) if !ok { return nil, nil } return &name, &help } func mustUnquote(str string) string { stringLiteral, err := strconv.Unquote(str) if err != nil { panic(err) } return stringLiteral } func getConstMetricType(name string) *dto.MetricType { metricType := dto.MetricType_UNTYPED if name == "CounterValue" { metricType = dto.MetricType_COUNTER } else if name == "GaugeValue" { metricType = dto.MetricType_GAUGE } return &metricType }