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 }