mcpField
K
k6-manage

k6-manage

github.com/grafana测试与质量2953.0k 次安装1 天前更新
代码仓库

关于 k6-manage

k6-manage 是通过 gcx 命令行或 curl 操作 Grafana Cloud k6 的技能参考,涵盖认证、API 探索、测试运行数据及端点调用注意事项。

主要功能

  1. 01gcx 命令行与 curl 操作指引
  2. 02Grafana Cloud 认证设置
  3. 03基于 OpenAPI 探索端点
  4. 04列表排序与分页指引
  5. 05REST、日志和追踪请求说明

适用场景

  1. 01登录 k6 Cloud 账户
  2. 02查找 gcx k6 未覆盖的 API 操作
  3. 03列出最新或全部测试运行
  4. 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
  • 不需要 /api/plugins/k6-app/resources/{cloud,logs,insights} 前缀。 删除此前缀;gcx 示例中此前缀之后的所有内容都是真正的 k6 路径。§2 中重复的 cloud/cloud/ 特殊情况会简化为单个 /cloud/——第一个 cloud/ 只是代理路由。
获取凭据

不要猜测这些信息——每个会话只需向用户询问一次:

  1. k6 API 令牌 — 长效 Bearer 令牌;gcx 配置完成后,gcx k6 auth token 会输出相同的值。

  2. 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/123curl https://api.k6.io/cloud/v6/test_runs/123 -H ...
gcx api /api/plugins/k6-app/resources/cloud/cloud/v5/test_runs/<id>/metricscurl https://api.k6.io/cloud/v5/test_runs/<id>/metrics -H ...
gcx api /api/plugins/k6-app/resources/cloud/cloud-resources/v1/files/indexcurl https://api.k6.io/cloud-resources/v1/files/index -H ...
gcx api /api/plugins/k6-app/resources/insights/insights/api/v1/testrun/<id>/executionscurl 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 调用时的行为完全相同。服务器返回的 @nextLink URL 已经是绝对的 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}/metricsgcx 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 规范中发现所需操作。

工作流
  1. 每次会话获取一次规范(文件较大;缓存到 /tmp):

    gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/openapi > /tmp/k6-openapi.json
    
  2. 先按 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”……)。

  3. 提取所选操作的完整架构——包括参数、请求正文和响应,并解析所有 $ref 间接引用:

    jq '.paths["<path>"]["<method>"]' /tmp/k6-openapi.json
    # Then for each "$ref": "#/components/schemas/Foo":
    jq '.components.schemas.Foo' /tmp/k6-openapi.json
    
  4. 按照 §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}(上文的 jq attrval 辅助函数可处理全部四种类型)。

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 也会确认成功。” 每当你修改状态时:

  1. 记录你预期会发生的变化(字段、数量、状态)。
  2. 再次 GET 资源,并确认变化已体现。
  3. 如果没有体现,请检查该修改是否需要使用专用端点(本节),而不是 update -f。

评论

支持 Markdown