k6-manage

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Grafana Cloud k6 — interaction reference

Grafana Cloud k6 — 交互参考

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.

默认方式是使用
gcx
CLI。如果未安装gcx,此处的所有端点仍可通过直接curl调用k6的公共主机访问——请参阅§1.2了解身份验证标头和主机转换规则。其余内容遵循两个原则:
  • **当gcx存在时,由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. Authentication

1. 身份验证

1.1 With gcx (default)

1.1 使用gcx(默认方式)

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>
.
bash
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 Without gcx — direct curl

1.2 不使用gcx——直接调用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.
首先检查
command -v gcx
。如果未安装gcx,本技能中的所有端点仍可直接调用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
    只是代理路由。

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".)
请勿猜测这些值——每个会话提示用户一次,获取以下信息:
  1. k6 API令牌——长期有效的Bearer令牌;与gcx配置完成后
    gcx k6 auth token
    输出的值相同。
  2. 堆栈——可以是整数类型的堆栈ID(直接用于
    X-Stack-ID
    )或Grafana堆栈URL(例如
    https://myorg.grafana.net
    )。如果用户提供URL,通过
    GET /cloud/v6/auth
    将其解析为ID一次,该接口接受
    X-Stack-Url
    标头中的URL,并返回
    {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')
    将会话中解析得到的ID缓存起来——后续每次调用都需要在
    X-Stack-ID
    中使用该ID。(注意:
    /cloud/v6/auth
    唯一接受
    X-Stack-Url
    而非
    X-Stack-ID
    的端点——这是从“用户已知URL”转换为“API所需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.

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交由您自行设置的端点特定标头——日志、追踪和文件端点(§4、§6、§7)所需的
    X-K6TestRun-Id
    ——仍需额外添加到身份验证标头对中。
  • §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. How
gcx api
paths are shaped

2.
gcx api
路径的构成

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 pathgcx 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
调用Grafana插件代理路由:
  • 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
flag quirks

gcx api
标志特性

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.

gcx api
并非curl的克隆版本——部分标志与curl的使用习惯不同:
  • 响应体会输出到标准输出。没有用于保存响应体的
    -o <file>
    标志;
    -o
    用于选择输出格式
    json
    yaml
    agents
    )。请使用shell重定向(
    > file
    )或
    $(...)
    捕获输出。
  • 请求体使用
    -d <string>
    -d @file
    -d @-
    (标准输入)。没有
    --data-binary
    -d @file
    已保留字节内容。
  • 无需jq即可选择字段
    --json field1,field2,...
    仅返回指定字段,
    --json list
    (或
    --json '?'
    )可查看可用字段。对于浅层提取,这通常比管道输出到jq更简洁。
  • 标准错误输出信息:gcx在大多数调用时会向标准错误输出打印一行
    hint:
    提示。管道输出到jq时应使用
    2>/dev/null
    重定向,避免意外问题。
  • 响应标头无法通过
    gcx api
    直接查看。当工作流程依赖
    Content-Type
    时(例如§5中的脚本GET请求),请使用
    file <path>
    检查下载的响应体。
  • 大响应会溢出到临时文件,标准输出仅保留一个包装信封。阈值约为几十KB,因此即使是中等大小的列表端点也会触发此行为。信封格式如下:
    json
    {"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
      **强制将输出内联,无论大小如何(推荐用于解析响应体的脚本)。代理模式的默认格式会触发溢出;
      -o json
      可禁用此行为。
    • 或者检测信封并从溢出文件重新读取
      python
      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. Discovering and calling endpoints

3. 发现并调用端点

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.
gcx k6
子命令(请参阅
gcx help-tree k6
)涵盖了常见的读取操作。对于其他所有操作——变更、小众读取、未作为子命令公开的端点——k6 Cloud API的范围很广且会随时间变化。与其依赖过时的对照表,不如在需要时从OpenAPI规范中发现所需操作。

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.
  1. 每个会话获取一次规范(文件较大;缓存到
    /tmp
    ):
    bash
    gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/openapi > /tmp/k6-openapi.json
  2. 首先按
    operationId
    +
    description
    索引
    ——这样在搜索时可保持工作负载较小:
    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搜索,查找所需操作(例如“abort”、“limits”、“schedule”等)。
  3. 提取所选操作的完整模式——参数、请求体、响应——解析所有
    $ref
    引用:
    bash
    jq '.paths["<path>"]["<method>"]' /tmp/k6-openapi.json
    # 对于每个"$ref": "#/components/schemas/Foo":
    jq '.components.schemas.Foo' /tmp/k6-openapi.json
  4. 构建
    gcx api
    调用
    ,使用§2中提到的前缀路径。gcx已注入身份验证——请勿自行添加。

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.
当调用的操作是列表端点时,默认按**
created
降序排序**(最新的在前)并按20条分页——两者均通过该操作的OpenAPI模式公开的查询参数实现。参数名称各不相同(
ordering=-created
order_by=created.desc
sort=-created
limit=20
page_size=20
),这正是步骤3中从规范中发现它们的原因。最新在前意味着用户通常关心的条目会出现在第一页;每页20条可保持响应足够小,便于汇总而不会占用过多上下文。

Full enumeration: paginating with
@nextLink

完整枚举:使用
@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:
ParamPurpose
$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 = []
当需要枚举所有行(而不仅仅是最新的一页)时,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
使第一页包含最新的运行(大多数情况下代理只关心这些),以及
-o json
禁用溢出信封(请参阅§2),使各页面的解析方式一致。
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().

从最新的开始;-o json保持响应体内联,便于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.
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返回的是完整的http://api.k6.io/... URL, # 已编码OData参数($skip、$top、$orderby)。将其 # 路径+查询转换为插件代理前缀: 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次运行。

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
— read that before constructing a metrics query.
时间序列和聚合指标位于v5 API(
/cloud/v5/...
)——采用OData风格的URL函数调用(
query_range_k6(query='...',metric='...')
)实现类Prometheus的查询语义,未被v6 OpenAPI捕获。完整的端点参考、选择器语法、各指标类型的查询方法和示例都内联在
references/metrics.md
中——构建指标查询前请先阅读该文档。

Logs

日志

See §4.

请参阅§4。

4. Logs (Loki via
gcx api
)

4. 日志(通过
gcx api
调用Loki)

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>"
插件将Loki代理到
/api/plugins/k6-app/resources/logs/
下。您唯一需要手动设置的标头是
X-K6TestRun-Id
;gcx处理身份验证。
bash
RUN_ID="<run_id>"

Scope the log window to the run itself:
created
for start,
ended

将日志窗口范围限定为运行本身:
created
为开始时间,
ended

for end. If
ended
is null the run is still in progress — fall back

为结束时间。如果
ended
为null,说明运行仍在进行中——退而使用"now"。仅当运行对象的

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)
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

k6输出的时间精度为亚秒级(例如"...:58.984041Z");jq的

fromdateiso8601 only accepts whole seconds, so strip the fraction.

fromdateiso8601仅接受整秒,因此需要去掉小数部分。

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)
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

将原始响应保存到/tmp/——日志负载可能很大;流式输出会占用过多上下文。从文件汇总信息,仅在需要时使用jq查询特定条目。

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
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.

---
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. Editing a test script safely

5. 安全编辑测试脚本

Two distinct script endpoints

两个不同的脚本端点

There are two GET endpoints that return a k6 script body, and they are not interchangeable:
EndpointWhat it returnsWhen 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.
有两个GET端点返回k6脚本内容,且它们不可互换:
端点返回内容使用场景
/cloud/v6/load_tests/<test_id>/script
当前负载测试脚本(可修改;支持PUT)编辑脚本;读取当前状态
/cloud/v6/test_runs/<run_id>/script
特定运行中打包的脚本快照(只读)调查过去的运行实际执行了什么;对比不同版本的差异
两者可能会出现差异:如果负载测试脚本在运行完成后被编辑,该运行的打包快照会保持执行时的状态不变,而负载测试端点会返回新的当前版本。打包快照端点仅支持GET——您无法修改历史字节内容。
下面的安全编辑流程使用负载测试端点(可编辑的那个)。如需对比不同运行的脚本差异(例如“最后一次通过的运行和第一次失败的运行之间脚本是否有变化?”),请GET两个运行的
/test_runs/<id>/script
并在本地对比差异。

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
脚本GET端点返回两种格式之一——在假设格式前务必先检测:
  • 单个js/ts文件。下面的流程直接处理此情况。
  • k6 tar归档文件(普通tar或gzip压缩)。多文件项目包(入口脚本 + 导入模块 + 资源)。如需编辑:提取JS文件,修改后使用
    k6 archive
    从修改后的JS重新构建归档文件。请勿手动使用
    tar
    重新打包
    ——归档文件包含
    metadata.json
    ,其中包含从脚本源正确生成的解析选项(projectID、阈值、场景)。手动打包会保留过时的元数据并导致运行时错误(例如projectID不匹配)。
    bash
    # 编辑k6 tar归档文件
    tar -xf /tmp/script_body -C /tmp/extracted/   # 提取
    # ... 编辑JS文件 ...
    k6 archive /tmp/edited_script.js -O /tmp/new_archive.tar  # 重新构建
gcx api
不暴露响应标头(请参阅§2),因此请使用
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

ASCII text → js/ts源码

POSIX tar archive → k6 tar bundle

POSIX tar archive → k6 tar包

gzip compressed data → gzipped tar bundle

gzip compressed data → gzip压缩的tar包


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>"

PUT请求体必须是原始脚本(或归档文件)的字节内容(`application/octet-stream`)。gcx会将请求原样转发给k6。

```bash
ID="<load_test_id>"

1. Pull current script (single-file js/ts case)

1. 获取当前脚本(单文件js/ts情况)

gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
/tmp/script_current.js
gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/load_tests/$ID/script \
/tmp/script_current.js

2. Backup

2. 备份

cp /tmp/script_current.js /tmp/script_backup_$(date +%s).js
cp /tmp/script_current.js /tmp/script_backup_$(date +%s).js

3. Edit → /tmp/script_new.js

3. 编辑 → /tmp/script_new.js

4. Local parse-only sanity check

4. 本地仅解析检查

k6 inspect /tmp/script_new.js | head -20
k6 inspect /tmp/script_new.js | head -20

5. 1-iteration smoke (edit IN-FILE; --iterations CLI flag breaks browser scenarios)

5. 1次迭代的冒烟测试(在文件内编辑;--iterations CLI标志会破坏浏览器场景)

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
sed 's/iterations: [0-9]+/iterations: 1/' /tmp/script_new.js > /tmp/script_1iter.js k6 run --quiet /tmp/script_1iter.js # 退出码0=通过,99=阈值未通过

6. PUT — Content-Type must be application/octet-stream

6. PUT请求——Content-Type必须为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"
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.

7. 通过重新获取并对比sha256验证——两个哈希值必须匹配。

Do not rely on the
updated
timestamp; it does not bump on script PUT.

请勿依赖
updated
时间戳;脚本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.

---
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. Browser screenshots

6. 浏览器截图

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>"
浏览器模块运行会将截图PNG写入每个运行的S3存储——这是文件API目前公开的唯一 artifact 类型。它们不会通过
/cloud/v6/
暴露;需要通过单独的
cloud-resources/v1/files/
插件路由分两步获取。两次调用都需要使用日志查询(§4)中用到的相同
X-K6TestRun-Id
标头——gcx仍处理身份验证,但必须手动提供运行ID,否则端点会返回HTTP 422错误。
bash
RUN_ID="<run_id>"

1. Index — JSON array of screenshot paths owned by this run. Empty

1. 索引——该运行拥有的截图路径JSON数组。如果运行未生成截图(例如非浏览器测试,或

array if the run took none (e.g. non-browser tests, or browser

未调用page.screenshot()的浏览器运行),则返回空数组。

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
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"

"7542817/files/screenshots/screenshots/login-success.png"

The doubled "screenshots/screenshots/" segment is real, not a typo —

重复的"screenshots/screenshots/"段是真实存在的,并非输入错误——

pass the path back unchanged to step 2.

第二步中请原样传递该路径。

2. Request pre-signed download URLs for the files you want. The

2. 请求所需文件的预签名下载URL。请求体必须为JSON;Content-Type很重要。

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
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

3. 直接下载每个预签名URL——它们是普通的S3 GET请求,无需gcx和身份验证标头(签名已包含在URL中)。URL的有效期为24小时(X-Amz-Expires=86400);过期后请重新运行第二步。

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
undefined
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
undefined

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.

  • 先索引再签名。
    generate-pre-signed-url
    不会验证文件是否存在——它会为任何密钥生成URL,只有在您尝试下载时才会出现404错误(S3返回
    <Error><Code>NoSuchKey</Code>...
    )。请始终从
    files/index
    获取文件列表,而非猜测路径格式。
  • 批量签名请求。
    files
    是数组,因此请在一次POST请求中获取所有需要的URL,而非每个文件调用一次——同一24小时有效期适用于整个批量请求。
  • **浏览器测试与协议测试。**只有调用了
    page.screenshot()
    的运行才会在索引中有条目;纯协议/HTTP运行会返回
    []
    。请勿假设索引非空。

7. Browser traces (Tempo via
gcx api
)

7. 浏览器追踪(通过
gcx api
调用Tempo)

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>"
浏览器模块运行会为每次迭代、导航、定位器点击、截图、Web性能指标观测等事件发送OTel span。插件将Tempo后端代理到
/api/plugins/k6-app/resources/logs/
下(与Loki使用相同前缀——请参阅§4),每次调用都需要使用相同的
X-K6TestRun-Id
标头。如果没有该标头,搜索和获取都会返回HTTP 401错误
"Test run ID missing"
获取流程分为两步:TraceQL搜索 → 通过ID获取完整追踪。完整追踪数据较大(对于包含120个span的迭代,
-o json
格式化后约为100 KB;压缩后约50 KB),因此合并+汇总步骤可保持代理上下文简洁。
bash
RUN_ID="<run_id>"

1. Find the run's start/end window and pick the scenario name.

1. 获取运行的开始/结束窗口并选择场景名称。

Same caveat as §4: strip the sub-second fraction before

与§4相同的注意事项:在fromdateiso8601之前去掉亚秒小数部分,如果运行仍在进行中则退而使用"now"。添加一个小缓冲区,因为span的时间可能略晚于运行对象报告的
ended
时间。

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
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" # 场景名称;请检查run.ui.exec / k6脚本

2. Search for iteration root spans. TraceQL goes in
q
; URL-encode

2. 搜索迭代根span。TraceQL放在
q
中;URL编码。

it.
start
/
end
must be unix seconds — RFC3339 returns

start
/
end
必须为unix秒——RFC3339格式会返回

HTTP 400
invalid start: strconv.ParseUint
. limit=500 caps the

HTTP 400
invalid start: strconv.ParseUint
。limit=500限制结果数量,足以覆盖数千次迭代。

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
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"),

每个条目包含:traceID(32位十六进制)、rootServiceName("k6")、

rootTraceName ("iteration"), durationMs, startTimeUnixNano, and a

rootTraceName("iteration")、durationMs、startTimeUnixNano,以及迭代根span属性的

spanSet preview of the iteration root span's attributes

spanSet预览(test.iteration.number、test.vu、test.scenario)。

(test.iteration.number, test.vu, test.scenario).

3. 获取完整追踪。响应为OTLP格式:

3. Fetch a full trace. The response is OTLP-shaped:

{ "batches": [ { "resource": {...}, "scopeSpans": [ { "spans": [...] } ] } ] }

{ "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
undefined
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
undefined

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")
       | {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
On a run like 7542817 the output is something like:
── 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}
  ...
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.
单次迭代的OTLP响应体格式化后约为100 KB。将其直接转储到上下文会浪费资源——大多数有用的信号都存在于span名称分布、最慢的单个span和Web性能指标评级中。下面的管道将追踪数据压缩为约1–2 KB的纯文本:
bash
jq -r '
  # OTel值信封:按键存在性选择,而非使用//链式操作——jq的//
  # 会将`false`视为缺失,从而丢弃合法的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名称汇总(数量 · 总耗时ms · 最大耗时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── 最慢的span(耗时ms · 名称 · 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性能指标评级 ──",
    ([$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错误 ──",
    ([$spans[] | select(.status != "OK" and .status != null)] | length | "  数量: \(.)")
' /tmp/trace_${TRACE_ID}.json
对于运行7542817,输出如下:
── span名称汇总(数量 · 总耗时ms · 最大耗时ms) ──
  1     154552   154552  iteration
  37    153391   17746   navigation
  11    21778    5168    page.waitForNavigation
  ...

── 最慢的span(耗时ms · 名称 · 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性能指标评级 ──
  LCP   {"poor":5,"needs-improvement":1}
  CLS   {"good":2}
  ...
两段文本即可捕获哪些导航较慢、哪些Web性能指标出现退化,以及整体span名称分布——无需翻阅OTLP树即可分析浏览器性能。

Notes worth knowing

值得注意的事项

  • Header is mandatory. Both
    /search
    and
    /traces/<id>
    return HTTP 401 without
    X-K6TestRun-Id
    . The header scopes the query to the run's tenant.
  • start
    /
    end
    are unix seconds.
    RFC3339 ISO strings get
    HTTP 400 invalid start: strconv.ParseUint
    . They're technically optional — search works without them — but providing a window matching the run's
    created
    /
    ended
    (plus ~60s buffer) keeps the query fast and avoids matching unrelated runs that share the scenario name.
  • TraceQL, not LogQL. The
    q
    parameter uses TraceQL — span predicates are written as
    span.<attribute>
    and combined with
    &&
    . The suggested query filters iteration root spans for a specific scenario; broaden by dropping
    test.scenario
    , narrow by adding e.g.
    span.test.vu = 3 && span.test.iteration.number = 5
    to pinpoint a single iteration on a single VU.
  • Scenario names come from the run. They're the keys of
    .options.scenarios
    on the run object —
    gcx api /api/plugins/k6-app/resources/cloud/cloud/v6/test_runs/$RUN_ID --json options 2>/dev/null | jq -r '.options.scenarios | keys[]'
    lists them. The default scenario is named
    default
    ; browser tests often name it
    ui
    ,
    browser
    , etc.
  • Span IDs in the OTLP body are base64. The
    traceID
    in the search response is hex (the form
    /traces/<id>
    accepts); the inner
    traceId
    /
    spanId
    /
    parentSpanId
    fields inside the OTLP batch are base64-encoded bytes. Cross-correlate parent/child via
    parentSpanId == spanId
    , both in base64 — no need to decode.
  • Useful attribute keys on browser-test spans:
    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
    . These are the ones worth surfacing in summaries; the OTel value envelope is
    {stringValue|intValue|boolValue|doubleValue}
    (the jq
    attrval
    helper above handles all four).

  • 标头是必填项。
    /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谓词写为
    span.<attribute>
    ,并使用
    &&
    组合。建议的查询会过滤特定场景的迭代根span;去掉
    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响应体中的Span ID是base64编码的。**搜索响应中的
    traceID
    是十六进制格式(
    /traces/<id>
    接受此格式);OTLP批处理中的内部
    traceId
    /
    spanId
    /
    parentSpanId
    字段是base64编码的字节。通过
    parentSpanId == spanId
    关联父/子span,两者均为base64编码——无需解码。
  • 浏览器测试span上的有用属性键
    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 (audit results for a run)

8. Cloud Insights(运行的审计结果)

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
/resources/insights/
plugin route (not
/cloud/v{N}/
). gcx still handles auth; no extra header is needed.
Three calls produce the data, and the agent-useful output is a join across two of them:
bash
RUN_ID="<run_id>"
BASE="/api/plugins/k6-app/resources/insights/insights/api/v1/testrun/$RUN_ID"
Cloud Insights会针对已完成的测试运行启发式检查——检查高基数、Web性能指标退化、缺失阈值、负载生成器过度使用等问题——并通过单独的
/resources/insights/
插件路由公开结果(而非
/cloud/v{N}/
)。gcx仍处理身份验证;无需额外标头。
三次调用可获取数据,对代理有用的输出是其中两次调用结果的合并:
bash
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

1. 列出运行的执行记录,取最新的一条。响应格式为

is { "executions": [ {id, version}, ... ] } — NOT a bare array.

{ "executions": [ {id, version}, ... ] } —— 不是裸数组。

Pick the last entry; insights re-runs append, and the newest one

选择最后一个条目;insights重新运行时会追加记录,最新的条目

reflects the current set of audits/scores.

反映当前的审计/评分集。

EXEC_ID=$(gcx api "$BASE/executions" -o json 2>/dev/null
| jq -r '.executions[-1].id')
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.

2. 审计定义(id、标题、描述、权重)。约15项。

gcx api "$BASE/executions/$EXEC_ID/audits" -o json 2>/dev/null \
/tmp/insights_audits_${RUN_ID}.json
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).

3. 审计结果(audit_id、状态、评分、说明、操作建议)。

gcx api "$BASE/executions/$EXEC_ID/audits/results" -o json 2>/dev/null \
/tmp/insights_results_${RUN_ID}.json

Both responses use the same top-level key — `audits` — but the
*contents* 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 `result.audit_id == audit.id` (1:1 in practice).
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`将两者关联(实际上是1:1关系)。

Joining into a compact, agent-readable summary

合并为代理可读的简洁汇总

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
jq
and emit a single tight block. The pipeline below is the recommended shape:
bash
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"
'
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.
原始审计+结果JSON合计约10 KB。将两者都转储到上下文只为读取每项审计的三行内容是浪费资源——请使用
jq
合并并输出单个紧凑块。推荐使用以下管道格式:
bash
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 "通过" else "失败" end)
        else (.score.value | tostring)
        end),
      explanation: (.explanation // "" | gsub("\n+"; " ") | .[0:300]),
      actions:     (.actions // [])
    })
  | .[]
  | "── \(.title) — 评分: \(.score)\(if .status == "failed" then " [审计未运行: \(.status_reason // "未知")]" else "" end)\n  \(.description)\n  → \(.explanation)\(if (.actions|length) > 0 then "\n  操作建议:\n    - " + (.actions|join("\n    - ")) else "" end)\n"
'
对于包含15项审计的运行,此管道会输出约5 KB的纯文本——标题、评分、一行描述、一行说明以及任何操作建议。这足以让代理分析测试的健康状况,而无需重新读取任何JSON blob。

Notes worth knowing

值得注意的事项

  • status
    ≠ verdict.
    status: "succeeded"
    means the audit executed. The verdict lives in
    score
    (
    binary
    true/false, or
    numeric
    0…1 where 1 is best).
    status: "failed"
    means the audit itself could not run (typically
    status_reason: "missing data"
    — e.g. the HTTP Spans audit on a non-tracing test); the
    score
    field is absent. Treat these as "no signal", not as failures.
  • Score thresholds vary per audit. A
    numeric
    0.94 might be fine for one audit and concerning for another — there's no global cutoff. The
    explanation
    is authoritative for what the score means; surface it verbatim rather than inventing a pass/fail rule.
  • actions
    is the actionable bit.
    Only present when the audit has concrete recommendations (e.g. "Reduce the cardinality of the
    url
    label …"). When summarising a run for a user who's trying to improve it, lead with audits that have a non-empty
    actions
    array.
  • Pick the last execution, not the first.
    .executions[]
    is in chronological order; re-runs append. Older executions reflect older audit logic and may have stale results.
  • Insights is a post-run analysis. 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
    length == 0
    .

  • status
    ≠ 结论。
    status: "succeeded"
    表示审计已执行。结论在
    score
    中(
    binary
    类型为true/false,
    numeric
    类型为0…1,1为最佳)。
    status: "failed"
    表示审计本身无法运行(通常
    status_reason: "missing data"
    ——例如非追踪测试上的HTTP Span审计);此时
    score
    字段不存在。请将这些情况视为“无信号”,而非失败。
  • 评分阈值因审计而异。
    numeric
    类型的0.94对某些审计来说可能没问题,但对其他审计来说可能值得关注——没有全局阈值。
    explanation
    是评分含义的权威说明;请原样展示,而非自行制定通过/失败规则。
  • **
    actions
    是可操作的部分。**仅当审计有具体建议时才会存在(例如“降低
    url
    标签的基数……”)。当为试图改进测试的用户汇总运行结果时,请优先展示
    actions
    数组非空的审计。
  • 选择最后一次执行记录,而非第一次。
    .executions[]
    按时间顺序排列;重新运行时会追加记录。较早的执行记录反映较旧的审计逻辑,结果可能过时。
  • **Insights是运行后分析。**如果运行尚未完成(或从未生成足够的数据供insights计算),执行记录列表可能为空——请在
    length == 0
    时优雅退出。

9. Local k6 CLI (smoke tests and
k6 cloud run
)

9. 本地k6 CLI(冒烟测试和
k6 cloud run

The local
k6
CLI is the right tool for parse checks and 1-iteration smoke runs before pushing to cloud:
bash
k6 inspect script.js | head -20      # parse-only sanity
k6 run --quiet script.js             # run locally
For
k6 cloud run
(uploads + runs in cloud from your laptop), authenticate
k6 cloud login
with the token and stack URL pulled from gcx — token comes from
gcx k6 auth token
, stack URL from the active gcx context's
grafana.server
field:
bash
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's cloud config (
~/.config/k6/cloud.json
) is single-context, so re-run
k6 cloud login
whenever you switch gcx contexts — otherwise
k6 cloud run
will keep targeting the previous stack.
Exit codes:
0
pass,
99
threshold fail, anything else = script/runtime error.

本地
k6
CLI是推送至云之前进行解析检查和1次迭代冒烟测试的合适工具:
bash
k6 inspect script.js | head -20      # 仅解析检查
k6 run --quiet script.js             # 本地运行
对于
k6 cloud run
(从笔记本电脑上传并在云中运行),使用从gcx获取的令牌和堆栈URL进行
k6 cloud login
身份验证——令牌来自
gcx k6 auth token
,堆栈URL来自活动gcx上下文的
grafana.server
字段:
bash
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. Gotchas

10. 常见问题

SymptomCauseFix
401 "Invalid or expired token — run gcx login to refresh"
gcx OAuth session expired
gcx login --context <ctx>
403 / 404
on a path that looks right
Forgot the doubled
cloud/cloud/
for a REST endpoint
Use
…/resources/cloud/cloud/v{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
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)
Run status
passed
but checks failed
Zero-observation thresholds report as pass;
check()
alone never fails a run
Add
'checks{check:<name>}': ['rate==1.0']
; in catch blocks,
check(null, {"script completed":()=>false})
to force an observation
Loki query returns nothing for a recent run
X-K6TestRun-Id
header missing
Always pass
-H "X-K6TestRun-Id: <run_id>"
on log queries
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
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
gcx k6 load-tests list --project-id <id>
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
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
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)
404
calling
api.k6.io/api/plugins/...
(curl mode)
Left the plugin-proxy prefix in by mistakeStrip
/api/plugins/k6-app/resources/{cloud,logs,insights}
— see §1.2

症状原因解决方法
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"
添加该标头;向
gcx api
传递
-vvv
(或
--log-http-payload
)检查请求
脚本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
是历史最早的记录,而非最近活动
使用
gcx api
并设置
$orderby=created desc
获取最新在前的切片,或使用§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本身会发送您设置的标头;代理会将其移除)
退而使用直接curl调用
api.k6.io
(§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. Mutations not covered by
update -f

11.
update -f
未覆盖的变更操作

gcx k6 <resource> update -f
follows the v6 PATCH schema for that resource. Fields outside the schema are silently dropped while gcx still prints
✔ Updated <resource> <id>
— see the §10 row. Several common mutations have dedicated endpoints instead, and the PATCH route will no-op on them.
MutationEndpointBody
Move test to another project
PUT /cloud/v6/load_tests/{id}/move
{"project_id": <int>}
Start a test run
POST /cloud/v6/load_tests/{id}/start
{}
(or run options)
Polling a started run: After starting a run, poll
GET /cloud/v6/test_runs/{id}
until
status
reaches
completed
or
aborted
. Always check the
result
field alongside
status
status: completed
with
result: error
means a configuration or infrastructure failure (not a threshold breach). On any non-
passed
result, immediately fetch logs (§4) to surface the error rather than waiting for the user to report it.
| Abort a running test |
POST /cloud/v6/test_runs/{id}/abort
| empty | | Set / overwrite a schedule |
POST /cloud/v6/load_tests/{id}/schedule
| Schedule body (recurrence_rule or cron) | | Deactivate / reactivate a schedule |
POST /cloud/v6/schedules/{id}/{deactivate,activate}
| empty | | Create a load test (multipart!) |
POST /cloud/v6/projects/{id}/load_tests
| multipart:
name
+
script
| | Persist a run past retention |
POST /cloud/v6/test_runs/{id}/save
| empty (paired with
/unsave
) |
The PATCH-style updates that do work via
update -f
:
ResourceUpdatable fields
load test
name
,
baseline_test_run_id
(per
PatchLoadTestApiModel
)
project
name
only (per
PatchProjectApiModel
)
Both schemas declare
additionalProperties: false
— any other field you put in the manifest is silently filtered out before the PATCH is sent, even though gcx still reports
✔ Updated
. Don't try to flip
is_default
or move a
grafana_folder_uid
through
update -f
; they're not exposed for mutation on the v6 PATCH.
Cross-check by reading the operation's request schema in the OpenAPI spec (workflow in §3) before assuming a field is mutable.
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
配合使用) |
可通过
update -f
实现的PATCH式更新:
资源可更新字段
负载测试
name
baseline_test_run_id
(符合
PatchLoadTestApiModel
项目
name
(符合
PatchProjectApiModel
两种模式都声明
additionalProperties: false
——您在清单中添加的任何其他字段都会在发送PATCH前被静默过滤,即使gcx仍会报告
✔ Updated
。请勿尝试通过
update -f
修改
is_default
或移动
grafana_folder_uid
;它们未在v6 PATCH中暴露为可修改字段。
在假设字段可修改之前,请通过OpenAPI规范中的操作请求模式交叉检查(§3中的工作流程)。

Worked example — move a test between projects

示例——在项目之间移动测试

bash
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>}'
bash
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.

验证——重新GET并检查project_id是否已更新为新值。

Do not trust the absence of an error from the PUT; HTTP 204 with

请勿依赖PUT请求无错误;HTTP 204且响应体为空是成功的标志,但gcx api不会输出任何内容。

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}'

The OpenAPI description for this endpoint is explicit: *"Move a load
test to a different project of the same organization. All respective
test runs will be also moved to the new project."* You don't need to
migrate runs separately.
gcx --context <ctx> k6 load-tests get <test_id> -o json | jq '{id, name, project_id}'

该端点的OpenAPI描述明确说明:*"将负载测试移动到同一组织的不同项目。所有相关的测试运行也会被移动到新项目中。"*您无需单独迁移运行记录。

Cascade behavior worth knowing

值得了解的级联行为

  • Deleting a load test cascade-deletes its schedule. The schedule is gone from
    /cloud/v6/schedules
    and
    /cloud/v6/load_tests/{id}/schedule
    immediately. No need to
    gcx k6 schedules delete <load-test-id>
    first as a defensive step.
  • Deleting a project with a running test fails with HTTP 409 ("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
    GET /cloud/v6/projects/{id}/load_tests
    (NOT
    gcx k6 load-tests list --project-id <id>
    , which doesn't filter — see §10).
  • Moving a test moves its runs and run history with it. Schedule attachment also follows the test (it's keyed by
    load_test_id
    , not by project).
  • **删除负载测试会级联删除其调度。**调度会立即从
    /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
    为键,而非项目)。

Verification rule of thumb

验证经验法则

The recurring pattern in this skill is "gcx confirms success even when the underlying call no-ops". Whenever you mutate state:
  1. Note what you expected to change (field, count, status).
  2. Re-GET the resource and confirm the change is reflected.
  3. If it isn't, check whether the mutation needed a dedicated endpoint (this section) rather than
    update -f
    .
本技能中反复出现的模式是*"gcx确认成功,但底层调用实际未生效"*。每当您修改状态时:
  1. 记录您期望变更的内容(字段、计数、状态)。
  2. 重新GET资源并确认变更已生效。
  3. 如果未生效,请检查该变更是否需要专用端点(本节内容)而非
    update -f