github.com/grafana/synthetic-monitoring-agent@v0.64.2/docs/architecture/k6runner.md (about)

     1  ---
     2  component: k6 runner
     3  source_paths:
     4    - internal/k6runner/
     5    - internal/k6version/
     6  last_reviewed_commit: f77bec5e
     7  generated: 2026-05-23
     8  ---
     9  
    10  # k6 runner — `internal/k6runner`
    11  
    12  ## Purpose
    13  
    14  The k6 runner is how the agent executes scripted, browser, and MultiHTTP
    15  checks. It abstracts two very different execution models behind a single
    16  `Runner` interface:
    17  
    18  - **Local** — spawn the `sm-k6` binary as a subprocess. Used in default standalone deployments.
    19  - **HTTP** — POST the script to a remote runner service. Used in managed deployments where execution is centralised.
    20  
    21  It also owns the *output parser* that turns k6's Prometheus-format
    22  metrics and logfmt logs back into a `prometheus.Registry` and a
    23  `logger.Logger` — the same shape probers use elsewhere.
    24  
    25  ## Where it lives
    26  
    27  `internal/k6runner/`
    28  
    29  | File                | Responsibility                                                                |
    30  | ------------------- | ----------------------------------------------------------------------------- |
    31  | `k6runner.go`       | `Runner` interface, factory (`New`), `Processor` (output parsing).            |
    32  | `local.go`          | `Local` runner — temp dir + subprocess.                                       |
    33  | `http.go`           | `HttpRunner` — POST to remote, retry/back-off, HTTP metrics.                  |
    34  | `env.go`            | k6 command-line environment construction.                                     |
    35  | `error.go`          | Error-code mapping (`ErrorCodeFailed`, `ErrorCodeTimeout`, etc.).             |
    36  | `version/`          | k6 version repository — resolves a channel manifest (semver) to an installed binary. See also `internal/k6version`. |
    37  | `testdata/`         | Fake k6 binary, golden script/output/log files.                               |
    38  
    39  ## How it fits in
    40  
    41  ```mermaid
    42  flowchart LR
    43      Prober["k6-backed prober<br/>(scripted/browser/multihttp)"] -->|Run| Processor
    44      Processor --> Runner["Runner interface"]
    45      Runner -.->|Uri starts with http| HttpRunner["HttpRunner<br/>POST /run"]
    46      Runner -.->|otherwise| Local["Local<br/>exec sm-k6"]
    47      HttpRunner -->|remote| Service["k6 runner service"]
    48      Local -->|subprocess| Bin["sm-k6 binary"]
    49      Processor -->|metrics| Registry["prometheus.Registry"]
    50      Processor -->|logs| Logger["logger.Logger"]
    51  ```
    52  
    53  ## The `Runner` interface
    54  
    55  ```go
    56  type Runner interface {
    57      WithLogger(logger *zerolog.Logger) Runner
    58      Run(ctx context.Context, script Script, secretStore SecretStore) (*RunResponse, error)
    59      Versions(context.Context) <-chan []string
    60  }
    61  ```
    62  
    63  - `WithLogger` returns a *copy* so per-check logging doesn't pollute the shared runner state.
    64  - `Run` is one execution. The `Script` carries the bytes, settings (including `Timeout`), `CheckInfo` metadata, and a `K6ChannelManifest` semver constraint for binary selection.
    65  - `Versions` returns a channel of supported k6 versions. Local pushes once and closes; HTTP polls the remote runner every `versionPollInterval` (30s default). Consumed by `internal/k6version`.
    66  
    67  `SecretStore` is a `{Url, Token}` pair. If both are set, the runner
    68  exposes a secret-store URL+token configuration to the k6 process so
    69  scripts can fetch credentials via the Synthetic Monitoring k6 extension.
    70  
    71  ### Selection
    72  
    73  `New(RunnerOpts)` (in `k6runner.go`):
    74  
    75  - `Uri` starts with `http` → `HttpRunner` (the path is treated as a base URL; the runner appends `/run`). Trailing `/run` is stripped for backwards compatibility.
    76  - otherwise → `Local`. `Repository` is the directory containing `sm-k6` binaries (default `/usr/libexec/sm-k6`).
    77  
    78  The `cmd/synthetic-monitoring-agent` flags `-k6-uri`, `-k6-repository`,
    79  and `-disable-k6` control this from outside. `-disable-k6` short-circuits
    80  runner construction entirely (the k6 feature flag is cleared and
    81  k6-backed probers refuse to build).
    82  
    83  ## Local execution
    84  
    85  `Local.Run` (`local.go`):
    86  
    87  1. Resolve the k6 binary via `version.Repository.Resolve(manifest)`. If the script doesn't carry a manifest, falls back to `*` (latest).
    88  2. Apply the per-check `Timeout` as a context timeout (`ErrNoTimeout` if zero).
    89  3. Create a temp workdir; write the script, an empty metrics file, an empty logs file, and (if the secret store is configured) a JSON config file referencing the secret store URL+token.
    90  4. Build the k6 command line via `env.go` — flags for prometheus metrics output, logfmt logs, and the blocked-CIDR list (from `-blocked-nets`).
    91  5. Run the subprocess; capture stdout/stderr to the logger for debugging.
    92  6. Read the metrics and logs files back as raw bytes; wrap them in a `RunResponse` along with an error code derived from the process exit status (see `error.go`).
    93  
    94  The temp directory is removed via `defer`; if cleanup fails the error
    95  is logged with `severity=critical`.
    96  
    97  ## HTTP execution
    98  
    99  `HttpRunner.Run` (`http.go`):
   100  
   101  1. Refuse a zero `backoff` (panic — misconfigured runner).
   102  2. Set a deadline if the parent context has none — default is twice the script timeout. This bounds the *total* time across all retries; *one* attempt is bounded by `checkTimeout + graceTime` (default grace = 20s).
   103  3. Loop: POST `HTTPRunRequest{Script, SecretStore, NotAfter}` to `<url>/run`. `NotAfter` tells the remote runner the absolute deadline so it can refuse late requests.
   104  4. Treat the response by status code (see `request`):
   105     - `200 / 408 / 422 / 500` — machine-readable response; parse a `RunResponse` (may contain `Error`/`ErrorCode`).
   106     - `400` — non-retryable, returns `ErrUnexpectedStatus`.
   107     - anything else — assume infra (ingress, k8s, ...) and mark as retryable.
   108  5. Any transport-level error (network, TLS, ...) is retryable.
   109  6. Between retries: linear back-off plus jitter. Each wait is shortened by the time already spent in the request, on the assumption that the most common retryable error is a timeout — so we don't double-wait.
   110  7. Returns when the context deadline is reached or a non-retryable error fires.
   111  
   112  The runner publishes its own metrics (`HTTPMetrics`):
   113  
   114  - `Requests{success, retriable}` counters
   115  - `RequestsPerRun{success}` histogram
   116  
   117  These are namespaced by the `prometheus.Registerer` passed to `New`.
   118  
   119  ## Output processing (`Processor`)
   120  
   121  `Processor.Run` (`k6runner.go`):
   122  
   123  1. Get a logger-bound runner via `runner.WithLogger(internalLogger)`.
   124  2. Call `runner.Run`; on error return early.
   125  3. Validate runner output — if exactly one of `Error` / `ErrorCode` is set, return `ErrBuggyRunner`.
   126  4. If `ErrorCode` is set, emit a deferred log line with the failure diagnostic.
   127  5. Pipe `result.Logs` through `k6LogsToLogger` (logfmt → `logger.Logger`). Lines with `level=debug` and no `source` are dropped — they're k6 noise.
   128  6. Decode `result.Metrics` (Prometheus text format) via `extractMetricSamples`. Three collectors share the stream:
   129     - `sampleCollector` — every metric becomes a `prometheus.Metric` in a `customCollector`. The collector emits *unchecked* metrics (`Describe` is empty) so identically-named metrics with different label sets coexist.
   130     - `checkResultCollector` — watches `probe_checks_total{result="fail"}`; sets `failure=true` if the count is non-zero.
   131     - `probeDurationCollector` — extracts `probe_script_duration_seconds` for the scraper's `patchDuration` fix-up.
   132  7. Register the `sampleCollector` in the supplied `prometheus.Registry`.
   133  8. Map the `ErrorCode` to a return value:
   134     - `""` → success.
   135     - `timeout`, `killed`, `user`, `failed`, `aborted` → user failure (`success=false`, no error returned).
   136     - anything else → wrapped `ErrFromRunner`.
   137  
   138  `"user"` is a legacy alias for `"aborted"` kept until the remote
   139  runner stops emitting it.
   140  
   141  ## Error codes
   142  
   143  Defined in `error.go`. Both runners produce them, and the
   144  `errorType(err)` helper maps Go errors to the string codes:
   145  
   146  | Code        | Cause                                                                |
   147  | ----------- | -------------------------------------------------------------------- |
   148  | `""`        | Success.                                                             |
   149  | `failed`    | k6 threw an uncaught exception, or `fail()` / threshold breach.      |
   150  | `timeout`   | Context deadline exceeded (the per-script timeout).                  |
   151  | `killed`    | Process killed by signal (exit code ≥ 128, including OOM-kill).      |
   152  | `aborted`   | k6 fatal — config error, syntax error, stacktrace, etc.              |
   153  | `unknown`   | Anything we cannot classify. Logged at error level.                  |
   154  
   155  `isUserError(err)` decides whether to *attribute* a failure to the
   156  user (script/settings) vs the runner. `errorFromLogs(logs)` peeks at
   157  the k6 logs for stacktrace/uncaught-exception markers to refine
   158  classification when the exit code alone isn't enough.
   159  
   160  ## Secret-store integration
   161  
   162  When `SecretStore.IsConfigured()` (`Url` and `Token` both non-empty):
   163  
   164  - Local: writes a JSON config file with the URL+token and passes `--config <file>` to k6.
   165  - HTTP: includes the `SecretStore` directly in the request body.
   166  
   167  The k6 process loads the synthetic-monitoring extension which uses
   168  these to fetch named secrets at script execution time.
   169  
   170  If the store is not configured, the runner logs a warning and lets the
   171  script proceed — useful for development, but checks that reference
   172  secrets will fail.
   173  
   174  ## Key types and entry points
   175  
   176  | Type / function                       | File          | Notes                                                  |
   177  | ------------------------------------- | ------------- | ------------------------------------------------------ |
   178  | `Runner` (interface)                  | `k6runner.go` | The contract.                                          |
   179  | `New(RunnerOpts)`                     | `k6runner.go` | Picks `Local` vs `HttpRunner` from `Uri`.              |
   180  | `Processor`, `(*Processor).Run(...)`  | `k6runner.go` | Output parser; called by the k6-backed probers.        |
   181  | `Script`, `Settings`, `CheckInfo`     | `k6runner.go` | Request payload.                                       |
   182  | `RunResponse`                         | `http.go`     | Common response (metrics + logs + error code).         |
   183  | `Local`                               | `local.go`    | Subprocess implementation.                             |
   184  | `HttpRunner`                          | `http.go`     | Remote-service implementation.                         |
   185  | `errorType(err)`, `isUserError(err)`  | `error.go`    | Error classification.                                  |
   186  | `version.Repository`                  | `version/`    | Maps a k6 channel manifest (semver) to an installed binary. |
   187  
   188  ## Testing strategy
   189  
   190  - **Table-driven** unit tests across `k6runner_test.go`, `local_test.go`, `http_test.go`, `error_test.go`, `env_test.go`.
   191  - `testdata/k6-fake` is a deterministic fake binary used by Local tests to avoid invoking real k6. It writes pre-baked metrics and logs based on its arguments.
   192  - `testdata/test.js`, `testdata/test.out`, `testdata/test.log` are golden script/output/log files for output-parser tests.
   193  - HTTP runner tests use `httptest.Server` to stand in for the remote service; they exercise both the happy path and the retry/back-off branches.
   194  - Some tests are gated by `testing.Short()` because they exercise the back-off timer.
   195  
   196  Run just this package:
   197  
   198  ```bash
   199  make test-go GO_TEST_ARGS=./internal/k6runner/...
   200  ```
   201  
   202  If you change command-line flags passed to k6 (in `env.go`), make
   203  sure `local_test.go` still asserts the expected argv shape.
   204  
   205  ## When to update this doc
   206  
   207  Update this document when you:
   208  
   209  - Add or remove a method on the `Runner` interface.
   210  - Add a new runner implementation (e.g. an in-process k6 host).
   211  - Change the runner-selection logic in `New(RunnerOpts)`.
   212  - Change the request/response shape (`HTTPRunRequest`, `RunResponse`).
   213  - Change the retry / back-off / grace-time behaviour in `HttpRunner`.
   214  - Add or rename an error code in `error.go`.
   215  - Change the secret-store wiring (config file format, request body).
   216  - Touch the `k6-fake` testdata in a way that changes its observable output.
   217  - Change the metrics published under `HTTPMetrics`.
   218  - Add new flags to the k6 invocation (`env.go`).