k6-manage — Claude Code skill: what it does, how to install
Manages Grafana Cloud k6 load tests, runs, metrics, and logs using the gcx CLI or direct curl calls.
npx skills add grafana/skills --skill k6-manage -a claude-codeWhat k6-manage does
The skill equips an agent with instructions for Grafana Cloud k6 (GCk6). It manages test runs, scripts, project limits, and schedules. By default, it prioritizes gcx k6 subcommands and falls back to gcx api for lower-level routes.
When gcx is absent, the skill switches to direct curl calls. It contacts https://api.k6.io and https://cloudlogs.k6.io. In curl mode, every call requires a manual bearer token and stack ID, or the API rejects the request. Specific queries for Loki logs and Tempo traces also require an explicit X-K6TestRun-Id header.
What it helps with
- run k6 scripts locally or trigger cloud load tests
- inspect execution logs and failure reasons in Loki
- query performance metrics and Tempo traces for specific test runs
- update k6 scripts, project limits, and schedules
Example prompts for the agent
- Show me logs for run 12345
- Why did my k6 test fail?
- Bump the VUh limit on project staging-load
How to install k6-manage
1Skills CLI (Vercel)
npx skills add grafana/skills --skill k6-manage -a claude-code2Manually: put the files in place
~/.claude/skills/k6-manage/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/k6-manage/
1Skills CLI (Vercel)
npx skills add grafana/skills --skill k6-manage -a codex2Manually: put the files in place
.agents/skills/k6-manage/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add grafana/skills --skill k6-manage -a cursor2Manually: put the files in place
.cursor/skills/k6-manage/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install grafana/skills k6-manage2Manually: put the files in place
.github/skills/k6-manage/SKILL.md1Skills CLI (Vercel)
npx skills add grafana/skills --skill k6-manage -a gemini-cli2Platform CLI
gemini skills install https://github.com/grafana/skills --path skills/grafana-k6/k6-manageManually: put the files in place
.devin/skills/k6-manage/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add grafana/skills --skill k6-manage -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about k6-manage
How do I install k6-manage in Claude Code?
Run npx skills add grafana/skills --skill k6-manage -a claude-code. The skill file will be created at ~/.claude/skills/k6-manage/SKILL.md.
What does k6-manage do?
It guides an agent through managing Grafana Cloud k6 resources. It helps launch tests, inspect logs in Loki, fetch test metrics, and configure project settings.
Is the gcx CLI mandatory to use k6-manage?
No, the CLI is not strictly required. The skill falls back to direct curl commands against https://api.k6.io if gcx is not installed on your system.
Source text
SKILL.md · Apache-2.0
---
name: k6-manage
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.
---
# Grafana Cloud k6 — interaction reference
The default path is the `gcx` 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 `X-K6TestRun-Id`, 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.
- **Reach for `gcx k6 ...` subcommands first.** They wrap the common
paths with friendlier ergonomics and handle pagination. Discover
what's available with `gcx help-tree k6` (and drill in further with
`gcx help-tree k6 <subcommand>`); fall back to `gcx api` only when
no subcommand exists for what you need.
---
## 1. Authentication
### 1.1 With gcx (default)
```bash
gcx login --context <ctx> # one-time OAuth, browser flow
gcx --context <ctx> config check # expect "✔ Connectivity: online"
```
Once a context is logged in, every `gcx api ...` and `gcx k6 ...` call
inherits its auth state. If a call returns *"Invalid or expired token —
run gcx login to refresh"*, the OAuth session has lapsed — re-run
`gcx login --context <ctx>`.
### 1.2 Without gcx — direct curl
Check `command -v gcx` 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:
- **Auth headers are manual.** Set both on every call:
- `Authorization: Bearer <k6_token>`
- `X-Stack-ID: <int>`
- **Hosts replace the plugin proxy.**
- REST (`/cloud/v6/...`, `/cloud/v5/...`, `/cloud-resources/v1/...`,
`/insights/...`) → `https://api.k6.io`
- Logs (Loki) and traces (Tempo) → `https://cloudlogs.k6.io`
- **No `/api/plugins/k6-app/resources/{cloud,logs,insights}` prefix.**
Drop it; everything after that prefix in the gcx examples is the
real k6 path. The doubled `cloud/cloud/` quirk from §2 collapses to
a single `/cloud/` — the first one was just the proxy route.
#### Obtaining the credentials
Don't guess these — prompt the user once per session for:
1. **k6 API token** — long-lived bearer; the same value
`gcx k6 auth token` would print when gcx is configured.
2. **Stack** — either the integer **stack ID** (used directly in
`X-Stack-ID`) or a Grafana **stack URL** (e.g.
`https://myorg.grafana.net`). If the user supplies a URL, resolve
it to an ID once with `GET /cloud/v6/auth`, which takes the URL in
the `X-Stack-Url` header and returns `{stack_id, default_project_id}`:
```bash
STACK_ID=$(curl -sS https://api.k6.io/cloud/v6/auth \
-H "Authorization: Bearer $K6_TOKEN" \
-H "X-Stack-Url: $STACK_URL" \
| jq -r '.stack_id')
```
Cache the resolved ID for the session — every subsequent call
needs it in `X-Stack-ID`. (Note: `/cloud/v6/auth` is the *only*
endpoint that takes `X-Stack-Url` instead of `X-Stack-ID` — it's
how you cross the gap from "user-known URL" to "API-required ID".)
#### Translation cheat-sheet
| gcx form (plugin proxy) | curl form (direct) |
|------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| `gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/123` | `curl https://api.k6.io/cloud/v6/test_runs/123 -H ...` |
| `gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/<id>/metrics` | `curl https://api.k6.io/cloud/v5/test_runs/<id>/metrics -H ...` |
| `gcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/index` | `curl https://api.k6.io/cloud-resources/v1/files/index -H ...` |
| `gcx api /api/plugins/k6-app/resources/insights/insights/api/v1/testrun/<id>/executions` | `curl https://api.k6.io/insights/api/v1/testrun/<id>/executions -H ...` |
| `gcx api /api/plugins/k6-app/resources/logs/api/v1/query_range?...` | `curl https://cloudlogs.k6.io/api/v1/query_range?... -H ...` |
| `gcx api /api/plugins/k6-app/resources/logs/api/v1/tempo/api/search?...` | `curl https://cloudlogs.k6.io/api/v1/tempo/api/search?... -H ...` |
In every `-H ...` slot above, send both auth headers:
`-H "Authorization: Bearer $K6_TOKEN" -H "X-Stack-ID: $STACK_ID"`.
Notes:
- Endpoint-specific headers gcx leaves to you — `X-K6TestRun-Id` on
log, trace, and files endpoints (§4, §6, §7) — are still required
*in addition* to the auth pair.
- The `gcx api` flag quirks in §2 (spill envelope, `--json field`
filtering, `-o` for output format) don't apply to curl. Use plain
curl flags: `-o file` to save body, `--data-binary @file` for PUT
payloads, `-w '%{http_code}'` for status code, etc.
- Pagination semantics (`$orderby`, `$top`, `$skip`, `@nextLink`
from §3) are properties of the v6 endpoints themselves and work
identically over curl. The `@nextLink` URL returned by the server
is already an absolute `https://api.k6.io/...` URL — pass it back
to curl unchanged; the plugin-proxy reshape in §3 isn't needed.
---
## 2. How `gcx api` paths are shaped
When no subcommand exists, fall back to `gcx api` against the Grafana
plugin-proxy routes:
- **REST API** (`/cloud/v6/`, `/cloud/v5/`) — prefix with
`/api/plugins/k6-app/resources/cloud/<k6-path>`.
- **Logs (Loki)** — prefix with
`/api/plugins/k6-app/resources/logs/<loki-path>`.
| k6 path | gcx invocation |
|-------------------------------------------|-----------------------------------------------------------------------------------|
| `/cloud/v6/test_runs/{id}` | `gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/{id}` |
| `/cloud/v5/test_runs/{id}/metrics` | `gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/{id}/metrics` |
| Loki `/api/v1/query_range?...` | `gcx api /api/plugins/k6-app/resources/logs/api/v1/query_range?...` |
Note the doubled `cloud/cloud/` in every REST path — the first `cloud`
is the proxy route, the second is k6's `/cloud/v{N}/` namespace.
### `gcx api` flag quirks
`gcx api` is not a curl clone — a few flags differ from what curl muscle
memory suggests:
- **Response body** is written to stdout. There is no `-o <file>` flag
for saving the body; `-o` selects output *format* (`json`, `yaml`,
`agents`). Use shell redirection (`> file`) or `$(...)` capture
instead.
- **Request body** uses `-d <string>`, `-d @file`, or `-d @-` (stdin).
There is no `--data-binary`; `-d @file` already preserves bytes.
- **Field selection without jq**: `--json field1,field2,...` returns
only the listed fields, and `--json list` (or `--json '?'`)
discovers what's available. Often cleaner than piping into jq for
shallow extractions.
- **Stderr noise**: gcx prints a one-line `hint:` to stderr on most
invocations. Pipelines into `jq` should redirect with `2>/dev/null`
to avoid surprises.
- **Response headers** are not directly exposed by `gcx api`. When a
branch in the workflow hinges on `Content-Type` (e.g. script GET in
§5), inspect the downloaded body with `file <path>` instead.
- **Large responses spill to a temp file**, 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:
```json
{"spilled_to":"/var/folders/.../gcx-results-<n>.json",
"bytes":2365037,
"preview_sample":["@nextLink","value"],
"message":"Response too large for stdout..."}
```
Naive `json.loads(stdout).get('value', [])` patterns silently return
empty in this case — the envelope has no `value` key. Two reliable
fixes:
- **Pass `-o json`** to force inline output regardless of size
(recommended for scripts that parse the body). The agent-mode
default formatting is what triggers the spill; `-o json` opts out.
- **Or detect the envelope and re-read from the spilled file**:
```python
d = json.loads(stdout)
if 'spilled_to' in d:
d = json.load(open(d['spilled_to']))
```
Either works; `-o json` is fewer lines.
- **The Grafana plugin proxy rewrites `Content-Type: multipart/...`
to `application/json`.** `gcx api` itself forwards your
`-H "Content-Type: ..."` header correctly (visible in
`--log-http-payload` 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 `POST /cloud/v6/projects/{id}/load_tests` for test
creation, which takes `name` + `script` as form parts — return
`HTTP 415 "Unsupported media type \"application/json\""` no
matter what header you set on the gcx side. Fall back to direct
curl against `api.k6.io` (§1.2) for these endpoints —
`curl -F name=... -F script=@...` builds the multipart body for
you and bypasses the plugin proxy. Other Content-Type values
(e.g. `application/octet-stream` for the script-update PUT in §5)
are forwarded through the proxy unchanged.
---
## 3. Discovering and calling endpoints
`gcx k6` subcommands (see `gcx help-tree k6`) 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.
### Workflow
1. **Fetch the spec once per session** (it's large; cache to `/tmp`):
```bash
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/openapi > /tmp/k6-openapi.json
```
2. **Index by `operationId` + `description` first** — this keeps the
working payload small while you search:
```bash
jq '[.paths | to_entries[] | .key as $p | .value | to_entries[]
| {path: $p, method: .key,
operationId: .value.operationId,
description: .value.description}]' /tmp/k6-openapi.json
```
Grep this for the action you need ("abort", "limits", "schedule", …).
3. **Pull the chosen operation's full schema** — parameters, request
body, response — resolving any `$ref` indirections:
```bash
jq '.paths["<path>"]["<method>"]' /tmp/k6-openapi.json
# Then for each "$ref": "#/components/schemas/Foo":
jq '.components.schemas.Foo' /tmp/k6-openapi.json
```
4. **Build the `gcx api` call** against the same path, prefixed per §2.
Authorization is already injected by gcx — do not add it yourself.
### List requests
When the operation you're calling is a list endpoint, default to
**ordering by `created` descending** (newest first) and **paginating
by 20** — both via whatever query parameter names the OpenAPI schema
for that operation exposes. Names vary (`ordering=-created`,
`order_by=created.desc`, `sort=-created`, `limit=20`, `page_size=20`),
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.
#### Full enumeration: paginating with `@nextLink`
When you need to enumerate *all* rows (not just the newest page), the
v6 list endpoints cap each response at **1000 rows** (the default
`$top`) and return an `@nextLink` field pointing at the next page.
**`gcx k6 runs list --limit 0` does NOT auto-follow** `@nextLink` and
defaults to ascending order — for tests with >1000 historical runs it
silently returns the **oldest** 1000, not the newest. The
practical effect: `(first/last_run)` summarised from this output can
be wildly stale (e.g. a daily-scheduled test that's been running for
years will show a `last_run` from ~3 years ago). Use `gcx api`
against the v6 endpoint with `$orderby=created desc` for the
newest-first slice, or loop on `@nextLink` until it's absent for full
enumeration.
The v6 list endpoints expose OData-style query parameters:
| Param | Purpose |
|------------|----------------------------------------------------------------------|
| `$orderby` | Ordering — value must be `<field> <direction>` (e.g. `created desc`). The allowed fields are operation-specific; `created` is the common one. Default is ascending. |
| `$top` | Page size, default 1000 (also the cap) |
| `$skip` | Offset (`@nextLink` already encodes this for you) |
| `$count` | Include total count in response |
Two practical defaults for the loop: `$orderby=created desc` so the
first page contains the newest runs (most of the time the agent only
cares about those), and `-o json` to opt out of the spill envelope
(see §2) so the parsing is uniform across pages.
```python
import json, subprocess, urllib.parse
CTX = "<stack>"
TEST_ID = "<test_id>"
PROXY = "/api/plugins/k6-app/resources/cloud"
all_runs = []
# Start with newest first; -o json keeps the body inline for clean json.loads().
path = (
f"{PROXY}/cloud/v6/load_tests/{TEST_ID}/test_runs"
"?%24orderby=created%20desc&%24top=1000"
)
while path:
r = subprocess.run(["gcx", "--context", CTX, "api", path, "-o", "json"],
capture_output=True, text=True, timeout=60)
d = json.loads(r.stdout)
all_runs.extend(d.get("value", []))
nxt = d.get("@nextLink")
if not nxt:
path = None
continue
# @nextLink comes back as an absolute http://api.k6.io/... URL with
# OData params already encoded ($skip, $top, $orderby). Reshape its
# path+query into the plugin-proxy prefix:
p = urllib.parse.urlparse(nxt)
path = f"{PROXY}{p.path}" + (f"?{p.query}" if p.query else "")
```
Same pattern works for any v6 list endpoint that returns `@nextLink`,
not just `/test_runs`. If you only need the few most-recent items,
stop the loop after the first page — with `$orderby=created desc` that
page is already the newest 1000.
### Metrics
Time-series and aggregate metrics live on the **v5** API
(`/cloud/v5/...`) — Prometheus-like query semantics inside OData-style
URL function-calls (`query_range_k6(query='...',metric='...')`), not
captured by the v6 OpenAPI. The full endpoint reference, selector
syntax, query methods per metric type, and worked examples are inlined
in [`references/metrics.md`](references/metrics.md) — read that before
constructing a metrics query.
### Logs
See §4.
---
## 4. Logs (Loki via `gcx api`)
The plugin proxies Loki under `/api/plugins/k6-app/resources/logs/`. The
only header you supply by hand is `X-K6TestRun-Id`; gcx handles
authorization.
```bash
RUN_ID="<run_id>"
# Scope the log window to the run itself: `created` for start, `ended`
# for end. If `ended` is null the run is still in progress — fall back
# to "now". Use a wider hard-coded window only when the run object's
# timestamps don't fit what you're investigating.
RUN=$(gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID 2>/dev/null)
# k6 emits sub-second precision (e.g. "...:58.984041Z"); jq's
# fromdateiso8601 only accepts whole seconds, so strip the fraction.
START=$(echo "$RUN" | jq -r '.created | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601')
END=$(echo "$RUN" | jq -r 'if .ended then (.ended | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) else (now | floor) end')
QUERY=$(printf '{test_run_id="%s"}' "$RUN_ID" | jq -sRr @uri)
# Save the raw response to /tmp/ — log payloads can be large; streaming
# them inline burns context. Summarise from the file, then jq into
# specific entries only when needed.
gcx api "/api/plugins/k6-app/resources/logs/api/v1/query_range?query=${QUERY}&direction=backward&start=${START}&end=${END}&limit=1000" \
-H "X-K6TestRun-Id: $RUN_ID" 2>/dev/null > /tmp/run_${RUN_ID}_logs.json
# Tight summary — counts and stream labels only:
jq '{status, streams: (.data.result | length),
total_entries: ([.data.result[].values | length] | add),
stream_labels: [.data.result[].stream]}' /tmp/run_${RUN_ID}_logs.json
```
Pull individual entries from `/tmp/run_${RUN_ID}_logs.json` as needed
(e.g. `jq '.data.result[].values[]' …`) rather than re-running the
query.
Every LogQL query **must** include the `{test_run_id="<run_id>"}` 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:
- `{test_run_id="<run_id>"}` — everything
- `{test_run_id="<run_id>"} | level=~"(error|warn)"` — errors and warnings only
- `{test_run_id="<run_id>"} |= "specific text"` — substring filter
Direction & window tips:
- **Scope `start`/`end` to the run's `created`/`ended` fields**, 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).
- `direction=forward` with `start = run.created` to find the *first* errors.
- `direction=backward` with `end = run.ended` (or now, if still running)
to find the *most recent* output.
- Loki retention typically outlives k6's cascade-delete, so logs of a
deleted child run may still be queryable for a while.
---
## 5. Editing a test script safely
### Two distinct script endpoints
There are two GET endpoints that return a k6 script body, and they are
not interchangeable:
| Endpoint | What it returns | When to use |
|---------------------------------------------|-------------------------------------------|--------------------------------------------------------|
| `/cloud/v6/load_tests/<test_id>/script` | The **current** load-test script (mutable; supports PUT) | Editing the script; reading current state |
| `/cloud/v6/test_runs/<run_id>/script` | The script **snapshot bundled into a specific run** (read-only) | Investigating what a past run actually executed; diffing across versions |
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.
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'
`/test_runs/<id>/script` and diff them locally.
### Script body format
The script GET endpoint returns one of two shapes — always detect which
before assuming the format:
- **Single js/ts file**. The recipe below handles this case directly.
- **k6 tar archive** (plain tar or gzipped). A multi-file project
bundle (entry script + imported modules + assets). To edit: extract
the JS file, modify it, then use `k6 archive` to rebuild the
archive from the modified JS. Do **not** manually repack with
`tar` — the archive contains a `metadata.json` with parsed options
(projectID, thresholds, scenarios) that `k6 archive` regenerates
correctly from the script source. Manual repacking preserves stale
metadata and causes runtime errors (e.g. projectID mismatch).
```bash
# Edit a k6 tar archive
tar -xf /tmp/script_body -C /tmp/extracted/ # extract
# ... edit the JS file ...
k6 archive /tmp/edited_script.js -O /tmp/new_archive.tar # rebuild
```
`gcx api` doesn't expose response headers (see §2), so detect the shape
by inspecting the downloaded body with `file(1)`:
```bash
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
> /tmp/script_body
file /tmp/script_body
# Expected output (examples):
# ASCII text → js/ts source
# POSIX tar archive → k6 tar bundle
# gzip compressed data → gzipped tar bundle
```
The PUT body must be the raw script (or archive) bytes
(`application/octet-stream`). gcx forwards the request to k6 unchanged.
```bash
ID="<load_test_id>"
# 1. Pull current script (single-file js/ts case)
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
> /tmp/script_current.js
# 2. Backup
cp /tmp/script_current.js /tmp/script_backup_$(date +%s).js
# 3. Edit → /tmp/script_new.js
# 4. Local parse-only sanity check
k6 inspect /tmp/script_new.js | head -20
# 5. 1-iteration smoke (edit IN-FILE; --iterations CLI flag breaks browser scenarios)
sed 's/iterations: [0-9]\+/iterations: 1/' /tmp/script_new.js > /tmp/script_1iter.js
k6 run --quiet /tmp/script_1iter.js # exit 0 = pass, 99 = threshold fail
# 6. PUT — Content-Type must be application/octet-stream
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
-X PUT \
-H "Content-Type: application/octet-stream" \
-d "@/tmp/script_new.js"
# 7. Verify by re-fetching and comparing sha256 — the two hashes MUST match.
# Do not rely on the `updated` timestamp; it does not bump on script PUT.
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
> /tmp/script_verify.js
shasum -a 256 /tmp/script_new.js /tmp/script_verify.js
```
If the PUT returns 415, double-check that `-H "Content-Type:
application/octet-stream"` made it through (some shell quoting mistakes
can drop it). Pass `-vvv` to `gcx api` for the request/response trace
when debugging.
---
## 6. Browser screenshots
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 `/cloud/v6/`; retrieval goes through a separate
`cloud-resources/v1/files/` plugin route in a two-step flow. Both
calls require the same `X-K6TestRun-Id` 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.
```bash
RUN_ID="<run_id>"
# 1. Index — JSON array of screenshot paths owned by this run. Empty
# array if the run took none (e.g. non-browser tests, or browser
# runs that didn't call page.screenshot()).
gcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/index \
-H "X-K6TestRun-Id: $RUN_ID" -o json 2>/dev/null > /tmp/files_${RUN_ID}.json
# Each entry looks like:
# "7542817/files/screenshots/screenshots/login-success.png"
# The doubled "screenshots/screenshots/" segment is real, not a typo —
# pass the path back unchanged to step 2.
# 2. Request pre-signed download URLs for the files you want. The
# request body must be JSON; Content-Type matters.
FILES=$(jq -c '[.[] | {name: .}]' /tmp/files_${RUN_ID}.json)
PAYLOAD=$(jq -nc --argjson files "$FILES" \
'{service:"aws_s3", operation:"download", files:$files}')
gcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/generate-pre-signed-url \
-X POST \
-H "X-K6TestRun-Id: $RUN_ID" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" -o json 2>/dev/null > /tmp/presigned_${RUN_ID}.json
# 3. Download each pre-signed URL directly — they're plain S3 GETs, no
# gcx and no auth header (the signature is in the URL). The URLs
# are valid for 24h (X-Amz-Expires=86400); re-run step 2 if they
# expire.
mkdir -p /tmp/run_${RUN_ID}_files
jq -r '.urls[] | [.name, .pre_signed_url] | @tsv' /tmp/presigned_${RUN_ID}.json |
while IFS=$'\t' read -r NAME URL; do
curl -sS -o "/tmp/run_${RUN_ID}_files/$(basename "$NAME")" "$URL"
done
```
### Notes worth knowing
- **Index before signing.** `generate-pre-signed-url` does *not*
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
`<Error><Code>NoSuchKey</Code>...`). Always derive the file list
from `files/index`, not from a guess at the path shape.
- **Batch the sign request.** `files` 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.
- **Browser tests vs. protocol tests.** Only runs that called
`page.screenshot()` have entries in the index; a pure protocol/HTTP
run will return `[]`. Don't assume the index is non-empty.
---
## 7. Browser traces (Tempo via `gcx api`)
Browser-module runs emit OTel spans for every iteration, navigation,
locator click, screenshot, web-vital observation, etc. The plugin
proxies a Tempo backend under `/api/plugins/k6-app/resources/logs/`
(same prefix as Loki — see §4), and the same `X-K6TestRun-Id` header
is required on every call. Without it, both search and fetch return
HTTP 401 `"Test run ID missing"`.
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 `-o json` pretty-prints; ~50 KB compact), so the
join+summarise step is what keeps agent context lean.
```bash
RUN_ID="<run_id>"
# 1. Find the run's start/end window and pick the scenario name.
# Same caveat as §4: strip the sub-second fraction before
# fromdateiso8601, and fall back to "now" if the run is still
# running. Add a small buffer because spans can land slightly
# after the run object reports `ended`.
RUN=$(gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID 2>/dev/null)
START=$(echo "$RUN" | jq -r '.created | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601')
END=$( echo "$RUN" | jq -r 'if .ended then (.ended | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) else (now|floor) end')
START=$((START - 60))
END=$((END + 60))
SCENARIO="ui" # whatever your scenario is named; check run.ui.exec / k6 script
# 2. Search for iteration root spans. TraceQL goes in `q`; URL-encode
# it. `start`/`end` must be unix seconds — RFC3339 returns
# HTTP 400 `invalid start: strconv.ParseUint`. limit=500 caps the
# result, enough to cover several thousand iterations.
Q=$(printf '{ name = "iteration" && span.test.iteration.number >= 0 && span.test.vu >= 0 && span.test.scenario = "%s" }' "$SCENARIO")
QE=$(printf '%s' "$Q" | jq -sRr @uri)
gcx api "/api/plugins/k6-app/resources/logs/api/v1/tempo/api/search?start=${START}&end=${END}&q=${QE}&limit=500" \
-H "X-K6TestRun-Id: $RUN_ID" -o json 2>/dev/null > /tmp/traces_${RUN_ID}.json
# Each entry has: traceID (32-char hex), rootServiceName ("k6"),
# rootTraceName ("iteration"), durationMs, startTimeUnixNano, and a
# spanSet preview of the iteration root span's attributes
# (test.iteration.number, test.vu, test.scenario).
# 3. Fetch a full trace. The response is OTLP-shaped:
# { "batches": [ { "resource": {...}, "scopeSpans": [ { "spans": [...] } ] } ] }
TRACE_ID=$(jq -r '.traces[0].traceID' /tmp/traces_${RUN_ID}.json)
gcx api "/api/plugins/k6-app/resources/logs/api/v1/tempo/api/traces/$TRACE_ID" \
-H "X-K6TestRun-Id: $RUN_ID" -o json 2>/dev/null > /tmp/trace_${TRACE_ID}.json
```
### Compact summary for an agent
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:
```bash
jq -r '
# OTel value envelope: pick by key presence, not //-chaining — jq's //
# treats `false` as missing and would drop legitimate boolValue:false.
def attrval:
if has("stringValue") then .stringValue
elif has("intValue") then .intValue
elif has("boolValue") then .boolValue
elif has("doubleValue") then .doubleValue
else null end;
def attrs: [.attributes[] | {(.key): (.value | attrval)}] | add // {};
def dur: (((.endTimeUnixNano|tonumber) - (.startTimeUnixNano|tonumber)) / 1000000 | floor);
[.batches[].scopeSpans[].spans[] | {name, dur: dur, a: attrs, status: (.status.code // "OK")}] as $spans
| "── span-name rollup (count · total ms · max ms) ──",
([$spans[] | {name, dur}]
| group_by(.name) | map({name: .[0].name, n: length,
t: (map(.dur)|add), m: (map(.dur)|max)})
| sort_by(-.t)[]
| " \(.n|tostring|.+" "|.[0:4]) \(.t|tostring|.+" "|.[0:7]) \(.m|tostring|.+" "|.[0:6]) \(.name)"),
"\n── slowest spans (ms · name · navigation.url / page.goto.url) ──",
([$spans[] | select(.name != "iteration")] | sort_by(-.dur) | .[0:8][]
| " \(.dur|tostring|.+" "|.[0:7]) \(.name)\(if .a["navigation.url"] // .a["page.goto.url"] then " " + (.a["navigation.url"] // .a["page.goto.url"]) else "" end)"),
"\n── web-vital ratings ──",
([$spans[] | select(.name == "web_vital")...
© grafana · Apache-2.0 · grafana/skills