Skip to content

Stream --local-execution logs to Grafana Cloud - #6171

Merged
ankur22 merged 22 commits into
masterfrom
local-exec-logs-take3
Jul 31, 2026
Merged

ankur22 merged 22 commits into
masterfrom
local-exec-logs-take3

Conversation

@ankur22

@ankur22 ankur22 commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

What?

Stacked on #6170 (external-provisioning), now on master after the provisioning migration (#6169) merged. Stream k6 cloud run --local-execution logs to Grafana Cloud k6, using the logs configuration the provisioning API already returns from start_local_execution (runtime_config.logs) but which was decoded and then dropped.

The flow:

  1. Map runtime_config.logs (push URL, level, limit, push period, message max size, allowed labels) into the provisioning response instead of dropping it.
  2. A cloud log pusher — a logrus hook that wraps the existing internal/log loki hook — is auto-registered on the logger for --local-execution (unless --no-cloud-logs) and buffers entries until it is configured (the push URL/token are only known after provisioning).
  3. It is configured from the provisioning response (self-provisioned) or from env vars (externally-provisioned, mirroring the metrics push in Support externally-provisioned runs in cloud run --local-execution #6170), then pushes k6's own logs to the logs push_url with the scoped test_run_token as Authorization: Bearer and the required test_run_id stream label.
  4. Logs are drained before the run is notified complete (otherwise the backend rejects the final batch with 403), and filtered to the backend-configured level (so --verbose still honours a level=info directive for the cloud; local output is unaffected).

Notable pieces:

  • New internal/log/cloud package (the pusher). It reuses the loki hook via a new programmatic constructor log.NewLokiHook (extracted from the existing config-line parser, no behaviour change) — no reimplementation of Loki batching/format.
  • cloudapi.Config gains env-only (json:"-") K6_CLOUD_LOGS_* fields, mirroring LogsTailURL, for the externally-provisioned case.
  • --no-cloud-logs opts out (mirrors --no-cloud-secrets).
  • The pusher is attached after the secrets-redaction hook, so secrets are redacted before logs are pushed (verified end-to-end).

k6 run --out cloud (path B) and managed cloud execution's --show-logs tail path are unchanged — no client-side log push there. Eight atomic commits, each individually buildable + lintable + green.

Why?

For --local-execution the test runs on the client, so its logs are produced locally and never reached the cloud run's log view (only managed cloud execution had server-side logs, tailed via --show-logs). This closes that gap — local-execution logs now stream to the run, matching how metrics were migrated. The externally-provisioned path lets an orchestrator that provisioned the run itself hand k6 the logs config via env.

Checklist

  • I have performed a self-review of my code.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests for my changes.
  • I have run linter and tests locally (make check) and all pass.

Related PR(s)/Issue(s)

@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 17, 2026 14:53 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 17, 2026 14:55 — with GitHub Actions Inactive
@ankur22 ankur22 added this to the v2.2.0 milestone Jul 17, 2026
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from 76aedf3 to 5910166 Compare July 22, 2026 15:40
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 22, 2026 15:46 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 22, 2026 15:48 — with GitHub Actions Inactive
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from 5910166 to 81ecc4e Compare July 22, 2026 16:20
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 22, 2026 16:26 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 22, 2026 16:41 — with GitHub Actions Inactive
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from 81ecc4e to 64599be Compare July 27, 2026 10:09
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 27, 2026 10:15 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 27, 2026 10:17 — with GitHub Actions Inactive
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from 64599be to cff82e9 Compare July 28, 2026 12:11
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 28, 2026 12:17 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 28, 2026 12:19 — with GitHub Actions Inactive
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from cff82e9 to f3cb7d6 Compare July 29, 2026 11:39
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 29, 2026 11:44 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 29, 2026 11:46 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 29, 2026 12:03 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 29, 2026 12:05 — with GitHub Actions Inactive
@ankur22
ankur22 marked this pull request as ready for review July 29, 2026 14:08
@ankur22
ankur22 requested a review from a team as a code owner July 29, 2026 14:08
@ankur22
ankur22 requested review from inancgumus and janHildebrandt98 and removed request for a team July 29, 2026 14:08

@inancgumus inancgumus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work 👍 Two real issues in the shutdown path, the rest are questions and nits.

case e := <-p.buf:
forward(e)
default:
lokiCancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finish() empties p.buf into the loki hook's channel, then cancels the loki context right away. But lokiHook.Listen returns on cancel without draining h.ch, so the final flush never sends what's still queued there. Fire 500 entries, call Drain, and the backend receives 0 of them. Can you add a test for this? TestPusher_Drain misses it because it waits for a periodic push before draining.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real gap. The loss is in the loki hook's shutdown path: lokiHook.Listen's <-ctx.Done() branch flushes only the already-consumed msgs and never drains what's still queued in h.ch. That behaviour predates this PR — it's the loki hook's long-standing shutdown handling (unchanged here) and is shared with --out loki; what's new is this PR's Drain() path, which is what makes it observable for cloud logs.

Since the proper fix belongs in the shared loki hook (and would fix --out loki's final-batch loss too), I'd rather not widen this PR with it and will handle it in a dedicated follow-up, including the regression test you described (Fire N with a long push period so nothing flushes periodically, Drain, assert the backend received all N).

If you'd prefer we don't ship the new Drain path with that known tail-loss, I can instead add a contained fix in the cloudlog layer here — happy to do whichever you'd rather. WDYT?

@inancgumus inancgumus Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A follow-up works for me 👍

Comment thread internal/log/cloud/cloud.go Outdated
minLevel = lvl
} else if p.fallbackLogger != nil {
p.fallbackLogger.WithField("level", c.Level).
Warn("invalid cloud log level; pushing all levels")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warn never fires. NewLokiHook already fails on the same invalid level, and Listen returns at the error branch above. So one bad logs.level from the backend turns off the whole log push instead of falling back to all levels.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 97aebc5c. You're right on both counts: NewLokiHook fails on the bad level and Listen returns at the error branch, so one bad logs.level disabled the whole push, and the fallback warn below was unreachable.

Root cause was passing Level to NewLokiHook at all — the pusher already filters by level itself in forward (the hook's Levels() dispatch is bypassed, since we call Fire directly), so it was redundant and the only thing turning a bad value into a hard failure. I dropped it and now interpret the level in the pusher:

  • valid → that level
  • invalid → fall back to info (the backend's own default) + a live warning
  • unset → keep all / defer to the logger (unchanged)

I chose info rather than "all" for the invalid case so a bad backend value can't start streaming debug to the cloud under -v — matching how --log-output=loki=…,level=info behaves. Added TestPusher_InvalidLevelDefaultsToInfo.

Comment thread internal/log/loki.go Outdated
if opts.PushPeriod != 0 {
h.pushPeriod = opts.PushPeriod
}
if opts.Limit != 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A negative Limit gets past this check and panics in lokiHook.Listen at make([]tmpMsg, h.limit). A negative PushPeriod does the same in time.NewTicker. Nothing recovers in that goroutine, so the panic takes down the whole run. Both can come in through K6_CLOUD_LOGS_LIMIT=-5 / K6_CLOUD_LOGS_PUSH_PERIOD=-1s or a negative backend logs.limit, while the parser for the config line still rejects them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53653a94. Non-positive Limit, PushPeriod and MsgMaxSize now clamp to the built-in defaults (with a warn) in NewLokiHook, instead of reaching make([]tmpMsg, limit) / time.NewTicker(pushPeriod) — or the message-size slicing on push — and panicking in that goroutine.

I hardened MsgMaxSize alongside the two you flagged, since a negative there panics the same way on the push path. The config-line path is unchanged — parseArgs still rejects limit < 1 etc. up front — so this only affects the programmatic/backend inputs (K6_CLOUD_LOGS_* / runtime_config.logs). Added TestNewLokiHookClampsNonPositiveOptions.

// logsPushPath is the path the local-execution mocks use for the cloud
// logs push endpoint, overriding the real host baked into
// DefaultStartLocalExecutionResponse so tests never contact it.
const logsPushPath = "/logs/v1/push"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupSelfProvisionedRun in cmd_cloud_run_push_credentials_test.go doesn't override the logs URL, so its five subtests now push logs to the real https://logs.k6.io:

level=error msg="Error while sending logs to loki" error="Post \"https://logs.k6.io\": dial tcp: lookup logs.k6.io: no such host"

On a CI machine with network access, that reaches production.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2ba2311b. setupSelfProvisionedRun now points the logs push URL at the mock (srv.URL + logsPushPath), mirroring how it already redirects the metrics URL — so the five self-provisioned subtests no longer egress to the real logs.k6.io (verified: those egress lines drop 5 → 0).

t.Parallel()

ts := makeTestState(t, script, []string{"--local-execution", "--no-cloud-logs"})
setupLocalExecutionProvMock(t, ts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also assert that no push reached the server? The helper already returns the recorder, and the nil check only covers the registration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 83bc1775. Captured the recorder and added assert.Empty(t, bodies, …) after the nil check, so the test now also asserts no log push reached the mock — the opt-out is guaranteed end-to-end, not just at registration.

}, got)
}

func TestBuildConfigFromRuntimeConfig_NilFieldsLeftUnset(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you cover the Logs fields here too? Nothing checks that they stay unset when provisioning omits them, and in that case an env K6_CLOUD_LOGS_PUSH_URL survives Apply and configures the pusher.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0a105cb — the test now checks the log fields stay unset when provisioning omits them, and that a stray env var doesn't leak in. See the config thread for the actual fix (dropped the env binding).

// the cloud log drainer is flushed before the run is notified complete, so
// in-run logs land while the run is still open (afterwards the backend
// rejects late pushes).
func TestOutputStopWithTestError_DrainsLogsBeforeNotify(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a test that the drain also runs in the PushRefID path? If a later change ties the drain to notify, the externally provisioned flow loses its final batch and no test fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout — added one in 6eb028c. The existing drain test only covered the self-provisioned path (where notify runs anyway); the new one checks the drain still runs on the PushRefID path, where notify is skipped. So if the drain ever gets tied to notify, that flow won't silently lose its last batch.

Comment thread internal/output/cloud/output.go Outdated
// complete the backend rejects late log pushes, so the final batch would
// be lost. Best-effort and bounded by the configured request timeout.
if out.logDrainer != nil {
dctx, cancel := context.WithTimeout(context.Background(), out.config.Timeout.TimeDuration())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens with K6_CLOUD_TIMEOUT=0? TimeDuration() ignores Valid, so this context expires immediately and notify runs while the flush is still going. The other cloud clients treat the same 0 as no timeout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — real bug. Fixed in a6d431f: the drain no longer bounds itself with K6_CLOUD_TIMEOUT — the push is already bounded by the loki hook's own client timeout, so it just waits for the flush on a fresh context. That removes both the instant-expiry at 0 and the mismatched timeout, so notify no longer races the flush.

Comment thread cloudapi/config.go Outdated
// by the provisioning API's runtime_config.logs (self-provisioned) or
// by an external orchestrator via env (externally-provisioned). Consumed
// by cmd to configure the cloud log pusher; env-only, not serialised.
LogsPushURL null.String `json:"-" envconfig:"K6_CLOUD_LOGS_PUSH_URL"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do these get envconfig tags when MetricsPushURL and TestRunToken above deliberately don't? A stray env value survives Apply whenever the backend sends an empty value, e.g. K6_CLOUD_LOGS_ALLOWED_LABELS overrides a legitimate allowed_labels: [].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — they shouldn't have. Fixed in 0a105cb: self-provisioned runs now take the log config only from the provisioning response, and only externally-provisioned runs read the env vars. So a stray env var can't quietly override a self-provisioned run anymore. They were all new in this PR, so nothing relied on the old behaviour.


// LogsConfig holds the log-push configuration from the provisioning
// API's runtime_config.logs object.
type LogsConfig struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgotten, or not needed for local execution? tail_url is the only SDK logs field that isn't mapped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed for local execution — tail_url is for tailing logs from the cloud (the live stream you get during a normal k6 cloud run), and --local-execution only pushes logs, it never tails. So it's deliberately skipped; added a note on LogsConfig to make that clear.

ankur22 added 4 commits July 30, 2026 15:47
The cloud log drain runs before notify, but the only test that
exercised it used the self-provisioned path, where notify also
runs. Add a test that the drain runs on the external (PushRefID)
path too, where notify is skipped, so a future change tying the
drain to notify can't silently drop the final batch.
The log drain wrapped the flush in context.WithTimeout using
conf.Timeout — the cloud API request timeout, which the log path
doesn't otherwise use and which every other client applies via
http.Client{Timeout} (where 0 means "no timeout"). As a context
deadline, K6_CLOUD_TIMEOUT=0 expired immediately, so the drain
was skipped and notify raced the flush.

The push is already bounded by the loki hook's own HTTP client
timeout, so drain on a plain fresh context and let that govern.
Add a test that the drain isn't handed an already-expired
context.
tail_url is for tailing logs from the cloud (the live stream in a
normal k6 cloud run); --local-execution only pushes logs, so it
is deliberately not mapped into LogsConfig. Note it on the type
so it doesn't read as an oversight.
The cloud log pusher observes entries only after the
secrets-redaction hook, so a logged secret should reach the
backend only in redacted form. Nothing tested it — a regression
that let secrets through to cloud logs would pass CI. Add an
e2e: a VU logs a secret from a mock source, and the pushed body
must carry ***SECRET_REDACTED***, never the raw value.
@ankur22
ankur22 force-pushed the local-exec-logs-take3 branch from af24d52 to 0f4ff23 Compare July 30, 2026 14:51
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 30, 2026 14:57 — with GitHub Actions Inactive
@ankur22
ankur22 temporarily deployed to azure-trusted-signing July 30, 2026 14:59 — with GitHub Actions Inactive
inancgumus
inancgumus previously approved these changes Jul 30, 2026
ankur22 added 2 commits July 30, 2026 16:41
An explicitly empty K6_CLOUD_LOGS_ALLOWED_LABELS decodes to a non-nil
empty slice. The loki hook treats nil as "keep all labels" but a
non-nil empty slice as "keep none", which stripped the required
test_run_id label and got the cloud log push rejected with 401.

Normalise an empty allow-list to nil so the keep-all path is taken
and test_run_id always survives. Covered by a new unit test and an
end-to-end case that sets K6_CLOUD_LOGS_ALLOWED_LABELS="".
--log-output=loki= (an empty URL) is rejected up front by the config
parser rather than silently falling back to the default endpoint, so
no empty address ever reaches the constructor. Bare loki still uses
the default. This is unchanged from before the loki hook constructor
extraction, since parseArgs still runs strvals.Parse first.

Add a test documenting both cases.
Comment thread internal/cmd/outputs.go
// so it flushes buffered logs before the run is notified complete. The
// concrete-nil guard avoids handing the output a typed-nil drainer once it is
// wrapped in an interface.
func attachCloudLogDrainer(out output.Output, gs *state.GlobalState) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: having this as a function called unconditionally in the output construction loop implies to me that every output gets the log drainer. Only when you check the function implementation you learn it's restricted to the cloud output.

I would get rid of the function and place the whole if into the loop, just like the others. Makes the intent clearer IMO.

// into the serialized cloud config that the Output reads. It also reads the
// K6_CLOUD_LOGS_* log-push config, which the external flow supplies the same
// way.
func applyExternalProvisioningCreds(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe rename this func to something like applyExternalProvisioningConfig because it no longer only applies the credentials.


// bufferCap bounds the pre-configuration entry buffer. It matches the loki
// hook's own channel capacity so Fire stays non-blocking under the same load.
const bufferCap = 1000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is supposed to mirror the capacity of the loki hook, it could make sense to introduce a shared constant so we make sure that these values do not drift apart in the future

if lvl, err := logrus.ParseLevel(c.Level); err == nil {
minLevel = lvl
} else {
// The level comes from the backend, not the user, so a bad value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is somewhat inaccurate. the level can also come as an env var when we are in the externally provisioned flow. Outcome is probably still the same but I think its worth to fix the comment :)

// explicitly empty K6_CLOUD_LOGS_ALLOWED_LABELS decodes to a non-nil []string{},
// which must still keep the required test_run_id label rather than strip every
// label from the stream.
func TestPusher_EmptyAllowedLabelsKeepsTestRunID(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is sufficiently covered by the cmd level integration tests. maybe we could drop it

// with Level "info" the pusher pushes info/warn/error entries but filters out
// the less-severe debug entry, even though the loki hook's Fire enqueues
// unconditionally. Entries reach the backend via the periodic push.
func TestPusher_FiltersBelowConfiguredLevel(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could merge this with the following test. this would remove a lot of boilerplate and would also improve TestPusher_EmptyLevelKeepsAll because it will actually send logs on all levels.

@ankur22

ankur22 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@janHildebrandt98 thank you for the review and feedback! I will action on the comments in a new PR 🙇

@ankur22
ankur22 merged commit 5880ca9 into master Jul 31, 2026
58 checks passed
@ankur22
ankur22 deleted the local-exec-logs-take3 branch July 31, 2026 16:23
moko-poi added a commit to moko-poi/k6 that referenced this pull request Aug 2, 2026
Brings the branch up to date with master (5880ca9) and resolves the
overlap with the cloud changes that landed in the meantime:

- grafana#6142 added `k6 cloud load-zone`, whose constructor took the `*cmdCloud`
  this branch removes. `getCmdCloudLoadZone` now takes a `*state.GlobalState`,
  like the `project` and `test` constructors already do here.
- grafana#6208 dropped the run flag set from the parent `k6 cloud` command and pinned
  that with a test. This branch had bound those flags to throwaway values to
  keep the help output unchanged, which is now both unnecessary and wrong, so
  that block is gone.
- grafana#6171 added `--no-cloud-logs`. Its validation stays in `cmdCloudRun.preRun`,
  followed by `applyCloudEnvOverrides` instead of the parent's `preRun`.
- master's new `newCloudV6ClientFromConfig` and `cloudStackName` helpers are
  kept as they are.

No behaviour change beyond what the merged commits already do.
@cursor cursor Bot mentioned this pull request Aug 4, 2026
3 of 4 tasks
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
k6 2.2.0

Created-by: HarmonybrewBot
Commit-by: HarmonybrewBot
Merged-by: HarmonybrewBot
Description: Created by `brew bump`

---

Created with `brew bump-formula-pr`.<details>
  <summary>release notes</summary>
  <pre>k6 `v2.2.0` is here 🎉! This release includes:

- `k6 cloud run --local-execution` now streams k6's logs to Grafana Cloud, so the test run's log view works for local execution too.
- `chromium.connectOverCDP()`, which connects browser tests to an already-running Chromium instance.
- `TextEncoder` and `TextDecoder` available as globals, and `WritableStream` support in `k6/experimental/streams`.
- A `k6 cloud load-zone list` command.
- Two new experimental feature flags: `merge-run-tags` and `freeze-env`.

## Breaking changes

There are no breaking changes in this release.

## New features

### `k6 cloud run --local-execution` streams logs to Grafana Cloud [#6171](grafana/k6#6171)

When a cloud test runs locally with `k6 cloud run --local-execution`, k6's logs now stream to the Grafana Cloud test run, so the run's log view is populated the same way it is for cloud execution. Previously, local-execution logs stayed on the machine running k6 and never reached the cloud. Work with Grafana's secrets management to safely work with secrets and redact them if they're accidentally leaked into logs are pushed. Use the new `--no-cloud-logs` flag opts out to opt out of streaming of logs when working with `--local-execution`:

```shell
k6 cloud run --local-execution script.js
k6 cloud run --local-execution --no-cloud-logs script.js
```

### Connect to a running browser with `chromium.connectOverCDP()` [#6165](grafana/k6#6165)

The browser module can now attach to an existing Chromium-based browser over the Chrome DevTools Protocol, mirroring Playwright's `browserType.connectOverCDP()`. Pass the browser's WebSocket endpoint and k6 manages the returned browser's connection — it's auto-closed at the end of the iteration, though you can call `close()` earlier to release the connection on demand.

```js
import { chromium } from 'k6/browser';

export default async function () {
  const browser = await chromium.connectOverCDP('ws://localhost:9222/devtools/browser/<id>');
  const page = await browser.newPage();

  try {
    await page.goto('https://quickpizza.grafana.com/');
  } finally {
    await page.close();
    await browser.close();
  }
}
```

Unlike the `K6_BROWSER_WS_URL` environment variable, the endpoint is a runtime value — you can, for example, request a fresh session URL from a browser provider's API in `setup()` and connect to it from the iterations.

### `TextEncoder` and `TextDecoder` globals [#6182](grafana/k6#6182)

`TextEncoder` and `TextDecoder` are now available as standard globals in both the init and VU contexts, no import required — matching how they are exposed in browsers and other JavaScript runtimes.

```js
const encoded = new TextEncoder().encode('Hello, world!');
const decoded = new TextDecoder().decode(encoded);
```

### `WritableStream` in `k6/experimental/streams` [#6132](grafana/k6#6132)

The experimental streams module now implements `WritableStream` and `WritableStreamDefaultWriter` following the WHATWG Streams specification, complementing the existing `ReadableStream` and paving the way for a future `TransformStream` implementation.

```js
import { WritableStream } from 'k6/experimental/streams';

export default async function () {
  const stream = new WritableStream({
    write(chunk) {
      console.log(`wrote ${chunk}`);
    },
  });

  const writer = stream.getWriter();
  await writer.write('hello');
  await writer.close();
}
```

### `k6 cloud load-zone list` command [#6142](grafana/k6#6142)

A new `k6 cloud load-zone list` subcommand lists the load zones — public and private — available in the configured Grafana Cloud k6 stack, mirroring the existing `k6 cloud project list` command. Output defaults to a human-readable table; pass `--json` to emit a JSON array instead.

```console
$ k6 cloud load-zone list
Load zones for https://example.grafana.net:

ID                     NAME                     TYPE      AVAILABLE
amazon:us:ashburn      Ashburn, US (Amazon)     public    yes
amazon:sa:cape town    Cape Town, SA (Amazon)   public    yes
```

### Configurable `handleSummary()` timeout [#5854](grafana/k6#5854)

The time budget for the `handleSummary()` callback — previously hardcoded to 120 seconds — is now configurable through the `handleSummaryTimeout` option or the `K6_HANDLE_SUMMARY_TIMEOUT` environment variable, so long-running tests with heavy summaries no longer fail with `handleSummary() execution timed out`. Thanks, @LBaronceli!

```js
export const options = {
  handleSummaryTimeout: '5m',
};
```

### New experimental feature flags: `merge-run-tags` and `freeze-env`

Two new experimental flags join the feature-flag system introduced in v2.1.0:

- [#5714](grafana/k6#5714) `merge-run-tags` merges run tags per key across config layers, so `options.tags` in a script is no longer silently discarded when `--tag` or `K6_TAGS` is also used — higher-priority layers win on conflicting keys instead of replacing the whole map. Thanks, @yordis!
- [#6032](grafana/k6#6032) `freeze-env` freezes the `__ENV` object, so modifications from script code throw a `TypeError` (in strict mode) instead of silently persisting across iterations and scenarios. Thanks, @lohitkolluri!

```shell
k6 run --features merge-run-tags,freeze-env script.js
```

## UX improvements and enhancements

- [#5631](grafana/k6#5631) Makes the browser module's header accessors — `response.allHeaders()`, `headerValue()`, `headerValues()`, and `headersArray()` — return the raw wire headers (including `Set-Cookie` and security-related headers), correctly paired with each hop of a redirect chain instead of Chrome's provisional headers. As part of this, `headerValues()` now matches header names case-insensitively and splits repeated values on newlines rather than commas, and the `browser_data_sent`/`browser_data_received` metrics now include the raw header bytes and no longer vary run-to-run with CDP event ordering.
- [#6208](grafana/k6#6208) Makes `k6 cloud` reject the run flags (for example, `--vus`) with an `unknown flag` error and a non-zero exit code. Previously `k6 cloud --vus 10 script.js` accepted the flags, printed the help text, and exited 0 — running tests with `k6 cloud` directly was deprecated in v2.0.0 in favor of `k6 cloud run`.
- [#6096](grafana/k6#6096) Points the cloud secrets error at `K6_CLOUD_SECRETS_TOKEN` and `K6_CLOUD_SECRETS_ENDPOINT` when a test run is reused via `K6_CLOUD_PUSH_REF_ID` under `--local-execution`, instead of suggesting the `--local-execution` flag the user is already using.
- [#6196](grafana/k6#6196) Adds `catch` blocks to the browser examples so a failing iteration reports the original error instead of a subsequent `page.close()` failure. Thanks, @locker95!

## Bug fixes

- [#6234](grafana/k6#6234) Classifies HTTP/2 errors by message so the `error_code` metric tag stays correct when k6 is built with Go 1.27 (whose `x/net/http2` delegates to the standard library), and explicitly enables HTTP/2 negotiation on VU transports.
- [#6232](grafana/k6#6232) Drains queued log entries in the Loki hook at shutdown so `--out loki` and cloud log streaming no longer lose the final batch, and emits a `k6 dropped N log messages` warning when the cloud log buffer overflows instead of dropping logs silently.
- [#6125](grafana/k6#6125) Serializes the first concurrent open of a file in the caching filesystem so parallel `fs.open()` calls on the same file no longer read zero or truncated bytes.
- [#6147](grafana/k6#6147) Fixes a data race and inconsistent request-interception state when browser routes are added or removed concurrently. Thanks, @somak2kai!
- [#6070](grafana/k6#6070) Flushes buffered file log output once per second so recent logs aren't lost when k6 is killed before shutdown. Thanks, @rohan-patnaik!
- [#6205](grafana/k6#6205) Stops sending an invalid `Sec-WebSocket-Protocol` header when tailing Grafana Cloud logs; spec-strict servers rejected the handshake with `websocket: bad handshake`.
- [#6200](grafana/k6#6200) Leaves a counter's `rate` unset when the observed duration is zero, instead of computing `+Inf` and spuriously failing `rate` thresholds. Thanks, @samarth70!
- [#6195](grafana/k6#6195) Initializes a gauge's maximum from the first sample so all-negative gauge series no longer report `max=0`. Thanks, @Solaris-star!
- [#6145](grafana/k6#6145) Prevents the OpenTelemetry output from panicking at startup when basic auth is configured without `K6_OTEL_HEADERS`. Thanks, @lukdz!
- [#6140](grafana/k6#6140) Stops `SharedArray` deep-freezing JS primitives, which needlessly wrapped large strings in `String` objects — cutting memory usage in the reported reproduction from roughly 1 GB to 100 MB.

## Maintenance and internal improvements

- [#6126](grafana/k6#6126), [#6224](grafana/k6#6224), [#6229](grafana/k6#6229) Adds anonymous extension usage to the k6 usage report: a run reports the Go module path, version, and type of registry-cataloged extensions it actually uses (imported `k6/x/` modules, output extensions selected with `--out`, and `k6 x` subcommands). Private and unlisted extensions are never reported, and the existing `--no-usage-report` opt-out covers it.
- [#6183](grafana/k6#6183), [#6218](grafana/k6#6218) Updates Sobek and regexp2, making `W

See merge request: Harmonybrew/homebrew-core!16299

This branch was previously deployed

1 inactive deployment
azure-trusted-signing — a5b07135 Deployed Jul 30, 2026 by ankur22 via sign-packages #10211
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants