go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/cmd/statsd-to-tsmon/config.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package main
    16  
    17  import (
    18  	"os"
    19  	"strings"
    20  
    21  	"github.com/golang/protobuf/proto"
    22  
    23  	"go.chromium.org/luci/common/data/stringset"
    24  	"go.chromium.org/luci/common/errors"
    25  	"go.chromium.org/luci/common/tsmon/distribution"
    26  	"go.chromium.org/luci/common/tsmon/field"
    27  	"go.chromium.org/luci/common/tsmon/metric"
    28  	"go.chromium.org/luci/common/tsmon/types"
    29  
    30  	"go.chromium.org/luci/server/cmd/statsd-to-tsmon/config"
    31  )
    32  
    33  // NameComponentIndex is an index of a statsd metric name components.
    34  //
    35  // E.g. in a metric "envoy.clusters.upstream.membership_healthy" the component
    36  // "clusters" has index 1.
    37  type NameComponentIndex int
    38  
    39  // Config holds rules for converting statsd metrics into tsmon metrics.
    40  //
    41  // Each rule tells how to transform statsd metric name into a tsmon metric
    42  // and its fields.
    43  type Config struct {
    44  	metrics   map[string]types.Metric // registered tsmon metrics
    45  	perSuffix map[string]*Rule        // statsd metric suffix -> its conversion rule
    46  }
    47  
    48  // Rule describes how to send a tsmon metric given a matching statsd metric.
    49  type Rule struct {
    50  	// Metric is some concrete tsmon metric.
    51  	//
    52  	// Its set of fields matches `Fields`.
    53  	Metric types.Metric
    54  
    55  	// Fields describes how to assemble metric fields.
    56  	//
    57  	// Each item either a string for a preset field value or a NameComponentIndex
    58  	// to grab field's value from parsed statsd metric name.
    59  	Fields []any
    60  
    61  	// Statsd metric name pattern, as taken from the config.
    62  	pattern *pattern
    63  }
    64  
    65  // pattern is a parsed conversion rule pattern.
    66  //
    67  // Parsed pattern "*.cluster.${upstream}.membership_healthy" results in
    68  //
    69  //	pattern{
    70  //	  str: "*.cluster.${upstream}.membership_healthy",
    71  //	  len: 4,
    72  //	  vars: {"upstream": 2},
    73  //	  static: [{1, "cluster"}, {3, "membership_healthy"}]
    74  //	  suffix: "membership_healthy",
    75  //	}
    76  type pattern struct {
    77  	str    string
    78  	len    int
    79  	vars   map[string]int
    80  	static []staticNameComponent
    81  	suffix string
    82  }
    83  
    84  // staticNameComponent is static component of a pattern.
    85  type staticNameComponent struct {
    86  	index int
    87  	value string
    88  }
    89  
    90  // LoadConfig parses and interprets the configuration file.
    91  //
    92  // It should be a config.Config proto encoded using jsonpb.
    93  func LoadConfig(path string) (*Config, error) {
    94  	blob, err := os.ReadFile(path)
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  	msg, err := parseConfig(blob)
    99  	if err != nil {
   100  		return nil, err
   101  	}
   102  	return loadConfig(msg)
   103  }
   104  
   105  // FindMatchingRule finds a conversion rule that matches the statsd metric name.
   106  //
   107  // The metric name is given as a list of its components, e.g. "a.b.c" is
   108  // represented by [][]byte{{'a'}, {'b'}, {'c'}}.
   109  func (cfg *Config) FindMatchingRule(name [][]byte) *Rule {
   110  	// Find the rule matching the suffix.
   111  	if len(name) == 0 {
   112  		return nil
   113  	}
   114  	rule := cfg.perSuffix[string(name[len(name)-1])]
   115  	if rule == nil {
   116  		return nil
   117  	}
   118  
   119  	// Skip if `name` doesn't match the rest of the pattern.
   120  	if len(name) != rule.pattern.len {
   121  		return nil
   122  	}
   123  	for _, s := range rule.pattern.static {
   124  		if string(name[s.index]) != s.value {
   125  			return nil
   126  		}
   127  	}
   128  
   129  	return rule
   130  }
   131  
   132  // parseConfig converts config jsonpb into a proto.
   133  func parseConfig(blob []byte) (*config.Config, error) {
   134  	cfg := &config.Config{}
   135  	if err := proto.UnmarshalText(string(blob), cfg); err != nil {
   136  		return nil, errors.Annotate(err, "bad config format").Err()
   137  	}
   138  	return cfg, nil
   139  }
   140  
   141  // loadConfig interprets the config proto.
   142  func loadConfig(cfg *config.Config) (*Config, error) {
   143  	metrics, err := loadMetrics(cfg.Metrics)
   144  	if err != nil {
   145  		return nil, err
   146  	}
   147  
   148  	perSuffix := map[string]*Rule{}
   149  
   150  	for _, metricSpec := range cfg.Metrics {
   151  		metric := metrics[metricSpec.Metric]
   152  		for idx, ruleSpec := range metricSpec.Rules {
   153  			if ruleSpec.Pattern == "" {
   154  				return nil, errors.Reason("metric %q: rule #%d: a pattern is required", metricSpec.Metric, idx+1).Err()
   155  			}
   156  
   157  			rule, err := loadRule(metric, ruleSpec)
   158  			if err != nil {
   159  				return nil, errors.Annotate(err, "metric %q: rule %q", metricSpec.Metric, ruleSpec.Pattern).Err()
   160  			}
   161  
   162  			if perSuffix[rule.pattern.suffix] != nil {
   163  				return nil, errors.Reason("metric %q: rule %q: there's already another rule with this suffix", metricSpec.Metric, ruleSpec.Pattern).Err()
   164  			}
   165  			perSuffix[rule.pattern.suffix] = rule
   166  		}
   167  	}
   168  
   169  	return &Config{
   170  		metrics:   metrics,
   171  		perSuffix: perSuffix,
   172  	}, nil
   173  }
   174  
   175  // loadMetrics instantiates tsmon metrics based on the configuration.
   176  func loadMetrics(cfg []*config.Metric) (map[string]types.Metric, error) {
   177  	metrics := make(map[string]types.Metric, len(cfg))
   178  
   179  	for idx, spec := range cfg {
   180  		name := spec.Metric
   181  		if name == "" {
   182  			return nil, errors.Reason("metric #%d: a name is required", idx+1).Err()
   183  		}
   184  		if metrics[name] != nil {
   185  			return nil, errors.Reason("duplicate metric %q", name).Err()
   186  		}
   187  
   188  		if len(spec.Fields) != stringset.NewFromSlice(spec.Fields...).Len() {
   189  			return nil, errors.Reason("metric %q: has duplicate fields", name).Err()
   190  		}
   191  		tsmonFields := make([]field.Field, len(spec.Fields))
   192  		for idx, fieldName := range spec.Fields {
   193  			tsmonFields[idx] = field.String(fieldName)
   194  		}
   195  
   196  		var metadata types.MetricMetadata
   197  		switch spec.Units {
   198  		case config.Unit_MILLISECONDS:
   199  			metadata.Units = types.Milliseconds
   200  		case config.Unit_BYTES:
   201  			metadata.Units = types.Bytes
   202  		case config.Unit_UNIT_UNSPECIFIED:
   203  			// no units, this is fine
   204  		default:
   205  			return nil, errors.Reason("metric %q: unrecognized units %s", name, spec.Units).Err()
   206  		}
   207  
   208  		var m types.Metric
   209  		switch spec.Kind {
   210  		case config.Kind_GAUGE:
   211  			m = metric.NewInt(name, spec.Desc, &metadata, tsmonFields...)
   212  		case config.Kind_COUNTER:
   213  			m = metric.NewCounter(name, spec.Desc, &metadata, tsmonFields...)
   214  		case config.Kind_CUMULATIVE_DISTRIBUTION:
   215  			// Distributions are used for StatsdMetricTimer metrics, they are always
   216  			// in milliseconds per statsd protocol.
   217  			m = metric.NewCumulativeDistribution(
   218  				name,
   219  				spec.Desc,
   220  				&types.MetricMetadata{Units: types.Milliseconds},
   221  				distribution.DefaultBucketer,
   222  				tsmonFields...)
   223  		default:
   224  			return nil, errors.Reason("metric %q: unrecognized type %s", name, spec.Kind).Err()
   225  		}
   226  
   227  		metrics[name] = m
   228  	}
   229  
   230  	return metrics, nil
   231  }
   232  
   233  // loadRule interprets single rule{...} config stanza.
   234  func loadRule(metric types.Metric, spec *config.Rule) (*Rule, error) {
   235  	pat, err := parsePattern(spec.Pattern)
   236  	if err != nil {
   237  		return nil, errors.Annotate(err, "bad pattern").Err()
   238  	}
   239  
   240  	// Make sure the rule specifies all required fields and only them.
   241  	tsmonFields := metric.Info().Fields
   242  	fields := make([]any, len(tsmonFields))
   243  	for idx, f := range tsmonFields {
   244  		val, ok := spec.Fields[f.Name]
   245  		if !ok {
   246  			return nil, errors.Reason("value of field %q is not provided", f.Name).Err()
   247  		}
   248  		// Here `val` may be a variable (e.g. "${var}"), referring to a position
   249  		// in the parsed pattern, or just some static string.
   250  		vr, err := parseVar(val)
   251  		if err != nil {
   252  			return nil, errors.Annotate(err, "field %q has bad value %q", f.Name, val).Err()
   253  		}
   254  		switch {
   255  		case vr != "":
   256  			componentIdx, ok := pat.vars[vr]
   257  			if !ok {
   258  				return nil, errors.Reason("field %q references undefined var %q", f.Name, vr).Err()
   259  			}
   260  			fields[idx] = NameComponentIndex(componentIdx)
   261  		case val != "":
   262  			fields[idx] = val // just a static string
   263  		default:
   264  			return nil, errors.Reason("field %q has empty value, this is not allowed", f.Name).Err()
   265  		}
   266  	}
   267  
   268  	// We checked metricsDesc.fields is a subset of spec.Fields. Now check there
   269  	// are in fact equal.
   270  	if len(spec.Fields) != len(tsmonFields) {
   271  		return nil, errors.Reason("has too many fields").Err()
   272  	}
   273  
   274  	return &Rule{
   275  		Metric:  metric,
   276  		Fields:  fields,
   277  		pattern: pat,
   278  	}, nil
   279  }
   280  
   281  // parsePattern parses a string like "*.cluster.${upstream}.membership_healthy"
   282  // into its components.
   283  //
   284  // Each var appearance must be unique and the pattern should end with a static
   285  // suffix (i.e. not ".*" and not ".${var}").
   286  func parsePattern(pat string) (*pattern, error) {
   287  	chunks := strings.Split(pat, ".")
   288  	p := &pattern{
   289  		str: pat,
   290  		len: len(chunks),
   291  	}
   292  	for idx, chunk := range chunks {
   293  		if chunk == "" {
   294  			return nil, errors.Reason("empty name component").Err()
   295  		}
   296  		if chunk == "*" {
   297  			continue // an index not otherwise mentioned in *pattern is a wildcard
   298  		}
   299  		switch vr, err := parseVar(chunk); {
   300  		case err != nil:
   301  			return nil, errors.Annotate(err, "in name component %q", chunk).Err()
   302  		case vr != "":
   303  			if _, hasIt := p.vars[vr]; hasIt {
   304  				return nil, errors.Reason("duplicate var %q", vr).Err()
   305  			}
   306  			if p.vars == nil {
   307  				p.vars = make(map[string]int, 1)
   308  			}
   309  			p.vars[vr] = idx
   310  		default:
   311  			p.static = append(p.static, staticNameComponent{
   312  				index: idx,
   313  				value: chunk,
   314  			})
   315  		}
   316  	}
   317  
   318  	// We require suffixes to be static to simplify FindMatchingRule.
   319  	if len(p.static) != 0 {
   320  		if last := p.static[len(p.static)-1]; last.index == p.len-1 {
   321  			p.suffix = last.value
   322  		}
   323  	}
   324  	if p.suffix == "" {
   325  		return nil, errors.Reason("must end with a static suffix").Err()
   326  	}
   327  
   328  	return p, nil
   329  }
   330  
   331  // parseVar takes "${<something>}" and returns "<something>".
   332  //
   333  // If the input doesn't look like "${...}" and doesn't have "${" in it at all,
   334  // returns an empty string and no error.
   335  //
   336  // If the input doesn't look like "${...}" but has "${" somewhere in it, returns
   337  // an error: var usage such as "foo-${bar}" is not supported.
   338  func parseVar(p string) (string, error) {
   339  	if strings.HasPrefix(p, "${") && strings.HasSuffix(p, "}") {
   340  		if len(p) == 3 {
   341  			return "", errors.Reason("var name is required").Err()
   342  		}
   343  		return p[2 : len(p)-1], nil
   344  	}
   345  	if strings.Contains(p, "${") {
   346  		return "", errors.Reason("var usage such as `foo-${bar}` is not allowed").Err()
   347  	}
   348  	return "", nil
   349  }