关于 k6-manage
k6-manage 是通过 gcx 命令行或 curl 操作 Grafana Cloud k6 的技能参考,涵盖认证、API 探索、测试运行数据及端点调用注意事项。
主要功能
- 01gcx 命令行与 curl 操作指引
- 02Grafana Cloud 认证设置
- 03基于 OpenAPI 探索端点
- 04列表排序与分页指引
- 05REST、日志和追踪请求说明
适用场景
- 01登录 k6 Cloud 账户
- 02查找 gcx k6 未覆盖的 API 操作
- 03列出最新或全部测试运行
- 04获取日志、追踪或测试文件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ grafana/ skills --skill k6-manage安装后在 Claude Code 中输入/k6-manage确认已加载。
附带文件references/metrics.md
详细介绍
Grafana Cloud k6 — 交互参考
默认路径是 gcx CLI。如果未安装 gcx,仍可通过 curl 直接访问 k6 的公共主机上的所有端点——身份验证标头和主机转换规则请参见 §1.2。其余内容遵循两条原则:
- 由 gcx 负责 Grafana 侧的身份验证(如果有)。 它会在每次调用时注入正确的标头,因此不应自行设置身份验证标头。唯一需要手动设置的标头是
X-K6TestRun-Id,用于 Loki 日志查询(§4)、浏览器截图/文件获取(§6)和 Tempo 追踪查询(§7)——其他任何标头都会被覆盖或导致冲突。使用 curl 时,身份验证标头需要手动设置;请参见 §1.2。 - 优先使用
gcx k6 ...子命令。 它们封装了常见路径,操作更便捷,并能处理分页。使用gcx help-tree k6查看可用命令(也可进一步查看gcx help-tree k6 <subcommand>);仅当所需功能没有对应子命令时,才使用gcx api。
1. 身份验证
1.1 使用 gcx(默认)
gcx login --context <ctx> # one-time OAuth, browser flow
gcx --context <ctx> config check # expect "✔ Connectivity: online"
登录某个上下文后,每次 gcx api ... 和 gcx k6 ... 调用都会继承其身份验证状态。如果调用返回 "Invalid or expired token —
run gcx login to refresh",说明 OAuth 会话已过期——请重新运行
gcx login --context <ctx>。
1.2 不使用 gcx — 直接使用 curl
先检查 command -v gcx。如果 gcx 未安装,仍可通过 curl 直接访问此技能中的所有端点对应的 k6 公共主机——与其他地方的 gcx 示例相比,有三点变化:
- 身份验证标头需手动设置。 每次调用都要设置以下两项:
Authorization: Bearer <k6_token>X-Stack-ID: <int>
- 主机替换插件代理。
- REST(
/cloud/v6/...、/cloud/v5/...、/cloud-resources/v1/...、/insights/...)→https://api.k6.io - 日志(Loki)和追踪(Tempo)→
https://cloudlogs.k6.io
- REST(
- 不需要
/api/plugins/k6-app/resources/{cloud,logs,insights}前缀。 删除此前缀;gcx 示例中此前缀之后的所有内容都是真正的 k6 路径。§2 中重复的cloud/cloud/特殊情况会简化为单个/cloud/——第一个cloud/只是代理路由。
获取凭据
不要猜测这些信息——每个会话只需向用户询问一次:
-
k6 API 令牌 — 长效 Bearer 令牌;gcx 配置完成后,
gcx k6 auth token会输出相同的值。 -
Stack — 可以是整数形式的 stack ID(直接用于
X-Stack-ID),也可以是 Grafana stack URL(例如https://myorg.grafana.net)。如果用户提供的是 URL,请通过GET /cloud/v6/auth将其解析为 ID;该请求在X-Stack-Url标头中接收 URL,并返回{stack_id, default_project_id}: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')在当前会话中缓存解析出的 ID——后续每次调用都需要在
X-Stack-ID中传入该 ID。(注意:/cloud/v6/auth是唯一使用X-Stack-Url而非X-Stack-ID的端点——它用于打通从“用户已知的 URL”到“API 要求的 ID”之间的环节。)
翻译速查表
| gcx 形式(插件代理) | curl 形式(直接调用) |
|---|---|
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 ... |
在上面每个 -H ... 位置,都要发送这两个身份验证标头:
-H "Authorization: Bearer $K6_TOKEN" -H "X-Stack-ID: $STACK_ID"。
注意:
- gcx 留给你自行设置的特定端点标头——日志、跟踪和文件端点上的
X-K6TestRun-Id(§4、§6、§7)——仍然是必需的,并且要与这对身份验证标头一同发送。 - §2 中
gcx api标志的特殊行为(溢出信封、--json field筛选、用于设置输出格式的-o)不适用于 curl。请使用常规 curl 标志:使用-o file保存响应正文,使用--data-binary @file发送 PUT 负载,使用-w '%{http_code}'获取状态码,等等。 - 分页语义(§3 中的
$orderby、$top、$skip、@nextLink)是 v6 端点本身的属性,通过 curl 调用时的行为完全相同。服务器返回的@nextLinkURL 已经是绝对的https://api.k6.io/...URL——原样传给 curl 即可;不需要 §3 中的插件代理路径重塑。
2. gcx api 路径的构成方式
没有可用的子命令时,可通过 Grafana 插件代理路由回退到 gcx api:
- REST API(
/cloud/v6/、/cloud/v5/)——添加前缀/api/plugins/k6-app/resources/cloud/<k6-path>。 - 日志(Loki)——添加前缀
/api/plugins/k6-app/resources/logs/<loki-path>。
| k6 路径 | gcx 调用 |
|---|---|
/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?... |
请注意,所有 REST 路径中都出现了重复的 cloud/cloud/——第一个 cloud
是代理路由,第二个是 k6 的 /cloud/v{N}/ 命名空间。
gcx api 标志的特殊之处
gcx api 并不是 curl 的克隆版——有些标志的行为与 curl 使用习惯所预期的不同:
-
响应正文会写入 stdout。没有用于保存正文的
-o <file>标志;-o用于选择输出格式(json、yaml、agents)。请改用 shell 重定向(> file)或$(...)捕获。 -
请求正文使用
-d <string>、-d @file或-d @-(stdin)。没有--data-binary;-d @file已经会保留字节。 -
无需 jq 即可选择字段:
--json field1,field2,...只返回列出的字段,--json list(或--json '?')则会列出可用字段。对于浅层提取,这通常比通过管道传给 jq 更简洁。 -
stderr 噪声:gcx 在大多数调用时都会向 stderr 打印一行
hint:。通过管道传给jq时,应使用2>/dev/null重定向,以免出现意外。 -
无法直接获取响应头:
gcx api不会直接公开响应头。当工作流中的某个分支取决于Content-Type(例如 §5 中的脚本 GET)时,请使用file <path>检查下载的正文。 -
大型响应会溢出到临时文件,stdout 则会缩减为一个包装信封。阈值为数十 KB,因此即使是中等大小的列表端点也会触发此行为。信封如下所示:
{"spilled_to":"/var/folders/.../gcx-results-<n>.json", "bytes":2365037, "preview_sample":["@nextLink","value"], "message":"Response too large for stdout..."}在这种情况下,直接使用
json.loads(stdout).get('value', [])会静默返回空结果——信封中没有value键。有两种可靠的解决方法:- 传入
-o json,无论响应大小如何都强制内联输出(建议用于需要解析正文的脚本)。触发溢出的是 Agent 模式的默认格式;使用-o json即可避免溢出。 - 或者检测信封,并从溢出文件中重新读取:
d = json.loads(stdout) if 'spilled_to' in d: d = json.load(open(d['spilled_to']))
两种方法都可行;使用
-o json所需代码更少。 - 传入
-
Grafana 插件代理会将
Content-Type: multipart/...重写为application/json。gcx api本身会正确转发你的-H "Content-Type: ..."标头(可在--log-http-payload跟踪记录中看到),但上游插件代理会在请求到达 k6 的 API 之前,将 multipart Content-Type 重写为 JSON。结果是:需要 multipart 正文的端点——尤其是用于创建测试的POST /cloud/v6/projects/{id}/load_tests,它需要name+script表单部分——无论你在 gcx 端设置什么标头,都会返回HTTP 415 "Unsupported media type \"application/json\""。这些端点请改用curl直接请求api.k6.io(§1.2)——curl -F name=... -F script=@...会为你构建 multipart 正文,并绕过插件代理。其他 Content-Type 值(例如 §5 中脚本更新 PUT 使用的application/octet-stream)则会原样通过代理转发。
3. 发现并调用端点
gcx k6 子命令(参见 gcx help-tree k6)涵盖了常见的读取操作。对于其他所有情况——变更操作、较少见的读取操作、未提供子命令的端点——k6 Cloud API 涵盖面广且会随时间变化。与其依赖过时的速查表,不如在需要时从 OpenAPI 规范中发现所需操作。
工作流
-
每次会话获取一次规范(文件较大;缓存到
/tmp):gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/openapi > /tmp/k6-openapi.json -
先按
operationId+description建立索引——这样搜索时需要处理的数据会更少:jq '[.paths | to_entries[] | .key as $p | .value | to_entries[] | {path: $p, method: .key, operationId: .value.operationId, description: .value.description}]' /tmp/k6-openapi.json在这里搜索你需要的操作(“abort”、“limits”、“schedule”……)。
-
提取所选操作的完整架构——包括参数、请求正文和响应,并解析所有
$ref间接引用:jq '.paths["<path>"]["<method>"]' /tmp/k6-openapi.json # Then for each "$ref": "#/components/schemas/Foo": jq '.components.schemas.Foo' /tmp/k6-openapi.json -
按照 §2 的规则添加路径前缀,构建
gcx api调用。gcx 已自动注入授权信息——请勿自行添加。
列表请求
调用列表端点时,默认按 created 降序排序(最新的在前),并按 20 条分页——这两项都使用该操作的 OpenAPI 架构所公开的查询参数名称。参数名称各不相同(ordering=-created、
order_by=created.desc、sort=-created、limit=20、page_size=20),这正是第 3 步中从规范发现它们很重要的原因。最新记录排在前面,意味着用户通常关心的条目会出现在第一页;每页 20 条则能让响应足够精简,便于总结且不会消耗过多上下文。
完整枚举:使用 @nextLink 分页
需要枚举所有行(而非仅查看最新一页)时,v6 列表端点每次响应最多包含 1000 行(默认 $top),并返回一个指向下一页的 @nextLink 字段。gcx k6 runs list --limit 0 不会自动跟进 @nextLink,且默认按升序排列——对于有超过 1000 条历史运行记录的测试,它会悄无声息地返回最旧的 1000 条,而非最新的记录。实际影响是:根据此输出汇总的 (first/last_run) 可能会严重过时(例如,一个每日定时运行多年的测试,其 last_run 会显示为约 3 年前)。若要获取按最新优先排列的数据片段,请使用 gcx api 请求 v6 端点并设置 $orderby=created desc;若要完整枚举,则持续循环处理 @nextLink,直到它不存在。
v6 列表端点公开了 OData 风格的查询参数:
| 参数 | 用途 |
|---|---|
$orderby | 排序——值必须采用 <field> <direction> 格式(例如 created desc)。允许使用的字段因操作而异;常用字段是 created。默认按升序排列。 |
$top | 页面大小,默认值为 1000(同时也是上限) |
$skip | 偏移量(@nextLink 已为你编码了此值) |
$count | 在响应中包含总数 |
循环有两个实用的默认设置:使用 $orderby=created desc,让第一页包含最新的运行记录(大多数时候,Agent 只关心这些记录);使用 -o json 选择退出溢出封装(参见 §2),让各页的解析方式保持一致。
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 "")
同样的模式适用于任何返回 @nextLink 的 v6 列表端点,而不仅是 /test_runs。如果只需要最近的几条记录,则在第一页后停止循环即可——设置 $orderby=created desc 后,该页已经包含最新的 1000 条记录。
指标
时间序列和聚合指标位于 v5 API(/cloud/v5/...)中——它在 OData 风格的 URL 函数调用(query_range_k6(query='...',metric='...'))中采用类似 Prometheus 的查询语义,不包含在 v6 OpenAPI 中。完整的端点参考、选择器语法、各指标类型的查询方法以及示例均已内嵌在 references/metrics.md 中——构造指标查询前请先阅读。
日志
参见 §4。
4. 日志(通过 gcx api 使用 Loki)
插件会代理 /api/plugins/k6-app/resources/logs/ 下的 Loki。你唯一需要手动提供的请求头是 X-K6TestRun-Id;授权由 gcx 处理。
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
按需从 /tmp/run_${RUN_ID}_logs.json 中提取单条日志(例如 jq '.data.result[].values[]' …),而不是重新运行查询。
每个 LogQL 查询必须包含 {test_run_id="<run_id>"} 流选择器——插件代理会按运行划分日志,并拒绝(或对不含此选择器的查询返回空结果)。在此选择器上叠加其他过滤条件:
{test_run_id="<run_id>"}— 所有日志{test_run_id="<run_id>"} | level=~"(error|warn)"— 仅错误和警告{test_run_id="<run_id>"} |= "specific text"— 子字符串过滤
方向和时间窗口提示:
- 将
start/end范围限定为运行的created/ended字段,不要使用墙上时钟的“最近一小时”——否则查询会悄无声息地漏掉时间窗口之外的内容。只有在你确实需要其他时间间隔(例如周边上下文)时才例外。 - 使用
direction=forward,并将start设为run.created,以查找最早的错误。 - 使用
direction=backward,并将end设为run.ended(如果仍在运行,则设为当前时间),以查找最近的输出。 - Loki 的保留时间通常长于 k6 的级联删除周期,因此已删除的子运行日志可能仍可查询一段时间。
5. 安全编辑测试脚本
两个不同的脚本端点
有两个 GET 端点会返回 k6 脚本正文,二者不可互换:
| 端点 | 返回内容 | 使用时机 |
|---|---|---|
/cloud/v6/load_tests/<test_id>/script | 当前负载测试脚本(可变;支持 PUT) | 编辑脚本;读取当前状态 |
/cloud/v6/test_runs/<run_id>/script | 特定运行中随附的脚本快照(只读) | 调查过去某次运行实际执行的内容;比较不同版本 |
二者可能会出现差异:如果负载测试脚本在运行完成后被编辑,该运行随附的快照仍会冻结为运行时实际执行的版本,而负载测试端点则会提供新的当前版本。随附快照端点仅支持 GET——无法修改历史字节内容。
下面的安全编辑流程使用负载测试端点(可编辑的端点)。要比较不同运行的脚本(例如“脚本在最后一次通过和第一次失败的运行之间是否发生了变化?”),请 GET 两次运行各自的 /test_runs/<id>/script,并在本地比较。
脚本正文格式
脚本 GET 端点会返回两种格式之一——在假设格式之前,务必先判断实际格式:
-
单个 js/ts 文件。下面的流程可直接处理这种情况。
-
k6 tar 归档(普通 tar 或 gzip 压缩)。包含多个文件的项目包(入口脚本、导入模块和资源)。要编辑:提取 JS 文件并修改,然后使用
k6 archive从修改后的 JS 重新构建归档。不要使用tar手动重新打包——归档中包含metadata.json,其中有解析后的选项(projectID、阈值、场景);k6 archive会根据脚本源码正确地重新生成这些选项。手动重新打包会保留过期的元数据并导致运行时错误(例如 projectID 不匹配)。# 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 不会公开响应头(参见 §2),因此请使用 file(1) 检查下载的正文,以判断其格式:
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
PUT 正文必须是原始脚本(或归档)字节(application/octet-stream)。gcx 会原样将请求转发给 k6。
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
如果 PUT 返回 415,请再次确认 -H "Content-Type: application/octet-stream" 已正确传递(某些 shell 引号错误可能会将其丢弃)。调试时,可向 gcx api 传递 -vvv,以查看请求/响应跟踪。
6. 浏览器截图
浏览器模块运行会将截图 PNG 写入每次运行对应的 S3 存储中——
这是文件 API 目前提供的唯一产物类型。它们不会通过 /cloud/v6/ 暴露;
获取截图需要通过 cloud-resources/v1/files/ 插件路由,分两步完成。两次
调用都需要使用与日志查询(§4)相同的 X-K6TestRun-Id 请求头——
gcx 仍负责处理身份验证,但必须手动提供运行 ID,否则端点会返回 HTTP 422。
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
值得了解的注意事项
- 先获取索引,再签名。
generate-pre-signed-url不会 验证文件是否存在——它会为任意键生成 URL,只有在尝试下载时 才会出现 404(S3 返回<Error><Code>NoSuchKey</Code>...)。务必从files/index获取文件列表,不要猜测路径格式。 - 批量发送签名请求。
files是一个数组,因此请在一次 POST 中请求所需的所有 URL,而不是每个文件单独调用一次——整个批次共用相同的 24 小时有效期。 - 浏览器测试与协议测试。 只有调用了
page.screenshot()的运行才会在索引中有条目;纯协议/HTTP 运行会返回[]。不要假设索引非空。
7. 浏览器追踪(通过 gcx api 使用 Tempo)
浏览器模块运行会为每次迭代、导航、
定位器点击、截图、Web Vitals 观测等生成 OTel span。插件
会代理 /api/plugins/k6-app/resources/logs/ 下的 Tempo 后端
(与 Loki 使用相同前缀——参见 §4),每次调用都必须提供相同的 X-K6TestRun-Id 请求头。
如果不提供,搜索和获取都会返回
HTTP 401 "Test run ID missing"。
获取分两步完成:TraceQL 搜索 → 按 ID 获取完整追踪。完整追踪很大(对于一个 120-span 的迭代,
磁盘上约为 100 KB,因为 -o json 会进行美化输出;压缩后约为 50 KB),因此
合并和总结这一步能让 Agent 上下文保持精简。
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
面向 Agent 的精简摘要
单次迭代的 OTLP 正文经美化输出后可能约为 100 KB。 将其原样倾倒到上下文中很浪费——大部分有用信息都体现在 span 名称分布、 耗时最长的单个 span 和 Web Vitals 评级中。下面的流水线会将 追踪压缩成约 1–2 KB 的纯文本:
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")
| {n: .a["web_vital.name"], v: .a["web_vital.value"], r: .a["web_vital.rating"]}]
| group_by(.n) | map({name: .[0].n,
ratings: ([.[].r] | group_by(.) | map({(.[0]): length}) | add)})[]
| " \(.name) \(.ratings)"),
"\n── span errors ──",
([$spans[] | select(.status != "OK" and .status != null)] | length | " count: \(.)")
' /tmp/trace_${TRACE_ID}.json
对于像 7542817 这样的运行,输出大致如下:
── span-name rollup (count · total ms · max ms) ──
1 154552 154552 iteration
37 153391 17746 navigation
11 21778 5168 page.waitForNavigation
...
── slowest spans (ms · name · navigation.url / page.goto.url) ──
17746 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects
14992 navigation https://k6e2etests.grafana-dev.net/a/k6-app/projects/58195
...
── web-vital ratings ──
LCP {"poor":5,"needs-improvement":1}
CLS {"good":2}
...
两段文字即可概括哪些导航速度较慢、哪些 Web Vitals 正在退化,以及 整体 span 名称分布——足以分析浏览器性能,而无需逐页翻阅 OTLP 树。
值得了解的注意事项
- 必须提供请求头。
/search和/traces/<id>在缺少X-K6TestRun-Id时都会返回 HTTP 401。此请求头会将查询范围限定为 该测试运行所属的租户。 start/end使用 Unix 秒数。 RFC3339 ISO 字符串会导致HTTP 400 invalid start: strconv.ParseUint。从技术上讲,这两个参数 都是可选的——不提供时搜索也能正常工作——但提供与测试运行的created/ended时间匹配的时间窗口(再加约 60 秒缓冲),可加快 查询速度,并避免匹配到共享同一 场景名称的无关测试运行。- 使用 TraceQL,而非 LogQL。
q参数使用 TraceQL——跨度 谓词写作span.<attribute>,并使用&&组合。建议的 查询会筛选特定场景的迭代根跨度;删除test.scenario可扩大范围, 添加例如span.test.vu = 3 && span.test.iteration.number = 5则可精确定位单个 VU 上的单次迭代。 - 场景名称来自测试运行。 它们是测试运行对象中
.options.scenarios的键——gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID --json options 2>/dev/null | jq -r '.options.scenarios | keys[]'会列出这些名称。默认场景名为default;浏览器测试 通常命名为ui、browser等。 - OTLP 请求体中的跨度 ID 采用 base64 编码。 搜索响应中的
traceID是十六进制格式(可用于/traces/<id>);OTLP 批次中的traceId/spanId/parentSpanId字段则是 base64 编码的字节。可通过parentSpanId == spanId关联父子跨度, 二者均为 base64 编码——无需解码。 - 浏览器测试跨度中实用的属性键包括:
navigation.url、page.goto.url、screenshot.path、web_vital.name、web_vital.value、web_vital.rating、test.scenario、test.vu、test.iteration.number、k6.test_run_id。总结时值得重点呈现这些属性; OTel 值封装格式为{stringValue|intValue|boolValue|doubleValue}(上文的 jqattrval辅助函数可处理全部四种类型)。
8. Cloud Insights(测试运行的审计结果)
Cloud Insights 会对已完成的测试运行执行启发式检查——检查高基数、
Web Vital 回归、缺少阈值、负载生成器利用率过高等问题——并通过单独的
/resources/insights/ 插件路由提供结果(而非
/cloud/v{N}/)。gcx 仍负责处理身份验证;无需额外请求头。
三次调用即可生成数据,而对 Agent 有用的输出需要将其中两次调用的结果 合并:
RUN_ID="<run_id>"
BASE="/api/plugins/k6-app/resources/insights/insights/api/v1/testrun/$RUN_ID"
# 1. List executions for the run, take the most recent. The response
# is { "executions": [ {id, version}, ... ] } — NOT a bare array.
# Pick the last entry; insights re-runs append, and the newest one
# reflects the current set of audits/scores.
EXEC_ID=$(gcx api "$BASE/executions" -o json 2>/dev/null \
| jq -r '.executions[-1].id')
# 2. Audit definitions (id, title, description, weight). ~15 items.
gcx api "$BASE/executions/$EXEC_ID/audits" -o json 2>/dev/null \
> /tmp/insights_audits_${RUN_ID}.json
# 3. Audit results (audit_id, status, score, explanation, actions).
gcx api "$BASE/executions/$EXEC_ID/audits/results" -o json 2>/dev/null \
> /tmp/insights_results_${RUN_ID}.json
两个响应都使用相同的顶层键——audits——但
内容不同:第 2 步是各项审计检查内容的目录,
第 3 步则是该审计在本次测试运行中的发现。通过
result.audit_id == audit.id 将二者关联起来(实际上为一对一)。
合并为简洁、便于 Agent 阅读的摘要
原始审计和结果 JSON 合计约 10 KB。为了读出每项审计的三行内容而将二者
全部输出到上下文中十分浪费——应使用 jq 执行合并,并输出一个精简的文本块。
下面的管道是推荐的形式:
jq -nr \
--slurpfile a /tmp/insights_audits_${RUN_ID}.json \
--slurpfile r /tmp/insights_results_${RUN_ID}.json '
($a[0].audits | INDEX(.id)) as $defs
| $r[0].audits
| map({
title: ($defs[.audit_id].title // "?"),
description: ($defs[.audit_id].description // ""),
status: .status,
status_reason: .status_reason,
score: (
if .score == null then "n/a"
elif .score.type == "binary" then (if .score.value then "pass" else "fail" end)
else (.score.value | tostring)
end),
explanation: (.explanation // "" | gsub("\n+"; " ") | .[0:300]),
actions: (.actions // [])
})
| .[]
| "── \(.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"
'
对于包含 15 项审计的测试运行,这会输出约 5 KB 的纯文本——包含标题、 分数、一行描述、一行说明以及任何行动项。这样 Agent 就能判断测试的健康状况, 无需重新读取任一 JSON 数据块。
值得了解的注意事项
status≠ 判定结果。status: "succeeded"表示审计已执行。判定结果取决于score(binary类型为 true/false,numeric类型为 0…1,其中 1 为最佳)。status: "failed"表示审计本身无法运行(通常是status_reason: "missing data",例如在非追踪测试中运行 HTTP Spans 审计);此时不会有score字段。应将其视为“无信号”,而不是失败。- 不同审计的分数阈值各不相同。
numeric类型的 0.94 对某项审计来说可能尚可,对另一项来说却可能令人担忧——不存在全局阈值。explanation是分数含义的权威说明;应原样呈现,而不是自行制定通过/失败规则。 actions是可采取行动的部分。 只有在审计给出了具体建议时才会出现(例如“降低url标签的基数……”)。如果要为希望改进结果的用户总结一次运行,应优先介绍actions数组非空的审计。- 选择最近一次执行,而不是第一次。
.executions[]按时间顺序排列;重新运行时会追加新记录。较早的执行反映的是较旧的审计逻辑,结果可能已经过时。 - Insights 是运行后的分析。 如果运行尚未结束(或从未产生足够的数据供 Insights 计算),执行列表可能为空;当
length == 0时应妥善退出。
9. 本地 k6 CLI(冒烟测试和 k6 cloud run)
在推送到云端之前,本地 k6 CLI 是进行解析检查和单次迭代冒烟测试的合适工具:
k6 inspect script.js | head -20 # parse-only sanity
k6 run --quiet script.js # run locally
对于 k6 cloud run(从笔记本电脑上传并在云端运行),请使用从 gcx 获取的令牌和堆栈 URL,通过 k6 cloud login 进行身份验证:令牌来自 gcx k6 auth token,堆栈 URL 来自当前 gcx 上下文的 grafana.server 字段:
TOKEN=$(gcx --context <ctx> k6 auth token)
STACK=$(gcx --context <ctx> config view --minify -o json | jq -r '.contexts[].grafana.server')
k6 cloud login --token "$TOKEN" --stack "$STACK"
k6 cloud run script.js
k6 的云端配置(~/.config/k6/cloud.json)仅支持单个上下文,因此每次切换 gcx 上下文时都要重新运行 k6 cloud login;否则,k6 cloud run 会继续指向之前的堆栈。
退出代码:0 表示通过,99 表示阈值未达标,其他任何代码均表示脚本/运行时错误。
10. 注意事项
| 症状 | 原因 | 解决方法 |
|---|---|---|
401 "Invalid or expired token — run gcx login to refresh" | gcx OAuth 会话已过期 | gcx login --context <ctx> |
路径看起来正确,但返回 403 / 404 | 忘记在 REST 端点路径中重复添加 cloud/cloud/ | 使用 …/resources/cloud/cloud/v{N}/… |
对脚本执行 PUT 时返回 415 unsupported media type | 缺少 -H "Content-Type: application/octet-stream" | 添加该请求头;将 -vvv(或 --log-http-payload)传给 gcx api 以检查请求 |
| 脚本 PUT 返回 200,但没有生效 | 修改脚本不会更新 updated 时间戳 | 根据 GET 的 sha256 验证(§5 步骤 7) |
运行状态为 passed,但检查失败 | 零观测值阈值会报告为通过;仅调用 check() 永远不会使运行失败 | 添加 'checks{check:<name>}': ['rate==1.0'];在 catch 块中调用 check(null, {"script completed":()=>false}) 以强制产生观测值 |
| Loki 查询不到近期运行的日志 | 缺少 X-K6TestRun-Id 请求头 | 查询日志时始终传入 -H "X-K6TestRun-Id: <run_id>" |
gcx k6 runs list --limit 0 返回最旧的 1000 条记录,而不是最新记录 | 子命令不会跟随 @nextLink,并且默认按升序排列——运行次数超过 1000 次时,你看到的 first/last 是历史记录的开端,而不是近期活动 | 使用带 $orderby=created desc 的 gcx api 获取按最新优先排列的记录片段,或使用 §3 中完整的 @nextLink 循环 |
gcx k6 load-tests update -f 返回 ✔ Updated,但更改没有生效 | v6 PATCH 架构以外的字段(PatchLoadTestApiModel 只允许 name、baseline_test_run_id——additionalProperties: false)会被静默丢弃。特别是 project_id 无法通过此方式更新 | 更改项目时使用专用的 /move 端点(§11);进行其他修改时,请对照 OpenAPI 规范中的 PATCH 架构。务必重新执行 GET 以确认 |
gcx k6 load-tests list --project-id <id> 返回所有项目中的测试 | 该标志虽被接受,但不会进行筛选——/cloud/v6/load_tests 没有项目查询参数 | 通过 gcx api 使用 GET /cloud/v6/projects/{id}/load_tests,或在客户端使用 jq 中的 select(.project_id == X) 进行筛选 |
通过 gcx api 发送 multipart POST 时返回 HTTP 415 "Unsupported media type \"application/json\"" | Grafana 插件代理会在转发前将 multipart Content-Type 重写为 application/json(gcx 本身会发送你设置的请求头;代理会将其剥除) | 改用直接向 api.k6.io 发起的 curl(§1.2)。影响测试创建(POST /cloud/v6/projects/{id}/load_tests)及其他所有 multipart 端点 |
直接调用 api.k6.io / cloudlogs.k6.io(curl 模式)时返回 401 Unauthorized | 缺少或错误的 Authorization: Bearer … 或 X-Stack-ID 请求头 | 重新检查这两个请求头;通过 /cloud/v6/auth 从堆栈 URL 获取 X-Stack-ID(§1.2) |
调用 api.k6.io/api/plugins/...(curl 模式)时返回 404 | 错误地保留了插件代理前缀 | 移除 /api/plugins/k6-app/resources/{cloud,logs,insights}——参见 §1.2 |
11. update -f 未涵盖的变更
gcx k6 <resource> update -f 遵循该资源的 v6 PATCH 架构。架构之外的字段会被静默丢弃,而 gcx 仍会打印 ✔ Updated <resource> <id>——参见 §10 中的行。有几种常见变更有专用端点,PATCH 路由对它们不会执行任何操作。
| 变更 | 端点 | 请求体 |
|---|---|---|
| 将测试移至另一个项目 | PUT /cloud/v6/load_tests/{id}/move | {"project_id": <int>} |
| 启动测试运行 | POST /cloud/v6/load_tests/{id}/start | {}(或运行选项) |
轮询已启动的运行: 启动运行后,轮询 GET /cloud/v6/test_runs/{id},直到 status 达到 completed 或 aborted。始终同时检查 result 字段和 status——status: completed 且 result: error 表示配置或基础设施故障(并非阈值未达标)。如果结果不是 passed,应立即获取日志(§4)以显示错误,而不是等用户报告。
| 中止正在运行的测试 | POST /cloud/v6/test_runs/{id}/abort | 空 |
| 设置/覆盖计划 | POST /cloud/v6/load_tests/{id}/schedule | 计划请求体(recurrence_rule 或 cron) |
| 停用/重新启用计划 | POST /cloud/v6/schedules/{id}/{deactivate,activate} | 空 |
| 创建负载测试(multipart!) | POST /cloud/v6/projects/{id}/load_tests | multipart:name + script |
| 将运行保留至超过保留期限 | POST /cloud/v6/test_runs/{id}/save | 空(与 /unsave 配套) |
以下 PATCH 式更新可以通过 update -f 完成:
| 资源 | 可更新字段 |
|---|---|
| 负载测试 | name、baseline_test_run_id(依据 PatchLoadTestApiModel) |
| 项目 | 仅 name(依据 PatchProjectApiModel) |
这两种架构都声明了 additionalProperties: false——你放入清单的任何其他字段都会在发送 PATCH 前被静默过滤,即使 gcx 仍会报告 ✔ Updated。不要尝试通过 update -f 切换 is_default 或移动 grafana_folder_uid;v6 PATCH 未开放这些字段的变更操作。
在假设某个字段可变更之前,请先阅读 OpenAPI 规范中的操作请求架构进行交叉核对(工作流见 §3)。
示例——在项目间移动测试
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/<test_id>/move \
-X PUT \
-H "Content-Type: application/json" \
-d '{"project_id": <new_project_id>}'
# Verify — re-GET and check project_id reflects the new value.
# Do not trust the absence of an error from the PUT; HTTP 204 with
# empty body is the success shape but gcx api prints nothing.
gcx --context <ctx> k6 load-tests get <test_id> -o json | jq '{id, name, project_id}'
此端点的 OpenAPI 描述明确指出:“将负载测试移至同一组织中的其他项目。所有相关测试运行也会一并移至新项目。”无需单独迁移运行记录。
值得了解的级联行为
- 删除负载测试会级联删除其计划。 该计划会立即从
/cloud/v6/schedules和/cloud/v6/load_tests/{id}/schedule中消失。无需先执行gcx k6 schedules delete <load-test-id>作为预防措施。 - 如果项目中有正在运行的测试,删除项目会失败并返回 HTTP 409(根据 OpenAPI 规范,错误为 “Cannot delete project with a running test.”)。非运行中的测试似乎会随项目一并删除;但如果你想在删除前盘点项目内容,请使用
GET /cloud/v6/projects/{id}/load_tests(不要使用gcx k6 load-tests list --project-id <id>,它不会进行筛选——见 §10)。 - 移动测试时,其运行记录和运行历史也会一并移动。 计划关联也会跟随测试(计划由
load_test_id关联,而非由项目关联)。
验证经验法则
此技能中反复出现的模式是:“即使底层调用未执行任何操作,gcx 也会确认成功。” 每当你修改状态时:
- 记录你预期会发生的变化(字段、数量、状态)。
- 再次 GET 资源,并确认变化已体现。
- 如果没有体现,请检查该修改是否需要使用专用端点(本节),而不是
update -f。