Ядро

Встроенные действия

Действия (actions) — это единицы, из которых состоят steps нативного теста. Каждый шаг указывает действие в use, передаёт параметры в with и может проверять результат в check.

Полные идентификаторы содержат пространство имён и версию (std/http@v1); короткие псевдонимы (http, graphql, tcp, udp, pubsub, llm, ws, ws-connect, ws-send, ws-recv, ws-ping, ws-close, grpc, grpc-connect, grpc-call, grpc-stream-open, grpc-stream-send, grpc-stream-recv, grpc-stream-close, db-connect, db-query, db-tx-begin, db-tx-commit, db-tx-rollback, db-close, check, random, sleep, log, file-read, read-json, pick, file-write, child_process, kill_process, set_shared_variable, get_shared_variable) разрешаются в те же реализации.

Ещё два пространства имён находятся за пределами встроенных: действия pro/* — проприетарные модули, регистрируемые сборкой агента, а lib/<alias>/<step> (RFC 009) вызывает пользовательский шаг, экспортированный WASM-библиотекой, объявленной в libraries: — компонент получает интерполированные параметры with: и возвращает тот же формат success/output/logs/metrics, что и встроенные. См. Библиотеки.

std/http@v1

Выполняет один HTTP-запрос за итерацию. Время выполнения попадает в метрики прогона.

ПараметрТипПо умолчаниюОписание
urlstringобязателенАбсолютный URL
methodstringGETЛюбой HTTP-метод, включая расширенные методы вроде QUERY (безопасный метод с телом, draft-ietf-httpbis-safe-method-w-body)
headersobject—{ "Name": "Value" }, значения — только строки
bodystring | object—Строка → text/plain; объект → сериализованный JSON с application/json. Взаимоисключающий с multipart
multipartarray—Отправка multipart/form-data — см. Multipart-загрузки. Взаимоисключающий с body
timeoutinteger (мс)10000Таймаут на запрос
insecurebooleanfalseПропустить проверку TLS-сертификата — для целей с самоподписанными сертификатами, например perfscale serve --tls. Никогда не используйте против хостов, которые вы не контролируете
poolstringper-vuper-vu привязывает шаг к шарду HTTP-клиента виртуального пользователя; shared переводит всех VU на один глобальный для процесса клиент; dedicated выдаёт каждому VU собственный клиент, живущий до выхода VU — см. Выделенные соединения на VU
source_ipsstring | array—Локальные исходные адреса для исходящих соединений — см. Исходные IP. VU i привязывается к ips[(i-1) % n]

Результат (доступен через outputs / __last__):

{
  "status": 200,
  "body": "...",
  "duration_ms": 42.37,
  "headers": { "content-type": "application/json", "x-request-id": "abc-123" }
}

Имена заголовков приводятся к нижнему регистру; повторяющиеся заголовки объединяются через ", ". Используйте их в последующих шагах через ${{ resp.headers.x-request-id }}.

Ответы с текстовым типом содержимого (text/*, application/json, application/*+json, application/*+xml, …) приходят в body, как и раньше. Бинарные данные (например, application/octet-stream) вместо этого возвращают пустое body плюс поле body_base64 — именно так полученный protobuf FileDescriptorSet передаётся в gRPC-шаг: descriptor_set: "${{ fetch.body_base64 }}". Отсутствующий тип содержимого определяется эвристически: корректный UTF-8 обрабатывается как текст, всё остальное — как бинарные данные.

Сжатые сервером ответы (Content-Encoding: gzip) прозрачно декодируются до всего этого, поэтому body и пути вида ${{ feed.body.posts }} видят исходные данные — чтобы включить сжатие, явно передайте Accept-Encoding: gzip в headers. Декодирование применяется только к ответам: тела запросов никогда не сжимаются.

Статусы ≥ 400, транспортные ошибки и таймауты считаются неудачными запросами в http_req_failed. Таймаут логируется отдельно (→ TIMEOUT after ...ms).

Выделенные соединения на VU

pool: dedicated выдаёт каждому VU собственный HTTP-клиент, созданный при первом запросе VU и живущий до его выхода — время обдумывания (std/sleep@v1) между шагами соединение не закрывает. Это режим для сценариев «N виртуальных пользователей = N открытых keep-alive соединений»: шардовые пулы по умолчанию держат не более 64 простаивающих соединений на хост на шард, поэтому после ~шарды × 64 VU простаивающее соединение VU вытесняется между запросами, и каждый запрос переподключается (connect churn, накопление TIME_WAIT). Режим dedicated снимает этот предел.

with:
  url: http://target:8080/api
  pool: dedicated

Семантика и стоимость:

  • Клиент каждого VU хранится в реестре прогона с ключом (vu_id, insecure, source_ip) и удаляется при выходе VU (фиксированные, staged ramp-down и arrival-rate воркеры одинаково) — соединения закрываются при выходе VU, а не в конце итерации и не при выходе процесса.
  • Совместим с source_ips: клиент VU привязан к его закреплённому исходному адресу, обе привязки действуют одновременно.
  • Семантика http_req_duration не меняется; учтите, что с выделенным клиентом TCP/TLS connect происходит один раз на VU — первый запрос VU включает его, последующие нет.
  • Соединения, простаивающие дольше 60 с, пересоздаются, как и в других режимах пула — цель, рвущая простаивающие сокеты раньше, проявится как переподключения.
  • Память: клиент с одним простаивающим сокетом стоит заметно меньше 100 КБ; 5 000 VU в режиме dedicated добавляют суммарно несколько сотен МБ RSS процесса. Первый запрос прогона логирует pool: dedicated — one HTTP client per VU.
  • pool: dedicated — режим std/http@v1; std/graphql@v1 принимает только per-vu / shared.

Исходные IP

source_ips привязывает исходящие соединения к локальным исходным адресам — это множитель бюджета ~64k одновременных соединений на endpoint: пространство эфемерных портов выделяется на кортеж (исходный IP, IP назначения, порт назначения), поэтому одна машина с одним адресом упирается в предел ниже 64k сокетов на один endpoint. Элементы — отдельные IP или CIDR-префиксы, IPv4 и IPv6:

with:
  url: http://target:8080/api
  source_ips: ["10.0.0.2", "10.0.0.16/28", "fd00::/120"]

CIDR раскрывается во все адреса префикса (включая network/broadcast — адрес, который нельзя привязать, явно падает на connect); суммарный список ограничен 4096 адресами, при превышении lint и валидация прогона падают с ошибкой «сузьте префикс». Виртуальный пользователь i привязывается к ips[(i-1) % n] — стабильно между итерациями, поэтому каждый VU держит один тёплый keep-alive пул в своём клиенте (шард, исходный IP). Клиенты создаются лениво — большой список не выделяет заранее тысячи пулов: прогон создаёт не больше source-IP-клиентов, чем реально использованных пар (шард, адрес).

Адреса должны существовать на интерфейсах генератора нагрузки (например, дополнительные loopback-алиасы: sudo ifconfig lo0 alias 127.0.0.2 up на macOS; Linux по умолчанию маршрутизирует весь 127/8). Первый запрос прогона логирует, сколько исходных адресов активно. source_ips не поддерживает интерполяцию ${{ … }} — список валидируется на lint и кешируется. В режиме pool: dedicated собственный клиент VU привязывается к его закреплённому адресу — ключ становится (vu_id, адрес) вместо (шард, адрес).

Эффект измеряет набор sourceips в bench (см. Бенчмарки): VU держат постоянные keep-alive соединения при суженном диапазоне эфемерных портов, с одного IP источника против нескольких loopback-адресов — один IP упирается в бюджет портов, и остальные VU не могут подключиться (EADDRNOTAVAIL), несколько IP держат всех.

Multipart-загрузки

multipart отправляет multipart/form-data (загрузка файлов, эндпоинты в стиле HTML-форм). Каждый элемент массива — одна часть:

Поле частиОбязательноОписание
nameдаИмя поля формы
valueодно из value/fileСодержимое текстового поля
fileодно из value/fileПуть к файлу на диске (относительно рабочего каталога)
filenameнетИмя файла, отправляемое серверу; по умолчанию — базовое имя файла
content_typeнетMIME-тип части (например, application/octet-stream)
steps:
  - name: upload report
    use: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/upload"
      multipart:
        - name: file
          file: ./fixtures/report.csv
          content_type: text/csv
        - name: description
          value: "uploaded by ${{ __last__.status }} check run"
    check:
      status: 201

Примечания:

  • Заголовок Content-Type: multipart/form-data; boundary=… выставляется автоматически — не добавляйте его в headers.
  • Плейсхолдеры ${{ ... }} работают в значениях частей и путях, как и везде в with:.
  • Файлы читаются с диска на каждой итерации: страничный кэш ОС делает повторные чтения дешёвыми, а файл, изменённый между прогонами, подхватывается. При высоком RPS предпочитайте небольшие файлы-фикстуры.
  • Отсутствующий или недоступный для чтения файл приводит к ошибке шага до отправки любого запроса.

std/graphql@v1

Отправляет одну GraphQL-операцию (запрос или мутацию) за итерацию по HTTP, с опциональной валидацией схемы по схеме, полученной интроспекцией эндпоинта. Полное пошаговое руководство — в Нагрузочное тестирование GraphQL.

ПараметрТипПо умолчаниюОписание
urlstringобязателенGraphQL-эндпоинт, например https://api.example.com/graphql
querystringодно из query/query_fileВстроенный GraphQL-документ
query_filestring (путь)одно из query/query_fileПрочитать документ из файла .graphql. Доступ к файловой системе: требует allow_file_actions, учитывает fs_root
variablesobject—Переменные запроса. Применяется интерполяция ${{ … }}, а токены-генераторы с одинарными фигурными скобками ${…} (${uuid}, ${rand}, ${now}) раскрываются при каждом выполнении
operationstring—operationName для выполнения. Обязателен, когда документ содержит несколько операций; также служит тегом метрик по операциям
methodstringPOSTPOST (тело JSON) или GET (параметры в URL-запросе — для чтений, кэшируемых CDN)
headersobject—{ "Name": "Value" }, значения — только строки
timeoutinteger (мс)10000Таймаут на запрос
insecurebooleanfalseПропустить проверку TLS-сертификата
introspectionbooleantrueОдин раз за прогон получить схему эндпоинта и валидировать каждый запрос перед отправкой. Ошибка получения схемы деградирует до прогона без валидации (логируется один раз)
schema_filestring (путь)—Валидировать по локальному SDL-файлу вместо интроспекции (запасной вариант для эндпоинтов с отключённой интроспекцией). Те же правила файловой системы, что и для query_file
poolstringper-vuper-vu привязывает шаг к шарду HTTP-клиента виртуального пользователя (поведение std/http@v1); shared переводит всех VU на один глобальный для процесса клиент

Результат (доступен через outputs / __last__):

{
  "status": 200,
  "data": { "viewer": { "id": "u-1" } },
  "errors": [ { "message": "widgets timed out" } ],
  "body": "{\"data\":…}",
  "duration_ms": 12.31,
  "headers": { "content-type": "application/json" }
}

data и errors — декодированная полезная нагрузка GraphQL (errors присутствует, только когда сервер её прислал); body хранит сырой текст для проверок body_contains. Извлечение читает структурированную нагрузку: ${{ create.data.createWidget.id }}.

Семантика ответов соответствует реальности GraphQL-over-HTTP: статусы ≥ 400 приводят к ошибке шага; 200 с errors и без data — тоже ошибка; частичные data плюс errors считаются успехом (сервер разрешил то, что смог), а ошибки учитываются в метрике graphql_errors.

Синтаксическая ошибка, ошибка валидации схемы или нечитаемый query_file приводят к ошибке шага до отправки запроса — цель никогда не увидит некорректный запрос.

steps:
  - name: fetch viewer
    use: std/graphql@v1
    with:
      url: https://api.example.com/graphql
      query: |
        query GetViewer($id: ID!) {
          viewer(id: $id) { id name }
        }
      variables: { "id": "${{ vars.viewer_id }}" }
    check:
      status: 200
    outputs: viewer

  - name: rename widget
    use: std/graphql@v1
    with:
      url: https://api.example.com/graphql
      query_file: ./queries/rename-widget.graphql
      variables: { "id": "${{ viewer.data.viewer.id }}", "name": "w-${seq}" }

Метрики: graphql_req_duration (гистограмма с производной частотой graphql_req_failed), graphql_errors (счётчик) и graphql_op_<operationName>_duration, когда операция именована, — плюс стандартные агрегаты http_req_*, которые пополняет каждый HTTP-шаг.

std/tcp@v1

Открывает «сырое» TCP-соединение, опционально отправляет данные, опционально читает ответ и измеряет время всего обмена. Без протокольного фрейминга — строительный блок для проверки произвольных строковых/бинарных сервисов (Redis, SMTP, собственные шлюзы).

ПараметрТипПо умолчаниюОписание
hoststringобязателен*Целевой хост (вместе с port)
portintegerобязателен*Целевой порт
addressstring—Сокращение host:port, вместо host+port
sendstring—Текстовые данные для отправки после подключения
send_base64string—Данные в Base64 для бинарных протоколов. Взаимоисключающий с send
readbooleantrue, если задан expect, иначе falseПрочитать один фрагмент ответа
read_bytesinteger65536Ограничение на число прочитанных байт
expectstring—Подстрока, которую должен содержать ответ (подразумевает read)
timeoutinteger (мс)10000Таймаут на подключение + обмен

* Укажите либо address, либо host + port.

Результат (доступен через outputs / __last__):

{ "connected": true, "sent": 4, "received": 4, "response": "pong", "duration_ms": 0.63 }

response — UTF-8 с потерями (lossy), поэтому для бинарных сервисов проверяйте received, а не строку. Ошибки подключения, таймауты и несовпадение expect помечают шаг неудачным и учитываются в http_req_failed; время попадает в http_req_duration наравне с HTTP и UDP, так что перцентили сопоставимы между транспортами.

std/udp@v1

Отправляет UDP-датаграмму цели и опционально ждёт ответ. Задержка «туда и обратно» измеряется от отправки до датаграммы-ответа (или только сама отправка, когда ответ не ожидается). Те же поля with, что у std/tcp@v1, за исключением того, что send (или send_base64) обязателен.

UDP не устанавливает соединения: «успешная» отправка означает лишь, что датаграмма покинула хост. Задайте read (или expect), чтобы действительно проверить ответ.

Результат:

{ "sent": 4, "received": 4, "response": "pong", "duration_ms": 0.21 }

std/pubsub@v1

Пошаговое описание на уровне концепций (драйверы, шаблоны producer/consumer, метрики, пороги): руководство по Pub/Sub.

Публикует сообщения в subject и/или ждёт сообщения из него — разовый pub/sub обмен, измеряющий задержку публикации и сквозную (end-to-end) задержку. Транспорт подключается через драйвер:

ДрайверТранспорт
memory (по умолчанию)Внутрипроцессная шина рассылки — брокер не нужен. Шина глобальна для процесса: один канал на subject, общий для всех VU, поэтому публикация одного VU доставляется каждому VU, подписанному на этот subject (fan-out между VU — это особенность)
natsНастоящий сервер NATS (core NATS, без JetStream) через async-nats

Проприетарные драйверы (Kafka, Redis, …) регистрируются через ту же точку подключения в pro-сборке; неизвестное значение driver приводит к ошибке шага со списком зарегистрированных драйверов — так вы узнаёте, что в вашей сборке их нет.

ПараметрТипПо умолчаниюОписание
driverstringmemorymemory, nats или драйвер, зарегистрированный downstream
subjectstringобязателенSubject NATS / имя топика в памяти
urlstring—URL брокера; обязателен для nats, игнорируется memory
publishstring | array—Одно сообщение или список; не-строки сериализуются в текст JSON
subscribeobject—{ count, until_contains, timeout_ms } — ждать count сообщений (по умолчанию 1), каждое из которых содержит until_contains (опционально), в течение timeout_ms (по умолчанию 5000)
optionsobject—Специфичная для драйвера настройка, передаваемая как есть — встроенные драйверы её игнорируют; pro-драйверы (Kafka, Redis, MQTT) используют для QoS, consumer-групп, аутентификации и т.д.
scopestringexchangeВремя жизни подключения: exchange открывает свежее подключение к брокеру на каждый обмен (стоимость подключения включена, см. pubsub_connect_ms); vu держит одно подключение на VU и переиспользует его между обменами. Только брокерные драйверы; memory игнорирует
flushstringmessageРежим ack публикации: message делает flush и ждёт ack брокера после публикации (pubsub_publish_ms = round trip); none — fire-and-forget (pubsub_publish_ms = постановка в очередь). Сочетается со scope: vu; при scope: exchange перед разрывом подключения всё равно выполняется один flush вне замеров

Требуется хотя бы один из publish / subscribe. Когда заданы оба, подписка устанавливается первой, поэтому roundtrip на одном и том же subject видит свои же сообщения. Только публикация — чистый шаг-производитель (успех = все публикации приняты); только подписка — чистый шаг-потребитель. Шаг завершается ошибкой при сбое подключения, ошибке публикации или таймауте подписки — в ошибке указано, сколько сообщений из count пришло и сколько отклонил сопоставитель until_contains.

Результат:

{ "driver": "memory", "subject": "orders.created", "published": 2,
  "received": 1, "duration_ms": 3.21,
  "body": "<joined received payloads>",
  "metrics": { "pubsub_msgs_published": 2, "pubsub_msgs_received": 1,
               "pubsub_connect_ms": [2.1], "pubsub_publish_ms": [0.8],
               "pubsub_errors": 0, "pubsub_e2e_ms": [1.2] } }

received / body и метрики pubsub_msgs_received / pubsub_e2e_ms появляются, только когда задан subscribe. pubsub_e2e_ms содержит по одному замеру на сопоставленное сообщение: начало фазы публикации → получено. pubsub_connect_ms содержит один замер на обмен, открывший подключение к брокеру — каждый обмен при scope: exchange, только первый на VU при scope: vu. pubsub_publish_ms содержит по замеру на опубликованное сообщение — латентность вызова publish транспорта (у nats с flush: message замер последнего сообщения включает flush пачки и ожидание ack). pubsub_errors эмитируется на каждый обмен и складывает синхронный признак ошибки с асинхронными ошибками драйвера (ошибки доставки Kafka, таймауты ack MQTT, ошибочные ответы Redis pipeline), поэтому значение может быть больше 1 за обмен; асинхронные ошибки не роняют шаг. При успехе значение 0 — конвенция db_errors. ${…}-токены генераторов раскрываются на каждое выполнение в subject, publish и subscribe.until_contains; одно выполнение — одно сообщение, поэтому нагрузка и матчер делят одно значение ${seq} / ${vu}. body — это сопоставленные нагрузки, объединённые переводами строк, поэтому работает check: { body_contains: … }.

Примеры. Roundtrip в памяти (без брокера — обе стороны в одном шаге):

steps:
  - name: order events roundtrip
    use: std/pubsub@v1
    with:
      subject: orders.created
      publish: ["order-1 created", "order-2 created"]
      subscribe: { count: 2, until_contains: created, timeout_ms: 2000 }
    check:
      body_contains: order-2

Производитель NATS, только публикация:

steps:
  - name: produce order events
    use: std/pubsub@v1
    with:
      driver: nats
      url: nats://127.0.0.1:4222
      subject: orders.created
      publish:
        - '{"id":"ord-1","total":42.50}'
        - '{"id":"ord-2","total":17.00}'

Потребитель NATS с сопоставителем, таймаутом и блоком check:

steps:
  - name: await shipment event
    use: std/pubsub@v1
    with:
      driver: nats
      url: nats://127.0.0.1:4222
      subject: orders.shipped
      subscribe: { count: 1, until_contains: '"id":"ord-1"', timeout_ms: 3000 }
    check:
      body_contains: ord-1
    outputs: shipment

std/set_shared_variable@v1

Пошаговое описание на уровне концепций (объявление, шаблоны, драйверы): руководство по разделяемым переменным.

Атомарно изменяет разделяемую переменную — единое хранилище «ключ/значение» в рамках прогона, общее для всех VU процесса, для координации между VU (разделяемые счётчики, очереди producer/consumer, барьеры), которую не могут выразить неизменяемые variables отдельного VU. Имена должны быть объявлены в блоке shared_variables: конфигурационного файла; шаг, ссылающийся на необъявленное имя, или op, несовместимая с объявленным типом (increment над списком), завершится ошибкой валидации конфигурации до старта любого VU.

ПараметрТипПо умолчаниюОписание
driverstringmemorymemory (глобальное для процесса хранилище) или драйвер, зарегистрированный downstream (pro-сборки: Redis, …). Неизвестное значение приводит к ошибке шага со списком зарегистрированных драйверов
namestringобязателенИмя объявленной разделяемой переменной
opstringsetset (любой тип), increment (число — value это приращение), append (список — value это элемент)
valueanyобязателенНовое значение / приращение / добавляемый элемент

increment возвращает новое значение, append — новую длину списка, set — сохранённое значение. Каждая операция — одно атомарное захватывание: пары «прочитал-изменил-записал», которая могла бы породить гонку, нет.

Результат:

{ "driver": "memory", "name": "approved_count", "op": "increment",
  "value": 3, "duration_ms": 0.03 }

std/get_shared_variable@v1

Атомарно читает разделяемую переменную, опционально блокируясь до выполнения условия.

ПараметрТипПо умолчаниюОписание
driverstringmemorymemory или драйвер, зарегистрированный downstream
namestringобязателенИмя объявленной разделяемой переменной
opstringgetget (любой тип) или pop (список: удалить и вернуть первый элемент FIFO; null, когда пусто)
wait_forobject—{ exists | equals: <json> | length_gte: <int>, timeout_ms } — блокироваться, пока условие не выполнится; ровно один ключ условия, timeout_ms по умолчанию 5000
extractobject—{ key: dotted-path } — извлечь поля из значения; тот же синтаксис $.a.b[0], что в extract у std/llm@v1

wait_for опрашивает условие с небольшой асинхронной паузой (без активного ожидания). По таймауту шаг завершается ошибкой (строка [err], тот же контракт, что у subscribe в std/pubsub@v1) и сообщает последнее наблюдавшееся значение — медленный производитель это провал проверки, а не молчаливый null.

Результат:

{ "driver": "memory", "name": "pending_orders", "op": "pop",
  "value": {"id": "ord-1"}, "duration_ms": 0.05 }

С extract каждый извлечённый ключ добавляется на верхний уровень вместо value (неразрешимые пути превращаются в null). Ожидание wait_for добавляет в результат waited_ms и замер метрики shared_variable_wait_ms.

Примеры. Очередь producer/consumer поверх разделяемого списка:

# config.yaml
shared_variables:
  pending_orders: []      # type inferred: list
# producer step
- name: enqueue order
  use: std/set_shared_variable@v1
  with: { name: pending_orders, op: append, value: { id: "ord-${seq}" } }

# consumer step — block until something is there, then take it
- name: dequeue order
  use: std/get_shared_variable@v1
  with:
    name: pending_orders
    op: pop
    wait_for: { length_gte: 1, timeout_ms: 10000 }
    extract: { order_id: $.id }

Разделяемый счётчик:

# config.yaml
shared_variables:
  approved_count: 0       # type inferred: number
- name: count approval
  use: std/set_shared_variable@v1
  with: { name: approved_count, op: increment, value: 1 }

std/llm@v1

Пошаговое описание на уровне концепций (эндпоинты, примеры, метрики, пороги): руководство по LLM.

Отправляет один запрос LLM chat-completion за итерацию, по умолчанию в потоковом режиме, измеряя время до первого токена (TTFT), пропускную способность генерации и расход токенов. Три проводных формата эндпоинтов:

ЭндпоинтПроводной формат
openai (по умолчанию)OpenAI chat completions — а также OpenAI-совместимые серверы (Ollama, vLLM, LM Studio, …). При стриминге отправляет stream_options: { include_usage: true }
anthropicAnthropic messages API; отправляет anthropic-version: 2023-06-01, ключ — в x-api-key
genericТело — объект params как есть; поля ответа извлекаются через extract
ПараметрТипПо умолчаниюОписание
endpointstringopenaiopenai, anthropic или generic
urlstringобязателенURL эндпоинта completion
modelstring—Имя модели; обязательно для openai / anthropic
promptstringодно из prompt/messagesСахар для одного сообщения user (взаимоисключающий с messages)
messagesarrayодно из prompt/messages[{ role, content }] — сообщения чата
max_tokensinteger256Ограничение на токены ответа
streambooleantrue (openai/anthropic), false (generic)Стримить ответ как SSE
api_keystring—Authorization: Bearer (openai/generic) или x-api-key (anthropic)
headersobject—Дополнительные заголовки запроса, строковые значения
paramsobject—Сквозные поля тела (temperature, …); для generic это и ЕСТЬ тело
extractobject—Только generic: { text, prompt_tokens, completion_tokens } — каждое значение это точечный путь ($.usage.completion_tokens, $.choices[0].text) или регулярное выражение с одной группой захвата
timeout_msinteger (мс)120000Таймаут на весь запрос (подключение → последний фрагмент)

Статус не 2xx приводит к ошибке шага с указанием статуса и первых ~500 символов тела ошибки.

Результат:

{ "endpoint": "openai", "model": "gpt-4o-mini", "status": 200,
  "ttft_ms": 120.31, "duration_ms": 850.02,
  "prompt_tokens": 12, "completion_tokens": 96,
  "tokens_per_sec": 131.5, "chunks": 34, "text": "…",
  "metrics": { "llm_ttft_ms": [120.31], "llm_tokens_per_sec": [131.5],
               "llm_prompt_tokens": 12, "llm_completion_tokens": 96,
               "llm_chunks": 34 } }

ttft_ms появляется только для потоковых ответов (TTFT = начало запроса → первый фрагмент контента); tokens_per_sec делит токены ответа на время генерации после первого токена, а если TTFT не был измерен — на время всего запроса. text обрезается до ~4 КиБ. llm_ttft_ms / llm_tokens_per_sec — тренды (доступны перцентили в std/thresholds@v1); llm_prompt_tokens / llm_completion_tokens / llm_chunks — счётчики.

Примеры. Локальный Ollama (OpenAI-совместимый, без ключа):

steps:
  - name: llama completion
    use: std/llm@v1
    with:
      url: http://127.0.0.1:11434/v1/chat/completions
      model: llama3.1
      prompt: "Summarize the CAP theorem in two sentences."
      max_tokens: 128
    check:
      status: 200

Стриминг Anthropic с ключом из окружения:

steps:
  - name: claude completion
    use: std/llm@v1
    with:
      endpoint: anthropic
      url: https://api.anthropic.com/v1/messages
      model: claude-sonnet-4-5
      api_key: ${{ env.ANTHROPIC_API_KEY }}
      prompt: "Explain backpressure in one paragraph."

Эндпоинт generic с извлечением полей:

steps:
  - name: tgi generate
    use: std/llm@v1
    with:
      endpoint: generic
      url: http://127.0.0.1:8080/generate
      params:
        inputs: "Tell me a joke."
        parameters: { max_new_tokens: 64 }
      extract:
        text: "$.generated_text"
        completion_tokens: '"generated_tokens": (\d+)'

WebSocket: std/ws@v1 и семейство std/ws-*@v1

Пошаговое описание на уровне концепций (стили, динамические сообщения, проверки, метрики): руководство по WebSocket.

Два способа нагрузочного тестирования WebSocket-эндпоинта:

  • Разовая сессия — std/ws@v1 открывает соединение, обменивается сообщениями и закрывает его, всё в одном шаге (как FIX-сессия). Самый простой вариант; вся сессия измеряется как один замер http_req_duration.
  • Живое соединение — std/ws-connect@v1 открывает соединение, которое остаётся активным между шагами внутри итерации; ws-send / ws-recv / ws-ping / ws-close обращаются к нему по id, который вернул шаг connect. Используйте это, чтобы чередовать WS-трафик с другими шагами (например, подписаться по WS, инициировать событие по HTTP, проверить, что push пришёл).

Соединение, оставленное открытым в конце итерации, разрывается аварийно (без Close-рукопожатия) — вызывайте std/ws-close@v1 для корректного завершения. Живые соединения никогда не переживают переход в следующую итерацию, а ws-connect внутри before: setup бесполезен (контекст setup исчезает до старта VU).

Профиль соединения

Все шаги, умеющие подключаться (std/ws@v1, std/ws-connect@v1), принимают одни и те же параметры цели — инлайн или собранные в объект-профиль под connection (инлайн-поля побеждают). Профиль, определённый в шаге before: конфига, передаётся как connection: "${{ config.<name> }}".

ПараметрТипПо умолчаниюОписание
urlstringобязателенЦель ws:// или wss://
headersobject—Дополнительные заголовки рукопожатия (токены авторизации и т.п.)
subprotocolsarray | string—Предлагаемые значения Sec-WebSocket-Protocol (например, graphql-ws)
skipTLSVerifybooleanfalseПринимать любой сертификат сервера (только staging с самоподписанными сертификатами)
connectionobject | string—Профиль, задающий значения по умолчанию для любого поля выше

timeout (integer, мс, по умолчанию 10000) принимается обоими шагами инлайн, но не является частью профиля — timeout внутри connection игнорируется. Для std/ws-connect@v1 он ограничивает рукопожатие; для std/ws@v1 — всю сессию.

std/ws-connect@v1

Открывает живое соединение. Результат:

{ "id": "ws-1", "connected": true, "subprotocol": "graphql-ws", "duration_ms": 3.1 }

subprotocol — согласованный с сервером протокол или null, когда сервер не выбрал ни одного. Рукопожатие попадает в http_req_duration; неудачное рукопожатие учитывается в http_req_failed, а результат выглядит как { "connected": false, "error": "…", "duration_ms": … }.

Сохраните результат (outputs: feed) и передавайте id: "${{ feed.id }}" остальным шагам ws-*. Идентификаторы выдаются на каждый VU (ws-1, ws-2, …) и действительны только внутри текущей итерации этого VU.

std/ws-send@v1

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор соединения от std/ws-connect@v1
sendstringодно из send/send_base64Текстовая нагрузка; токены ${…} раскрываются при каждой отправке (см. ниже)
send_base64stringодно из send/send_base64Бинарная нагрузка
repeatinteger1Отправить N сообщений из одного шаблона
interval_msinteger0Пауза между повторными отправками
timeoutinteger (мс)10000На весь цикл отправки

Результат: { "sent": N, "bytes": B, "duration_ms": …, "metrics": { "ws_msgs_sent": N } }. Учитывается в частоте ws_msgs_sent. Транспортная ошибка приводит к ошибке шага и разрывает соединение — последующие шаги по этому id получают ошибку «unknown connection id». Ошибка параметров (например, заданы и send, и send_base64) оставляет соединение пригодным к использованию.

Текстовые нагрузки могут содержать токены с одинарными фигурными скобками ${…}, раскрываемые заново при каждой отправке, — в отличие от ${{ … }} движка, которые разрешаются один раз до запуска действия:

ТокенРаскрывается в
${seq}Монотонный счётчик, уникальный для каждого сообщения (продолжает считать между отправками по одному соединению)
${uuid}Случайный 32-символьный hex-идентификатор
${now}UTC YYYYMMDD-HH:MM:SS.sss (формат FIX SendingTime)
${now_ms}Unix-миллисекунды
${now_iso}UTC RFC 3339 YYYY-MM-DDTHH:MM:SS.sssZ
${rand(a,b)}Случайное целое в [a,b]
${randf(a,b[,dp])}Случайное число с плавающей точкой, dp знаков после запятой (по умолчанию 2)
${choice(x|y|z)}Случайный выбор

Неизвестные токены остаются как есть. Нагрузки send_base64 декодируются один раз и отправляются без изменений — раскрытия токенов нет.

std/ws-recv@v1

Читает, пока не сработает правило остановки — если оно не сработало за timeout, шаг завершается ошибкой:

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор соединения
until_containsstring—Остановиться, когда сообщение содержит эту подстроку (взаимоисключающий с until_json)
until_jsonobject—Остановиться, когда сообщение совпадает с JSON-подмножеством (поля шаблона должны быть равны; лишние поля игнорируются)
countinteger1Без правила until_*: остановиться после N сообщений с данными
timeoutinteger (мс)10000Дедлайн для правила остановки

Результат:

{ "messages": ["…"], "body": "…", "count": 2, "matched": true, "duration_ms": 8.4,
  "metrics": { "ws_msgs_received": 2, "ws_msg_rtt": [7.9] } }

matched показывает, было ли достигнуто правило остановки (в режиме count: достигнуто ли количество). Текстовые фреймы приходят строками, бинарные — строками base64; body — текстовая форма, объединённая переводами строк (поэтому работает check: { body_contains: … }). Полученные сообщения учитываются в частоте ws_msgs_received. Когда правило until_* совпало и ранее по этому соединению был ws-send, время «отправка → совпадение» записывается как замер гистограммы ws_msg_rtt — круговой путь сообщения на уровне приложения.

Каждое прочитанное по пути сообщение остаётся в messages, каким бы ни был итог. Если пир закрыл соединение или транспорт умер до срабатывания правила, шаг завершается ошибкой, результат получает поле error, а соединение разрывается; обычный таймаут тоже приводит к ошибке шага, но оставляет соединение пригодным для последующих шагов. Ping/pong-фреймы, приходящие во время чтения, игнорируются как транспортный шум.

Собственный duration_ms шага намеренно не попадает в http_req_duration: то, как долго сервер решит ждать перед отправкой push, — не задержка цели, и такие замеры отравили бы общие перцентили.

std/ws-ping@v1

Круговой путь ping→pong на уровне транспорта: { "pong": true, "duration_ms": 0.4 }. Принимает id и timeout. RTT не агрегируется ни в одну гистограмму — ограничивайте его через check: { duration_ms_lt: … }, когда нужно. Сообщения с данными, приходящие во время ожидания pong, буферизуются для следующего ws-recv. Отсутствие pong за timeout (или закрытое соединение) приводит к ошибке шага и разрыву соединения.

std/ws-close@v1

Корректное Close-рукопожатие. Принимает id, code (по умолчанию 1000, нормальное закрытие), reason (по умолчанию пусто) — оба отправляются в Close-фрейме — и timeout. Результат: { "closed": true, "duration_ms": … }, возвращается, даже когда пир не подтвердил закрытие за timeout: сокет в любом случае исчезает, а id освобождается.

std/ws@v1 — разовая сессия

Параметры профиля как выше, плюс messages — список, где каждый элемент это строка (шаблон ${…} для отправки) или объект:

Поле элементаОписание
send / send_base64Нагрузка, как в ws-send
repeat / interval_msРазвёртывание потока, как в ws-send
until_contains / until_jsonЖдать подходящий ответ перед следующим элементом; даёт замер ws_msg_rtt
steps:
  - name: subscribe and await first trade
    use: std/ws@v1
    with:
      url: wss://stream.example.com/feed
      messages:
        - send: '{"op":"subscribe","channel":"trades","id":"sub-${seq}"}'
          until_json: { type: trade }
    check:
      message_matches: { type: trade }
    outputs: feed

Результат: { "connected": true, "sent": N, "received": M, "messages": […], "body": "…", "subprotocol": …, "duration_ms": …, "metrics": { "ws_msgs_sent": N, "ws_msgs_received": M, "ws_msg_rtt": […] } }. Вся сессия — один замер http_req_duration; шаг завершается ошибкой при ошибках рукопожатия/транспорта или любом элементе, чьё правило until_* не совпало вовремя. Ошибка в середине сессии всё равно сообщает всё, что было передано до этого момента, плюс поле error с именем проблемного элемента (message[i]: …); ошибка рукопожатия даёт { "connected": false, "error": "…", "duration_ms": … } и учитывается в http_req_failed.

Ограничения

Входящие протокольные ограничения берутся из значений по умолчанию WebSocket-библиотеки: сообщения до 64 МиБ, отдельные фреймы до 16 МиБ — более крупное входящее сообщение приводит к ошибке соединения (а значит, и читающего его шага). Значения таймаутов, счётчики repeat и список messages встроенных ограничений не имеют.

gRPC: std/grpc@v1 и семейство std/grpc-*@v1

Пошаговое описание на уровне концепций (источники схемы, стримы, проверки, метрики): руководство по gRPC.

Два способа нагрузочного тестирования gRPC-эндпоинта:

  • Разовый вызов — std/grpc@v1 открывает канал, загружает схему, делает один унарный вызов и закрывает, всё в одном шаге. Самый простой вариант для эпизодических проверок.
  • Живой канал — std/grpc-connect@v1 открывает HTTP/2-канал, который остаётся активным между шагами внутри итерации; grpc-call (унарный) и семейство grpc-stream-* обращаются к нему по id, который вернул шаг connect. Используйте это для любой серьёзной нагрузки: соединение и загрузка схемы оплачиваются один раз на итерацию, а не на вызов.

Вызовы динамические: никакой кодогенерации protobuf — схема приходит во время выполнения, а запросы/ответы представлены в JSON (правила protobuf-JSON: имена полей принимают и proto-имя, и его camelCase-вариант json_name; 64-битные целые — строки; enum — имена).

Канал, оставленный открытым в конце итерации, разрывается — живые каналы и стримы никогда не переживают переход в следующую итерацию, а grpc-connect внутри before: setup бесполезен (контекст setup исчезает до старта VU).

Профиль канала

std/grpc@v1 и std/grpc-connect@v1 принимают одни и те же параметры цели — инлайн или собранные в объект-профиль под connection (инлайн-поля побеждают). Профиль, определённый в шаге before: конфига, передаётся как connection: "${{ config.<name> }}".

ПараметрТипПо умолчаниюОписание
urlstringобязателенЦель grpc:// (открытый текст) или grpcs:// (TLS); хост без схемы означает grpcs://
metadataobject—Метаданные вызовов по умолчанию (токены авторизации и т.п.); metadata на вызов переопределяет их по ключу
skipTLSVerifybooleanfalseПринимать любой сертификат сервера (только staging с самоподписанными сертификатами)
descriptor_setstringодин источник схемыBase64 сериализованного FileDescriptorSet (взаимоисключающий с reflection)
reflectionbooleanодин источник схемыtrue: получить схему через сервис reflection сервера (v1)
max_recv_sizeinteger (байты)16777216Ограничение входящего сообщения (16 МиБ)
connectionobject | string—Профиль, задающий значения по умолчанию для любого поля выше

Требуется ровно один источник схемы. descriptor_set — это base64 FileDescriptorSet — создайте его через protoc --descriptor_set_out или получите по HTTP и сошлитесь на ${{ fetch.body_base64 }}. С reflection: true сервер должен включить gRPC reflection; полученный пул кэшируется по URL до конца прогона, так что повторные подключения к одному серверу оплачивают один круговой путь.

timeout (integer, мс, по умолчанию 10000) принимается обоими шагами инлайн, но не является частью профиля. Для std/grpc-connect@v1 он ограничивает подключение + загрузку схемы; для std/grpc@v1 — весь шаг (подключение → схема → вызов; заголовок grpc-timeout вызова получает оставшийся бюджет).

Методы именуются как "package.Service/Method". Опечатка приводит к ошибке с подсказкой «did you mean», когда известный метод находится в расстоянии редактирования 2.

Нагрузки

Запросы принимают payload (JSON → динамическое protobuf-сообщение) или payload_base64 (base64 сериализованных байт protobuf) — взаимоисключающие. Строковые листья payload могут содержать токены с одинарными фигурными скобками ${…}, описанные в std/ws-send@v1, раскрываемые на каждый вызов/отправку (${seq} продолжает считать в рамках канала/стрима). Ответы появляются в body как JSON по тем же правилам отображения.

std/grpc-connect@v1

Открывает живой канал и загружает схему. Результат:

{ "id": "grpc-1", "connected": true, "duration_ms": 4.8 }

Сохраните результат (outputs: conn) и передавайте id: "${{ conn.id }}" остальным шагам grpc-*. Идентификаторы выдаются на каждый VU (grpc-1, grpc-2, …) и действительны только внутри текущей итерации этого VU. Неудачное подключение или загрузка схемы даёт { "connected": false, "error": "…", "duration_ms": … }.

std/grpc-call@v1

Один унарный вызов по живому каналу.

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор канала от std/grpc-connect@v1
methodstringобязателен"package.Service/Method" (только унарные методы)
payloadobject | array | stringодно из payload/payload_base64JSON-сообщение запроса; токены ${…} раскрываются на каждый вызов
payload_base64stringодно из payload/payload_base64Сериализованные байты protobuf
metadataobject—Метаданные вызова (переопределяют значения канала по ключу)
expect_statusinteger0Ожидаемый код статуса gRPC — любой другой приводит к ошибке шага
timeoutinteger (мс)10000Отправляется как grpc-timeout и контролируется локально

Результат:

{ "status": 0, "body": { "message": "hello" }, "duration_ms": 2.4,
  "metrics": { "grpc_req_duration": [2.4], "grpc_msgs_sent": 1,
               "grpc_msgs_received": 1, "grpc_msg_rtt": [2.3], "grpc_req_failed": 0 } }

При ненулевом статусе результат содержит error (текст статуса) вместо body. expect_status делает тесты ошибочных веток естественными: expect_status: 5 проходит, когда сервер возвращает NOT_FOUND, и падает на OK. Неудачный RPC приводит к ошибке шага, но канал остаётся пригодным (HTTP/2-каналы восстанавливаются; мёртвый WebSocket — нет). Унарные метрики: гистограммы grpc_req_duration и grpc_msg_rtt, счётчики grpc_msgs_sent / grpc_msgs_received, grpc_req_failed для RPC, не удовлетворивших expect_status.

std/grpc@v1 — разовый вызов

Параметры профиля канала и вызова в одном шаге; подключается, загружает схему, вызывает, закрывает. Результат тот же, что у std/grpc-call@v1.

steps:
  - name: fetch schema
    use: std/http@v1
    with: { url: "https://schema.example.com/echo.pb" }
    outputs: fetch

  - name: unary probe
    use: std/grpc@v1
    with:
      url: grpcs://api.example.com:443
      descriptor_set: "${{ fetch.body_base64 }}"
      method: "echo.v1.Echo/Unary"
      payload: { message: "ping ${seq}" }
    check:
      duration_ms_lt: 500

std/grpc-stream-open@v1

Запускает client-streaming, bidi или server-streaming вызов по живому каналу и возвращает id стрима (grpcs-1, …).

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор канала
methodstringобязателен"package.Service/Method" (только стриминговые методы)
payloadobjectserver-streaming: обязателенЕдинственное сообщение запроса для server-streaming методов
payload_base64string—Сериализованная форма вышесказанного
metadataobject—Метаданные вызова

Результат: { "id": "grpcs-1", "kind": "server"|"client"|"bidi", "open": true, "duration_ms": … }.

Для server-streaming единственный запрос уходит при открытии; для client-streaming/bidi сообщения отправляются через std/grpc-stream-send@v1 (передача payload при открытии — ошибка). Open возвращается немедленно — вызов выполняется в задаче-ретрансляторе, потому что client-streaming сервер отправляет свои начальные метаданные только после half-close со стороны клиента. Ошибка на стороне сервера (UNIMPLEMENTED, авторизация, …) поэтому проявляется при первом recv/close, а не при open.

std/grpc-stream-send@v1

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор стрима
payloadobjectодно из payload/payload_base64JSON-сообщение; токены ${…} раскрываются на каждую отправку
payload_base64stringодно из payload/payload_base64Сериализованные байты protobuf
repeatinteger1Отправить N сообщений из одного шаблона
interval_msinteger0Пауза между повторными отправками
timeoutinteger (мс)10000На весь цикл отправки

Результат: { "sent": N, "duration_ms": …, "metrics": { "grpc_msgs_sent": N } }. Отправка в server-streaming стрим — ошибка параметров (стрим остаётся пригодным). Отправка, завершившаяся ошибкой из-за того, что пир завершил вызов, приводит к ошибке шага и аннулирует id стрима.

std/grpc-stream-recv@v1

Читает, пока не сработает правило остановки — если оно не сработало за timeout, шаг завершается ошибкой:

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор стрима
until_containsstring—Остановиться, когда сообщение содержит эту подстроку (объекты: компактная JSON-форма)
until_jsonobject—Остановиться, когда сообщение совпадает с JSON-подмножеством (взаимоисключающий с until_contains)
countinteger1Без правила until_*: остановиться после N сообщений
timeoutinteger (мс)10000Дедлайн для правила остановки

Результат:

{ "messages": [ { "message": "hello" } ], "count": 1, "matched": true,
  "duration_ms": 3.1,
  "metrics": { "grpc_msgs_received": 1, "grpc_msg_rtt": [2.9] } }

grpc_msg_rtt появляется, только когда правило until_* совпало и ранее по этому стриму был grpc-stream-send — прикладной RTT «отправка → совпадение». Обычный таймаут приводит к ошибке шага, но оставляет стрим пригодным; завершение стрима (корректно или со статусом) до срабатывания правила приводит к ошибке шага и разрыву стрима.

std/grpc-stream-close@v1

Выполняет half-close стороны запроса (client-streaming/bidi: сервер видит конец ввода) и дочитывает оставшиеся сообщения сервера до финального статуса, в пределах timeout.

ПараметрТипПо умолчаниюОписание
idstringобязателенИдентификатор стрима
expect_statusinteger0Ожидаемый финальный код статуса gRPC
timeoutinteger (мс)10000Дедлайн дочитывания

Результат: { "closed": true, "status": 0, "received": N, "messages": […], "duration_ms": …, "metrics": { "grpc_msgs_received": N, "grpc_req_failed": 0|1 } }. Для client-streaming метода дочитанное единственное сообщение — это ответ вызова. Id стрима освобождается в любом случае.

steps:
  - name: open channel
    use: std/grpc-connect@v1
    with:
      url: grpcs://api.example.com:443
      reflection: true
      metadata: { authorization: "Bearer ${{ vars.token }}" }
    outputs: conn

  - name: open bidi stream
    use: std/grpc-stream-open@v1
    with:
      id: "${{ conn.id }}"
      method: "echo.v1.Echo/Bidi"
    outputs: stream

  - name: send events
    use: std/grpc-stream-send@v1
    with:
      id: "${{ stream.id }}"
      payload: { message: "evt-${seq}" }
      repeat: 5
      interval_ms: 20

  - name: await echoes
    use: std/grpc-stream-recv@v1
    with:
      id: "${{ stream.id }}"
      until_contains: "evt-5"
      timeout: 5000
    outputs: got

  - name: close stream
    use: std/grpc-stream-close@v1
    with: { id: "${{ stream.id }}" }

Ограничения gRPC

Входящие сообщения ограничены max_recv_size (по умолчанию 16 МиБ); превышающее лимит сообщение приводит к ошибке вызова с RESOURCE_EXCEEDED. Бинарные (-bin) ключи метаданных не поддерживаются — значения метаданных это строки. Время жизни стримов охватывает пользовательские шаги, поэтому стримы намеренно не попадают в grpc_req_duration (только унарные вызовы); именно grpc-stream-close превращает финальный статус стрима в grpc_req_failed. Таймауты, счётчики repeat и длины дочитывания встроенных ограничений не имеют.

База данных: семейство std/db-*@v1

Нагрузочные тесты SQL для PostgreSQL, MySQL/MariaDB и SQLite. Два стиля, по аналогии с семейством WebSocket: живое соединение, удерживаемое между шагами (db-connect → db-query / транзакции → db-close), и режим per-query, где каждый запрос оплачивает подключение + запрос (см. Режимы подключения).

steps:
  - name: open db
    use: std/db-connect@v1
    with:
      driver: postgres
      dsn: "${{ vars.db_dsn }}"      # keep the password out of the file
    outputs: db

  - name: begin tx
    use: std/db-tx-begin@v1
    with: { id: "${{ db.id }}" }

  - name: write
    use: std/db-query@v1
    with:
      id: "${{ db.id }}"
      query: INSERT INTO hits (path, status) VALUES ($1, $2)
      params: ["/api/checkout", 200]

  - name: commit
    use: std/db-tx-commit@v1
    with: { id: "${{ db.id }}" }

  - name: hang up
    use: std/db-close@v1
    with: { id: "${{ db.id }}" }

std/db-connect@v1

Открывает пул соединений (режим persistent) или сохраняет разобранную конфигурацию подключения (режим per-query) и возвращает Connection ID.

ПараметрТипПо умолчаниюОписание
driverstringобязателенpostgres, mysql (включая MariaDB) или sqlite
dsnstringобязателенРодная для драйвера строка подключения, например postgres://user:pass@host:5432/db, mysql://user:pass@host:3306/db, sqlite://data.db?mode=rwc, sqlite::memory:. Может использовать интерполяцию ${{ … }} для секретов
tlsbool | stringtruetrue проверяет сертификат + имя хоста; false — открытый текст; "skip-verify" шифрует без проверки. Игнорируется для sqlite
modestringpersistentpersistent (пул удерживается между шагами) или per-query (только конфигурация — каждый запрос подключается заново)
scopestringiterationiteration (соединение закрывается в конце итерации) или vu (живёт до конца VU; повторный идентичный db-connect в том же VU переиспользует припаркованное соединение — одно тёплое соединение на VU)
preparedboolfalseКэшировать именованные операторы на припаркованном соединении — повторные запросы пропускают Parse-раундтрип (PostgreSQL: один RTT вместо двух). Несовместимо с пулерами в режиме транзакций (PgBouncer/Supavisor); в режиме per-query не действует
pool_sizeinteger1Размер пула в режиме persistent (максимум 1024)
timeout_msinteger30000Таймаут подключения

Результат: { "id": "db-1", "driver": "postgres", "mode": "persistent", "prepared": false, "scope": "iteration", "connected": true, "duration_ms": …, "metrics": … }. Переиспользованное vu-scoped подключение дополнительно несёт "reused": true. В режиме per-query connected равен false — пока ничего не открыто. DSN и его пароль никогда не логируются; в строках лога используется очищенная метка host:port/database.

std/db-query@v1

Выполняет один параметризованный оператор. INSERT/UPDATE/DELETE/DDL разрешены по умолчанию.

ПараметрТипПо умолчаниюОписание
idstringобязателенConnection ID от std/db-connect@v1 (id: "${{ db.id }}")
querystringобязателенSQL с родными для драйвера плейсхолдерами ($1, $2, … для postgres; ? для mysql/sqlite). Никогда не интерполируется — ${{ в SQL доходит до базы как есть. Жёсткий лимит 64 КиБ (max_query_bytes)
paramsarray[]Позиционные значения привязки; каждый элемент интерполируется. Строки → text, числа → i64/f64, булевы → bool, null → типизированный NULL (текстового типа на postgres — приводите в SQL, например $1::int). Массивы/объекты привязать нельзя
max_rowsinteger10000Жёсткое ограничение на строки, читаемые в память; лишние строки только устанавливают truncated: true
store_rowsbooltruefalse считает строки (rows) без материализации: data пуст, построчного JSON-декода нет — для сценариев, не читающих data. truncated по-прежнему отражает max_rows
timeout_msinteger30000Таймаут запроса (режим per-query: подключение + запрос)

Результат: { "rows": <u64>, "rows_affected": <u64>, "truncated": <bool>, "data": [ … ], "duration_ms": …, "metrics": … }. Ячейки строк декодируются как booleans/integers/floats/text/JSON/base64 (бинарные); неотображённые типы (NUMERIC, temporal, UUID, массивы) дают null — строка всё равно учитывается. При открытой на соединении транзакции запрос выполняется внутри неё. Неудачный запрос приводит к ошибке шага, но оставляет соединение — и любую открытую транзакцию — в припаркованном состоянии.

std/db-tx-begin@v1, std/db-tx-commit@v1, std/db-tx-rollback@v1

Контекст транзакции на persistent-соединении (параметр id; применяется timeout_ms). Каждый шаг измеряется как единое целое (duration_ms + db_query_duration). Одна открытая транзакция на соединение — begin при уже открытой или commit/rollback при отсутствующей приводят к ошибке шага. Транзакции отклоняются на per-query идентификаторах.

std/db-close@v1

Закрывает соединение и освобождает его id (параметр id). Открытая транзакция откатывается при закрытии. Соединения, оставленные открытыми, неявно закрываются в конце итерации — а при scope: vu при завершении VU.

Режимы подключения к БД

persistent (по умолчанию): db-connect открывает пул (pool_size, по умолчанию 1 — шаги внутри VU выполняются строго последовательно), удерживаемый между шагами VU; db_query_duration измеряет только сам запрос.

per-query: шаг connect лишь валидирует и сохраняет конфигурацию; каждый db-query открывает новое соединение, выполняет запрос и закрывает, поэтому db_query_duration измеряет подключение + запрос как единое целое — форма serverless-драйверов и бенчмарков времени подключения. Транзакции недоступны. sqlite::memory: здесь бессмысленен (каждое новое соединение — пустая база) — используйте файловый DSN; и наоборот, in-memory пул обязан оставаться на pool_size: 1.

Скоуп (scope на db-connect): по умолчанию припаркованное соединение закрывается в конце итерации. С scope: vu оно живёт до конца VU, а идентичный db-connect позже в том же VU переиспользует его (возвращается тот же Connection ID, в выводе "reused": true) вместо открытия нового — одно тёплое соединение на VU через все итерации, форма k6/locust. db-close освобождает id досрочно; следующий connect тогда открывает заново. Id никогда не пересекают границы VU.

Пулеры соединений (PgBouncer, Supabase)

Запросы выполняются как неименованные операторы, поэтому пулеры в режиме транзакций (PgBouncer, Supavisor от Supabase на порту 6543) поддерживаются — кэшированный именованный оператор конфликтовал бы на разделяемом бэкенде (prepared statement "sqlx_s_1" already exists). Если такой пулер разбивает протокольный обмен запроса между бэкендами под конкуренцией, шаг прозрачно повторяет попытку внутри транзакции (которая закрепляет бэкенд), и с этого момента соединение продолжает оборачивать запросы; прямые соединения за это никогда не платят. Для Supabase: используйте DSN пулера (порт 6543, IPv4) с tls: "skip-verify" (их цепочки CA нет в webpki-roots) или установите их CA. С prepared: true на db-connect запросы выполняются как кэшированные именованные операторы — быстрее на прямых соединениях (нет Parse на каждый запрос), но несовместимо с пулерами в режиме транзакций: кэшированный оператор конфликтует на разделяемом бэкенде, и именно поэтому по умолчанию false.

Метрики БД

ИмяТипИсточникЗначение
db_connect_durationгистограммаstd/db-connect@v1 (успех)Задержка подключения + настройки пула
db_query_durationгистограммаstd/db-query@v1, шаги транзакцийЗадержка запроса; включает новое подключение в режиме per-query
db_rowsсчётчикstd/db-query@v1Возвращённые строки, либо затронутые строки, когда оператор ничего не вернул
db_errorsсчётчиквсе шаги БД (ошибка)Неудачные шаги БД, всего
db_errors_{connection,constraint,deadlock,timeout,other}счётчиквсе шаги БД (ошибка)То же, разбитое по классам (SQLSTATE / errno / код результата SQLite; также в error_kind результата шага)

Метки метрик несут только имя шага — никогда сырой SQL.

std/file-read@v1

Читает файл в общий для процесса кэш и делает его содержимое доступным последующим шагам. Первое обращение оплачивает чтение с диска; каждая последующая итерация — всех VU — обслуживается из RAM. Кэш перевалидируется по (mtime, size) файла при каждом обращении, поэтому файл, отредактированный между прогонами долгоживущего агента, подхватывается автоматически.

ПараметрТипПо умолчаниюОписание
pathstringобязателенФайл для чтения
encodingstringtexttext (файл должен быть корректным UTF-8) или base64 (бинарное содержимое)
querystring—jq-подобный путь (см. std/read-json@v1); содержимое парсится как JSON, и подзначение по пути становится всем результатом шага

Результат (доступен через outputs / __last__):

{ "content": "...", "size": 1024, "path": "./fixtures/payload.json" }

С query результат — само извлечённое JSON-подзначение (то есть ${{ payload }} и есть значение по пути).

steps:
  - name: load payload
    use: std/file-read@v1
    with: { path: ./fixtures/payload.json }
    outputs: payload

  - name: send it
    use: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/items"
      headers: { content-type: application/json }
      body: "${{ payload.content }}"
    check:
      status: 201

Примечания:

  • Не-UTF-8 файл с encoding: text приводит к ошибке шага — используйте encoding: base64 для бинарного содержимого.
  • С query содержимое, не являющееся корректным JSON, приводит к ошибке шага; отсутствующий путь — тоже, с именем неудавшегося сегмента, если только сегмент не помечен суффиксом ?, который превращает промах в null.
  • Не пишет строк лога на итерацию: попадания в кэш — горячий путь.
  • Обращение к ${{ payload.content }} копирует содержимое в запрос — кэш экономит чтения с диска, а не копирование на каждый запрос. Держите фикстуры небольшими при высоком RPS.
  • Для загрузок файлов предпочитайте параметр multipart у std/http@v1, который отправляет корректный multipart/form-data.

std/file-write@v1

Пишет содержимое в файл — обычно чтобы сохранить ответ предыдущего шага.

ПараметрТипПо умолчаниюОписание
pathstringобязателенФайл для записи (родительский каталог должен существовать)
contentstringобязателенДанные для записи; плейсхолдеры ${{ ... }} делают типичной нагрузку ${{ resp.body }}
encodingstringtexttext пишет строку как есть; base64 сначала декодирует её (обратное к base64 у file-read)
appendbooleanfalseДописывать вместо перезаписи
querystring—jq-подобный путь (см. std/read-json@v1): чтение-модификация-запись — загрузить JSON-файл, установить content по пути, записать документ обратно

Результат: { "path": <string>, "size": <записанные байты> }

steps:
  - name: fetch report
    use: std/http@v1
    with: { url: "https://api.example.com/report" }
    outputs: resp

  - name: save it
    use: std/file-write@v1
    with:
      path: ./out/report.json
      content: "${{ resp.body }}"

Примечания:

  • Запись по пути, закэшированному std/file-read@v1, автоматически инвалидирует эту запись кэша — кэш чтения перевалидируется по (mtime, size).
  • С append: true каждый вызов — одна запись O_APPEND, поэтому конкурентные VU не перемешиваются внутри содержимого; порядок между VU не определён.
  • С query content, парсящийся как JSON, сохраняется как JSON (иначе как строка); промежуточные объекты/массивы создаются как в jq, индекс массива должен быть в пределах границ или равен длине (добавление), отсутствующий файл начинается с пустого документа, а существующее не-JSON содержимое приводит к ошибке шага. query не сочетается с append и encoding: base64.
  • Не пишет строк лога на итерацию.

std/read-json@v1

Читает и парсит JSON-файл — структурный собрат std/file-read@v1. Распарсенный документ кэшируется на весь процесс и перевалидируется по (mtime, size) файла при каждом обращении, поэтому итерации после первой пропускают и чтение с диска, и парсинг. Fail-closed, как и остальные файловые действия: прогон должен включить allow_file_actions: true.

ПараметрТипПо умолчаниюОписание
filestringобязателенJSON-файл для чтения
querystring—jq-подобный путь, выбирающий подзначение (см. ниже)

Результат: распарсенный JSON-документ или подзначение по query.

Грамматика query — подмножество jq-путей (без пайпов/фильтров/функций): необязательная ведущая ., затем сегменты .key, ["key"] или [0]; точечный сегмент из одних цифр тоже индексирует массив, так что posts.0.id ≡ .posts[0].id. Суффикс ? на сегменте делает его терпимым — промах разрешается в null вместо ошибки шага. Та же грамматика работает в параметре query у std/file-read@v1 / std/file-write@v1 и в путях внутри интерполяции ${{ ... }}.

steps:
  - name: users fixture
    use: std/read-json@v1
    with: { file: ./fixtures/users.json, query: ".users[0]" }
    outputs: user

  - name: log in
    use: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/login"
      body: '{"token": "${{ user.token }}"}'

Примечания:

  • Некорректный JSON приводит к ошибке шага с понятным сообщением; как и отсутствующий путь query (ошибка называет неудавшийся сегмент), если сегмент не помечен ?.
  • Не пишет строк лога на итерацию: попадания в кэш — горячий путь.

std/child_process@v1

Запускает локальный процесс ОС и держит его живым весь прогон — mock-сервер, база-фикстура, sidecar, нужный тестируемой системе. Типичное место — блок before: конфига (запуск) в паре с after: (остановка); можно использовать и в шагах теста. Fail-closed: прогон должен явно разрешить это через allow_process_actions: true в конфиге.

ПараметрТипПо умолчаниюОписание
commandstringобязателенИсполняемый файл для запуска
argsarray of strings[]Аргументы команды
envobject—Дополнительные переменные окружения ({ "NAME": "value" }, значения — только строки)
cwdstring—Рабочий каталог потомка
portinteger—Порт, на котором отвечает процесс (пробрасывается в outputs); 0 автоматически назначает свободный порт и экспортирует его потомку как переменную окружения PORT
restartstringneverПолитика перезапуска: never, on-failure (ненулевой код выхода или сигнал), always
max_restartsinteger3Бюджет перезапусков для политики
backoff_msinteger1000Задержка перед каждым перезапуском
buffer_kbinteger64Размер захватываемого хвоста stdout/stderr на поток (КиБ)
waitUntilobject | string—Гейт готовности — см. ниже

Результат (доступен через outputs / __last__):

{ "pid": 12345, "ppid": 12300, "pgid": 12345, "port": 8080,
  "stdout": "<tail>", "stderr": "<tail>", "restart_count": 0 }

ppid — сам процесс perfscale; pgid — группа процессов потомка — каждый потомок возглавляет собственную группу, поэтому убийство дерева никогда не заденет perfscale. port присутствует, только когда шаг его объявил. pid/pgid/restart_count — это снимок: они меняются при перезапусках, поэтому останавливайте процессы по имени в реестре (std/kill_process@v1), а не по захваченному pid.

Потомок продолжает работать после возврата шага. Его вывод стримится в лог прогона с префиксом {step}: (та же форма, что у раннера k6) и накапливается в ограниченных буферах хвоста за stdout/stderr. Супервизор применяет политику перезапуска до конца прогона и логирует каждый перезапуск. Всё, что ещё живо к концу прогона — нормальное завершение, упавший before: или Ctrl-C — останавливается автоматически (SIGTERM, с эскалацией до SIGKILL после льготного периода, вся группа процессов), поэтому забытый kill_process никогда не оставляет утечку сервера.

waitUntil — гейт готовности

Блокирует шаг до готовности процесса; каждый перечисленный сопоставитель должен сработать. Объектная форма:

ПолеТипОписание
stdout_containsstringПодстрока в захваченном stdout
stderr_containsstringПодстрока в захваченном stderr
stdout_matchesstring (regex)Регулярное выражение по захваченному stdout
stderr_matchesstring (regex)Регулярное выражение по захваченному stderr
port_openintegerTCP-подключение к 127.0.0.1:<port> успешно; 0 проверяет собственный port шага
timeoutstringДлительность вида 30s/1m (по умолчанию 30s)
on_timeoutstringfail (по умолчанию) приводит к ошибке шага; continue логирует промах и идёт дальше

Процесс, завершившийся до достижения готовности, приводит к ошибке шага с его кодом выхода и хвостом stderr. Строковая форма покрывает частый случай одного сопоставителя: waitUntil: 'contains(stdout, "Serving HTTP")', 'matches(stderr, "re")', 'port_open(8080)'.

allow_process_actions: true

before:
  - name: web
    uses: std/child_process@v1
    with:
      command: python3
      args: ["-m", "http.server", "8080"]
      waitUntil:
        port_open: 8080
        timeout: 10s
      restart: on-failure
    outputs: web          # → ${{ config.web.pid }}, ${{ config.web.port }}

std/kill_process@v1

Останавливает процесс, запущенный через std/child_process@v1 — пара к after:. Тот же гейт allow_process_actions.

ПараметрТипПо умолчаниюОписание
namestringодно из name/pidИмя управляемого процесса в реестре (имя его шага или имя outputs). Предпочтительно: поиск всегда указывает на текущий pid, даже через перезапуски
pidintegerодно из name/pidСырой pid ОС — запасной вариант «как получится» для процессов вне реестра (только unix)
signalstringTERMTERM, KILL, INT, HUP, QUIT, USR1, USR2 (префикс SIG допускается)
grace_msinteger5000Ждать завершения процесса столько времени перед эскалацией до SIGKILL
treebooleantrueСигналить всей группе процессов, а не только лидеру

Результат: { "pid": 12345, "signal": "TERM", "exit_code": null, "waited_ms": 12 }

exit_code равен null, когда процесс погиб от сигнала, и всегда null для сырых pid (статус завершения не-потомка собрать нельзя). Остановка уже остановленного процесса — no-op, а не ошибка; автоматическая остановка в конце прогона покрывает всё, что не было остановлено явно.

after:
  - name: stop web
    uses: std/kill_process@v1
    with: { name: web, signal: TERM }

На не-unix платформах нет POSIX-сигналов и групп процессов: std/kill_process@v1 по name завершает только непосредственного потомка (tree не действует), а pid: не поддерживается.

std/thresholds@v1

SLO-гейты уровня прогона поверх метрик, собранных за прогон, — выражения порогов в стиле k6, вычисляемые один раз, после остановки всех VU. Их дом — блок after: конфига:

after:
  - name: slo gate
    use: std/thresholds@v1
    with:
      db_query_duration: ["p95<500", "max<2000"]
      db_query_failed: ["rate<0.05"]
      db_errors: ["count==0"]
      http_req_duration: ["avg<300"]
    severity: fail            # fail (default) | warn | info
    message: "checkout SLO"   # optional, interpolated
ПараметрТипПо умолчаниюОписание
with.<metric>string или array of stringsобязателен (≥1)Выражения порогов для метрики прогона
severitystringfailПоле уровня шага: во что превращается нарушенный гейт — fail (прогон завершается с ненулевым кодом), warn или info (оба с нулевым кодом)
messagestring—Поле уровня шага: пользовательская метка, добавляемая к сводке нарушений; интерполируется

Выражения имеют вид <agg><op><number>: agg ∈ avg, min, max, p50, p90, p95, p99, count, rate; op ∈ <, <=, >, >=, ==, !=; число — обычный float (целое или десятичное, без единиц). Пробелы вокруг частей допустимы (p95 < 500 парсится).

Какие агрегаты применимы — зависит от вида метрики:

  • Метрики-замеры (db_query_duration, http_req_duration, ws_msg_rtt, …): avg/min/max/p50/p90/p95/p99 вычисляются по всем замерам, сделанным за прогон, из тех же HDR-гистограмм, что печатает итоговая сводка — числа гейтов совпадают со сводкой. count — число замеров.
  • Метрики-счётчики (db_errors, db_rows, …): только count — финальное значение счётчика.
  • Метрики ошибок (http_req_failed, db_query_failed, …): rate — доля неудачных вызовов от всех в 0.0..=1.0; count — число вызовов. Раннер выводит их автоматически — каждый вызов шага записывает замер 0/1 под <family>_failed для каждой метрики длительности, которую он выпустил (db_query_duration → db_query_failed). См. metrics.md.

Ошибки жёсткие. Неизвестное имя метрики приводит к ошибке гейта — и прогона — со списком метрик, которые есть; то же делают пустой with:, непарсящееся выражение или агрегат, неприменимый к виду метрики. Сломанный гейт никогда незаметно не пройдёт CI.

Результат (доступен через outputs / __last__):

{ "status": "fail",
  "message": "db_query_duration p95=612ms ≥ 500ms; checkout SLO",
  "violations": [{ "metric": "db_query_duration", "expr": "p95<500", "actual": 612.0 }] }

status равен pass, когда выполняются все выражения, иначе — значение severity. message — сводка нарушений, объединённая через ; , плюс пользовательское сообщение, обрезанная на 200 символах с …. JSON сводки прогона (perfscale run --summary-export) получает поле thresholds той же формы (несколько гейтов объединяются: побеждает худший статус).

std/check@v1

Проверяет свойства результата предыдущего шага. Обычно записывается как инлайн-блок check: шага, который выполняет это действие над результатом того шага; автономное использование выбирает цель через on.

ПараметрТипОписание
onstringЧто проверять (по умолчанию — результат последнего шага). Точки спускаются вглубь значения: on: got.messages.0 адресует одно сообщение по позиции
statusintegerHTTP-статус должен быть равен этому значению
duration_ms_ltintegerduration_ms должен быть строго меньше
body_containsstringТело ответа должно содержать эту подстроку
message_containsstringКакое-то сообщение в списке messages содержит эту подстроку
message_matchesobjectКакое-то сообщение совпадает с этим объектом как JSON-подмножество
messages_count_gteintegerСписок messages содержит не менее N элементов

Проверки message_* работают по списку messages, который предоставляют действия обмена сообщениями (std/ws@v1, std/ws-recv@v1, pro/fix@v1), и используют квантор any — хотя бы одно сообщение должно совпасть, потому что потоки несут шум (heartbeats, посторонние события). Текстовые фреймы WS (строки) парсятся как JSON для message_matches; фреймы FIX (объекты «тег→значение») совпадают напрямую, поэтому message_matches: { "35": "8", "150": "F" } проверяет исполненный ExecutionReport. Для детерминированных обменов адресуйте одно сообщение по индексу через on (on: got.messages.0) — хрупко на неупорядоченных потоках.

Каждая проверка логирует PASS/FAIL; неудачи идут в stderr, но не останавливают прогон. Результат: { "passed": true|false }.

std/random@v1

Генерирует одно числовое значение. Результат — само число, доступное для интерполяции ${{ name }} в следующих шагах.

ПараметрТипПо умолчаниюОписание
typestringобязателенinteger (включительно [min, max]) или float ([min, max))
minnumberобязателенНижняя граница; не больше max
maxnumberобязателенВерхняя граница; не меньше min

При одинаковых границах возвращается эта константа. seed: прогона делает последовательность воспроизводимой; у каждого VU она своя.

steps:
  - use: std/random@v1
    with: { type: float, min: 0, max: 1 }
    outputs: like_roll
  - use: std/http@v1
    when: "${{ like_roll }} < 0.15"
    with: { method: POST, url: "https://api.example.com/posts/1/like" }
  - use: std/random@v1
    with: { type: float, min: 3, max: 7 }
    outputs: think_time
  - use: std/sleep@v1
    with: { seconds: "${{ think_time }}" }

std/sleep@v1

Приостанавливает текущего VU.

ПараметрТипПо умолчаниюОписание
msinteger or string1000Сколько миллисекунд спать
secondsnumber or string—Альтернатива ms (допускаются дробные значения)

Строковое значение сначала раскрывает ${…}-токены, поэтому ms: "${rand(3000,7000)}" задаёт случайную паузу. ${{ … }} интерполяция происходит до запуска действия, поэтому seconds: "${{ think_time }}" может использовать результат std/random@v1. Нечисловая строка после раскрытия завершает шаг ошибкой (раньше молча использовалась пауза 1000 мс).

std/pick@v1

Выбирает элемент JSON-массива, подставляет значение по ключу объекта или переименовывает поля объекта — например, выбирает тестового пользователя для конкретного VU из fixture-файла.

ПараметрТипПо умолчаниюОписание
fromarray, object, or stringодно из from/fileМассив/объект или строка с JSON-массивом/объектом ("${{ feed.body.posts }}")
filestring (path)одно из from/fileФайл с JSON-массивом/объектом, парсится один раз на процесс и кешируется по (mtime, size), как std/file-read@v1. Требует allow_file_actions, учитывает fs_root
indexinteger or stringrandomИндекс элемента, заворачивается по модулю длины. Если опущен — равномерно случайный элемент. Только для массивов
index_offsetinteger or string0Знаковое смещение к index перед модулем; отрицательные значения тоже заворачиваются. Только для массивов
keystring or number—Для объекта: выбрать значение по ключу (число канонически приводится к строке). Отсутствующий ключ завершает шаг ошибкой
remapobject—Переименование top-level полей результата: source-field: destination-field. Неуказанные поля сохраняются; переименованные source-поля удаляются. Отсутствующий source, нестроковый/пустой или дублирующийся destination, а также destination, конфликтующий с непереименованным полем, завершают шаг ошибкой

Результат: сам выбранный элемент, с применённым remap, если он задан.

С объектом key выбирает значение — либо опустите key и передайте remap, чтобы переименовать поля самого объекта:

steps:
  - use: std/pick@v1
    with:
      from: { dev: "https://dev.example.com", stage: "https://stage.example.com" }
      key: "${{ inputs.env }}"
    outputs: base_url
  - use: std/pick@v1
    with: { from: "${{ user_row }}", remap: { id: user_id, name: username } }
    outputs: user

remap строит результат из исходного объекта, поэтому обмен имён (remap: { a: b, b: a }) работает без сюрпризов с порядком, а source: source — no-op.

index: "${{ __vu__ }}" с index_offset: -1 отображает 1-based VU на 0-based элементы массива — точно (VU-1) % length. Для случайного выбора через явный std/random@v1 передайте целый index из этого шага вместо случайного выбора внутри pick.

steps:
  - use: std/pick@v1
    with: { file: fixtures/users.json, index: "${{ __vu__ }}", index_offset: -1 }
    outputs: user
  - use: std/http@v1
    with: { url: "https://api.example.com/feed" }
    outputs: feed
  - use: std/random@v1
    with: { type: integer, min: 0, max: 19 }
    outputs: post_index
  - use: std/pick@v1
    with: { from: "${{ feed.body.posts }}", index: "${{ post_index }}" }
    outputs: post
  - use: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/posts/${{ post.id }}/like"
      headers: { Authorization: "Bearer ${{ user.token }}" }

std/log@v1

Выводит строку в stdout — наиболее полезно с интерполяцией:

ПараметрТипОписание
messagestringТекст для вывода; ссылки ${{ var.field }} разрешаются первыми

Правила интерполяции

Все строковые листья в with/check интерполируются перед запуском действия:

  • ${{ name }} — сохранённое значение, приведённое к строке
  • ${{ name.field }} — одно поле сохранённого JSON-объекта
  • неизвестные имена разрешаются в ""; незавершённый ${{ остаётся как есть

Добавление нового действия (для контрибьюторов)

  1. Реализуйте его в crates/perfscale-core/src/step/actions.rs и добавьте ветку диспетчеризации в execute_action.
  2. Возвращайте ActionOutput: сохраняемое value, строки лога, success и http_sample, если действие выполняет HTTP-работу, которая должна учитываться в метриках.
  3. Добавьте юнит-тесты рядом с существующими (для HTTP доступен wiremock).
  4. Задокументируйте его здесь и, если оно вводит новые поля шагов, перегенерируйте схемы (cargo run -p perfscale-core --example gen_schema).

Пользовательские действия из downstream-крейтов

Действия, которым не место в этом OSS-крейте, — протоколы более высоких тарифов или проприетарные, такие как FIX-действие pro/fix@v1 — подключаются без форка. Реализуйте трейт perfscale_core::step::actions::ActionHandler в своём крейте и вызовите register_action(Arc::new(MyHandler)) один раз при старте процесса (например, в main агента). Зарегистрированные обработчики опрашиваются только для идентификаторов действий, которым не соответствует ни одно встроенное std/*, поэтому встроенные действия не платят за поиск. Параметры доходят до обработчика с уже применённой интерполяцией ${{ }}.