Grafana Cloud k6 — interaction reference The default path is the CLI. When gcx isn't installed, every endpoint here is still reachable via direct curl against k6's public hosts — see §1.2 for the auth-header and host-translation rules. Two principles shape the rest: - gcx owns Grafana-side auth (when present). It injects the right headers on every call, so you should not set auth headers yourself. The only header you ever set by hand is , on Loki log queries (§4), browser screenshot/file fetches (§6), and Tempo trace queries (§7) — anything else gets overwritten or causes conflicts. In curl m…

\\t' read -r NAME URL; do\n curl -sS -o \"/tmp/run_${RUN_ID}_files/$(basename \"$NAME\")\" \"$URL\"\n done\n```\n\n### Notes worth knowing\n\n- **Index before signing.** `generate-pre-signed-url` does *not*\n validate that the file exists — it will happily mint a URL for any\n key, and the 404 only surfaces when you try to download (S3 returns\n `\u003cError>\u003cCode>NoSuchKey\u003c/Code>...`). Always derive the file list\n from `files/index`, not from a guess at the path shape.\n- **Batch the sign request.** `files` is an array, so request all the\n URLs you need in one POST rather than one call per file — same\n 24h expiry covers the whole batch.\n- **Browser tests vs. protocol tests.** Only runs that called\n `page.screenshot()` have entries in the index; a pure protocol/HTTP\n run will return `[]`. Don't assume the index is non-empty.\n\n---\n\n## 7. Browser traces (Tempo via `gcx api`)\n\nBrowser-module runs emit OTel spans for every iteration, navigation,\nlocator click, screenshot, web-vital observation, etc. The plugin\nproxies a Tempo backend under `/api/plugins/k6-app/resources/logs/`\n(same prefix as Loki — see §4), and the same `X-K6TestRun-Id` header\nis required on every call. Without it, both search and fetch return\nHTTP 401 `\"Test run ID missing\"`.\n\nRetrieval is a two-step flow: TraceQL search → fetch full trace by\nID. The full trace is large (≈100 KB on disk for a 120-span iteration\nbecause `-o json` pretty-prints; ~50 KB compact), so the\njoin+summarise step is what keeps agent context lean.\n\n```bash\nRUN_ID=\"\u003crun_id>\"\n\n# 1. Find the run's start/end window and pick the scenario name.\n# Same caveat as §4: strip the sub-second fraction before\n# fromdateiso8601, and fall back to \"now\" if the run is still\n# running. Add a small buffer because spans can land slightly\n# after the run object reports `ended`.\nRUN=$(gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID 2>/dev/null)\nSTART=$(echo \"$RUN\" | jq -r '.created | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601')\nEND=$( echo \"$RUN\" | jq -r 'if .ended then (.ended | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) else (now|floor) end')\nSTART=$((START - 60))\nEND=$((END + 60))\n\nSCENARIO=\"ui\" # whatever your scenario is named; check run.ui.exec / k6 script\n\n# 2. Search for iteration root spans. TraceQL goes in `q`; URL-encode\n# it. `start`/`end` must be unix seconds — RFC3339 returns\n# HTTP 400 `invalid start: strconv.ParseUint`. limit=500 caps the\n# result, enough to cover several thousand iterations.\nQ=$(printf '{ name = \"iteration\" && span.test.iteration.number >= 0 && span.test.vu >= 0 && span.test.scenario = \"%s\" }' \"$SCENARIO\")\nQE=$(printf '%s' \"$Q\" | jq -sRr @uri)\n\ngcx api \"/api/plugins/k6-app/resources/logs/api/v1/tempo/api/search?start=${START}&end=${END}&q=${QE}&limit=500\" \\\n -H \"X-K6TestRun-Id: $RUN_ID\" -o json 2>/dev/null > /tmp/traces_${RUN_ID}.json\n\n# Each entry has: traceID (32-char hex), rootServiceName (\"k6\"),\n# rootTraceName (\"iteration\"), durationMs, startTimeUnixNano, and a\n# spanSet preview of the iteration root span's attributes\n# (test.iteration.number, test.vu, test.scenario).\n\n# 3. Fetch a full trace. The response is OTLP-shaped:\n# { \"batches\": [ { \"resource\": {...}, \"scopeSpans\": [ { \"spans\": [...] } ] } ] }\nTRACE_ID=$(jq -r '.traces[0].traceID' /tmp/traces_${RUN_ID}.json)\ngcx api \"/api/plugins/k6-app/resources/logs/api/v1/tempo/api/traces/$TRACE_ID\" \\\n -H \"X-K6TestRun-Id: $RUN_ID\" -o json 2>/dev/null > /tmp/trace_${TRACE_ID}.json\n```\n\n### Compact summary for an agent\n\nThe OTLP body for a single iteration can run ~100 KB pretty-printed.\nDumping it verbatim into context is wasteful — most of the useful\nsignal lives in the span-name distribution, the slowest individual\nspans, and the web-vital ratings. The pipeline below collapses a\ntrace into ~1–2 KB of plain text:\n\n```bash\njq -r '\n # OTel value envelope: pick by key presence, not //-chaining — jq's //\n # treats `false` as missing and would drop legitimate boolValue:false.\n def attrval:\n if has(\"stringValue\") then .stringValue\n elif has(\"intValue\") then .intValue\n elif has(\"boolValue\") then .boolValue\n elif has(\"doubleValue\") then .doubleValue\n else null end;\n def attrs: [.attributes[] | {(.key): (.value | attrval)}] | add // {};\n def dur: (((.endTimeUnixNano|tonumber) - (.startTimeUnixNano|tonumber)) / 1000000 | floor);\n\n [.batches[].scopeSpans[].spans[] | {name, dur: dur, a: attrs, status: (.status.code // \"OK\")}] as $spans\n\n | \"── span-name rollup (count · total ms · max ms) ──\",\n ([$spans[] | {name, dur}]\n | group_by(.name) | map({name: .[0].name, n: length,\n t: (map(.dur)|add), m: (map(.dur)|max)})\n | sort_by(-.t)[]\n | \" \\(.n|tostring|.+\" \"|.[0:4]) \\(.t|tostring|.+\" \"|.[0:7]) \\(.m|tostring|.+\" \"|.[0:6]) \\(.name)\"),\n\n \"\\n── slowest spans (ms · name · navigation.url / page.goto.url) ──\",\n ([$spans[] | select(.name != \"iteration\")] | sort_by(-.dur) | .[0:8][]\n | \" \\(.dur|tostring|.+\" \"|.[0:7]) \\(.name)\\(if .a[\"navigation.url\"] // .a[\"page.goto.url\"] then \" \" + (.a[\"navigation.url\"] // .a[\"page.goto.url\"]) else \"\" end)\"),\n\n \"\\n── web-vital ratings ──\",\n ([$spans[] | select(.name == \"web_vital\")\n | {n: .a[\"web_vital.name\"], v: .a[\"web_vital.value\"], r: .a[\"web_vital.rating\"]}]\n | group_by(.n) | map({name: .[0].n,\n ratings: ([.[].r] | group_by(.) | map({(.[0]): length}) | add)})[]\n | \" \\(.name) \\(.ratings)\"),\n\n \"\\n── span errors ──\",\n ([$spans[] | select(.status != \"OK\" and .status != null)] | length | \" count: \\(.)\")\n' /tmp/trace_${TRACE_ID}.json\n```\n\nOn a run like 7542817 the output is something like:\n\n```\n── span-name rollup (count · total ms · max ms) ──\n 1 154552 154552 iteration\n 37 153391 17746 navigation\n 11 21778 5168 page.waitForNavigation\n ...\n\n── slowest spans (ms · name · navigation.url / page.goto.url) ──\n 17746 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects\n 14992 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects/58195\n ...\n\n── web-vital ratings ──\n LCP {\"poor\":5,\"needs-improvement\":1}\n CLS {\"good\":2}\n ...\n```\n\nTwo paragraphs of text capture which navigations are slow, which\nweb vitals are regressing, and the overall span-name distribution\n— enough to reason about browser performance without paging through\nthe OTLP tree.\n\n### Notes worth knowing\n\n- **Header is mandatory.** Both `/search` and `/traces/\u003cid>` return\n HTTP 401 without `X-K6TestRun-Id`. The header scopes the query to\n the run's tenant.\n- **`start`/`end` are unix seconds.** RFC3339 ISO strings get\n `HTTP 400 invalid start: strconv.ParseUint`. They're technically\n optional — search works without them — but providing a window\n matching the run's `created`/`ended` (plus ~60s buffer) keeps the\n query fast and avoids matching unrelated runs that share the\n scenario name.\n- **TraceQL, not LogQL.** The `q` parameter uses TraceQL — span\n predicates are written as `span.\u003cattribute>` and combined with\n `&&`. The suggested query filters iteration root spans for a\n specific scenario; broaden by dropping `test.scenario`, narrow by\n adding e.g. `span.test.vu = 3 && span.test.iteration.number = 5`\n to pinpoint a single iteration on a single VU.\n- **Scenario names come from the run.** They're the *keys* of\n `.options.scenarios` on the run object —\n `gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID\n --json options 2>/dev/null | jq -r '.options.scenarios | keys[]'`\n lists them. The default scenario is named `default`; browser tests\n often name it `ui`, `browser`, etc.\n- **Span IDs in the OTLP body are base64.** The `traceID` in the\n search response is hex (the form `/traces/\u003cid>` accepts); the\n inner `traceId`/`spanId`/`parentSpanId` fields inside the OTLP\n batch are base64-encoded bytes. Cross-correlate parent/child via\n `parentSpanId == spanId`, both in base64 — no need to decode.\n- **Useful attribute keys** on browser-test spans: `navigation.url`,\n `page.goto.url`, `screenshot.path`, `web_vital.name`,\n `web_vital.value`, `web_vital.rating`, `test.scenario`, `test.vu`,\n `test.iteration.number`, `k6.test_run_id`. These are the ones\n worth surfacing in summaries; the OTel value envelope is\n `{stringValue|intValue|boolValue|doubleValue}` (the jq\n `attrval` helper above handles all four).\n\n---\n\n## 8. Cloud Insights (audit results for a run)\n\nCloud Insights runs heuristics against a finished test — checks for\nhigh cardinality, web-vital regressions, missing thresholds,\noverutilised load generators, and so on — and exposes the results\nthrough a separate `/resources/insights/` plugin route (not\n`/cloud/v{N}/`). gcx still handles auth; no extra header is needed.\n\nThree calls produce the data, and the agent-useful output is a join\nacross two of them:\n\n```bash\nRUN_ID=\"\u003crun_id>\"\nBASE=\"/api/plugins/k6-app/resources/insights/insights/api/v1/testrun/$RUN_ID\"\n\n# 1. List executions for the run, take the most recent. The response\n# is { \"executions\": [ {id, version}, ... ] } — NOT a bare array.\n# Pick the last entry; insights re-runs append, and the newest one\n# reflects the current set of audits/scores.\nEXEC_ID=$(gcx api \"$BASE/executions\" -o json 2>/dev/null \\\n | jq -r '.executions[-1].id')\n\n# 2. Audit definitions (id, title, description, weight). ~15 items.\ngcx api \"$BASE/executions/$EXEC_ID/audits\" -o json 2>/dev/null \\\n > /tmp/insights_audits_${RUN_ID}.json\n\n# 3. Audit results (audit_id, status, score, explanation, actions).\ngcx api \"$BASE/executions/$EXEC_ID/audits/results\" -o json 2>/dev/null \\\n > /tmp/insights_results_${RUN_ID}.json\n```\n\nBoth responses use the same top-level key — `audits` — but the\n*contents* differ: step 2 is the catalog of what each audit checks,\nstep 3 is what that audit found on this particular run. Cross-join\nthem via `result.audit_id == audit.id` (1:1 in practice).\n\n### Joining into a compact, agent-readable summary\n\nThe raw audit + results JSON together is ~10 KB. Dumping both into\ncontext just to read out three lines per audit is wasteful — do the\njoin in `jq` and emit a single tight block. The pipeline below is\nthe recommended shape:\n\n```bash\njq -nr \\\n --slurpfile a /tmp/insights_audits_${RUN_ID}.json \\\n --slurpfile r /tmp/insights_results_${RUN_ID}.json '\n ($a[0].audits | INDEX(.id)) as $defs\n | $r[0].audits\n | map({\n title: ($defs[.audit_id].title // \"?\"),\n description: ($defs[.audit_id].description // \"\"),\n status: .status,\n status_reason: .status_reason,\n score: (\n if .score == null then \"n/a\"\n elif .score.type == \"binary\" then (if .score.value then \"pass\" else \"fail\" end)\n else (.score.value | tostring)\n end),\n explanation: (.explanation // \"\" | gsub(\"\\n+\"; \" \") | .[0:300]),\n actions: (.actions // [])\n })\n | .[]\n | \"── \\(.title) — score: \\(.score)\\(if .status == \"failed\" then \" [audit did not run: \\(.status_reason // \"unknown\")]\" else \"\" end)\\n \\(.description)\\n → \\(.explanation)\\(if (.actions|length) > 0 then \"\\n actions:\\n - \" + (.actions|join(\"\\n - \")) else \"\" end)\\n\"\n'\n```\n\nFor a 15-audit run this emits ~5 KB of plain text — title, score,\none-line description, one-line explanation, and any action items.\nThat's enough for the agent to reason about the test's health\nwithout re-reading either JSON blob.\n\n### Notes worth knowing\n\n- **`status` ≠ verdict.** `status: \"succeeded\"` means *the audit\n executed*. The verdict lives in `score` (`binary` true/false, or\n `numeric` 0…1 where 1 is best). `status: \"failed\"` means the audit\n itself could not run (typically `status_reason: \"missing data\"` —\n e.g. the HTTP Spans audit on a non-tracing test); the `score` field\n is absent. Treat these as \"no signal\", not as failures.\n- **Score thresholds vary per audit.** A `numeric` 0.94 might be\n fine for one audit and concerning for another — there's no global\n cutoff. The `explanation` is authoritative for what the score\n means; surface it verbatim rather than inventing a pass/fail rule.\n- **`actions` is the actionable bit.** Only present when the audit\n has concrete recommendations (e.g. \"Reduce the cardinality of the\n `url` label …\"). When summarising a run for a user who's trying to\n improve it, lead with audits that have a non-empty `actions` array.\n- **Pick the last execution, not the first.** `.executions[]` is in\n chronological order; re-runs append. Older executions reflect\n older audit logic and may have stale results.\n- **Insights is a post-run analysis.** If the run hasn't finished\n (or never produced enough data for insights to compute), the\n executions list may be empty — bail out gracefully on\n `length == 0`.\n\n---\n\n## 9. Local k6 CLI (smoke tests and `k6 cloud run`)\n\nThe local `k6` CLI is the right tool for parse checks and 1-iteration\nsmoke runs before pushing to cloud:\n\n```bash\nk6 inspect script.js | head -20 # parse-only sanity\nk6 run --quiet script.js # run locally\n```\n\nFor `k6 cloud run` (uploads + runs in cloud from your laptop), authenticate\n`k6 cloud login` with the token and stack URL pulled from gcx — token\ncomes from `gcx k6 auth token`, stack URL from the active gcx context's\n`grafana.server` field:\n\n```bash\nTOKEN=$(gcx --context \u003cctx> k6 auth token)\nSTACK=$(gcx --context \u003cctx> config view --minify -o json | jq -r '.contexts[].grafana.server')\nk6 cloud login --token \"$TOKEN\" --stack \"$STACK\"\nk6 cloud run script.js\n```\n\nk6's cloud config (`~/.config/k6/cloud.json`) is single-context, so\nre-run `k6 cloud login` whenever you switch gcx contexts — otherwise\n`k6 cloud run` will keep targeting the previous stack.\n\nExit codes: `0` pass, `99` threshold fail, anything else = script/runtime error.\n\n---\n\n## 10. Gotchas\n\n| Symptom | Cause | Fix |\n|--------------------------------------------------------------------------------------|----------------------------------------------------------------------|-----|\n| `401 \"Invalid or expired token — run gcx login to refresh\"` | gcx OAuth session expired | `gcx login --context \u003cctx>` |\n| `403 / 404` on a path that looks right | Forgot the doubled `cloud/cloud/` for a REST endpoint | Use `…/resources/cloud/cloud/v{N}/…` |\n| `415 unsupported media type` on script PUT | Missing `-H \"Content-Type: application/octet-stream\"` | Add it; pass `-vvv` (or `--log-http-payload`) to `gcx api` to inspect the request |\n| Script PUT returns 200 but doesn't take effect | `updated` timestamp does not bump on script change | Verify by sha256 of GET (§5 step 7) |\n| Run status `passed` but checks failed | Zero-observation thresholds report as pass; `check()` alone never fails a run | Add `'checks{check:\u003cname>}': ['rate==1.0']`; in catch blocks, `check(null, {\"script completed\":()=>false})` to force an observation |\n| Loki query returns nothing for a recent run | `X-K6TestRun-Id` header missing | Always pass `-H \"X-K6TestRun-Id: \u003crun_id>\"` on log queries |\n| `gcx k6 runs list --limit 0` returns the **oldest** 1000 rows, not the newest | Subcommand doesn't follow `@nextLink` and defaults to ascending order — for >1000 runs the `first/last` you see are the start of the history, not the recent activity | Use `gcx api` with `$orderby=created desc` for a newest-first slice, or the full `@nextLink` loop in §3 |\n| `gcx k6 load-tests update -f` returns `✔ Updated` but the change doesn't take effect | Fields outside the v6 PATCH schema (`PatchLoadTestApiModel` allows only `name`, `baseline_test_run_id` — `additionalProperties: false`) are silently dropped. Notably `project_id` is NOT updatable this way | For project moves use the dedicated `/move` endpoint (§11); for other mutations cross-check the PATCH schema in the OpenAPI spec. Always re-GET to confirm |\n| `gcx k6 load-tests list --project-id \u003cid>` returns tests from all projects | The flag is accepted but never filters — `/cloud/v6/load_tests` has no project query param | Use `GET /cloud/v6/projects/{id}/load_tests` via `gcx api`, or filter client-side with `select(.project_id == X)` in jq |\n| `HTTP 415 \"Unsupported media type \\\"application/json\\\"\"` on a multipart POST through `gcx api` | The Grafana plugin proxy rewrites multipart Content-Types to `application/json` before forwarding (gcx itself sends the header you set; the proxy strips it) | Fall back to direct curl against `api.k6.io` (§1.2). Affects test creation (`POST /cloud/v6/projects/{id}/load_tests`) and any other multipart endpoint |\n| `401 Unauthorized` calling `api.k6.io` / `cloudlogs.k6.io` directly (curl mode) | Missing/wrong `Authorization: Bearer …` or `X-Stack-ID` header | Re-check both headers; resolve `X-Stack-ID` from a stack URL via `/cloud/v6/auth` (§1.2) |\n| `404` calling `api.k6.io/api/plugins/...` (curl mode) | Left the plugin-proxy prefix in by mistake | Strip `/api/plugins/k6-app/resources/{cloud,logs,insights}` — see §1.2 |\n\n---\n\n## 11. Mutations not covered by `update -f`\n\n`gcx k6 \u003cresource> update -f` follows the v6 PATCH schema for that\nresource. Fields outside the schema are silently dropped while gcx\nstill prints `✔ Updated \u003cresource> \u003cid>` — see the §10 row. Several\ncommon mutations have **dedicated endpoints instead**, and the PATCH\nroute will no-op on them.\n\n| Mutation | Endpoint | Body |\n|---------------------------------------|-------------------------------------------------------|-------------------------------|\n| Move test to another project | `PUT /cloud/v6/load_tests/{id}/move` | `{\"project_id\": \u003cint>}` |\n| Start a test run | `POST /cloud/v6/load_tests/{id}/start` | `{}` (or run options) |\n\n**Polling a started run:** After starting a run, poll\n`GET /cloud/v6/test_runs/{id}` until `status` reaches `completed` or\n`aborted`. Always check the `result` field alongside `status` —\n`status: completed` with `result: error` means a configuration or\ninfrastructure failure (not a threshold breach). On any non-`passed`\nresult, immediately fetch logs (§4) to surface the error rather than\nwaiting for the user to report it.\n\n| Abort a running test | `POST /cloud/v6/test_runs/{id}/abort` | empty |\n| Set / overwrite a schedule | `POST /cloud/v6/load_tests/{id}/schedule` | Schedule body (recurrence_rule or cron) |\n| Deactivate / reactivate a schedule | `POST /cloud/v6/schedules/{id}/{deactivate,activate}` | empty |\n| Create a load test (multipart!) | `POST /cloud/v6/projects/{id}/load_tests` | multipart: `name` + `script` |\n| Persist a run past retention | `POST /cloud/v6/test_runs/{id}/save` | empty (paired with `/unsave`) |\n\nThe PATCH-style updates that *do* work via `update -f`:\n\n| Resource | Updatable fields |\n|--------------|---------------------------------------------------------------|\n| load test | `name`, `baseline_test_run_id` (per `PatchLoadTestApiModel`) |\n| project | `name` only (per `PatchProjectApiModel`) |\n\nBoth schemas declare `additionalProperties: false` — any other field\nyou put in the manifest is silently filtered out before the PATCH is\nsent, even though gcx still reports `✔ Updated`. Don't try to flip\n`is_default` or move a `grafana_folder_uid` through `update -f`;\nthey're not exposed for mutation on the v6 PATCH.\n\nCross-check by reading the operation's request schema in the OpenAPI\nspec (workflow in §3) before assuming a field is mutable.\n\n### Worked example — move a test between projects\n\n```bash\ngcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/\u003ctest_id>/move \\\n -X PUT \\\n -H \"Content-Type: application/json\" \\\n -d '{\"project_id\": \u003cnew_project_id>}'\n\n# Verify — re-GET and check project_id reflects the new value.\n# Do not trust the absence of an error from the PUT; HTTP 204 with\n# empty body is the success shape but gcx api prints nothing.\ngcx --context \u003cctx> k6 load-tests get \u003ctest_id> -o json | jq '{id, name, project_id}'\n```\n\nThe OpenAPI description for this endpoint is explicit: *\"Move a load\ntest to a different project of the same organization. All respective\ntest runs will be also moved to the new project.\"* You don't need to\nmigrate runs separately.\n\n### Cascade behavior worth knowing\n\n- **Deleting a load test cascade-deletes its schedule.** The schedule\n is gone from `/cloud/v6/schedules` and `/cloud/v6/load_tests/{id}/schedule`\n immediately. No need to `gcx k6 schedules delete \u003cload-test-id>`\n first as a defensive step.\n- **Deleting a project with a running test fails with HTTP 409**\n (\"Cannot delete project with a running test.\" per the OpenAPI\n spec). Non-running tests appear to be removed with the project,\n but if you want to inventory a project's contents before deleting\n it, use `GET /cloud/v6/projects/{id}/load_tests` (NOT\n `gcx k6 load-tests list --project-id \u003cid>`, which doesn't filter —\n see §10).\n- **Moving a test moves its runs and run history with it.** Schedule\n attachment also follows the test (it's keyed by `load_test_id`,\n not by project).\n\n### Verification rule of thumb\n\nThe recurring pattern in this skill is *\"gcx confirms success even\nwhen the underlying call no-ops\"*. Whenever you mutate state:\n\n1. Note what you expected to change (field, count, status).\n2. Re-GET the resource and confirm the change is reflected.\n3. If it isn't, check whether the mutation needed a dedicated\n endpoint (this section) rather than `update -f`.","attachment_filenames":["references/metrics.md"],"attachments":[{"filename":"references/metrics.md","content":"# Grafana Cloud k6 — Metrics REST API reference\n\nReference for the **v5** metrics endpoints. Mirrored from\n\u003chttps://grafana.com/docs/grafana-cloud/testing/k6/reference/cloud-rest-api/metrics/>\nso the agent can answer metrics questions without a web fetch every time.\n\n## Calling via `gcx api`\n\nThe raw k6 paths in this doc start with `/cloud/v5/...`. To call them via\nthe Grafana plugin proxy (which is what `gcx api` uses), prefix them\nwith `/api/plugins/k6-app/resources/cloud/` — same doubled-`cloud/`\npattern as v6 (see SKILL.md §2). Example mapping:\n\n| k6 path | gcx invocation |\n|------------------------------------------------------|-------------------------------------------------------------------------------------------|\n| `/cloud/v5/test_runs/{id}/metrics` | `gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/{id}/metrics` |\n| `/cloud/v5/test_runs/{id}/query_range_k6(...)` | `gcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/{id}/query_range_k6(...)\"` |\n\n`Authorization` and stack scoping are injected by gcx — do not set them.\n\n**Quoting tip**: the `query_range_k6(...)` / `query_aggregate_k6(...)`\nendpoints use OData function-call URL syntax with single-quoted string\narguments. Wrap the whole path in double quotes when invoking `gcx api`\nso the inner single quotes survive shell parsing.\n\nThere is also an alternative `/ms` alias for the load-test-level\nendpoints (used by the k6 UI to dodge ad-blockers); from `gcx api` you\ndon't need it.\n\n---\n\n## Endpoints\n\n### 1. List metrics for a single test run\n\n`GET /cloud/v5/test_runs/{testRunId}/metrics`\n\nReturns every metric emitted by a run.\n\n```bash\ngcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152779/metrics\n```\n\nResponse fields per metric:\n\n- `id` (string)\n- `name` (string) — e.g. `http_req_duration`, `vus`, `data_sent`\n- `origin` (string) — e.g. `builtin`\n- `test_run_id` (integer)\n- `type` (string) — one of `counter`, `gauge`, `trend`, `rate`\n\nThe metric's `type` determines which query methods are valid against it\n(see \"Query methods\" below).\n\n### 2. List metrics across multiple test runs\n\n#### Last N runs of a load test\n\n`GET /cloud/v5/load_tests/{loadTestId}/metrics(test_run_count={n})`\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/load_tests/1529/metrics(test_run_count=5)\"\n```\n\n`test_run_count` defaults to 30 if omitted.\n\n#### Specific runs by ID\n\n`GET /cloud/v5/load_tests/{loadTestId}/metrics(test_run_ids=[{id1},{id2},...])`\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/load_tests/1529/metrics(test_run_ids=[152779,152782,152785])\"\n```\n\nResponse per metric:\n\n- `name` (string)\n- `type` (string) — if a metric has different types across the selected\n runs, the **latest** run's type wins\n- `labels` (array of strings) — union of label names seen across the\n selected runs\n\nResults are alphabetised by name.\n\n### 3. List time series\n\n`GET /cloud/v5/test_runs/{testRunId}/series?match[]=\u003cselector>&...`\n\nPrometheus-like series API: returns the set of label combinations that\nmatch each selector. Repeat `match[]` for multiple selectors; at least\none is required.\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152777/series?match[]=http_req_duration{expected_response=\\\"true\\\"}\"\n```\n\nResponse:\n\n```json\n{\n \"status\": \"success\",\n \"data\": [\n {\"__name__\": \"http_req_duration\", \"expected_response\": \"true\", \"name\": \"...\", \"test_run_id\": \"152777\", ...}\n ]\n}\n```\n\n### 4. List label names\n\n`GET /cloud/v5/test_runs/{testRunId}/labels?match[]=\u003cselector>`\n\n`match[]` is optional; without it, all labels for the run are returned.\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152777/labels?match[]=http_req_duration\"\n```\n\nResponse:\n\n```json\n{\n \"status\": \"success\",\n \"data\": [\"__name__\", \"expected_response\", \"group\", \"load_zone\", \"method\", \"name\", \"proto\", \"scenario\", \"status\", \"test_run_id\", \"url\"]\n}\n```\n\n### 5. List label values\n\n`GET /cloud/v5/test_runs/{testRunId}/label/{labelName}/values?match[]=\u003cselector>`\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152777/label/load_zone/values?match[]=http_req_duration\"\n```\n\nResponse:\n\n```json\n{\"status\": \"success\", \"data\": [\"amazon:us:columbus\", \"amazon:eu:dublin\"]}\n```\n\n### 6. Query a range (time series)\n\n`GET /cloud/v5/test_runs/{testRunId}/query_range_k6(query='...',metric='...',step=...,start=...,end=...)`\n\nReturns one sample per `step` seconds over `[start, end]`. Parameters\ngo **inside the parens**, comma-separated, with string values in single\nquotes.\n\nParameters:\n\n| Name | Required | Notes |\n|----------|----------|----------------------------------------------------------------------|\n| `query` | yes | Query expression (see \"Query methods\" / \"Aggregation\" below) |\n| `metric` | yes | Metric name with optional selector — `http_req_duration{status=\"200\"}` |\n| `step` | no | Sample interval in seconds |\n| `start` | no | ISO 8601; defaults to test run start |\n| `end` | no | ISO 8601; defaults to test run end |\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152779/query_range_k6(query='increase by (name,status)',metric='http_reqs{status!=\\\"0\\\"}',step=5)\"\n```\n\nResponse shape (matrix):\n\n```json\n{\n \"status\": \"success\",\n \"data\": {\n \"resultType\": \"matrix\",\n \"result\": [\n {\n \"metric\": {\"__name__\": \"http_reqs\", \"name\": \"login page\", \"status\": \"200\", \"test_run_id\": \"152779\"},\n \"values\": [[1684949415, 845], [1684949420, 1034]]\n }\n ]\n }\n}\n```\n\n### 7. Query an aggregate (single value)\n\n`GET /cloud/v5/test_runs/{testRunId}/query_aggregate_k6(query='...',metric='...',start=...,end=...)`\n\nSame shape as `query_range_k6` but collapses the window into one value\nper series.\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/152779/query_aggregate_k6(query='histogram_quantile(0.95) by (name,status)',metric='http_req_duration')\"\n```\n\nResponse shape (vector):\n\n```json\n{\n \"status\": \"success\",\n \"data\": {\n \"resultType\": \"vector\",\n \"result\": [\n {\n \"metric\": {\"__name__\": \"http_req_duration\", \"name\": \"...\", \"status\": \"200\", \"test_run_id\": \"152779\"},\n \"values\": [[1684950639, 14207]]\n }\n ]\n }\n}\n```\n\n### 8. Aggregate across multiple runs\n\n`GET /cloud/v5/load_tests/{loadTestId}/query_aggregate_k6(...)`\n\nSame parameters as the single-run version, plus one of:\n\n- `test_run_count={n}` — last N runs\n- `test_run_ids=[{id1},{id2},...]` — explicit list\n\n```bash\ngcx api \"/api/plugins/k6-app/resources/cloud/cloud/v5/load_tests/1529/query_aggregate_k6(query='histogram_quantile(0.95)',metric='http_req_duration',test_run_count=10)\"\n```\n\nResults include `test_run_id` as a label so you can distinguish runs in\nthe response.\n\n---\n\n## Selector syntax\n\nSelectors look like `metric_name{label_op=\"value\", ...}`. Operators:\n\n| Operator | Meaning |\n|----------|---------------------|\n| `=` | Exact match |\n| `!=` | Not equal |\n| `=~` | Regex match |\n| `!~` | Regex non-match |\n\nExamples:\n\n```\nhttp_req_duration\nhttp_req_duration{status=\"200\"}\nhttp_req_duration{status=~\"(2|3)\\d\\d\"}\nhttp_req_duration{status!=\"0\"}\nhttp_req_duration{status!=\"0\",name=\"login page\"}\n```\n\n---\n\n## Query methods\n\nThe valid methods depend on the metric's `type`. Pick from the matching\ntable below; passing a counter method to a trend metric (or vice versa)\nwill return an error or empty result.\n\n### Counter methods\n\n| Method | Description |\n|--------------------------|--------------------------------------------|\n| `increase` | Total increase across the window |\n| `rate` | Average increase per second |\n| `value` | Current counter value |\n| `cumrate` | Cumulative rate from test start |\n| `max_rate(\u003cinterval>)` | Peak rate within a sliding window (seconds) |\n\n### Gauge methods\n\n| Method | Description |\n|-------------|--------------------------------------|\n| `last` | Last observed value |\n| `min` | Minimum observed value |\n| `max` | Maximum observed value |\n| `avg` | Average of observations |\n| `last_time` | Timestamp of the last observation |\n\n### Rate methods\n\nFor rate metrics (true/false observations like `check{...}`):\n\n| Method | Description |\n|-------------------|---------------------------------------------|\n| `increase_total` | Total of all observations |\n| `rate_total` | Avg rate per second of all observations |\n| `value_total` | Current total |\n| `increase_nz` | Total of **non-zero** (true) observations |\n| `rate_nz` | Avg rate per second of non-zero |\n| `value_nz` | Current non-zero value |\n| `increase_z` | Total of **zero** (false) observations |\n| `rate_z` | Avg rate per second of zero |\n| `value_z` | Current zero value |\n| `ratio` | Fraction of non-zero to total |\n\n### Trend methods\n\nFor latency/duration trend metrics:\n\n| Method | Description |\n|----------------------------------|-----------------------------------|\n| `histogram_max` | Max value |\n| `histogram_min` | Min value |\n| `histogram_avg` | Mean |\n| `histogram_stddev` | Standard deviation |\n| `histogram_quantile(\u003cq>)` | Quantile, `q` in `[0, 1]` |\n| `histogram_count_increase` | Increase in observation count |\n| `histogram_count_value` | Current observation count |\n\n---\n\n## Aggregation across labels\n\n### Keep selected labels\n\n`\u003cmethod> by (\u003clabel1>,\u003clabel2>,...)` returns one series per distinct\ncombination of the listed labels:\n\n```\nhistogram_quantile(0.95) by (name,status)\nmax by (instance_id)\n```\n\n### Cross-label aggregator\n\nWrap a method expression in an aggregator (`min`, `max`, `sum`, `avg`)\nto collapse across the inner labels, optionally regrouping with `by`:\n\n```\nsum(last by (instance_id)) # total of per-instance last values\navg(rate by (name, method)) by (method) # mean rate per method, across endpoints\n```\n\n---\n\n## Common metric names\n\n| Metric | Type | Meaning |\n|---------------------------------|----------|-------------------------------|\n| `http_req_duration` | trend | Request latency |\n| `http_reqs` | counter | Total HTTP requests |\n| `vus` | gauge | Virtual users in flight |\n| `data_sent` | counter | Bytes sent |\n| `iteration_duration` | trend | Time per iteration |\n| `load_generator_file_handles` | gauge | Open file handles on LG |\n| `load_generator_cpu_percent` | gauge | LG CPU usage |\n\nThe full list for a given run comes from endpoint §1\n(`/test_runs/{id}/metrics`); k6 emits more (and users can emit\ncustom metrics from their scripts).\n\n---\n\n## Worked queries\n\n```text\n# VU count over time\nquery_range_k6(metric='vus', query='sum(last by (instance_id))')\n\n# Requests per second\nquery_range_k6(metric='http_reqs', query='rate')\n\n# RPS excluding network errors, per endpoint+method\nquery_range_k6(metric='http_reqs{status!=\"0\"}', query='rate by (name,method)')\n\n# p95 response time, 10s resolution, per endpoint+method+status\nquery_range_k6(metric='http_req_duration', query='histogram_quantile(0.95) by (name,method,status)', step=10)\n\n# Total HTTP requests over the whole run\nquery_aggregate_k6(metric='http_reqs', query='increase')\n\n# Total requests in a sub-window\nquery_aggregate_k6(metric='http_reqs', query='increase', start=2021-08-09T12:01:10Z, end=2021-08-09T12:09:13Z)\n\n# p95 latency per endpoint+method+status\nquery_aggregate_k6(metric='http_req_duration', query='histogram_quantile(0.95) by (name,method,status)')\n\n# Peak RPS (5s windows) for 2xx/3xx by endpoint\nquery_aggregate_k6(metric='http_reqs{status=~\"[23][0-9]{2}\"}', query='max_rate(5) by (name)')\n\n# Peak CPU per LG, averaged across LGs\nquery_aggregate_k6(metric='load_generator_cpu_percent', query='avg(max by (instance))')\n\n# Peak VUs across the run\nquery_aggregate_k6(metric='vus', query='sum(max by (instance_id))')\n```\n\n---\n\n## Gotchas\n\n- **OData function-call syntax, not query strings.** `query`, `metric`,\n `step`, `start`, `end`, `test_run_count`, `test_run_ids` go *inside*\n the parens, comma-separated. They are not URL query params and\n putting them after a `?` will fail.\n- **Mind the shell quoting.** Single quotes around string arguments are\n part of the URL; wrap the whole `gcx api` argument in double quotes\n so they survive.\n- **Method must match metric type.** `histogram_quantile` on a counter,\n or `rate` on a trend, will return nothing useful. Check the metric's\n `type` via endpoint §1 first.\n- **Multi-run type collisions.** When listing metrics across runs (§2),\n if the metric's type differs between runs you only see the latest\n run's type. Aggregations using methods from the older type silently\n break.\n- **Use `test_run_id` to distinguish in multi-run responses.** It\n appears as a label so you can split results by run.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14093,"content_sha256":"171baaef7a6313b3301690dda7f60f835d6a20e38953af09d614725fdc1bc0f7"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Grafana Cloud k6 — interaction reference","type":"text"}]},{"type":"paragraph","content":[{"text":"The default path is the ","type":"text"},{"text":"gcx","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI. When gcx isn't installed, every endpoint here is still reachable via direct curl against k6's public hosts — see §1.2 for the auth-header and host-translation rules. Two principles shape the rest:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gcx owns Grafana-side auth (when present).","type":"text","marks":[{"type":"strong"}]},{"text":" It injects the right headers on every call, so you should not set auth headers yourself. The only header you ever set by hand is ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":", on Loki log queries (§4), browser screenshot/file fetches (§6), and Tempo trace queries (§7) — anything else gets overwritten or causes conflicts. In curl mode the auth headers are manual; see §1.2.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reach for ","type":"text","marks":[{"type":"strong"}]},{"text":"gcx k6 ...","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" subcommands first.","type":"text","marks":[{"type":"strong"}]},{"text":" They wrap the common paths with friendlier ergonomics and handle pagination. Discover what's available with ","type":"text"},{"text":"gcx help-tree k6","type":"text","marks":[{"type":"code_inline"}]},{"text":" (and drill in further with ","type":"text"},{"text":"gcx help-tree k6 \u003csubcommand>","type":"text","marks":[{"type":"code_inline"}]},{"text":"); fall back to ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" only when no subcommand exists for what you need.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"1. Authentication","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1.1 With gcx (default)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcx login --context \u003cctx> # one-time OAuth, browser flow\ngcx --context \u003cctx> config check # expect \"✔ Connectivity: online\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Once a context is logged in, every ","type":"text"},{"text":"gcx api ...","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"gcx k6 ...","type":"text","marks":[{"type":"code_inline"}]},{"text":" call inherits its auth state. If a call returns ","type":"text"},{"text":"\"Invalid or expired token — run gcx login to refresh\"","type":"text","marks":[{"type":"em"}]},{"text":", the OAuth session has lapsed — re-run ","type":"text"},{"text":"gcx login --context \u003cctx>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1.2 Without gcx — direct curl","type":"text"}]},{"type":"paragraph","content":[{"text":"Check ","type":"text"},{"text":"command -v gcx","type":"text","marks":[{"type":"code_inline"}]},{"text":" first. If it's missing, every endpoint in this skill is still reachable directly against k6's public hosts — three things change versus the gcx examples elsewhere:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Auth headers are manual.","type":"text","marks":[{"type":"strong"}]},{"text":" Set both on every call:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Authorization: Bearer \u003ck6_token>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"X-Stack-ID: \u003cint>","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hosts replace the plugin proxy.","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"REST (","type":"text"},{"text":"/cloud/v6/...","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"/cloud/v5/...","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"/cloud-resources/v1/...","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"/insights/...","type":"text","marks":[{"type":"code_inline"}]},{"text":") → ","type":"text"},{"text":"https://api.k6.io","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Logs (Loki) and traces (Tempo) → ","type":"text"},{"text":"https://cloudlogs.k6.io","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No ","type":"text","marks":[{"type":"strong"}]},{"text":"/api/plugins/k6-app/resources/{cloud,logs,insights}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" prefix.","type":"text","marks":[{"type":"strong"}]},{"text":" Drop it; everything after that prefix in the gcx examples is the real k6 path. The doubled ","type":"text"},{"text":"cloud/cloud/","type":"text","marks":[{"type":"code_inline"}]},{"text":" quirk from §2 collapses to a single ","type":"text"},{"text":"/cloud/","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the first one was just the proxy route.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Obtaining the credentials","type":"text"}]},{"type":"paragraph","content":[{"text":"Don't guess these — prompt the user once per session for:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"k6 API token","type":"text","marks":[{"type":"strong"}]},{"text":" — long-lived bearer; the same value ","type":"text"},{"text":"gcx k6 auth token","type":"text","marks":[{"type":"code_inline"}]},{"text":" would print when gcx is configured.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stack","type":"text","marks":[{"type":"strong"}]},{"text":" — either the integer ","type":"text"},{"text":"stack ID","type":"text","marks":[{"type":"strong"}]},{"text":" (used directly in ","type":"text"},{"text":"X-Stack-ID","type":"text","marks":[{"type":"code_inline"}]},{"text":") or a Grafana ","type":"text"},{"text":"stack URL","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. ","type":"text"},{"text":"https://myorg.grafana.net","type":"text","marks":[{"type":"code_inline"}]},{"text":"). If the user supplies a URL, resolve it to an ID once with ","type":"text"},{"text":"GET /cloud/v6/auth","type":"text","marks":[{"type":"code_inline"}]},{"text":", which takes the URL in the ","type":"text"},{"text":"X-Stack-Url","type":"text","marks":[{"type":"code_inline"}]},{"text":" header and returns ","type":"text"},{"text":"{stack_id, default_project_id}","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"STACK_ID=$(curl -sS https://api.k6.io/cloud/v6/auth \\\n -H \"Authorization: Bearer $K6_TOKEN\" \\\n -H \"X-Stack-Url: $STACK_URL\" \\\n | jq -r '.stack_id')","type":"text"}]},{"type":"paragraph","content":[{"text":"Cache the resolved ID for the session — every subsequent call needs it in ","type":"text"},{"text":"X-Stack-ID","type":"text","marks":[{"type":"code_inline"}]},{"text":". (Note: ","type":"text"},{"text":"/cloud/v6/auth","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the ","type":"text"},{"text":"only","type":"text","marks":[{"type":"em"}]},{"text":" endpoint that takes ","type":"text"},{"text":"X-Stack-Url","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of ","type":"text"},{"text":"X-Stack-ID","type":"text","marks":[{"type":"code_inline"}]},{"text":" — it's how you cross the gap from \"user-known URL\" to \"API-required ID\".)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Translation cheat-sheet","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx form (plugin proxy)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl form (direct)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/123","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://api.k6.io/cloud/v6/test_runs/123 -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/\u003cid>/metrics","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://api.k6.io/cloud/v5/test_runs/\u003cid>/metrics -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/index","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://api.k6.io/cloud-resources/v1/files/index -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/insights/insights/api/v1/testrun/\u003cid>/executions","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://api.k6.io/insights/api/v1/testrun/\u003cid>/executions -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/logs/api/v1/query_range?...","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://cloudlogs.k6.io/api/v1/query_range?... -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/logs/api/v1/tempo/api/search?...","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"curl https://cloudlogs.k6.io/api/v1/tempo/api/search?... -H ...","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"In every ","type":"text"},{"text":"-H ...","type":"text","marks":[{"type":"code_inline"}]},{"text":" slot above, send both auth headers: ","type":"text"},{"text":"-H \"Authorization: Bearer $K6_TOKEN\" -H \"X-Stack-ID: $STACK_ID\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Notes:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Endpoint-specific headers gcx leaves to you — ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":" on log, trace, and files endpoints (§4, §6, §7) — are still required ","type":"text"},{"text":"in addition","type":"text","marks":[{"type":"em"}]},{"text":" to the auth pair.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag quirks in §2 (spill envelope, ","type":"text"},{"text":"--json field","type":"text","marks":[{"type":"code_inline"}]},{"text":" filtering, ","type":"text"},{"text":"-o","type":"text","marks":[{"type":"code_inline"}]},{"text":" for output format) don't apply to curl. Use plain curl flags: ","type":"text"},{"text":"-o file","type":"text","marks":[{"type":"code_inline"}]},{"text":" to save body, ","type":"text"},{"text":"--data-binary @file","type":"text","marks":[{"type":"code_inline"}]},{"text":" for PUT payloads, ","type":"text"},{"text":"-w '%{http_code}'","type":"text","marks":[{"type":"code_inline"}]},{"text":" for status code, etc.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pagination semantics (","type":"text"},{"text":"$orderby","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"$top","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"$skip","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" from §3) are properties of the v6 endpoints themselves and work identically over curl. The ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" URL returned by the server is already an absolute ","type":"text"},{"text":"https://api.k6.io/...","type":"text","marks":[{"type":"code_inline"}]},{"text":" URL — pass it back to curl unchanged; the plugin-proxy reshape in §3 isn't needed.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"2. How ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" paths are shaped","type":"text"}]},{"type":"paragraph","content":[{"text":"When no subcommand exists, fall back to ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" against the Grafana plugin-proxy routes:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"REST API","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"/cloud/v6/","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"/cloud/v5/","type":"text","marks":[{"type":"code_inline"}]},{"text":") — prefix with ","type":"text"},{"text":"/api/plugins/k6-app/resources/cloud/\u003ck6-path>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Logs (Loki)","type":"text","marks":[{"type":"strong"}]},{"text":" — prefix with ","type":"text"},{"text":"/api/plugins/k6-app/resources/logs/\u003cloki-path>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"k6 path","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx invocation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/cloud/v6/test_runs/{id}","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/{id}","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/cloud/v5/test_runs/{id}/metrics","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/{id}/metrics","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Loki ","type":"text"},{"text":"/api/v1/query_range?...","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx api /api/plugins/k6-app/resources/logs/api/v1/query_range?...","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Note the doubled ","type":"text"},{"text":"cloud/cloud/","type":"text","marks":[{"type":"code_inline"}]},{"text":" in every REST path — the first ","type":"text"},{"text":"cloud","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the proxy route, the second is k6's ","type":"text"},{"text":"/cloud/v{N}/","type":"text","marks":[{"type":"code_inline"}]},{"text":" namespace.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag quirks","type":"text"}]},{"type":"paragraph","content":[{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" is not a curl clone — a few flags differ from what curl muscle memory suggests:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Response body","type":"text","marks":[{"type":"strong"}]},{"text":" is written to stdout. There is no ","type":"text"},{"text":"-o \u003cfile>","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag for saving the body; ","type":"text"},{"text":"-o","type":"text","marks":[{"type":"code_inline"}]},{"text":" selects output ","type":"text"},{"text":"format","type":"text","marks":[{"type":"em"}]},{"text":" (","type":"text"},{"text":"json","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"agents","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Use shell redirection (","type":"text"},{"text":"> file","type":"text","marks":[{"type":"code_inline"}]},{"text":") or ","type":"text"},{"text":"$(...)","type":"text","marks":[{"type":"code_inline"}]},{"text":" capture instead.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Request body","type":"text","marks":[{"type":"strong"}]},{"text":" uses ","type":"text"},{"text":"-d \u003cstring>","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"-d @file","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"-d @-","type":"text","marks":[{"type":"code_inline"}]},{"text":" (stdin). There is no ","type":"text"},{"text":"--data-binary","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"-d @file","type":"text","marks":[{"type":"code_inline"}]},{"text":" already preserves bytes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Field selection without jq","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"--json field1,field2,...","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns only the listed fields, and ","type":"text"},{"text":"--json list","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"--json '?'","type":"text","marks":[{"type":"code_inline"}]},{"text":") discovers what's available. Often cleaner than piping into jq for shallow extractions.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stderr noise","type":"text","marks":[{"type":"strong"}]},{"text":": gcx prints a one-line ","type":"text"},{"text":"hint:","type":"text","marks":[{"type":"code_inline"}]},{"text":" to stderr on most invocations. Pipelines into ","type":"text"},{"text":"jq","type":"text","marks":[{"type":"code_inline"}]},{"text":" should redirect with ","type":"text"},{"text":"2>/dev/null","type":"text","marks":[{"type":"code_inline"}]},{"text":" to avoid surprises.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Response headers","type":"text","marks":[{"type":"strong"}]},{"text":" are not directly exposed by ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":". When a branch in the workflow hinges on ","type":"text"},{"text":"Content-Type","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g. script GET in §5), inspect the downloaded body with ","type":"text"},{"text":"file \u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Large responses spill to a temp file","type":"text","marks":[{"type":"strong"}]},{"text":", with stdout reduced to a wrapper envelope. The threshold is a few tens of KB, so even moderately-sized list endpoints trigger it. The envelope looks like this:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\"spilled_to\":\"/var/folders/.../gcx-results-\u003cn>.json\",\n \"bytes\":2365037,\n \"preview_sample\":[\"@nextLink\",\"value\"],\n \"message\":\"Response too large for stdout...\"}","type":"text"}]},{"type":"paragraph","content":[{"text":"Naive ","type":"text"},{"text":"json.loads(stdout).get('value', [])","type":"text","marks":[{"type":"code_inline"}]},{"text":" patterns silently return empty in this case — the envelope has no ","type":"text"},{"text":"value","type":"text","marks":[{"type":"code_inline"}]},{"text":" key. Two reliable fixes:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pass ","type":"text","marks":[{"type":"strong"}]},{"text":"-o json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" to force inline output regardless of size (recommended for scripts that parse the body). The agent-mode default formatting is what triggers the spill; ","type":"text"},{"text":"-o json","type":"text","marks":[{"type":"code_inline"}]},{"text":" opts out.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Or detect the envelope and re-read from the spilled file","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"d = json.loads(stdout)\nif 'spilled_to' in d:\n d = json.load(open(d['spilled_to']))","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Either works; ","type":"text"},{"text":"-o json","type":"text","marks":[{"type":"code_inline"}]},{"text":" is fewer lines.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The Grafana plugin proxy rewrites ","type":"text","marks":[{"type":"strong"}]},{"text":"Content-Type: multipart/...","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" to ","type":"text","marks":[{"type":"strong"}]},{"text":"application/json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" itself forwards your ","type":"text"},{"text":"-H \"Content-Type: ...\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" header correctly (visible in ","type":"text"},{"text":"--log-http-payload","type":"text","marks":[{"type":"code_inline"}]},{"text":" traces), but the upstream plugin proxy rewrites multipart Content-Types to JSON before they reach k6's API. The net effect: endpoints that require multipart bodies — notably ","type":"text"},{"text":"POST /cloud/v6/projects/{id}/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":" for test creation, which takes ","type":"text"},{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"script","type":"text","marks":[{"type":"code_inline"}]},{"text":" as form parts — return ","type":"text"},{"text":"HTTP 415 \"Unsupported media type \\\"application/json\\\"\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" no matter what header you set on the gcx side. Fall back to direct curl against ","type":"text"},{"text":"api.k6.io","type":"text","marks":[{"type":"code_inline"}]},{"text":" (§1.2) for these endpoints — ","type":"text"},{"text":"curl -F name=... -F script=@...","type":"text","marks":[{"type":"code_inline"}]},{"text":" builds the multipart body for you and bypasses the plugin proxy. Other Content-Type values (e.g. ","type":"text"},{"text":"application/octet-stream","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the script-update PUT in §5) are forwarded through the proxy unchanged.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"3. Discovering and calling endpoints","type":"text"}]},{"type":"paragraph","content":[{"text":"gcx k6","type":"text","marks":[{"type":"code_inline"}]},{"text":" subcommands (see ","type":"text"},{"text":"gcx help-tree k6","type":"text","marks":[{"type":"code_inline"}]},{"text":") cover the common reads. For everything else — mutations, niche reads, endpoints not surfaced as subcommands — the k6 Cloud API surface is large and changes over time. Rather than rely on a cheat-sheet that goes stale, discover the operation you need from the OpenAPI spec at request time.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Workflow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fetch the spec once per session","type":"text","marks":[{"type":"strong"}]},{"text":" (it's large; cache to ","type":"text"},{"text":"/tmp","type":"text","marks":[{"type":"code_inline"}]},{"text":"):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/openapi > /tmp/k6-openapi.json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Index by ","type":"text","marks":[{"type":"strong"}]},{"text":"operationId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" + ","type":"text","marks":[{"type":"strong"}]},{"text":"description","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" first","type":"text","marks":[{"type":"strong"}]},{"text":" — this keeps the working payload small while you search:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"jq '[.paths | to_entries[] | .key as $p | .value | to_entries[]\n | {path: $p, method: .key,\n operationId: .value.operationId,\n description: .value.description}]' /tmp/k6-openapi.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Grep this for the action you need (\"abort\", \"limits\", \"schedule\", …).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pull the chosen operation's full schema","type":"text","marks":[{"type":"strong"}]},{"text":" — parameters, request body, response — resolving any ","type":"text"},{"text":"$ref","type":"text","marks":[{"type":"code_inline"}]},{"text":" indirections:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"jq '.paths[\"\u003cpath>\"][\"\u003cmethod>\"]' /tmp/k6-openapi.json\n# Then for each \"$ref\": \"#/components/schemas/Foo\":\njq '.components.schemas.Foo' /tmp/k6-openapi.json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Build the ","type":"text","marks":[{"type":"strong"}]},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" call","type":"text","marks":[{"type":"strong"}]},{"text":" against the same path, prefixed per §2. Authorization is already injected by gcx — do not add it yourself.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"List requests","type":"text"}]},{"type":"paragraph","content":[{"text":"When the operation you're calling is a list endpoint, default to ","type":"text"},{"text":"ordering by ","type":"text","marks":[{"type":"strong"}]},{"text":"created","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" descending","type":"text","marks":[{"type":"strong"}]},{"text":" (newest first) and ","type":"text"},{"text":"paginating by 20","type":"text","marks":[{"type":"strong"}]},{"text":" — both via whatever query parameter names the OpenAPI schema for that operation exposes. Names vary (","type":"text"},{"text":"ordering=-created","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"order_by=created.desc","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sort=-created","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"limit=20","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"page_size=20","type":"text","marks":[{"type":"code_inline"}]},{"text":"), which is exactly why discovering them from the spec in step 3 matters. Newest-first means the entries the user usually cares about land in the first page; a page size of 20 keeps the response small enough to summarise without burning context.","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Full enumeration: paginating with ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"When you need to enumerate ","type":"text"},{"text":"all","type":"text","marks":[{"type":"em"}]},{"text":" rows (not just the newest page), the v6 list endpoints cap each response at ","type":"text"},{"text":"1000 rows","type":"text","marks":[{"type":"strong"}]},{"text":" (the default ","type":"text"},{"text":"$top","type":"text","marks":[{"type":"code_inline"}]},{"text":") and return an ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" field pointing at the next page. ","type":"text"},{"text":"gcx k6 runs list --limit 0","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" does NOT auto-follow","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" and defaults to ascending order — for tests with >1000 historical runs it silently returns the ","type":"text"},{"text":"oldest","type":"text","marks":[{"type":"strong"}]},{"text":" 1000, not the newest. The practical effect: ","type":"text"},{"text":"(first/last_run)","type":"text","marks":[{"type":"code_inline"}]},{"text":" summarised from this output can be wildly stale (e.g. a daily-scheduled test that's been running for years will show a ","type":"text"},{"text":"last_run","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ~3 years ago). Use ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" against the v6 endpoint with ","type":"text"},{"text":"$orderby=created desc","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the newest-first slice, or loop on ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" until it's absent for full enumeration.","type":"text"}]},{"type":"paragraph","content":[{"text":"The v6 list endpoints expose OData-style query parameters:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Param","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"$orderby","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ordering — value must be ","type":"text"},{"text":"\u003cfield> \u003cdirection>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g. ","type":"text"},{"text":"created desc","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The allowed fields are operation-specific; ","type":"text"},{"text":"created","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the common one. Default is ascending.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"$top","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Page size, default 1000 (also the cap)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"$skip","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Offset (","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" already encodes this for you)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"$count","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Include total count in response","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Two practical defaults for the loop: ","type":"text"},{"text":"$orderby=created desc","type":"text","marks":[{"type":"code_inline"}]},{"text":" so the first page contains the newest runs (most of the time the agent only cares about those), and ","type":"text"},{"text":"-o json","type":"text","marks":[{"type":"code_inline"}]},{"text":" to opt out of the spill envelope (see §2) so the parsing is uniform across pages.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"import json, subprocess, urllib.parse\n\nCTX = \"\u003cstack>\"\nTEST_ID = \"\u003ctest_id>\"\nPROXY = \"/api/plugins/k6-app/resources/cloud\"\n\nall_runs = []\n# Start with newest first; -o json keeps the body inline for clean json.loads().\npath = (\n f\"{PROXY}/cloud/v6/load_tests/{TEST_ID}/test_runs\"\n \"?%24orderby=created%20desc&%24top=1000\"\n)\nwhile path:\n r = subprocess.run([\"gcx\", \"--context\", CTX, \"api\", path, \"-o\", \"json\"],\n capture_output=True, text=True, timeout=60)\n d = json.loads(r.stdout)\n all_runs.extend(d.get(\"value\", []))\n nxt = d.get(\"@nextLink\")\n if not nxt:\n path = None\n continue\n # @nextLink comes back as an absolute http://api.k6.io/... URL with\n # OData params already encoded ($skip, $top, $orderby). Reshape its\n # path+query into the plugin-proxy prefix:\n p = urllib.parse.urlparse(nxt)\n path = f\"{PROXY}{p.path}\" + (f\"?{p.query}\" if p.query else \"\")","type":"text"}]},{"type":"paragraph","content":[{"text":"Same pattern works for any v6 list endpoint that returns ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":", not just ","type":"text"},{"text":"/test_runs","type":"text","marks":[{"type":"code_inline"}]},{"text":". If you only need the few most-recent items, stop the loop after the first page — with ","type":"text"},{"text":"$orderby=created desc","type":"text","marks":[{"type":"code_inline"}]},{"text":" that page is already the newest 1000.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Metrics","type":"text"}]},{"type":"paragraph","content":[{"text":"Time-series and aggregate metrics live on the ","type":"text"},{"text":"v5","type":"text","marks":[{"type":"strong"}]},{"text":" API (","type":"text"},{"text":"/cloud/v5/...","type":"text","marks":[{"type":"code_inline"}]},{"text":") — Prometheus-like query semantics inside OData-style URL function-calls (","type":"text"},{"text":"query_range_k6(query='...',metric='...')","type":"text","marks":[{"type":"code_inline"}]},{"text":"), not captured by the v6 OpenAPI. The full endpoint reference, selector syntax, query methods per metric type, and worked examples are inlined in ","type":"text"},{"text":"references/metrics.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/metrics.md","title":null}},{"type":"code_inline"}]},{"text":" — read that before constructing a metrics query.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Logs","type":"text"}]},{"type":"paragraph","content":[{"text":"See §4.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"4. Logs (Loki via ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"paragraph","content":[{"text":"The plugin proxies Loki under ","type":"text"},{"text":"/api/plugins/k6-app/resources/logs/","type":"text","marks":[{"type":"code_inline"}]},{"text":". The only header you supply by hand is ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":"; gcx handles authorization.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"RUN_ID=\"\u003crun_id>\"\n\n# Scope the log window to the run itself: `created` for start, `ended`\n# for end. If `ended` is null the run is still in progress — fall back\n# to \"now\". Use a wider hard-coded window only when the run object's\n# timestamps don't fit what you're investigating.\nRUN=$(gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID 2>/dev/null)\n# k6 emits sub-second precision (e.g. \"...:58.984041Z\"); jq's\n# fromdateiso8601 only accepts whole seconds, so strip the fraction.\nSTART=$(echo \"$RUN\" | jq -r '.created | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601')\nEND=$(echo \"$RUN\" | jq -r 'if .ended then (.ended | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) else (now | floor) end')\n\nQUERY=$(printf '{test_run_id=\"%s\"}' \"$RUN_ID\" | jq -sRr @uri)\n\n# Save the raw response to /tmp/ — log payloads can be large; streaming\n# them inline burns context. Summarise from the file, then jq into\n# specific entries only when needed.\ngcx api \"/api/plugins/k6-app/resources/logs/api/v1/query_range?query=${QUERY}&direction=backward&start=${START}&end=${END}&limit=1000\" \\\n -H \"X-K6TestRun-Id: $RUN_ID\" 2>/dev/null > /tmp/run_${RUN_ID}_logs.json\n\n# Tight summary — counts and stream labels only:\njq '{status, streams: (.data.result | length),\n total_entries: ([.data.result[].values | length] | add),\n stream_labels: [.data.result[].stream]}' /tmp/run_${RUN_ID}_logs.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Pull individual entries from ","type":"text"},{"text":"/tmp/run_${RUN_ID}_logs.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" as needed (e.g. ","type":"text"},{"text":"jq '.data.result[].values[]' …","type":"text","marks":[{"type":"code_inline"}]},{"text":") rather than re-running the query.","type":"text"}]},{"type":"paragraph","content":[{"text":"Every LogQL query ","type":"text"},{"text":"must","type":"text","marks":[{"type":"strong"}]},{"text":" include the ","type":"text"},{"text":"{test_run_id=\"\u003crun_id>\"}","type":"text","marks":[{"type":"code_inline"}]},{"text":" stream selector — the plugin proxy partitions logs by run and rejects (or returns nothing for) queries without it. Layer additional filters on top of that selector:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{test_run_id=\"\u003crun_id>\"}","type":"text","marks":[{"type":"code_inline"}]},{"text":" — everything","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{test_run_id=\"\u003crun_id>\"} | level=~\"(error|warn)\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — errors and warnings only","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"{test_run_id=\"\u003crun_id>\"} |= \"specific text\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — substring filter","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Direction & window tips:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scope ","type":"text","marks":[{"type":"strong"}]},{"text":"start","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"/","type":"text","marks":[{"type":"strong"}]},{"text":"end","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" to the run's ","type":"text","marks":[{"type":"strong"}]},{"text":"created","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"/","type":"text","marks":[{"type":"strong"}]},{"text":"ended","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" fields","type":"text","marks":[{"type":"strong"}]},{"text":", not to wall-clock \"last hour\" — otherwise queries silently miss anything older than the window. The only reason to deviate is when you specifically want a different interval (e.g. surrounding context).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"direction=forward","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"start = run.created","type":"text","marks":[{"type":"code_inline"}]},{"text":" to find the ","type":"text"},{"text":"first","type":"text","marks":[{"type":"em"}]},{"text":" errors.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"direction=backward","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"end = run.ended","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or now, if still running) to find the ","type":"text"},{"text":"most recent","type":"text","marks":[{"type":"em"}]},{"text":" output.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Loki retention typically outlives k6's cascade-delete, so logs of a deleted child run may still be queryable for a while.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"5. Editing a test script safely","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Two distinct script endpoints","type":"text"}]},{"type":"paragraph","content":[{"text":"There are two GET endpoints that return a k6 script body, and they are not interchangeable:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Endpoint","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What it returns","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When to use","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/cloud/v6/load_tests/\u003ctest_id>/script","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"current","type":"text","marks":[{"type":"strong"}]},{"text":" load-test script (mutable; supports PUT)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Editing the script; reading current state","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/cloud/v6/test_runs/\u003crun_id>/script","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The script ","type":"text"},{"text":"snapshot bundled into a specific run","type":"text","marks":[{"type":"strong"}]},{"text":" (read-only)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Investigating what a past run actually executed; diffing across versions","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The two can drift apart: if the load-test script is edited after a run completes, that run's bundled snapshot stays frozen at what it executed, while the load-test endpoint serves the new current version. The bundled-snapshot endpoint is GET-only — you cannot mutate historical bytes.","type":"text"}]},{"type":"paragraph","content":[{"text":"The safe-edit recipe below uses the load-test endpoint (the editable one). For run-vs-run script diffs (e.g. \"did the script change between the last passing and first failing run?\"), GET both runs' ","type":"text"},{"text":"/test_runs/\u003cid>/script","type":"text","marks":[{"type":"code_inline"}]},{"text":" and diff them locally.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Script body format","type":"text"}]},{"type":"paragraph","content":[{"text":"The script GET endpoint returns one of two shapes — always detect which before assuming the format:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Single js/ts file","type":"text","marks":[{"type":"strong"}]},{"text":". The recipe below handles this case directly.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"k6 tar archive","type":"text","marks":[{"type":"strong"}]},{"text":" (plain tar or gzipped). A multi-file project bundle (entry script + imported modules + assets). To edit: extract the JS file, modify it, then use ","type":"text"},{"text":"k6 archive","type":"text","marks":[{"type":"code_inline"}]},{"text":" to rebuild the archive from the modified JS. Do ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" manually repack with ","type":"text"},{"text":"tar","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the archive contains a ","type":"text"},{"text":"metadata.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with parsed options (projectID, thresholds, scenarios) that ","type":"text"},{"text":"k6 archive","type":"text","marks":[{"type":"code_inline"}]},{"text":" regenerates correctly from the script source. Manual repacking preserves stale metadata and causes runtime errors (e.g. projectID mismatch).","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Edit a k6 tar archive\ntar -xf /tmp/script_body -C /tmp/extracted/ # extract\n# ... edit the JS file ...\nk6 archive /tmp/edited_script.js -O /tmp/new_archive.tar # rebuild","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" doesn't expose response headers (see §2), so detect the shape by inspecting the downloaded body with ","type":"text"},{"text":"file(1)","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \\\n > /tmp/script_body\nfile /tmp/script_body\n# Expected output (examples):\n# ASCII text → js/ts source\n# POSIX tar archive → k6 tar bundle\n# gzip compressed data → gzipped tar bundle","type":"text"}]},{"type":"paragraph","content":[{"text":"The PUT body must be the raw script (or archive) bytes (","type":"text"},{"text":"application/octet-stream","type":"text","marks":[{"type":"code_inline"}]},{"text":"). gcx forwards the request to k6 unchanged.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ID=\"\u003cload_test_id>\"\n\n# 1. Pull current script (single-file js/ts case)\ngcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \\\n > /tmp/script_current.js\n\n# 2. Backup\ncp /tmp/script_current.js /tmp/script_backup_$(date +%s).js\n\n# 3. Edit → /tmp/script_new.js\n\n# 4. Local parse-only sanity check\nk6 inspect /tmp/script_new.js | head -20\n\n# 5. 1-iteration smoke (edit IN-FILE; --iterations CLI flag breaks browser scenarios)\nsed 's/iterations: [0-9]\\+/iterations: 1/' /tmp/script_new.js > /tmp/script_1iter.js\nk6 run --quiet /tmp/script_1iter.js # exit 0 = pass, 99 = threshold fail\n\n# 6. PUT — Content-Type must be application/octet-stream\ngcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \\\n -X PUT \\\n -H \"Content-Type: application/octet-stream\" \\\n -d \"@/tmp/script_new.js\"\n\n# 7. Verify by re-fetching and comparing sha256 — the two hashes MUST match.\n# Do not rely on the `updated` timestamp; it does not bump on script PUT.\ngcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \\\n > /tmp/script_verify.js\nshasum -a 256 /tmp/script_new.js /tmp/script_verify.js","type":"text"}]},{"type":"paragraph","content":[{"text":"If the PUT returns 415, double-check that ","type":"text"},{"text":"-H \"Content-Type: application/octet-stream\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" made it through (some shell quoting mistakes can drop it). Pass ","type":"text"},{"text":"-vvv","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the request/response trace when debugging.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"6. Browser screenshots","type":"text"}]},{"type":"paragraph","content":[{"text":"Browser-module runs write screenshot PNGs to per-run S3 storage — that's the only artifact type the files API surfaces today. They're not exposed via ","type":"text"},{"text":"/cloud/v6/","type":"text","marks":[{"type":"code_inline"}]},{"text":"; retrieval goes through a separate ","type":"text"},{"text":"cloud-resources/v1/files/","type":"text","marks":[{"type":"code_inline"}]},{"text":" plugin route in a two-step flow. Both calls require the same ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":" header used for log queries (§4) — gcx still handles auth, but the run ID must be supplied by hand or the endpoint rejects with HTTP 422.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"RUN_ID=\"\u003crun_id>\"\n\n# 1. Index — JSON array of screenshot paths owned by this run. Empty\n# array if the run took none (e.g. non-browser tests, or browser\n# runs that didn't call page.screenshot()).\ngcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/index \\\n -H \"X-K6TestRun-Id: $RUN_ID\" -o json 2>/dev/null > /tmp/files_${RUN_ID}.json\n\n# Each entry looks like:\n# \"7542817/files/screenshots/screenshots/login-success.png\"\n# The doubled \"screenshots/screenshots/\" segment is real, not a typo —\n# pass the path back unchanged to step 2.\n\n# 2. Request pre-signed download URLs for the files you want. The\n# request body must be JSON; Content-Type matters.\nFILES=$(jq -c '[.[] | {name: .}]' /tmp/files_${RUN_ID}.json)\nPAYLOAD=$(jq -nc --argjson files \"$FILES\" \\\n '{service:\"aws_s3\", operation:\"download\", files:$files}')\n\ngcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/generate-pre-signed-url \\\n -X POST \\\n -H \"X-K6TestRun-Id: $RUN_ID\" \\\n -H \"Content-Type: application/json\" \\\n -d \"$PAYLOAD\" -o json 2>/dev/null > /tmp/presigned_${RUN_ID}.json\n\n# 3. Download each pre-signed URL directly — they're plain S3 GETs, no\n# gcx and no auth header (the signature is in the URL). The URLs\n# are valid for 24h (X-Amz-Expires=86400); re-run step 2 if they\n# expire.\nmkdir -p /tmp/run_${RUN_ID}_files\njq -r '.urls[] | [.name, .pre_signed_url] | @tsv' /tmp/presigned_${RUN_ID}.json |\n while IFS=

Grafana Cloud k6 — interaction reference The default path is the CLI. When gcx isn't installed, every endpoint here is still reachable via direct curl against k6's public hosts — see §1.2 for the auth-header and host-translation rules. Two principles shape the rest: - gcx owns Grafana-side auth (when present). It injects the right headers on every call, so you should not set auth headers yourself. The only header you ever set by hand is , on Loki log queries (§4), browser screenshot/file fetches (§6), and Tempo trace queries (§7) — anything else gets overwritten or causes conflicts. In curl m…

\\t' read -r NAME URL; do\n curl -sS -o \"/tmp/run_${RUN_ID}_files/$(basename \"$NAME\")\" \"$URL\"\n done","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Notes worth knowing","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Index before signing.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"generate-pre-signed-url","type":"text","marks":[{"type":"code_inline"}]},{"text":" does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"em"}]},{"text":" validate that the file exists — it will happily mint a URL for any key, and the 404 only surfaces when you try to download (S3 returns ","type":"text"},{"text":"\u003cError>\u003cCode>NoSuchKey\u003c/Code>...","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Always derive the file list from ","type":"text"},{"text":"files/index","type":"text","marks":[{"type":"code_inline"}]},{"text":", not from a guess at the path shape.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Batch the sign request.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"files","type":"text","marks":[{"type":"code_inline"}]},{"text":" is an array, so request all the URLs you need in one POST rather than one call per file — same 24h expiry covers the whole batch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Browser tests vs. protocol tests.","type":"text","marks":[{"type":"strong"}]},{"text":" Only runs that called ","type":"text"},{"text":"page.screenshot()","type":"text","marks":[{"type":"code_inline"}]},{"text":" have entries in the index; a pure protocol/HTTP run will return ","type":"text"},{"text":"[]","type":"text","marks":[{"type":"code_inline"}]},{"text":". Don't assume the index is non-empty.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"7. Browser traces (Tempo via ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"paragraph","content":[{"text":"Browser-module runs emit OTel spans for every iteration, navigation, locator click, screenshot, web-vital observation, etc. The plugin proxies a Tempo backend under ","type":"text"},{"text":"/api/plugins/k6-app/resources/logs/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (same prefix as Loki — see §4), and the same ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":" header is required on every call. Without it, both search and fetch return HTTP 401 ","type":"text"},{"text":"\"Test run ID missing\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Retrieval is a two-step flow: TraceQL search → fetch full trace by ID. The full trace is large (≈100 KB on disk for a 120-span iteration because ","type":"text"},{"text":"-o json","type":"text","marks":[{"type":"code_inline"}]},{"text":" pretty-prints; ~50 KB compact), so the join+summarise step is what keeps agent context lean.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"RUN_ID=\"\u003crun_id>\"\n\n# 1. Find the run's start/end window and pick the scenario name.\n# Same caveat as §4: strip the sub-second fraction before\n# fromdateiso8601, and fall back to \"now\" if the run is still\n# running. Add a small buffer because spans can land slightly\n# after the run object reports `ended`.\nRUN=$(gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID 2>/dev/null)\nSTART=$(echo \"$RUN\" | jq -r '.created | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601')\nEND=$( echo \"$RUN\" | jq -r 'if .ended then (.ended | sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601) else (now|floor) end')\nSTART=$((START - 60))\nEND=$((END + 60))\n\nSCENARIO=\"ui\" # whatever your scenario is named; check run.ui.exec / k6 script\n\n# 2. Search for iteration root spans. TraceQL goes in `q`; URL-encode\n# it. `start`/`end` must be unix seconds — RFC3339 returns\n# HTTP 400 `invalid start: strconv.ParseUint`. limit=500 caps the\n# result, enough to cover several thousand iterations.\nQ=$(printf '{ name = \"iteration\" && span.test.iteration.number >= 0 && span.test.vu >= 0 && span.test.scenario = \"%s\" }' \"$SCENARIO\")\nQE=$(printf '%s' \"$Q\" | jq -sRr @uri)\n\ngcx api \"/api/plugins/k6-app/resources/logs/api/v1/tempo/api/search?start=${START}&end=${END}&q=${QE}&limit=500\" \\\n -H \"X-K6TestRun-Id: $RUN_ID\" -o json 2>/dev/null > /tmp/traces_${RUN_ID}.json\n\n# Each entry has: traceID (32-char hex), rootServiceName (\"k6\"),\n# rootTraceName (\"iteration\"), durationMs, startTimeUnixNano, and a\n# spanSet preview of the iteration root span's attributes\n# (test.iteration.number, test.vu, test.scenario).\n\n# 3. Fetch a full trace. The response is OTLP-shaped:\n# { \"batches\": [ { \"resource\": {...}, \"scopeSpans\": [ { \"spans\": [...] } ] } ] }\nTRACE_ID=$(jq -r '.traces[0].traceID' /tmp/traces_${RUN_ID}.json)\ngcx api \"/api/plugins/k6-app/resources/logs/api/v1/tempo/api/traces/$TRACE_ID\" \\\n -H \"X-K6TestRun-Id: $RUN_ID\" -o json 2>/dev/null > /tmp/trace_${TRACE_ID}.json","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Compact summary for an agent","type":"text"}]},{"type":"paragraph","content":[{"text":"The OTLP body for a single iteration can run ~100 KB pretty-printed. Dumping it verbatim into context is wasteful — most of the useful signal lives in the span-name distribution, the slowest individual spans, and the web-vital ratings. The pipeline below collapses a trace into ~1–2 KB of plain text:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"jq -r '\n # OTel value envelope: pick by key presence, not //-chaining — jq's //\n # treats `false` as missing and would drop legitimate boolValue:false.\n def attrval:\n if has(\"stringValue\") then .stringValue\n elif has(\"intValue\") then .intValue\n elif has(\"boolValue\") then .boolValue\n elif has(\"doubleValue\") then .doubleValue\n else null end;\n def attrs: [.attributes[] | {(.key): (.value | attrval)}] | add // {};\n def dur: (((.endTimeUnixNano|tonumber) - (.startTimeUnixNano|tonumber)) / 1000000 | floor);\n\n [.batches[].scopeSpans[].spans[] | {name, dur: dur, a: attrs, status: (.status.code // \"OK\")}] as $spans\n\n | \"── span-name rollup (count · total ms · max ms) ──\",\n ([$spans[] | {name, dur}]\n | group_by(.name) | map({name: .[0].name, n: length,\n t: (map(.dur)|add), m: (map(.dur)|max)})\n | sort_by(-.t)[]\n | \" \\(.n|tostring|.+\" \"|.[0:4]) \\(.t|tostring|.+\" \"|.[0:7]) \\(.m|tostring|.+\" \"|.[0:6]) \\(.name)\"),\n\n \"\\n── slowest spans (ms · name · navigation.url / page.goto.url) ──\",\n ([$spans[] | select(.name != \"iteration\")] | sort_by(-.dur) | .[0:8][]\n | \" \\(.dur|tostring|.+\" \"|.[0:7]) \\(.name)\\(if .a[\"navigation.url\"] // .a[\"page.goto.url\"] then \" \" + (.a[\"navigation.url\"] // .a[\"page.goto.url\"]) else \"\" end)\"),\n\n \"\\n── web-vital ratings ──\",\n ([$spans[] | select(.name == \"web_vital\")\n | {n: .a[\"web_vital.name\"], v: .a[\"web_vital.value\"], r: .a[\"web_vital.rating\"]}]\n | group_by(.n) | map({name: .[0].n,\n ratings: ([.[].r] | group_by(.) | map({(.[0]): length}) | add)})[]\n | \" \\(.name) \\(.ratings)\"),\n\n \"\\n── span errors ──\",\n ([$spans[] | select(.status != \"OK\" and .status != null)] | length | \" count: \\(.)\")\n' /tmp/trace_${TRACE_ID}.json","type":"text"}]},{"type":"paragraph","content":[{"text":"On a run like 7542817 the output is something like:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"── span-name rollup (count · total ms · max ms) ──\n 1 154552 154552 iteration\n 37 153391 17746 navigation\n 11 21778 5168 page.waitForNavigation\n ...\n\n── slowest spans (ms · name · navigation.url / page.goto.url) ──\n 17746 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects\n 14992 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects/58195\n ...\n\n── web-vital ratings ──\n LCP {\"poor\":5,\"needs-improvement\":1}\n CLS {\"good\":2}\n ...","type":"text"}]},{"type":"paragraph","content":[{"text":"Two paragraphs of text capture which navigations are slow, which web vitals are regressing, and the overall span-name distribution — enough to reason about browser performance without paging through the OTLP tree.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Notes worth knowing","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Header is mandatory.","type":"text","marks":[{"type":"strong"}]},{"text":" Both ","type":"text"},{"text":"/search","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"/traces/\u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":" return HTTP 401 without ","type":"text"},{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":". The header scopes the query to the run's tenant.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"start","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"/","type":"text","marks":[{"type":"strong"}]},{"text":"end","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" are unix seconds.","type":"text","marks":[{"type":"strong"}]},{"text":" RFC3339 ISO strings get ","type":"text"},{"text":"HTTP 400 invalid start: strconv.ParseUint","type":"text","marks":[{"type":"code_inline"}]},{"text":". They're technically optional — search works without them — but providing a window matching the run's ","type":"text"},{"text":"created","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"ended","type":"text","marks":[{"type":"code_inline"}]},{"text":" (plus ~60s buffer) keeps the query fast and avoids matching unrelated runs that share the scenario name.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"TraceQL, not LogQL.","type":"text","marks":[{"type":"strong"}]},{"text":" The ","type":"text"},{"text":"q","type":"text","marks":[{"type":"code_inline"}]},{"text":" parameter uses TraceQL — span predicates are written as ","type":"text"},{"text":"span.\u003cattribute>","type":"text","marks":[{"type":"code_inline"}]},{"text":" and combined with ","type":"text"},{"text":"&&","type":"text","marks":[{"type":"code_inline"}]},{"text":". The suggested query filters iteration root spans for a specific scenario; broaden by dropping ","type":"text"},{"text":"test.scenario","type":"text","marks":[{"type":"code_inline"}]},{"text":", narrow by adding e.g. ","type":"text"},{"text":"span.test.vu = 3 && span.test.iteration.number = 5","type":"text","marks":[{"type":"code_inline"}]},{"text":" to pinpoint a single iteration on a single VU.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scenario names come from the run.","type":"text","marks":[{"type":"strong"}]},{"text":" They're the ","type":"text"},{"text":"keys","type":"text","marks":[{"type":"em"}]},{"text":" of ","type":"text"},{"text":".options.scenarios","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the run object — ","type":"text"},{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID --json options 2>/dev/null | jq -r '.options.scenarios | keys[]'","type":"text","marks":[{"type":"code_inline"}]},{"text":" lists them. The default scenario is named ","type":"text"},{"text":"default","type":"text","marks":[{"type":"code_inline"}]},{"text":"; browser tests often name it ","type":"text"},{"text":"ui","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"browser","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Span IDs in the OTLP body are base64.","type":"text","marks":[{"type":"strong"}]},{"text":" The ","type":"text"},{"text":"traceID","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the search response is hex (the form ","type":"text"},{"text":"/traces/\u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":" accepts); the inner ","type":"text"},{"text":"traceId","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"spanId","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"parentSpanId","type":"text","marks":[{"type":"code_inline"}]},{"text":" fields inside the OTLP batch are base64-encoded bytes. Cross-correlate parent/child via ","type":"text"},{"text":"parentSpanId == spanId","type":"text","marks":[{"type":"code_inline"}]},{"text":", both in base64 — no need to decode.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Useful attribute keys","type":"text","marks":[{"type":"strong"}]},{"text":" on browser-test spans: ","type":"text"},{"text":"navigation.url","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"page.goto.url","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"screenshot.path","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web_vital.name","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web_vital.value","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"web_vital.rating","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"test.scenario","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"test.vu","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"test.iteration.number","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"k6.test_run_id","type":"text","marks":[{"type":"code_inline"}]},{"text":". These are the ones worth surfacing in summaries; the OTel value envelope is ","type":"text"},{"text":"{stringValue|intValue|boolValue|doubleValue}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the jq ","type":"text"},{"text":"attrval","type":"text","marks":[{"type":"code_inline"}]},{"text":" helper above handles all four).","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"8. Cloud Insights (audit results for a run)","type":"text"}]},{"type":"paragraph","content":[{"text":"Cloud Insights runs heuristics against a finished test — checks for high cardinality, web-vital regressions, missing thresholds, overutilised load generators, and so on — and exposes the results through a separate ","type":"text"},{"text":"/resources/insights/","type":"text","marks":[{"type":"code_inline"}]},{"text":" plugin route (not ","type":"text"},{"text":"/cloud/v{N}/","type":"text","marks":[{"type":"code_inline"}]},{"text":"). gcx still handles auth; no extra header is needed.","type":"text"}]},{"type":"paragraph","content":[{"text":"Three calls produce the data, and the agent-useful output is a join across two of them:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"RUN_ID=\"\u003crun_id>\"\nBASE=\"/api/plugins/k6-app/resources/insights/insights/api/v1/testrun/$RUN_ID\"\n\n# 1. List executions for the run, take the most recent. The response\n# is { \"executions\": [ {id, version}, ... ] } — NOT a bare array.\n# Pick the last entry; insights re-runs append, and the newest one\n# reflects the current set of audits/scores.\nEXEC_ID=$(gcx api \"$BASE/executions\" -o json 2>/dev/null \\\n | jq -r '.executions[-1].id')\n\n# 2. Audit definitions (id, title, description, weight). ~15 items.\ngcx api \"$BASE/executions/$EXEC_ID/audits\" -o json 2>/dev/null \\\n > /tmp/insights_audits_${RUN_ID}.json\n\n# 3. Audit results (audit_id, status, score, explanation, actions).\ngcx api \"$BASE/executions/$EXEC_ID/audits/results\" -o json 2>/dev/null \\\n > /tmp/insights_results_${RUN_ID}.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Both responses use the same top-level key — ","type":"text"},{"text":"audits","type":"text","marks":[{"type":"code_inline"}]},{"text":" — but the ","type":"text"},{"text":"contents","type":"text","marks":[{"type":"em"}]},{"text":" differ: step 2 is the catalog of what each audit checks, step 3 is what that audit found on this particular run. Cross-join them via ","type":"text"},{"text":"result.audit_id == audit.id","type":"text","marks":[{"type":"code_inline"}]},{"text":" (1:1 in practice).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Joining into a compact, agent-readable summary","type":"text"}]},{"type":"paragraph","content":[{"text":"The raw audit + results JSON together is ~10 KB. Dumping both into context just to read out three lines per audit is wasteful — do the join in ","type":"text"},{"text":"jq","type":"text","marks":[{"type":"code_inline"}]},{"text":" and emit a single tight block. The pipeline below is the recommended shape:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"jq -nr \\\n --slurpfile a /tmp/insights_audits_${RUN_ID}.json \\\n --slurpfile r /tmp/insights_results_${RUN_ID}.json '\n ($a[0].audits | INDEX(.id)) as $defs\n | $r[0].audits\n | map({\n title: ($defs[.audit_id].title // \"?\"),\n description: ($defs[.audit_id].description // \"\"),\n status: .status,\n status_reason: .status_reason,\n score: (\n if .score == null then \"n/a\"\n elif .score.type == \"binary\" then (if .score.value then \"pass\" else \"fail\" end)\n else (.score.value | tostring)\n end),\n explanation: (.explanation // \"\" | gsub(\"\\n+\"; \" \") | .[0:300]),\n actions: (.actions // [])\n })\n | .[]\n | \"── \\(.title) — score: \\(.score)\\(if .status == \"failed\" then \" [audit did not run: \\(.status_reason // \"unknown\")]\" else \"\" end)\\n \\(.description)\\n → \\(.explanation)\\(if (.actions|length) > 0 then \"\\n actions:\\n - \" + (.actions|join(\"\\n - \")) else \"\" end)\\n\"\n'","type":"text"}]},{"type":"paragraph","content":[{"text":"For a 15-audit run this emits ~5 KB of plain text — title, score, one-line description, one-line explanation, and any action items. That's enough for the agent to reason about the test's health without re-reading either JSON blob.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Notes worth knowing","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"status","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ≠ verdict.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"status: \"succeeded\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" means ","type":"text"},{"text":"the audit executed","type":"text","marks":[{"type":"em"}]},{"text":". The verdict lives in ","type":"text"},{"text":"score","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"binary","type":"text","marks":[{"type":"code_inline"}]},{"text":" true/false, or ","type":"text"},{"text":"numeric","type":"text","marks":[{"type":"code_inline"}]},{"text":" 0…1 where 1 is best). ","type":"text"},{"text":"status: \"failed\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" means the audit itself could not run (typically ","type":"text"},{"text":"status_reason: \"missing data\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — e.g. the HTTP Spans audit on a non-tracing test); the ","type":"text"},{"text":"score","type":"text","marks":[{"type":"code_inline"}]},{"text":" field is absent. Treat these as \"no signal\", not as failures.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Score thresholds vary per audit.","type":"text","marks":[{"type":"strong"}]},{"text":" A ","type":"text"},{"text":"numeric","type":"text","marks":[{"type":"code_inline"}]},{"text":" 0.94 might be fine for one audit and concerning for another — there's no global cutoff. The ","type":"text"},{"text":"explanation","type":"text","marks":[{"type":"code_inline"}]},{"text":" is authoritative for what the score means; surface it verbatim rather than inventing a pass/fail rule.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"actions","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is the actionable bit.","type":"text","marks":[{"type":"strong"}]},{"text":" Only present when the audit has concrete recommendations (e.g. \"Reduce the cardinality of the ","type":"text"},{"text":"url","type":"text","marks":[{"type":"code_inline"}]},{"text":" label …\"). When summarising a run for a user who's trying to improve it, lead with audits that have a non-empty ","type":"text"},{"text":"actions","type":"text","marks":[{"type":"code_inline"}]},{"text":" array.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pick the last execution, not the first.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":".executions[]","type":"text","marks":[{"type":"code_inline"}]},{"text":" is in chronological order; re-runs append. Older executions reflect older audit logic and may have stale results.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Insights is a post-run analysis.","type":"text","marks":[{"type":"strong"}]},{"text":" If the run hasn't finished (or never produced enough data for insights to compute), the executions list may be empty — bail out gracefully on ","type":"text"},{"text":"length == 0","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"9. Local k6 CLI (smoke tests and ","type":"text"},{"text":"k6 cloud run","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"paragraph","content":[{"text":"The local ","type":"text"},{"text":"k6","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI is the right tool for parse checks and 1-iteration smoke runs before pushing to cloud:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"k6 inspect script.js | head -20 # parse-only sanity\nk6 run --quiet script.js # run locally","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"k6 cloud run","type":"text","marks":[{"type":"code_inline"}]},{"text":" (uploads + runs in cloud from your laptop), authenticate ","type":"text"},{"text":"k6 cloud login","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the token and stack URL pulled from gcx — token comes from ","type":"text"},{"text":"gcx k6 auth token","type":"text","marks":[{"type":"code_inline"}]},{"text":", stack URL from the active gcx context's ","type":"text"},{"text":"grafana.server","type":"text","marks":[{"type":"code_inline"}]},{"text":" field:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"TOKEN=$(gcx --context \u003cctx> k6 auth token)\nSTACK=$(gcx --context \u003cctx> config view --minify -o json | jq -r '.contexts[].grafana.server')\nk6 cloud login --token \"$TOKEN\" --stack \"$STACK\"\nk6 cloud run script.js","type":"text"}]},{"type":"paragraph","content":[{"text":"k6's cloud config (","type":"text"},{"text":"~/.config/k6/cloud.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") is single-context, so re-run ","type":"text"},{"text":"k6 cloud login","type":"text","marks":[{"type":"code_inline"}]},{"text":" whenever you switch gcx contexts — otherwise ","type":"text"},{"text":"k6 cloud run","type":"text","marks":[{"type":"code_inline"}]},{"text":" will keep targeting the previous stack.","type":"text"}]},{"type":"paragraph","content":[{"text":"Exit codes: ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" pass, ","type":"text"},{"text":"99","type":"text","marks":[{"type":"code_inline"}]},{"text":" threshold fail, anything else = script/runtime error.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"10. Gotchas","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Symptom","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Cause","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"401 \"Invalid or expired token — run gcx login to refresh\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx OAuth session expired","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx login --context \u003cctx>","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"403 / 404","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a path that looks right","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Forgot the doubled ","type":"text"},{"text":"cloud/cloud/","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a REST endpoint","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"…/resources/cloud/cloud/v{N}/…","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"415 unsupported media type","type":"text","marks":[{"type":"code_inline"}]},{"text":" on script PUT","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Missing ","type":"text"},{"text":"-H \"Content-Type: application/octet-stream\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Add it; pass ","type":"text"},{"text":"-vvv","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"--log-http-payload","type":"text","marks":[{"type":"code_inline"}]},{"text":") to ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" to inspect the request","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Script PUT returns 200 but doesn't take effect","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" timestamp does not bump on script change","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify by sha256 of GET (§5 step 7)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Run status ","type":"text"},{"text":"passed","type":"text","marks":[{"type":"code_inline"}]},{"text":" but checks failed","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Zero-observation thresholds report as pass; ","type":"text"},{"text":"check()","type":"text","marks":[{"type":"code_inline"}]},{"text":" alone never fails a run","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Add ","type":"text"},{"text":"'checks{check:\u003cname>}': ['rate==1.0']","type":"text","marks":[{"type":"code_inline"}]},{"text":"; in catch blocks, ","type":"text"},{"text":"check(null, {\"script completed\":()=>false})","type":"text","marks":[{"type":"code_inline"}]},{"text":" to force an observation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Loki query returns nothing for a recent run","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"X-K6TestRun-Id","type":"text","marks":[{"type":"code_inline"}]},{"text":" header missing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always pass ","type":"text"},{"text":"-H \"X-K6TestRun-Id: \u003crun_id>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on log queries","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx k6 runs list --limit 0","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns the ","type":"text"},{"text":"oldest","type":"text","marks":[{"type":"strong"}]},{"text":" 1000 rows, not the newest","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Subcommand doesn't follow ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" and defaults to ascending order — for >1000 runs the ","type":"text"},{"text":"first/last","type":"text","marks":[{"type":"code_inline"}]},{"text":" you see are the start of the history, not the recent activity","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"$orderby=created desc","type":"text","marks":[{"type":"code_inline"}]},{"text":" for a newest-first slice, or the full ","type":"text"},{"text":"@nextLink","type":"text","marks":[{"type":"code_inline"}]},{"text":" loop in §3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx k6 load-tests update -f","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns ","type":"text"},{"text":"✔ Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" but the change doesn't take effect","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fields outside the v6 PATCH schema (","type":"text"},{"text":"PatchLoadTestApiModel","type":"text","marks":[{"type":"code_inline"}]},{"text":" allows only ","type":"text"},{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"baseline_test_run_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"additionalProperties: false","type":"text","marks":[{"type":"code_inline"}]},{"text":") are silently dropped. Notably ","type":"text"},{"text":"project_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" is NOT updatable this way","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"For project moves use the dedicated ","type":"text"},{"text":"/move","type":"text","marks":[{"type":"code_inline"}]},{"text":" endpoint (§11); for other mutations cross-check the PATCH schema in the OpenAPI spec. Always re-GET to confirm","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gcx k6 load-tests list --project-id \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns tests from all projects","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The flag is accepted but never filters — ","type":"text"},{"text":"/cloud/v6/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":" has no project query param","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"GET /cloud/v6/projects/{id}/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":" via ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]},{"text":", or filter client-side with ","type":"text"},{"text":"select(.project_id == X)","type":"text","marks":[{"type":"code_inline"}]},{"text":" in jq","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HTTP 415 \"Unsupported media type \\\"application/json\\\"\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" on a multipart POST through ","type":"text"},{"text":"gcx api","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The Grafana plugin proxy rewrites multipart Content-Types to ","type":"text"},{"text":"application/json","type":"text","marks":[{"type":"code_inline"}]},{"text":" before forwarding (gcx itself sends the header you set; the proxy strips it)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fall back to direct curl against ","type":"text"},{"text":"api.k6.io","type":"text","marks":[{"type":"code_inline"}]},{"text":" (§1.2). Affects test creation (","type":"text"},{"text":"POST /cloud/v6/projects/{id}/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":") and any other multipart endpoint","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"401 Unauthorized","type":"text","marks":[{"type":"code_inline"}]},{"text":" calling ","type":"text"},{"text":"api.k6.io","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cloudlogs.k6.io","type":"text","marks":[{"type":"code_inline"}]},{"text":" directly (curl mode)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Missing/wrong ","type":"text"},{"text":"Authorization: Bearer …","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"X-Stack-ID","type":"text","marks":[{"type":"code_inline"}]},{"text":" header","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Re-check both headers; resolve ","type":"text"},{"text":"X-Stack-ID","type":"text","marks":[{"type":"code_inline"}]},{"text":" from a stack URL via ","type":"text"},{"text":"/cloud/v6/auth","type":"text","marks":[{"type":"code_inline"}]},{"text":" (§1.2)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"404","type":"text","marks":[{"type":"code_inline"}]},{"text":" calling ","type":"text"},{"text":"api.k6.io/api/plugins/...","type":"text","marks":[{"type":"code_inline"}]},{"text":" (curl mode)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Left the plugin-proxy prefix in by mistake","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Strip ","type":"text"},{"text":"/api/plugins/k6-app/resources/{cloud,logs,insights}","type":"text","marks":[{"type":"code_inline"}]},{"text":" — see §1.2","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"11. Mutations not covered by ","type":"text"},{"text":"update -f","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"gcx k6 \u003cresource> update -f","type":"text","marks":[{"type":"code_inline"}]},{"text":" follows the v6 PATCH schema for that resource. Fields outside the schema are silently dropped while gcx still prints ","type":"text"},{"text":"✔ Updated \u003cresource> \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — see the §10 row. Several common mutations have ","type":"text"},{"text":"dedicated endpoints instead","type":"text","marks":[{"type":"strong"}]},{"text":", and the PATCH route will no-op on them.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mutation","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Endpoint","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Body","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Move test to another project","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PUT /cloud/v6/load_tests/{id}/move","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{\"project_id\": \u003cint>}","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Start a test run","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POST /cloud/v6/load_tests/{id}/start","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{}","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or run options)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Polling a started run:","type":"text","marks":[{"type":"strong"}]},{"text":" After starting a run, poll ","type":"text"},{"text":"GET /cloud/v6/test_runs/{id}","type":"text","marks":[{"type":"code_inline"}]},{"text":" until ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":" reaches ","type":"text"},{"text":"completed","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"aborted","type":"text","marks":[{"type":"code_inline"}]},{"text":". Always check the ","type":"text"},{"text":"result","type":"text","marks":[{"type":"code_inline"}]},{"text":" field alongside ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"status: completed","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"result: error","type":"text","marks":[{"type":"code_inline"}]},{"text":" means a configuration or infrastructure failure (not a threshold breach). On any non-","type":"text"},{"text":"passed","type":"text","marks":[{"type":"code_inline"}]},{"text":" result, immediately fetch logs (§4) to surface the error rather than waiting for the user to report it.","type":"text"}]},{"type":"paragraph","content":[{"text":"| Abort a running test | ","type":"text"},{"text":"POST /cloud/v6/test_runs/{id}/abort","type":"text","marks":[{"type":"code_inline"}]},{"text":" | empty | | Set / overwrite a schedule | ","type":"text"},{"text":"POST /cloud/v6/load_tests/{id}/schedule","type":"text","marks":[{"type":"code_inline"}]},{"text":" | Schedule body (recurrence_rule or cron) | | Deactivate / reactivate a schedule | ","type":"text"},{"text":"POST /cloud/v6/schedules/{id}/{deactivate,activate}","type":"text","marks":[{"type":"code_inline"}]},{"text":" | empty | | Create a load test (multipart!) | ","type":"text"},{"text":"POST /cloud/v6/projects/{id}/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":" | multipart: ","type":"text"},{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"script","type":"text","marks":[{"type":"code_inline"}]},{"text":" | | Persist a run past retention | ","type":"text"},{"text":"POST /cloud/v6/test_runs/{id}/save","type":"text","marks":[{"type":"code_inline"}]},{"text":" | empty (paired with ","type":"text"},{"text":"/unsave","type":"text","marks":[{"type":"code_inline"}]},{"text":") |","type":"text"}]},{"type":"paragraph","content":[{"text":"The PATCH-style updates that ","type":"text"},{"text":"do","type":"text","marks":[{"type":"em"}]},{"text":" work via ","type":"text"},{"text":"update -f","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resource","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Updatable fields","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"load test","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"baseline_test_run_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" (per ","type":"text"},{"text":"PatchLoadTestApiModel","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"project","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"name","type":"text","marks":[{"type":"code_inline"}]},{"text":" only (per ","type":"text"},{"text":"PatchProjectApiModel","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Both schemas declare ","type":"text"},{"text":"additionalProperties: false","type":"text","marks":[{"type":"code_inline"}]},{"text":" — any other field you put in the manifest is silently filtered out before the PATCH is sent, even though gcx still reports ","type":"text"},{"text":"✔ Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":". Don't try to flip ","type":"text"},{"text":"is_default","type":"text","marks":[{"type":"code_inline"}]},{"text":" or move a ","type":"text"},{"text":"grafana_folder_uid","type":"text","marks":[{"type":"code_inline"}]},{"text":" through ","type":"text"},{"text":"update -f","type":"text","marks":[{"type":"code_inline"}]},{"text":"; they're not exposed for mutation on the v6 PATCH.","type":"text"}]},{"type":"paragraph","content":[{"text":"Cross-check by reading the operation's request schema in the OpenAPI spec (workflow in §3) before assuming a field is mutable.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Worked example — move a test between projects","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/\u003ctest_id>/move \\\n -X PUT \\\n -H \"Content-Type: application/json\" \\\n -d '{\"project_id\": \u003cnew_project_id>}'\n\n# Verify — re-GET and check project_id reflects the new value.\n# Do not trust the absence of an error from the PUT; HTTP 204 with\n# empty body is the success shape but gcx api prints nothing.\ngcx --context \u003cctx> k6 load-tests get \u003ctest_id> -o json | jq '{id, name, project_id}'","type":"text"}]},{"type":"paragraph","content":[{"text":"The OpenAPI description for this endpoint is explicit: ","type":"text"},{"text":"\"Move a load test to a different project of the same organization. All respective test runs will be also moved to the new project.\"","type":"text","marks":[{"type":"em"}]},{"text":" You don't need to migrate runs separately.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Cascade behavior worth knowing","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deleting a load test cascade-deletes its schedule.","type":"text","marks":[{"type":"strong"}]},{"text":" The schedule is gone from ","type":"text"},{"text":"/cloud/v6/schedules","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"/cloud/v6/load_tests/{id}/schedule","type":"text","marks":[{"type":"code_inline"}]},{"text":" immediately. No need to ","type":"text"},{"text":"gcx k6 schedules delete \u003cload-test-id>","type":"text","marks":[{"type":"code_inline"}]},{"text":" first as a defensive step.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deleting a project with a running test fails with HTTP 409","type":"text","marks":[{"type":"strong"}]},{"text":" (\"Cannot delete project with a running test.\" per the OpenAPI spec). Non-running tests appear to be removed with the project, but if you want to inventory a project's contents before deleting it, use ","type":"text"},{"text":"GET /cloud/v6/projects/{id}/load_tests","type":"text","marks":[{"type":"code_inline"}]},{"text":" (NOT ","type":"text"},{"text":"gcx k6 load-tests list --project-id \u003cid>","type":"text","marks":[{"type":"code_inline"}]},{"text":", which doesn't filter — see §10).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Moving a test moves its runs and run history with it.","type":"text","marks":[{"type":"strong"}]},{"text":" Schedule attachment also follows the test (it's keyed by ","type":"text"},{"text":"load_test_id","type":"text","marks":[{"type":"code_inline"}]},{"text":", not by project).","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Verification rule of thumb","type":"text"}]},{"type":"paragraph","content":[{"text":"The recurring pattern in this skill is ","type":"text"},{"text":"\"gcx confirms success even when the underlying call no-ops\"","type":"text","marks":[{"type":"em"}]},{"text":". Whenever you mutate state:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note what you expected to change (field, count, status).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-GET the resource and confirm the change is reflected.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If it isn't, check whether the mutation needed a dedicated endpoint (this section) rather than ","type":"text"},{"text":"update -f","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"k6-manage","author":"@skillopedia","source":{"stars":302,"repo_name":"skills","origin_url":"https://github.com/grafana/skills/blob/HEAD/skills/grafana-k6/k6-manage/SKILL.md","repo_owner":"grafana","body_sha256":"065a2a0a9892c0cc2920d0cec2a831d2dd030897a94cfa5067e3ec239d004312","cluster_key":"507a451c6f4de3085ae68f1ec9d949e69141946858b841f7cef4bc5e02c2ac35","clean_bundle":{"format":"clean-skill-bundle-v1","source":"grafana/skills/skills/grafana-k6/k6-manage/SKILL.md","text_bytes":14093,"source_commit":"8fb72a527961f05e708f53d2a0500709e12d7970","skills_sh_rank":6418,"attachment_count":1,"text_attachments":1,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/grafana-k6/k6-manage/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"testing-qa","category_label":"Testing"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"testing-qa","import_tag":"skills-sh-top20k-2026-11","description":"Interact with Grafana Cloud k6 (GCk6) — manage load tests, test runs, scripts, projects, schedules, env vars, fetch metrics or logs, and run scripts locally — using the `gcx` CLI (or direct curl when gcx is unavailable). Use this skill whenever the user mentions a k6 cloud test or run, asks to list/edit/create/start/abort k6 load tests, wants to fetch logs or metrics for a test run, manage k6 project limits or schedules, run a k6 script locally or via `k6 cloud run`, or needs to call any `/cloud/v6/`, `/cloud/v5/`, or k6-app Loki endpoint against a Grafana Cloud stack. Trigger even when the user doesn't explicitly say \"gcx\" or \"API\" — phrases like \"why did my k6 test fail\", \"show me logs for run X\", \"bump VUh limit on project Y\", or \"update my k6 script\" all qualify."}},"renderedAt":1793765940505}

Grafana Cloud k6 — interaction reference The default path is the CLI. When gcx isn't installed, every endpoint here is still reachable via direct curl against k6's public hosts — see §1.2 for the auth-header and host-translation rules. Two principles shape the rest: - gcx owns Grafana-side auth (when present). It injects the right headers on every call, so you should not set auth headers yourself. The only header you ever set by hand is , on Loki log queries (§4), browser screenshot/file fetches (§6), and Tempo trace queries (§7) — anything else gets overwritten or causes conflicts. In curl m…