Обзор

Справочник по YAML

Нативный запуск управляется двумя файлами: определение теста (-f) описывает, что делать; конфиг (-c) описывает, сколько нагрузки подавать.

Оба файла валидируются по JSON Schema до выполнения — ошибки указывают на путь конкретного поля, а не на сырой вывод парсера. Схемы лежат в schema/ и могут включать автодополнение в редакторе через модлайн:

# yaml-language-server: $schema=https://raw.githubusercontent.com/Perfscale/perfscale/main/schema/test.schema.json

Определение теста (-f test.yaml)

Один массив steps. Каждый виртуальный пользователь (VU) выполняет весь список в цикле, пока не истечёт заданная длительность. Опциональный верхнеуровневый import: наследует базовый документ — см. Композиция документов. Опциональный верхнеуровневый libraries: объявляет библиотеки генераторов значений для токенов ${alias.fn(...)} — см. Библиотеки.

steps:
  - name: login                     # необязательная метка для вывода в лог
    use: std/http@v1                # обязательно — идентификатор действия
    with:                           # параметры действия (см. ниже)
      method: POST
      url: https://api.example.com/login
      body:
        user: demo
    check:                          # необязательные инлайн-проверки
      status: 200
      duration_ms_lt: 500
    outputs: login                  # необязательно — сохранить вывод для следующих шагов

Поля шага

ПолеОбязательноОписание
useдаИдентификатор действия: std/http@v1, std/graphql@v1, std/tcp@v1, std/udp@v1, std/pubsub@v1, std/llm@v1, std/ws@v1, std/ws-connect@v1, std/ws-send@v1, std/ws-recv@v1, std/ws-ping@v1, std/ws-close@v1, std/grpc@v1, std/grpc-connect@v1, std/grpc-call@v1, std/grpc-stream-open@v1, std/grpc-stream-send@v1, std/grpc-stream-recv@v1, std/grpc-stream-close@v1, std/db-connect@v1, std/db-query@v1, std/db-tx-begin@v1, std/db-tx-commit@v1, std/db-tx-rollback@v1, std/db-close@v1, std/check@v1, std/random@v1, std/sleep@v1, std/log@v1, std/file-read@v1, std/read-json@v1, std/pick@v1, std/file-write@v1, std/child_process@v1, std/kill_process@v1, std/thresholds@v1, std/set_shared_variable@v1, std/get_shared_variable@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, thresholds, set_shared_variable, get_shared_variable). uses: принимается как алиас для use:
nameнетЧеловекочитаемая метка, отображаемая в строках лога
withнетПараметры действия — см. Действия
checkнетПроверки вывода этого шага — те же ключи, что у std/check@v1
outputsнетИмя переменной, под которой сохраняется вывод шага
severityнетТолько для std/thresholds@v1: во что превращается нарушенный порог — fail (по умолчанию; запуск завершается с ненулевым кодом), warn или info
messageнетТолько для std/thresholds@v1: пользовательская метка, добавляемая к сводке нарушений (интерполируется)
abort_on_failнетТолько для std/thresholds@v1-шагов в after: (по умолчанию false): ранний останов в стиле k6 — пока работают VU, монитор переоценивает порог по уже собранным метрикам каждые 250 мс, и нарушенный порог с severity: fail досрочно останавливает всю нагрузку (между шагами; after:, репортеры и сводка отрабатывают как обычно). См. Останов по порогу и диагностика ошибок
http_debugнетТолько вместе с abort_on_fail: true на after:-шаге std/thresholds@v1: error (пока единственный режим) пишет одну маскированную строку http_debug: {…} на каждое упавшее действие и каждый упавший check: во время фазы VU — id VU, итерация, интерполированные параметры запроса (включая тело) и вывод ответа (включая тело). Успешные запросы и проверки ничего не выводят
labelsнетМетки метрик уровня шага (скаляры; ${{ inputs.* }} / ${{ env.* }} интерполируются один раз до запуска): проставляются на всех рядах метрик шага для стриминг-репортеров, приоритет слияния step > reporter > run. run_id и worker зарезервированы (проставляет движок)
if / whenнетВыполнять шаг только при истинном условии — см. Условные шаги

Условные шаги (if: / when:)

if: и when: — эквивалентные алиасы; одновременное задание обоих на одном шаге является ошибкой разбора. Шаг пропускается, если условие ложно. Сначала раскрываются ${{ … }} и ${…}; результат — сравнение (==, !=, <, <=, >, >= — числовое, если обе стороны являются числами, иначе строковое; литералы можно заключать в '…') или простое значение, ложное при пустой строке, false, 0 или null. Условия объединяются через && и || (&& имеет приоритет, скобки не поддерживаются). Пропущенный шаг не создаёт результат и метрики и не меняет __last__.

steps:
  - use: std/random@v1
    when: "${{ __iter__ }} == 0"
    with: { type: float, min: 0, max: 5 }
    outputs: startup_delay
  - use: std/sleep@v1
    when: "${{ __iter__ }} == 0"                 # только первая итерация
    with: { seconds: "${{ startup_delay }}" }
  - use: std/http@v1
    with: { url: "https://api.example.com/feed" }
    outputs: feed
  - use: std/random@v1
    with: { type: float, min: 0, max: 1 }
    outputs: like_roll
  - use: std/http@v1
    when: "${{ feed.status }} == 200 && ${{ like_roll }} < 0.15"
    with: { method: POST, url: "https://api.example.com/like" }

Переменные (${{ ... }})

Шаги передают данные последующим шагам через плейсхолдеры в стиле GitHub Actions. Шаг сохраняет свой вывод под именем, заданным в outputs:; любое строковое значение в with: или check: последующего шага может затем ссылаться на него:

ВыражениеРаскрывается в
${{ name }}Весь сохранённый вывод, приведённый к строке
${{ name.field }}Одно поле сохранённого объекта (например, .status, .body, .duration_ms)
${{ name.a.b }}Вложенный путь — один уровень JSON на каждую ., например ${{ resp.headers.x-request-id }}
${{ name.arr.0 }} / ${{ name.arr[0] }}Индексация массивов — числовой сегмент индексирует массив; точечный и скобочный синтаксис взаимозаменяемы
${{ name.body.a[0].b }}Путь, продолжающийся после строкового листа, парсит его как JSON по требованию — например, HTTP body — и идёт дальше по документу
${{ __last__ }} / ${{ __last__.field }}Вывод непосредственно предыдущего шага — всегда доступен, outputs: не нужен
${{ config.<name>.<field> }}Вывод шага инициализации before: из конфиг-файла (см. Конфиг)
${{ vars.<key> }}Статическое значение из блока variables: конфиг-файла
${{ inputs.<name> }}Разрешённое типизированное значение из блока inputs: конфига (см. Типизированные входы)
${{ env.NAME }}Переменная окружения процесса NAME — для секретов (api_key, токенов, DSN), которые не должны храниться в YAML
${{ __vu__ }} / ${{ __iter__ }}1-based id текущего VU и 0-based номер его итерации (аналоги __VU / __ITER в k6). Сохранённый результат с таким же именем имеет приоритет

Для std/http@v1 сохранённый вывод имеет вид { "status": <int>, "body": <string>, "duration_ms": <float>, "headers": <object> } — имена заголовков приводятся к нижнему регистру, поэтому заголовок сессии или токена из ответа 1 может перейти в запрос 2:

steps:
  - use: std/http@v1
    with: { url: "https://api.example.com/first" }
    outputs: r1
  - use: std/http@v1
    with:
      url: "https://api.example.com/second"
      headers:
        x-session: "${{ r1.headers.x-session }}"
steps:
  - name: login
    use: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/token"
      body: { user: demo }
    outputs: auth                          # ← сохраняется как `auth`

  - name: fetch profile
    use: std/http@v1
    with:
      url: "https://api.example.com/me"
      headers:
        authorization: "Bearer ${{ auth.body }}"   # вложенные значения работают
    check:
      body_contains: "${{ auth.body }}"            # и в check тоже

  - use: std/log@v1
    with:
      message: "login took ${{ auth.duration_ms }}ms → ${{ auth.status }}"

Правила и граничные случаи:

  • Плейсхолдеры работают в строковых значениях на любой глубине with/check — во вложенных объектах (заголовки), элементах массивов, телах запросов. Ключи никогда не интерполируются.
  • Пробелы внутри фигурных скобок игнорируются: ${{auth.status}} ≡ ${{ auth.status }}.
  • Отсутствующая переменная или поле раскрываются в пустую строку — запуск не падает. Проверяйте значение через check:, если отсутствие должно быть ошибкой. Исключение: ${{ env.NAME }} с неустановленным NAME проваливает шаг с ошибкой env var 'NAME' is not set до запуска действия — иначе молча пустой api_key проявился бы как непонятный 401 от API.
  • ${{ env.NAME }} читает окружение процесса perfscale (всё после env. — это имя переменной). env — зарезервированный префикс: сохранённый вывод с именем env будет им перекрыт. Раскрытые значения подставляются только в параметры шагов — движок сам никогда не пишет их в логи или сводки. Он также записывает каждое раскрытое значение env.* в реестр уровня запуска и маскирует его в логе запуска: значения длиной 4+ символа заменяются на *** везде, где встречаются (в том числе внутри URL и DSN), более короткие — только как целые токены, — так что секрет, случайно попавший в URL запроса или сообщение std/log@v1, всё равно не утечёт в вывод. Именно поэтому env.* — правильный канал для секретов.
  • Сегменты пути спускаются на один уровень JSON-объекта за каждую . — заголовки с точками в имени адресовать нельзя (редкий случай; всё остальное работает).
  • Плейсхолдеры раскрываются отдельно для каждого виртуального пользователя и каждой итерации — каждый VU видит выводы только своей цепочки шагов, никогда чужой.
  • Шаги без ${{ выполняются как есть: движок полностью пропускает проход интерполяции, поэтому обычные шаги не платят за эту возможность ничем.
  • Кавычки в YAML: работают и без кавычек (Bearer ${{ auth.body }}), и в кавычках ("${{ auth.body }}"); ставьте кавычки, если значение начинается с символа, который YAML трактует специально ({, [, *, …).

Конфиг (-c config.yaml)

vus: 10          # виртуальные пользователи, по умолчанию 1
duration: 5m     # "30s", "500ms", "1.5s", "1m", "5m30s", "1h" — по умолчанию "1m"

report:          # необязательно — пересылка в serve + отчёты в конце запуска
  url: http://localhost:7999
  during_run: true      # также стримить накопительные снапшоты во время работы
  labels:               # метки запуска в идентичности отчёта report@v1
    testid: smoke
  reporters:            # отчёты в конце запуска (report@v1) — use:/with: как у шагов
    - use: std/report/json@v1
      with: { path: report.json }
    - use: std/report/junit@v1
      with: { path: junit.xml }
    - use: std/report/html@v1      # самодостаточная тёмная HTML-страница, открывается офлайн
      with: { path: report.html }
    - use: std/report/json@v1
      with:
        url: https://collector.example.com/reports   # HTTP push
        headers: { Authorization: "Bearer ${REPORT_TOKEN}" }  # ${VAR} резолвится на хосте, никогда не логируется
        max_samples: 10000    # сырые сэмплы по шагам (по умолчанию 100000, 0 отключает)
    - use: wasm:./reporters/summary.wasm   # WASM-репортер (компонент perfscale:reporter@0.1.0)
      with:
        output: ./reports      # куда попадают файлы emit-file гостя (по умолчанию ./perfscale-report-<module>)
        config: { style: short }  # произвольный блок, передаётся гостю как есть
    - use: std/report/prometheus-rw@v1     # стриминг: Prometheus remote write
      during_run: true          # ключи движка — рядом с use:
      interval_ms: 5000
      labels: { env: staging }  # метки репортера (step > reporter > run)
      max_series: 10000         # защита от кардинальности (по умолчанию 10000)
      with:
        url: http://alloy:8429/api/v1/metrics/write
        histogram: classic      # classic (_bucket{le}) | native
        retry: { max_attempts: 5, backoff: 1s..30s }
        max_pending: 24         # ограниченная очередь; переполнение теряет СТАРЕЙШИЙ снапшот
        final_timeout: 30s      # предел синхронного финального сброса
    - use: std/report/otel@v1             # стриминг: OTLP/gRPC коллектор
      during_run: true
      with: { endpoint: http://otel-collector:4317 }
    - use: std/report/serve@v1            # стриминг: инстанс perfscale serve
      during_run: true
      with: { url: http://localhost:7999 }

gpu:             # необязательно — метрики GPU во время запуска (нативный движок)
  enabled: true
  interval_ms: 1000
  source: nvidia-smi
ПолеПо умолчаниюОписание
vus1Одновременные виртуальные пользователи (фиксированный профиль)
duration1mДлительность запуска по реальному времени; голые числа — секунды; доли секунды ("500ms", "1.5s") поддерживаются нативным движком
stages—Профиль ramping-VU (в стиле k6): список стадий { duration, target }. Переопределяет vus/duration; взаимоисключающий с arrival. Только нативный движок
arrival—Профиль arrival-rate (открытая модель): { max_vus, pre_allocated_vus?, co_correct?, stages: [{ duration, rate }] } — поддержание заданной частоты итераций/с. Взаимоисключающий со stages. Только нативный движок
report.url—Базовый URL инстанса perfscale serve; флаг CLI --report переопределяет его
report.during_runfalseТакже стримить накопительные снапшоты метрик во время запуска (батчевые POST на <url>/api/v1/metrics), а не только сводку в конце — см. Метрики во время запуска. Только нативный движок
report.interval_ms5000Интервал снапшотов/сброса во время запуска, миллисекунды (мин. 100)
report.batch_size500Сбрасывать батч, как только в нём накопится столько сэмплов
report.max_cpu_percent90Порог по CPU (стриминг в serve): пока загрузка CPU хоста ≥ этого значения, доставка приостанавливается, а снапшоты копятся в ограниченной очереди (переполнение теряет старейшие после max_pending); 0 отключает; вне Linux не действует
report.max_pending24Ограниченный бэклог для legacy-стрима в serve: переполнение теряет СТАРЕЙШИЙ снапшот/батч и считает это в perfscale_report_dropped_snapshots_total (значения кумулятивны — следующий снапшот их восстанавливает)
report.labels—Метки запуска (ключ → значение) в идентичности запуска report@v1
report.reporters—Репортеры: [{ use, during_run?, interval_ms?, labels?, max_series?, max_samples?, with }]. Ключи движка — рядом с use:: during_run включает экспорт во время запуска для стриминг-репортера (final-only репортеры его отклоняют), interval_ms переопределяет интервал снапшотов для репортера, labels — метки уровня репортера, max_series (по умолчанию 10000) ограничивает число выходных рядов (превышение считается в perfscale_report_dropped_series_total{reporter}), max_samples ограничивает сырые сэмплы по шагам (вынесено из with:; старый with.max_samples по-прежнему читается). Стриминг-встроенные: std/report/serve@v1 (путь report.url как репортер), std/report/prometheus-rw@v1 (with.url, with.histogram: classic|native) и std/report/otel@v1 (with.endpoint, OTLP/gRPC) — см. Стриминг-репортеры. Параметры доставки в with: (только стриминг — на final-only репортерах отклоняются): retry: { max_attempts, backoff: "1s..30s" }, batch_size (по умолчанию — report.batch_size уровня запуска; батчи никогда не превышают его — слишком большой снапшот разбивается), max_pending, final_timeout: "30s" (при закрытии бэклог схлопывается к финальному кумулятивному снапшоту — доставляется только он). Финальные репортеры (report@v1): [{ use, with }]. Встроенные: std/report/json@v1 (канонический JSON), std/report/junit@v1 (JUnit XML — testsuite = запуск, testcase на шаг, failures из вердиктов thresholds) и std/report/html@v1 (одна самодостаточная HTML-страница — тёмная тема, идентичность запуска, вердикты, таблица шагов со спарклайнами сэмплов, семейства метрик; весь CSS/JS инлайн, открывается офлайн). Назначение: ровно одно из with.path (файл) или with.url (HTTP push; в with.headers можно использовать ${VAR} — резолвятся на хосте, не логируются). with.max_samples ограничивает сырые сэмплы по шагам (по умолчанию 100000, 0 отключает; при превышении шаг помечается truncated: true). use: wasm:./path.wasm подключает WASM-репортер (perfscale:reporter@0.1.0, см. wit/reporter/reporter.wit; относительные пути — от файла-источника): гость получает документ report@v1 и свой with.config, а вывод пишет только через host-функцию emit-file в with.output (path traversal отклоняется; WASI fs/net не выдаются). Ошибки конфигурации (неизвестный репортер, нет назначения, не установлен ${VAR}, нечитаемый WASM-модуль) фейлят lint/validate до старта; ошибки доставки — best-effort (engine_report_errors), запуск завершается
gpu—Сбор метрик GPU — см. Метрики GPU. Только нативный движок
engine_metrics—Самонаблюдаемость движка — см. Собственные метрики движка. Только нативный движок
before[]Одноразовые шаги инициализации — см. Инициализация и переменные
after[]Одноразовые шаги завершения — см. Завершение
variables{}Статические значения, доступные шагам как ${{ vars.* }}. Не храните секреты в этом блоке — это обычный YAML, попадающий в репозитории и диффы; для них используйте ${{ env.NAME }} (окружение процесса, маскируется в логах запуска)
shared_variables{}Изменяемое общее состояние между VU для std/set_shared_variable@v1 / std/get_shared_variable@v1: отображение имя → начальное JSON-значение (тип выводится из него). Объявление обязательно — шаг, ссылающийся на необъявленное имя, или op, несовместимая с объявленным типом, проваливает валидацию до старта запуска. См. руководство по общим переменным. Только нативный движок
allow_process_actionsfalseРазрешить шагам запускать/сигналить процессам ОС (std/child_process@v1, std/kill_process@v1). Fail-closed: список шагов из недоверенного источника не может трогать процессы, пока вы явно не разрешите
allow_library_capabilitiesfalseРазрешить записям libraries: нести непустые выдачи capabilities:. Fail-closed, тот же паттерн, что у allow_file_actions. Выдача process (RFC 009, опосредованный хостом exec из WASM-шага) дополнительно требует allow_process_actions: true
seed—Seed детерминизма уровня запуска: каждый генератор ${…} и экземпляр библиотеки выводит свой seed как hash(seed, vu_id, conn_seq), поэтому запуск с seed воспроизводит те же сгенерированные значения. Реальное время остаётся недетерминированным
max_response_body_bytes16777216 (16 МиБ)Лимит размера тела ответа для std/http@v1 / std/graphql@v1. Больший ответ завершает шаг понятной ошибкой (никогда не обрезается молча — обрезанное тело могло бы пройти check:). Только нативный движок
libraries—Библиотеки генераторов значений для токенов ${alias.fn(...)} — см. Библиотеки
import—Базовый документ для наследования — см. Композиция документов

Типизированные входы (inputs:)

Конфиг может объявлять типизированные входы запуска (issue #11); шаги ссылаются на них через ${{ inputs.<name> }} везде, где работает интерполяция ${{ ... }}, а CLI переопределяет их при запуске:

inputs:
  users: number                                    # короткая форма — обязателен, без default
  host: { type: string, default: localhost }
  verbose: { type: bool, default: false }          # coercion: 0/1/false/true
  env: { type: choice, options: [staging, prod], default: staging }
  pacing_ms: { type: number, min: 1, max: 10000 }  # границы min/max (только number)
perfscale run -f test.yaml -c config.yaml -i users=10 -i env=prod
perfscale run -f test.yaml -c config.yaml -i users=10,env=prod   # форма через запятую
perfscale run -f test.yaml -c config.yaml -e staging.env          # env-файл KEY=VALUE
perfscale lint config.yaml -i users=10   # печатает разрешённые значения

Правила:

  • Приоритет (высший побеждает): -i key=value > env-файл -e > default: из объявления. env-файл может содержать посторонние переменные — используются только ключи объявленных входов.
  • Обязательность: вход без default: и без переопределения — жёсткая ошибка до старта. Неизвестные ключи -i, несовпадение типа, выход за границы и невалидные choice — тоже жёсткие ошибки, на lint и на старте.
  • Запятая в значении: экранируется как \, (бэкслеш — \\).
  • Входы фиксированы на весь запуск. Типизированные скаляры конфига (vus:, duration:) остаются статически типизированными — входы в профиле нагрузки это follow-up; сегодня входы интерполируются в with:/check:.

Метрики GPU (gpu:)

Сэмплирование GPU хоста (utilization, VRAM, температура, мощность) на протяжении всего запуска с секцией gpu в сводке запуска — в первую очередь чтобы коррелировать нагрузку std/llm@v1 с состоянием GPU. По умолчанию выключено; сбои сбора (нет GPU, отсутствуют утилиты) пишут одно предупреждение и никогда не проваливают запуск.

gpu:
  enabled: true
  interval_ms: 1000                          # по умолчанию 1000 (мин. 10)
  source: nvidia-smi                         # nvidia-smi (по умолчанию) | dcgm
  dcgm_url: http://127.0.0.1:9400/metrics    # для source: dcgm
  devices: [0, 1]                            # необязательно; по умолчанию — все GPU

Полное руководство: core/gpu.md.

Собственные метрики движка (engine_metrics:)

Движок измеряет тестируемую систему, но по умолчанию не измеряет себя. С engine_metrics.enabled: true задача-пробник просыпается каждые interval_ms во время фазы VU и записывает собственные накладные расходы движка в сводку запуска:

  • engine_scheduler_lag_ms (гистограмма) — лаг планировщика tokio: насколько позже расписания проснулся пробник. Голодание рантайма видно здесь раньше, чем в перцентилях нагрузки.
  • engine_log_channel_depth_max (gauge) — пиковое число ожидающих строк в ограниченном канале лога запуска (ёмкость 512). Значение, близкое к ёмкости, означает, что логирование шагов тормозит VU (back-pressure).
engine_metrics:
  enabled: true
  interval_ms: 100        # по умолчанию 100 (мин. 10) — также разрешение лага

По умолчанию выключено: без enabled: true ничего не запускается, обычный запуск ничего не платит. Только нативный движок.

Профили нагрузки

Нативный движок поддерживает три профиля нагрузки. stages/arrival переопределяют vus/duration (lint предупреждает, если задано и то, и другое) и взаимоисключают друг друга; длительность запуска — сумма длительностей стадий.

Фиксированный (по умолчанию): vus воркеров крутят шаги в цикле в течение duration.

vus: 10
duration: 5m

Ramping VUs (stages:, в стиле k6): целевое число VU линейно интерполируется между целями стадий — первая стадия поднимается с 0, каждая следующая — от target предыдущей стадии. Снижение происходит плавно: VU, который убирается, завершает текущий шаг и выходит на следующей границе шага.

stages:
  - { duration: 30s, target: 10 }  # рост 0→10 VU
  - { duration: 1m,  target: 50 }  # рост 10→50 VU
  - { duration: 30s, target: 0 }   # снижение до нуля

Arrival-rate (arrival:, открытая модель): движок поддерживает профиль частоты итераций в секунду и масштабирует пул воркеров, чтобы успевать, — новые итерации стартуют по расписанию, даже когда тестируемая система замедляется (там, где цикл с фиксированным числом VU растянулся бы). rate линейно растёт между стадиями (допустимы дроби: 0.5 = одна итерация каждые 2 с).

arrival:
  max_vus: 100              # потолок пула воркеров — обязательно, ≥ 1
  pre_allocated_vus: 10     # воркеры, созданные заранее (по умолчанию 1); пул растёт лениво до max_vus
  stages:
    - { duration: 30s, rate: 5 }    # рост 0→5 итераций/с
    - { duration: 1m,  rate: 20 }   # рост 5→20 итераций/с

Разрешение, пришедшее, пока все max_vus воркеров заняты, отбрасывается и учитывается в сводной метрике dropped_iterations (плюс предупреждение в лог не чаще раза в 5 с) — увеличьте max_vus или снизьте частоту, если она растёт.

По умолчанию co_correct: false. co_correct: true включает коррекцию coordinated omission в стиле zrk/wrk2: разрешения при насыщенном пуле ставятся в очередь, а не отбрасываются, поэтому подвисание видно как задержка в engine_arrival_iteration_ms / engine_arrival_queue_wait_ms (обе считаются от запланированного старта), а не пропадает в dropped_iterations; диспетчер при этом выдерживает темп с субмиллисекундной точностью (отдельный поток-пейсер спит почти до момента старта и досчитывает только последние ~20 мкс). Отброшенными считается только очередь, оставшаяся к дедлайну. Включайте, когда вопрос — «какую задержку увидит пользователь, приходящий с такой частотой»; без опции сохраняется k6-совместимая семантика отбрасывания.

arrival:
  max_vus: 1024
  co_correct: true          # по умолчанию false (отбрасывание при насыщении, как в k6)
  stages:
    - { duration: 1s,  rate: 10000 }
    - { duration: 20s, rate: 10000 }

Для запусков со stages/arrival строка vus в сводке показывает наблюдаемый параллелизм (vus....................: <last> min=<min> max=<max>), периодическая строка [stats] получает в конце поле vus=N, а экспорты сводки (--summary-export) возвращают vus: null с duration, равным сумме длительностей стадий.

Длительности стадий используют ту же грамматику "500ms"/"30s"/"1m30s"/"1h", что и duration, но валидируются строго: нечитаемая или нулевая длительность стадии проваливает запуск (и perfscale lint) с понятной ошибкой. stages и arrival доступны только в нативном движке — с --locust они отклоняются (используйте vus/duration), с --k6 конфиг-файл всё равно игнорируется. См. examples/ramping.config.yaml, examples/spike.config.yaml и examples/arrival-rate.config.yaml.

Библиотеки (libraries:)

Библиотеки предоставляют функции-генераторы значений для токенов ${...} (RFC 005) — настоящие UUID, ULID, данные в стиле faker, даты — помимо встроенных ${seq}/${rand(a,b)}/${uuid}/${now}. Объявляются в конфиг-файле, файле теста или в обоих (объявления конкатенируются; дублирующийся алиас — ошибка валидации):

libraries:
  - use: '@std/random@v1'          # встроенная; алиас по умолчанию: random
    capabilities: []               # обязательный ключ — [] означает «без разрешений»
  - use: '@std/random@v1'
    as: ids                        # алиас → префикс токена ${ids.fn(...)}
    capabilities: []
  - use: ./libs/fixer-ids.wasm     # локальный WASM-компонент (путь относительно этого файла)
    capabilities: []
  - use: 'https://vendor.example.com/fixer-ids.wasm'   # удалённый: perfscale install
    sha256: '9f2c…64 hex…'
    capabilities: []
  - use: 'git+https://github.com/org/repo.git@v1.2.3#libs/fixer-ids.wasm'  # git: perfscale install
    capabilities: []

Полезные нагрузки в действиях, раскрывающих ${...} (http, ws, gRPC, GraphQL, tcp, udp, llm, параметры db, pubsub, file-write — всё, кроме std/log), могут затем вызывать функции библиотеки:

with:
  send: '{ "id": "${random.ulid()}", "who": "${random.email()}", "n": "${ids.int(1000,9999)}" }'

Правила:

  • Встроенные токены (${seq}, ${rand}, …) сопоставляются первыми и не изменяются. Алиас не может перекрывать их имена.
  • Неизвестный алиас → токен остаётся как есть. Известный алиас + неизвестная функция (или неверные аргументы) → шаг проваливается — опечатка не должна молча попасть в полезную нагрузку.
  • Аргументы отображаются текст → JSON по фиксированному контракту: разбиение по запятым вне двойных кавычек, обрезка пробелов, снятие одной пары окружающих кавычек, разбор как JSON при возможности, иначе — строка. Так pick(a|b|c) передаёт одну строку "a|b|c", а int(1,100) — два числа.
  • Опциональный завершающий аргумент key любой функции мемоизирует результат в пределах одного сообщения: ${random.uuid4(order)}, встретившийся дважды в одном сообщении, даёт один id; следующее сообщение генерирует новый.
  • seed: 42 в конфиге делает запуск воспроизводимым: seed экземпляров выводятся как hash(seed, vu_id, conn_seq).
  • capabilities: обязателен в каждой записи — явный пустой список (capabilities: []) означает «без разрешений»; пропущенный ключ — ошибка валидации при загрузке, lint и запуске. Непустые разрешения (fs, clock, process или { net: [hosts] }) требуют allow_library_capabilities: true (fail-closed, тот же паттерн, что у allow_file_actions). Выдача process (RFC 009: опосредованный хостом одноразовый exec WASM-шага) дополнительно требует allow_process_actions: true. @std/random@v1 не объявляет никаких capability — выдача ей любых — ошибка валидации. Библиотека, чей info() сообщает "pure": true, не нуждается в разрешениях для импортов wasi:filesystem / wasi:clocks/wall-clock (шум тулчейна jco) — и не получает preopen'ов, поэтому реальный доступ к файлам падает в рантайме.

WASM-библиотека может также экспортировать пользовательские шаги (RFC 009): когда её манифест info() перечисляет steps: [{name, description}], каждый становится доступен в use: как lib/<alias>/<step> — см. Библиотеки. Блок with: шага интерполируется заранее, результат компонента — стандартная форма действия (success / output / logs / metrics / http_sample), а строка libraries: сводки прогона сообщает step_calls / step_errors / step_p95_ms рядом со счётчиками токен-вызовов.

Настройки запуска (CallCtx.settings_json)

Каждый вызов библиотеки получает настройки запуска как JSON-объект, замороженный один раз при старте запуска, — строка идентична для каждого VU, итерации и вызова в запуске:

{
  "vus": 10,
  "duration_ms": 300000,
  "seed": 42,
  "stages": null,
  "arrival": null,
  "variables": {},
  "extensions": {
    "configlib": {
      "endpoint": "https://api.test",
      "pool_size": 16
    }
  }
}

vus/duration_ms задаются только для фиксированного профиля нагрузки; запуски со стадиями и arrival-rate оставляют их null и несут профиль в stages/arrival (длительности нормализованы до миллисекунд). seed — это seed из конфига или null. Элементы stages содержат duration_ms и target; arrival содержит max_vus, pre_allocated_vus и собственные элементы с duration_ms и rate. variables — это блок variables: из конфига, раскрытый через ту же интерполяцию, что используют шаги, — поэтому значение из env, которое теперь получает библиотека, также записывается в реестр секретов запуска и остаётся замаскированным в логе. extensions содержит блоки конфига, принадлежащие библиотекам, после проверки по их JSON Schema; пример соответствует расширению configlib, объявленному ниже. Компоненты, собранные под ABI WIT 0.1, получают "{}" (в контексте 0.1 нет поля настроек).

Правила политики: secret / allow / deny / log

Запись библиотеки может ограничивать, какие функции можно вызывать, и принудительно маскировать результаты (всё опционально):

libraries:
  - use: '@std/random@v1'
    capabilities: []      # обязательно в каждой записи; [] = без разрешений
    secret: true          # маскировать каждый результат этой библиотеки в логе запуска
    allow: [uuid4, ulid]  # белый список: остальные вызовы проваливают шаг
    deny: [email]         # чёрный список: побеждает allow
    log: [uuid4]          # всегда маскировать результаты этих функций
  • deny: побеждает allow:, если имя встречается в обоих; заблокированный вызов проваливает шаг с сообщением, называющим правило, а perfscale lint помечает его в токенах ${alias.fn(...)}.

  • Маскирование аддитивно: результат маскируется, если функция объявляет secret: true в своих метаданных, запись задаёт secret: true или функция находится в списке log: записи. Способа снять маску намеренно нет. Замаскированные значения записываются в реестр секретов запуска и отображаются как *** в логе запуска; полезная нагрузка по сети сохраняет реальное значение.

  • Каждое имя в allow:/deny:/log: должно быть функцией, которую библиотека действительно экспортирует, — опечатка — жёсткая ошибка валидации (иначе она молча никогда бы не сработала).

  • Локальные пути (use: ./libs/fixer-ids.wasm) загружают библиотеки WASM-компонентов (WASI Preview 2; актуальный WIT — perfscale:library@0.3.0, при этом компоненты @0.1.x/@0.2.x продолжают загружаться, но компоненты 0.1 не видят настройки запуска, а расширения конфига требуют 0.3; см. RFC 005). Пути разрешаются относительно директории объявляющего файла, как пути import:. Бинарник perfscale CLI поставляется с поддержкой WASM; встраивающим perfscale-core нужна cargo-фича wasm-libs.

Удалённые источники (https://… и git+…) — это ссылки на дистрибуцию — они никогда не скачиваются во время запуска. perfscale install <files> скачивает каждую удалённую библиотеку один раз, проверяет её дайджест, складывает в контентно-адресуемый кэш (<cache>/libraries/<sha256>.wasm) и пишет perfscale.lock рядом с объявляющим файлом. perfscale run и perfscale lint затем разрешают каждую удалённую ссылку через lock + кэш, полностью офлайн: отсутствующий lock, отсутствующая запись или отсутствующий артефакт в кэше — жёсткая ошибка с указанием запустить perfscale install.

# HTTPS: sha256 закрепляет артефакт — переопубликованный артефакт с другим
# дайджестом — жёсткая ошибка, никогда не молчаливая подмена. Закрепление
# опционально: без него `perfscale install` закрепляет вычисленный дайджест
# в lock-файле и печатает предупреждение с советом добавить его сюда.
- use: 'https://vendor.example.com/fixer-ids.wasm'
  sha256: '9f2c…(64 hex)…'

# git: URL репозитория + ref (тег/ветка/коммит) + путь к артефакту внутри репозитория.
# Ref разрешается в коммит во время установки и закрепляется вместе с
# дайджестом артефакта; `perfscale install --refresh` разрешает его заново.
- use: 'git+https://github.com/org/repo.git@v1.2.3#libs/fixer-ids.wasm'

perfscale.lock — это TOML, ключованный точной строкой use:, и он предназначен для коммита:

version = 1

[[libraries]]
use = "https://vendor.example.com/fixer-ids.wasm"
sha256 = "…"

[[libraries]]
use = "git+https://github.com/org/repo.git@v1.2.3#libs/fixer-ids.wasm"
commit = "…resolved sha…"
sha256 = "…artifact digest…"

Замечания:

  • Кэш учитывает PERFSCALE_CACHE_DIR (затем XDG_CACHE_HOME/perfscale, по умолчанию ~/.cache/perfscale) — тот же корень, что и кэш git-клонов для import:.
  • Библиотеки, объявленные в документе, импортированном из git (import: { git: … }), закрепляются в perfscale.lock внутри того репозитория (в его корне); запустите perfscale install для импортирующего файла, и он запишется туда. Install следует по цепочкам import: (включая удалённые) именно по этой причине.
  • Для ссылок git+ YAML-ключ sha256: опционален: закреплённый коммит — якорь целостности. Если он задан, install проверяет его, и несовпадение — жёсткая ошибка.

AOT-компиляция (burn-кэш) и perfscale burn

Компиляция WASM-компонента (Cranelift) стоит реального времени при каждом run/lint — секунды для больших компонентов. Два механизма убирают её:

  • Burn-кэш. perfscale install предкомпилирует каждую объявленную WASM-библиотеку — удалённую и локальную ./path.wasm (локальные библиотеки не получают записи в perfscale.lock; сам файл уже локален) — в <cache>/libraries/<sha256>.cwasm. Загрузчик сначала проверяет этот кэш и десериализует артефакт (нативный код, быстрый mmap); при любом промахе или несовпадении он молча откатывается на полную компиляцию, так что кэш чисто опционален. Артефакты несут заголовок, закрепляющий версию wasmtime, целевой триплет, конфигурацию движка и дайджест исходника: обновление perfscale инвалидирует их — перезапустите perfscale install, чтобы перепрожечь. WASM-репортеры (use: wasm:./reporter.wasm) используют тот же формат артефактов в <cache>/reporters/<sha256>.cwasm, но со сквозной записью: первая компиляция репортера сама кладёт артефакт в кэш, а внутри одного запуска валидация и доставка делят одну компиляцию. Валидация (lint / перед запуском) пишет артефакт в фоне, поэтому для гарантированно прогретого кэша используйте perfscale install — он тоже предкомпилирует репортеры.
  • Встраивание в бинарник. perfscale burn -f test.yaml [-c config.yaml] -o ./perfscale+libs пишет копию запущенного бинарника с дописанными .cwasm-артефактами библиотек документов (трейлер PFSEMBED — без патчинга исполняемых секций, работает для ELF/Mach-O/PE). Производный бинарник разрешает эти библиотеки из самого себя по точной строке use:, поэтому работает без .wasm-файлов, кэша и perfscale.lock на диске — один файл для доставки на генераторы нагрузки. Удалённые ссылки должны быть сначала установлены. Поскольку встраивание запекает артефакты, прожжённый бинарник, обнаруживший несовпадение заголовка, падает с ошибкой «перепрожгите», а не откатывается; перезапустите perfscale burn с текущим perfscale.

Что burn не меняет: накладные расходы на вызов (JSON-маршалинг аргументов, store/память на экземпляр на VU) неизменны — burn убирает компиляцию на запуск, а не стоимость вызова.

Правила WASM-библиотек:

  • Песочница fail-closed: WIT-импорты компонента инспектируются при загрузке, и всё, что выходит за выдачу capabilities: в YAML, — жёсткая ошибка загрузки с указанием нужных и выданных capability — если только info() компонента не объявляет "pure": true; в этом случае импорты wasi:filesystem / wasi:clocks/wall-clock прощаются как шум тулчейна (гость всё равно не получает preopen'ов, поэтому реальный доступ к файлам падает в рантайме). fs — это read-only preopen, ограниченный fs_root (по умолчанию: директория, содержащая .wasm); clock — это wasi:clocks; net: [hosts] пока не поддерживается (выдача net проваливает загрузку с понятной ошибкой); wasi:random и сырые сокеты не предоставляются никогда — библиотеки берут случайность из PRNG с seed (ctx.seed) в своём SDK.
  • with: передаётся в init() компонента как JSON; сбой init фатален для запуска.
  • Трап или бесконечный вызов проваливает шаг: каждый вызов выполняется под бюджетом fuel (~50M), а каждый экземпляр ограничен 64 МиБ памяти.
  • Файл компонента может быть не больше 64 МиБ при загрузке (тот же лимит, что perfscale install применяет к удалённым скачиваниям) — память компиляции масштабируется с размером модуля, и лимит применяется раньше, чем ограничения fuel/памяти смогут.
  • Кэш артефактов проверяется во время запуска, а не только при установке: закэшированный .wasm, чьи байты больше не хешируются в закреплённый дайджест (повреждение или подделка), — жёсткая ошибка с указанием перезапустить perfscale install. Артефакты burn (.cwasm, встроенные полезные нагрузки) несут заголовок, закрепляющий дайджест исходника, версию wasmtime, целевой триплет и конфигурацию движка, и десериализуются только после полного совпадения заголовка.
  • Пишите библиотеки на Rust с perfscale-library-sdk.

Функции @std/random@v1:

ФункцияВозвращает
uuid4([key])Случайный UUID RFC 4122 v4
uuid7([key])Временно́ упорядоченный UUID RFC 9562 v7 (48-битный префикс unix-мс)
ulid([key])26-символьный ULID Crockford-base32 (префикс unix-мс)
nanoid([len], [key])URL-безопасный id из A-Za-z0-9_- (длина по умолчанию 21)
int(a, b, [key])Случайное целое в [a, b] включительно
float(a, b, [dp], [key])Случайное число в [a, b], dp знаков после запятой (по умолчанию 2)
pick(a|b|c, [key])Случайный выбор среди вариантов, разделённых |
weighted(a:10|b:90, [key])Взвешенный случайный выбор среди пар значение:вес
seq(name)Именованный монотонный счётчик на экземпляр генератора, начиная с 1
pattern("ORD-####-????")Заполнение шаблона: #→цифра, ?→a-z, ^→A-Z, *→буква или цифра
first_name() / last_name() / name()Случайные имена (небольшие встроенные корпуса)
username() / email() / company()Случайный username / email / компания
lorem([words])Слова lorem ipsum (по умолчанию 5)
phone()Случайный номер телефона (+1-NNN-NNN-NNNN)
date(a, b)Случайная дата между YYYY-MM-DD a и b включительно, на выходе YYYY-MM-DD
timestamp(a, b)Случайная метка времени unix-мс в [a, b]
datetime(a, b)Случайный datetime RFC 3339 между датами a и b (весь конечный день включён)

Функции @std/json@v1 (fail-closed, как файловые шаги: прогон должен включить allow_file_actions: true; пути подчиняются fs_root):

ФункцияВозвращает
read(file)Весь распарсенный JSON-файл компактной JSON-строкой (общий на процесс кэш, разделённый с std/read-json@v1, перевалидация по (mtime, size))
read(file, query)Подзначение по jq-подобному пути (.posts[0].id ≡ posts.0.id); строковый лист — как есть, остальные значения — компактным JSON. Обязательный промах → ошибка раскрытия с именем сегмента; суффикс ? разрешает промах в пустую строку
pick(file) / pick(file, index)Элемент JSON-массива из file — случайный на сообщение (детерминирован при seed: прогона) или index по модулю длины массива. Не-массив, пустой массив или нецелый index → ошибка раскрытия

Расширения конфига (топ-левел ключи библиотек, issue #10)

WASM-библиотека, собранная против perfscale:library@0.3.0, может экспортировать config-schema, объявляя один топ-левел ключ конфига и JSON Schema для его значения. Экспорт возвращает JSON-строку, например:

{
  "key": "configlib",
  "schema": {
    "type": "object",
    "properties": {
      "endpoint": { "type": "string" },
      "pool_size": { "type": "integer", "minimum": 1 }
    },
    "required": ["endpoint"],
    "additionalProperties": false
  }
}

Эта декларация заявляет ключ configlib. Если необязательный блок задан, он должен содержать endpoint, может содержать pool_size не меньше 1 и не может содержать другие поля. Если блок отсутствует, в extensions нет записи configlib. Соответствующий конфиг:

libraries:
  - use: ./configlib.wasm
    capabilities: []
configlib:                    # ключ заявлен config-schema библиотеки
  endpoint: https://api.test  # обязательная строка
  pool_size: 16               # необязательное целое, минимум 1

На lint и на старте движок загружает объявленные библиотеки, регистрирует заявленные ключи и валидирует каждый блок по схеме библиотеки — fail-fast. Проверенный блок попадает гостю в run settings (extensions; в TS SDK — ctx.settings.extensions["configlib"]). Ключ должен соответствовать [a-z][a-z0-9_]* и не должен пересекаться со встроенным ключом конфига или ключом другой загруженной библиотеки. Любой топ-левел ключ, который не заявлен ни одной загруженной библиотекой, остаётся жёсткой ошибкой (защита от опечаток живёт тут). В TS SDK блок объявляется через defineLibrary({ configSchema: { key, schema }, ... }); perfscale-library-build сам выбирает world perfscale-library-config, когда configSchema присутствует. Полный пример создания библиотеки есть в руководстве по SDK.

Отложено (не в v1): интерполяция внутри extension-блоков, эволюция схем между пинами perfscale.lock.

Композиция документов: import

И определения тестов, и конфиги принимают верхнеуровневый import: с именем базового документа. База загружается первой (она может импортировать свою базу, рекурсивно), затем текущий документ глубоко сливается поверх: объекты сливаются поключно, скаляры и массивы (включая steps:) заменяются импортирующей стороной. Единственное исключение — libraries:, который конкатенируется по цепочке, — дублирующийся алиас — ошибка валидации.

# конфиг команды — наследует общий базовый документ организации, переопределяет одну переменную
import: ../shared/_base.yaml
variables:
  region: us

Три формы источника:

# относительный путь в файловой системе (разрешается относительно директории импортирующего файла)
import: ../shared/_base.yaml

# сырой HTTP(S) URL — закрепите ref в пути
import: "https://raw.githubusercontent.com/org/repo/v1.2.0/perf/config/_base.yaml"

# любой git-хост (SSH или HTTPS remote, включая self-hosted)
import:
  git: git@gitlab.example.com:group/repo.git
  ref: v1.2.0          # тег, ветка или SHA коммита
  file: perf/config/_base.yaml

Удалённые импорты (URL и git) fail-closed: они выполняются только когда вызывающая сторона передаёт --allow-remote-import. Разрешение принадлежит вызывающей стороне, потому что разрешение импортов происходит до проверок allow_file_actions / allow_process_actions — иначе удалённая база могла бы выдать себе эти права и подтянуть шаг std/child_process@v1. Документ никогда не может сам разрешить себе сеть.

Источники остаются ограниченными: документ, полученный по URL, разрешает относительные импорты относительно своего собственного URL; документ из git-репозитория может импортировать только файлы внутри того же клона (выходы через ../ отклоняются). Удалённый документ никогда не может прочитать вашу локальную файловую систему. Циклы импортов падают с выводом цепочки.

Git-импорты клонируются с --depth 1 через ваш системный git (SSH-ключи и credential helpers применяются) и кэшируются в ~/.cache/perfscale/imports/. Теги и SHA коммитов неизменны — кэшируются навсегда. Ветки ревалидируются по удалённому репозиторию после короткого TTL, так что ref: main следует за веткой; --refresh-imports принудительно перескачивает. Полное руководство — в docs/core/imports.md.

Инициализация и переменные

Шаги before: выполняются один раз, по порядку, до старта любого VU — для одноразовой инициализации вроде получения токена или построения профиля соединения. Каждый шаг before — обычный шаг (use/with/outputs); его имя в outputs доступно каждому тестовому шагу в пространстве имён config. variables: хранит статические значения, доступные в vars.

vus: 50
variables:
  region: eu-west
before:
  - uses: std/http@v1
    with:
      method: POST
      url: "https://api.example.com/token"
      body: { user: demo, region: "${{ vars.region }}" }
    outputs: auth            # ← доступен тестовым шагам как config.auth
# test.yaml
steps:
  - uses: std/http@v1
    with:
      url: "https://api.example.com/me"
      headers:
        authorization: "Bearer ${{ config.auth.body }}"   # из шага before
        x-region: "${{ vars.region }}"                      # из variables
  • before выполняется независимо от --quiet (его сбои всегда печатаются). Если любой шаг инициализации падает, запуск прерывается до запуска VU — сломанная инициализация привела бы к одинаковому провалу каждой итерации.
  • Шаги before видят ${{ vars.* }} и выводы предыдущих шагов инициализации (под их собственными именами outputs). Тестовые шаги видят config.* и vars.*, но не выводы друг друга.
  • Интерполяция всегда даёт строку, поэтому числовое значение конфига вроде ${{ config.fix_config.port }} доходит до действия как "1111". Действия, принимающие числа, принимают и строковую форму.

Полный жизненный цикл (включая фоновые процессы, запущенные в before:) — см. Инициализация и завершение.

Завершение (after:)

Шаги after: выполняются один раз после остановки нагрузки — при нормальном завершении, при провалившемся запуске, при провалившемся before: и при Ctrl-C/SIGTERM одинаково. Они видят те же ${{ config.* }} и ${{ vars.* }}, что и тестовые шаги. В отличие от before:, провалившийся шаг завершения логируется, но не прерывает оставшиеся (очистка по принципу best-effort). Типичный шаг after: — std/kill_process@v1 для сервера, запущенного в before::

allow_process_actions: true

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

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

Процессы, ещё живые после шагов after:, останавливаются автоматически, так что явный kill — про чистую и своевременную остановку, а не про предотвращение утечек. См. std/child_process@v1 для полного списка параметров (политика рестарта, захват вывода, waitUntil), examples/with-processes.config.yaml для готового примера и Инициализация и завершение для всего жизненного цикла (прерывания, авто-kill, поток данных).

after: — также дом для SLO-порогов уровня запуска: std/thresholds@v1 вычисляет выражения в стиле k6 (p95<500, rate<0.05, count==0) по метрикам, собранным за весь запуск, один раз и проваливает запуск (ненулевой код выхода), когда нарушен порог с severity: fail:

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"]
    severity: fail            # fail (по умолчанию) | warn | info
    message: "checkout SLO"   # необязательно, интерполируется

См. std/thresholds@v1 для грамматики выражений, видов метрик и поля thresholds в JSON сводки запуска.

Останов по порогу и диагностика ошибок

С abort_on_fail: true порог с severity: fail становится монитором в стиле k6 abortOnFail: пока работают VU, движок переоценивает порог по уже собранным метрикам каждые 250 мс, и первое нарушение останавливает все расписания нагрузки (между шагами, как Ctrl-C — текущий шаг завершается), пишет одну строку

abort_on_fail: threshold gate 'slo gate' violated — stopping load early (after: teardown and the summary still run): …

и проходит обычный путь after:-завершения, репортеров и сводки — оценка самого after:-шага остаётся единственным записанным результатом порога, так что fail-порог по-прежнему даёт ненулевой код выхода. Запуск, завершившийся по расписанию (или по Ctrl-C) без нарушений, не печатает строку останова. Метрики порога, по которым ещё нет сэмплов, пропускают тик, а не останавливают запуск; монитор стартует только после успешного before: и останавливается перед after:-завершением. Оценки монитора чистые — промежуточные результаты никуда не записываются.

Добавьте http_debug: error (пока единственный режим; валиден только вместе с abort_on_fail: true и только на after:-шагах std/thresholds@v1 — всё остальное — ошибка конфигурации на lint/запуске), чтобы получать диагностику ошибок в стиле k6 #6618 прямо во время запуска: каждое упавшее действие и каждый упавший check: пишут одну маскированную строку http_debug: {…} с полями vu, iteration, step, action, интерполированными параметрами request (включая тело запроса), выводом response (включая тело ответа) и — для проверок — деталями упавших failed_checks. Успешные запросы и проверки ничего не выводят, и ничего не буферизуется на потом: вывод порождают только ошибки.

after:
  - name: slo gate
    use: std/thresholds@v1
    with:
      http_req_failed: ["rate<0.05"]
      http_req_duration: ["p95<500"]
    severity: fail
    abort_on_fail: true     # остановить нагрузку досрочно при нарушении порога
    http_debug: error       # логировать упавшие запросы/проверки (с маскированием)

С --locust тот же конфиг отображается в --users/--spawn-rate/--run-time locust. С --k6 конфигурация нагрузки живёт в собственном блоке options скрипта, а конфиг-файл игнорируется.

Валидация без запуска: perfscale lint

Проверяйте файлы заранее — в CI, pre-commit-хуках или прямо во время написания:

perfscale lint test.yaml config.yaml

Помимо валидации по схеме, lint помечает неизвестные и опечаточные имена полей с подсказками «возможно, вы имели в виду» (chek → check, vsu → vus, std/htp@v1 → std/http@v1), включая параметры with: для конкретных действий. См. Команды CLI → lint.

Ошибки валидации

perfscale валидирует перед запуском. Разбор строгий: неизвестные ключи отклоняются, а не игнорируются — опечатка вроде iterations: 1 (или chek: вместо check:) завершает валидацию ошибкой с именем ключа, а не молча меняет профиль нагрузки. Это действует на верхнем уровне, для фиксированных полей каждого шага и для блоков report:, gpu:, stages:/arrival: и libraries:. Примеры того, что вы увидите:

error: schema validation failed:
  /steps/0 — every step must name an action: `use: std/http@v1` (or the `uses:` alias)
error: invalid YAML: found unexpected end of stream

Перегенерируйте схемы после изменения Rust-типов:

cargo run -p perfscale-core --example gen_schema

(Тест CI shipped_schemas_match_generated_ones падает, если schema/ устарел.)