github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/k6runner/k6runner.go (about)

     1  package k6runner
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/go-logfmt/logfmt"
    13  	"github.com/grafana/synthetic-monitoring-agent/internal/k6runner/version"
    14  	smmodel "github.com/grafana/synthetic-monitoring-agent/internal/model"
    15  	"github.com/grafana/synthetic-monitoring-agent/internal/prober/logger"
    16  	"github.com/prometheus/client_golang/prometheus"
    17  	dto "github.com/prometheus/client_model/go"
    18  	"github.com/prometheus/common/expfmt"
    19  	"github.com/prometheus/common/model"
    20  	"github.com/rs/zerolog"
    21  	"github.com/spf13/afero"
    22  )
    23  
    24  // Script is a k6 script that a runner is able to run, with some added instructions for that runner to act on.
    25  type Script struct {
    26  	// Script is the blob of bytes that is to be run.
    27  	Script []byte `json:"script"`
    28  	// Settings is a common representation of the fields common to all implementation-specific check settings that the
    29  	// runners are interested about.
    30  	Settings Settings `json:"settings"`
    31  	// CheckInfo holds information about the SM check that triggered this script.
    32  	CheckInfo CheckInfo `json:"check"`
    33  	// K6ChannelManifest is the manifest for the k6 channel associated with this script. This must be a semver
    34  	// constraint, that is used in runtime to figure which k6 binary, of those available, is used to run the script.
    35  	K6ChannelManifest string `json:"k6ChannelManifest"`
    36  }
    37  
    38  // SecretStore holds the location and token for accessing secrets
    39  type SecretStore struct {
    40  	Url   string `json:"url"`
    41  	Token string `json:"token"`
    42  }
    43  
    44  // IsConfigured returns true if the SecretStore has both URL and token configured.
    45  func (s SecretStore) IsConfigured() bool {
    46  	return s.Url != "" && s.Token != ""
    47  }
    48  
    49  // Settings is a common representation of the fields common to all implementation-specific check settings that the
    50  // runners are interested about.
    51  type Settings struct {
    52  	// Timeout for k6 run, in milliseconds. This value is a configuration value for remote runners, which will instruct
    53  	// them to return an error if the operation takes longer than this time to complete. Clients should expect that
    54  	// requests to remote runners may take longer than this value due to network and other latencies, and thus clients
    55  	// should wait additional time before aborting outgoing requests.
    56  	Timeout int64 `json:"timeout"`
    57  }
    58  
    59  // CheckInfo holds information about the SM check that triggered this script.
    60  type CheckInfo struct {
    61  	// Type is the string representation of the check type this script belongs to (browser, scripted, multihttp, etc.)
    62  	Type string `json:"type"`
    63  	// Metadata is a collection of key/value pairs containing information about this check, such as check and tenant ID.
    64  	// It is loosely typed on purpose: Metadata should only be used for informational properties that will make its way
    65  	// into telemetry, and not for making decision on it.
    66  	Metadata map[string]any `json:"metadata"`
    67  }
    68  
    69  // CheckInfoFromSM returns a CheckInfo from the information of the given SM check.
    70  func CheckInfoFromSM(smc smmodel.Check) CheckInfo {
    71  	ci := CheckInfo{
    72  		Metadata: map[string]any{},
    73  	}
    74  
    75  	ci.Type = smc.Type().String()
    76  	ci.Metadata["id"] = smc.Id
    77  	ci.Metadata["tenantID"] = smc.TenantId
    78  	ci.Metadata["regionID"] = smc.RegionId
    79  	ci.Metadata["created"] = smc.Created
    80  	ci.Metadata["modified"] = smc.Modified
    81  
    82  	return ci
    83  }
    84  
    85  // MarshalZerologObject implements zerolog.LogObjectMarshaler so it can be logged in a friendly way.
    86  func (ci *CheckInfo) MarshalZerologObject(e *zerolog.Event) {
    87  	e.Str("type", ci.Type)
    88  
    89  	for k, v := range ci.Metadata {
    90  		e.Any(k, v)
    91  	}
    92  }
    93  
    94  // ErrNoTimeout is returned by [Runner] implementations if the supplied script has a timeout of zero.
    95  var ErrNoTimeout = errors.New("check has no timeout")
    96  
    97  type Runner interface {
    98  	// WithLogger returns a copy of the runner configured to use the specified logger.
    99  	WithLogger(logger *zerolog.Logger) Runner
   100  	// Run makes the runner run the script.
   101  	Run(ctx context.Context, script Script, secretStore SecretStore, executionID string) (*RunResponse, error)
   102  	// Versions returns a channel to which the list of versions supported by this runner are pushed. Runner
   103  	// implementations that have a predictable list of versions over time may push a single list to the channel and then
   104  	// close it, while others may push lists of versions regularly as a result of polling a decoupled system.
   105  	// Cancelling the context stops this background polling, if any.
   106  	Versions(context.Context) <-chan []string
   107  }
   108  
   109  type RunnerOpts struct {
   110  	Uri           string
   111  	Repository    string
   112  	BlacklistedIP string
   113  	Registerer    prometheus.Registerer
   114  }
   115  
   116  func New(opts RunnerOpts) (Runner, error) {
   117  	var r Runner
   118  
   119  	logger := zerolog.Nop()
   120  
   121  	var registerer prometheus.Registerer = prometheus.NewRegistry() // NOOP registry.
   122  	if opts.Registerer != nil {
   123  		registerer = opts.Registerer
   124  	}
   125  
   126  	if strings.HasPrefix(opts.Uri, "http") {
   127  		r = &HttpRunner{
   128  			// Remote URLs used to point directly to the `/run` endpoint. This is no longer the case, and HttpRunner now
   129  			// adds this endpoint automatically to this URL, which is understood as a base URL. To preserve backwards
   130  			// compatibility, we look for this suffix and strip it if present.
   131  			// TODO: Remove at some point, as a breaking change.
   132  			url:                 trimRunSuffix(opts.Uri),
   133  			logger:              &logger,
   134  			graceTime:           defaultGraceTime,
   135  			backoff:             defaultBackoff,
   136  			metrics:             NewHTTPMetrics(registerer),
   137  			versionPollInterval: 30 * time.Second,
   138  		}
   139  	} else {
   140  		repo, err := version.NewRepository(opts.Repository, opts.Uri)
   141  		if err != nil {
   142  			return nil, fmt.Errorf("building repository: %w", err)
   143  		}
   144  
   145  		r = Local{
   146  			repository:    repo,
   147  			logger:        &logger,
   148  			blacklistedIP: opts.BlacklistedIP,
   149  			fs:            afero.NewOsFs(),
   150  		}
   151  	}
   152  
   153  	return r, nil
   154  }
   155  
   156  // Processor runs a script with a runner and parses the k6 output.
   157  type Processor struct {
   158  	runner Runner
   159  	script Script
   160  }
   161  
   162  func NewProcessor(script Script, k6runner Runner) (*Processor, error) {
   163  	r := Processor{
   164  		runner: k6runner,
   165  		script: script,
   166  	}
   167  
   168  	return &r, nil
   169  }
   170  
   171  var (
   172  	ErrBuggyRunner = errors.New("runner returned buggy response")
   173  	ErrFromRunner  = errors.New("runner reported an error")
   174  )
   175  
   176  func (r Processor) Run(ctx context.Context, registry *prometheus.Registry, logger logger.Logger, internalLogger zerolog.Logger, secretStore SecretStore, executionID string) (bool, time.Duration, error) {
   177  	k6runner := r.runner.WithLogger(&internalLogger)
   178  
   179  	// TODO: This error message is okay to be Debug for local k6 execution, but should be Error for remote runners.
   180  	result, err := k6runner.Run(ctx, r.script, secretStore, executionID)
   181  	if err != nil {
   182  		internalLogger.Debug().
   183  			Err(err).
   184  			Msg("k6 script exited with error code")
   185  
   186  		return false, 0, err
   187  	}
   188  
   189  	// If only one of Error and ErrorCode are non-empty, the proxy is misbehaving.
   190  	switch {
   191  	case result.Error == "" && result.ErrorCode != "":
   192  		fallthrough
   193  	case result.Error != "" && result.ErrorCode == "":
   194  		return false, 0, fmt.Errorf(
   195  			"%w: only one of error (%q) and errorCode (%q) is non-empty",
   196  			ErrBuggyRunner, result.Error, result.ErrorCode,
   197  		)
   198  	}
   199  
   200  	// If the script was not successful, send a log line saying why.
   201  	// Do this in a deferred function to ensure that we send it both after script logs, and regardless of errors sending
   202  	// other logs.
   203  	if result.ErrorCode != "" {
   204  		defer func() {
   205  			err := logger.Log(
   206  				"level", "error",
   207  				"msg", "script did not execute successfully",
   208  				"error", result.Error,
   209  				"errorCode", result.ErrorCode,
   210  			)
   211  			if err != nil {
   212  				internalLogger.Error().
   213  					Err(err).
   214  					Msg("sending diagnostic log")
   215  			}
   216  		}()
   217  	}
   218  
   219  	// Send logs before metrics to make sure logs are submitted even if the metrics output is not parsable.
   220  	if err := k6LogsToLogger(result.Logs, logger); err != nil {
   221  		internalLogger.Debug().
   222  			Err(err).
   223  			Msg("cannot load logs to logger")
   224  
   225  		return false, 0, err
   226  	}
   227  
   228  	var (
   229  		collector         sampleCollector
   230  		resultCollector   checkResultCollector
   231  		durationCollector probeDurationCollector
   232  	)
   233  
   234  	if err := extractMetricSamples(result.Metrics, internalLogger, collector.process, resultCollector.process, durationCollector.process); err != nil {
   235  		internalLogger.Debug().
   236  			Err(err).
   237  			Msg("cannot extract metric samples")
   238  
   239  		return false, 0, err
   240  	}
   241  
   242  	if err := registry.Register(&collector.collector); err != nil {
   243  		internalLogger.Error().
   244  			Err(err).
   245  			Msg("cannot register collector")
   246  
   247  		return false, 0, err
   248  	}
   249  
   250  	// https://github.com/grafana/sm-k6-runner/blob/b811839d444a7e69fd056b0a4e6ccf7e914197f3/internal/mq/runner.go#L51
   251  	switch result.ErrorCode {
   252  	case "":
   253  		// No error, all good.
   254  		return true, durationCollector.duration, nil
   255  	// TODO: Remove "user" from this list, which has been renamed to "aborted".
   256  	case "timeout", "killed", "user", "failed", "aborted":
   257  		// These are user errors. The probe failed, but we don't return an error.
   258  		return false, durationCollector.duration, nil
   259  	default:
   260  		// We got an "unknown" error, or some other code we do not recognize. Return it so we log it.
   261  		return false, durationCollector.duration, fmt.Errorf("%w: %s: %s", ErrFromRunner, result.ErrorCode, result.Error)
   262  	}
   263  }
   264  
   265  type customCollector struct {
   266  	metrics []prometheus.Metric
   267  }
   268  
   269  func (c *customCollector) Describe(ch chan<- *prometheus.Desc) {
   270  	// We do not send any descriptions in order to create an unchecked
   271  	// metric. This allows us to have a metric with the same name and
   272  	// different label values.
   273  	//
   274  	// TODO(mem): reevaluate if this is really want we want to do.
   275  }
   276  
   277  func (c *customCollector) Collect(ch chan<- prometheus.Metric) {
   278  	for _, m := range c.metrics {
   279  		ch <- m
   280  	}
   281  }
   282  
   283  type sampleProcessorFunc func(*dto.MetricFamily, *model.Sample) error
   284  
   285  type sampleCollector struct {
   286  	collector customCollector
   287  }
   288  
   289  func (sc *sampleCollector) process(mf *dto.MetricFamily, sample *model.Sample) error {
   290  	// TODO(mem): This is really crappy. We have a
   291  	// set of metric families, and we are
   292  	// converting back to samples so that we can
   293  	// add that to the registry. We need to rework
   294  	// the logic in the prober so that it can
   295  	// return a set of metric families. The probes
   296  	// that don't have this, can create a registry
   297  	// locally and get the metric families from
   298  	// that.
   299  	name, found := sample.Metric[model.MetricNameLabel]
   300  	if !found {
   301  		return fmt.Errorf("missing metric name")
   302  	}
   303  
   304  	desc := prometheus.NewDesc(string(name), mf.GetHelp(), nil, metricToLabels(sample.Metric))
   305  	// TODO(mem): maybe change this to untyped?
   306  	m, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, float64(sample.Value))
   307  	if err != nil {
   308  		return fmt.Errorf("creating prometheus metric: %w", err)
   309  	}
   310  
   311  	sc.collector.metrics = append(sc.collector.metrics, m)
   312  
   313  	return nil
   314  }
   315  
   316  type checkResultCollector struct {
   317  	failure bool
   318  }
   319  
   320  func (rc *checkResultCollector) process(mf *dto.MetricFamily, sample *model.Sample) error {
   321  	if sample.Metric[model.MetricNameLabel] != "probe_checks_total" {
   322  		return nil
   323  	}
   324  
   325  	if sample.Metric["result"] != "fail" {
   326  		return nil
   327  	}
   328  
   329  	if sample.Value != 0 {
   330  		rc.failure = true
   331  	}
   332  
   333  	return nil
   334  }
   335  
   336  type probeDurationCollector struct {
   337  	duration time.Duration
   338  }
   339  
   340  func (dc *probeDurationCollector) process(_ *dto.MetricFamily, sample *model.Sample) error {
   341  	if sample.Metric[model.MetricNameLabel] != "probe_script_duration_seconds" {
   342  		return nil
   343  	}
   344  
   345  	dc.duration = time.Duration(float64(sample.Value) * float64(time.Second))
   346  
   347  	return nil
   348  }
   349  
   350  func extractMetricSamples(metrics []byte, logger zerolog.Logger, processors ...sampleProcessorFunc) error {
   351  	promDecoder := expfmt.NewDecoder(bytes.NewBuffer(metrics), expfmt.NewFormat(expfmt.TypeTextPlain))
   352  	decoderOpts := expfmt.DecodeOptions{Timestamp: model.Now()}
   353  
   354  	for {
   355  		var mf dto.MetricFamily
   356  		switch err := promDecoder.Decode(&mf); err {
   357  		case nil:
   358  			// Got one metric family
   359  			samples, err := expfmt.ExtractSamples(&decoderOpts, &mf)
   360  			if err != nil {
   361  				logger.Error().Err(err).Msg("extracting samples")
   362  				return fmt.Errorf("extracting samples: %w", err)
   363  			}
   364  
   365  			for _, sample := range samples {
   366  				for _, p := range processors {
   367  					err := p(&mf, sample)
   368  					if err != nil {
   369  						logger.Error().Err(err).Msg("processing samples")
   370  						return err
   371  					}
   372  				}
   373  			}
   374  
   375  		case io.EOF:
   376  			// nothing was returned, we are done
   377  			return nil
   378  
   379  		default:
   380  			logger.Error().Err(err).Msg("decoding prometheus metrics")
   381  			return fmt.Errorf("decoding prometheus metrics: %w", err)
   382  		}
   383  	}
   384  }
   385  
   386  func metricToLabels(metrics model.Metric) prometheus.Labels {
   387  	// Ugh.
   388  	labels := make(prometheus.Labels)
   389  
   390  	for name, value := range metrics {
   391  		name := string(name)
   392  		if name == model.MetricNameLabel {
   393  			continue
   394  		}
   395  
   396  		labels[name] = string(value)
   397  	}
   398  
   399  	return labels
   400  }
   401  
   402  func k6LogsToLogger(logs []byte, logger logger.Logger) error {
   403  	// This seems a little silly, we should be able to take the out of k6
   404  	// and pass it directly to Loki. The problem with that is that the only
   405  	// thing probers have access to is the logger.Logger.
   406  	//
   407  	// We could pass another object to the prober, that would take Loki log
   408  	// entries, and the publisher could decorate that with the necessary
   409  	// labels.
   410  	dec := logfmt.NewDecoder(bytes.NewBuffer(logs))
   411  
   412  NEXT_RECORD:
   413  	for dec.ScanRecord() {
   414  		var (
   415  			line          []any
   416  			source, level string
   417  		)
   418  
   419  		for dec.ScanKeyval() {
   420  			key := string(dec.Key())
   421  			value := string(dec.Value())
   422  
   423  			switch key {
   424  			case "source":
   425  				source = value
   426  			case "level":
   427  				level = value
   428  			}
   429  
   430  			line = append(line, key, value)
   431  		}
   432  
   433  		if level == "debug" && source == "" { // if there's no source, it's probably coming from k6
   434  			continue NEXT_RECORD
   435  		}
   436  
   437  		_ = logger.Log(line...)
   438  	}
   439  
   440  	return dec.Err()
   441  }