Разберём, как настроить нагрузочное тестирование с нуля и встроить его в CI на примере xk6-sip — расширения нагрузочного инструмента k6 для VoIP/SIP-телефонии. На каждый push в GitHub Actions идут шесть функциональных звонковых сценариев и двухминутный нагрузочный прогон с порогами качества, а на странице прогона появляется сводка: каждый сценарий и каждый шаг, ключевые метрики нагрузки против порогов. В артефактах остаются JUnit-отчёты, WAV проваленных проверок звука и скриншот дашборда Grafana за окно теста.
Это четвёртая статья серии. В первой мы разобрали, как описывать звонки как код и как устроен движок xk6-sip, во второй — настроили мониторинг нагрузки на Prometheus и Grafana. Здесь объединяем их в CI-пайплайн на GitHub Actions, который проверяет АТС на каждом коммите.
Для VoIP/SIP-телефонии автоматизация тестирования в CI исторически давалась тяжело. Классический SIPp описывает сценарии в XML и не отдаёт ни метрики в Prometheus, ни JUnit-отчёт, поэтому в пайплайне его приходится обвязывать скриптами. В xk6-sip сценарий — обычный скрипт k6, а пороги, JUnit и экспорт метрик — стандартные возможности k6.
Нагрузочное тестирование часто живёт отдельно от разработки: раз в релиз инженер вручную запускает прогон, смотрит на графики и пишет отчёт. Деградация при этом обнаруживается через недели после коммита, который её принёс. Автоматизация нагрузочного тестирования в CI сокращает этот срок до минут: пороги производительности становятся quality gates, как юнит-тесты.
Всё ниже — из реального пайплайна репозитория xk6-sip: функциональные тесты занимают 40 секунд, нагрузка с мониторингом — 3,5 минуты, 401 звонок и 2 005 проверок за прогон.
В статье:
Архитектура пайплайна: что запускается на раннере.
Как настроить нагрузочное тестирование с нуля: три уровня проверок.
Функциональные звонковые тесты на каждый push.
Нагрузочный прогон в CI и пороги как quality gates.
Отчёты: сводка прогона на странице GitHub, JUnit и дашборд за окно прогона.
Грабли, на которые мы наступили.
Что дальше: реальная АТС, запуски CI по расписанию и сравнение с базовой линией.
Архитектура пайплайна
На каждый push и pull request рядом с юнит-тестами, сборкой и линтером параллельно идут два job со звонками на обычных раннерах GitHub Actions, без внешних стендов: всё — и тестируемая АТС, и мониторинг — поднимается на самом раннере и исчезает вместе с ним.

Результат сборки решают коды выхода k6: проваленная проверка или порог делают job красным, а артефакты сохраняются в любом случае.
Как настроить нагрузочное тестирование с нуля
Нагрузочное тестирование в CI — это не один большой прогон, а три уровня проверок с разной частотой и ценой. Чем дороже проверка, тем реже она запускается.
Уровень |
Когда |
Длится |
Что ловит |
|---|---|---|---|
Функциональные сценарии звонков |
каждый push и PR |
секунды |
сломанные call flow: удержание, переводы, отказы, звук |
Короткая нагрузка (smoke-performance) |
каждый push и PR |
минуты |
грубую деградацию, утечки, рассинхрон сигнализации и медиа |
Полноценная нагрузка: поиск предела, стабильность |
по расписанию или перед релизом |
часы |
потолок CAPS, деградацию между версиями АТС |
Первые два уровня живут в обычном CI на общих раннерах. Третий требует выделенного генератора и стенда, и о нём — в конце. Для старта нужны четыре вещи:
Генератор нагрузки, который собирается и запускается одной командой. Здесь это k6 с расширением xk6-sip:
xk6 buildсобирает один бинарник без внешних зависимостей.Тестируемая система внутри CI. Для расширения это тестовая АТС testpbx: регистратор и B2BUA на Go с digest-авторизацией, удержанием и переводами. Для настоящей АТС — её стенд или контейнер (Asterisk, FreeSWITCH).
Пороги в скрипте. k6 проверяет их сам и выходит с ненулевым кодом, если порог нарушен. Это и есть quality gate: никакой отдельной проверки поверх не нужно.
Мониторинг и отчёт. Prometheus и Grafana в Docker прямо на раннере и скрипт, который после прогона снимает дашборд за окно теста. Без отчёта красная сборка говорит «стало хуже», но не говорит, что и когда.
Функциональные звонковые тесты на каждый push
Шесть сценариев проходят за 40 секунд и покрывают основные call flow АТС. Каждый — один VU и одна итерация, потому что здесь проверяется логика, а не объём.
Сценарий |
Что проверяет |
|---|---|
|
звонок, гудки, ответ, звук в обе стороны, DTMF, кто положил трубку |
|
удержание и возврат re-INVITE: звук пропадает и возвращается |
|
слепой перевод REFER |
|
сопровождаемый перевод с Replaces |
|
каждая сторона чисто слышит фразу собеседника и не слышит себя |
|
отказ (486), отмена во время вызова (487), несуществующий номер (404) |
Сценарий — это обычный скрипт k6, который читается как тест-кейс. Вот отказ и отмена целиком:
export default function () { // B занят let out = step('A calls B', A.call({ callee: B })); let inc = step('B gets the call', B.expectCall({ caller: A, timeout: '10s' })); inc.reject(486, 'Busy Here'); step('A: busy call ended', out.expectDisconnected('10s')); step(`A gets 486 (got ${out.status()})`, out.status() === 486); // A кладёт трубку, пока у B звонит out = step('A calls B again', A.call({ callee: B })); inc = step('B gets the second call', B.expectCall({ caller: A, timeout: '10s' })); step('A hears ringing', out.expectRinging('10s')); out.hangup(); step('B: call cancelled', inc.expectDisconnected('10s')); step(`B: ended with 487 (got ${inc.status()})`, inc.status() === 487); // номера не существует out = step('A calls 1999', A.call({ callee: '1999' })); step('A: call to unknown number ended', out.expectDisconnected('10s')); step(`A gets 404 (got ${out.status()})`, out.status() === 404); }
Три принципа, на которых это держится:
step()останавливает сценарий на первом провале. Если B не получил звонок, проверять статус бессмысленно, и отчёт показывает одну настоящую причину вместо каскада вторичных ошибок.Фактическое значение — в имени проверки. В JUnit и в итогах k6 видно «A gets 404 (got 480)», и лог открывать не нужно.
Порог
checks: ['rate==1']делает любую проваленную проверку падением k6 с ненулевым кодом, аhandleSummary()пишет JUnit-отчёт для CI.
Job в GitHub Actions собирает k6 и testpbx, поднимает АТС и гоняет сценарии по очереди. Один упавший сценарий не останавливает остальные: в конце видно все сломанные сразу.
functional: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: stable - name: build k6 and testpbx run: | go install go.k6.io/xk6@latest mkdir -p bin xk6 build v2.3.0 --with github.com/Dmitry-Fedotov-Dev/xk6-sip=. --output bin/k6 go build -o bin/testpbx ./cmd/testpbx - name: run scenarios run: | ./bin/testpbx -addr 127.0.0.1:5070 -users 3 > testpbx.log 2>&1 & sleep 1 mkdir -p reports cd examples/functional failed=0 for s in basic-call hold blind-transfer attended-transfer audio-quality reject-cancel; do echo "::group::$s" if ! ../../bin/k6 run -q -e JUNIT="../../reports/$s.xml" -e RECORD_DIR=../../reports/recordings "$s.js"; then echo "::error title=functional::$s failed" failed=1 fi echo "::endgroup::" done exit $failed - name: keep reports if: always() uses: actions/upload-artifact@v7 with: name: functional-reports path: | reports/ testpbx.log
В артефактах остаются JUnit по каждому сценарию, лог АТС и WAV-записи звонков, где провалилась проверка звука: их можно послушать.
Нагрузочный прогон в CI
На каждый push идёт двухминутная нагрузка: 20 виртуальных пользователей, каждый ведёт пару абонентов и звонит по кругу с разговором 5–6 секунд. Профиль намеренно маленький: задача этого уровня — поймать грубую деградацию и рассинхрон, а не найти потолок.
Каждый звонок проверяется полностью: B получил звонок с верным АОН, соединение установилось, звук идёт, B слышит фразу A без искажений (compareAudio()), звонок завершился. Над этим — пороги, которые и решают судьбу сборки:
export const options = { scenarios: { calls: { executor: 'constant-vus', vus: 20, duration: '2m' } }, thresholds: { sip_call_success: ['rate>0.99'], // звонки соединяются sip_call_setup_time: ['p(95)<500'], // и быстро rtp_audio_heard: ['rate>0.99'], // без one-way audio rtp_audio_score: ['p(95)>0.9'], // и без искажений }, };
Результаты последнего прогона на общем раннере GitHub Actions:
Метрика |
Значение |
Порог |
|---|---|---|
Звонков |
401, в среднем 3,15 CAPS |
— |
Успешных |
100% |
> 99% |
Setup time p95 |
1,05 мс |
< 500 мс |
Post-dial delay p95 |
0,90 мс |
— |
Первый ответ на INVITE p95 |
0,35 мс |
— |
Джиттер p95 |
0,52 мс |
— |
RTP-пакетов принято / потеряно |
224 107 / 0 |
— |
Оценка звука p95 |
0,993 |
> 0,9 |
Проверок |
2 005 из 2 005 |
— |
Сверка по закону Литтла L = λW: 20 VU, одна итерация — это установление звонка, 5,6 с разговора и 0,5 с паузы, около 6,3 с. Отсюда 20 / 6,3 ≈ 3,2 CAPS против измеренных 3,15: генератор дал ровно ту нагрузку, которую заказали.
Почему порог setup time — 500 мс при фактических 1 мс. У общих раннеров CI шумные соседи: виртуальная машина делит CPU с чужими сборками, и тайминги плавают в разы. Порог на уровне таймера SIP T1 = 500 мс ловит настоящую поломку (АТС отвечает так медленно, что клиент уже повторяет запрос) и не краснеет от случайных скачков. Точные сравнения миллисекунд — дело выделенного стенда. Правило: на общих раннерах пороги проверяют корректность и грубую деградацию, а не точные цифры.
В job нагрузки три шага: поднять мониторинг и АТС, прогнать k6, снять отчёт:
- name: start monitoring and the test PBX run: | docker compose -f monitoring/docker-compose.yml --profile linux-host up -d ./bin/testpbx -addr 127.0.0.1:5070 -users 200 -csv examples/subscribers.csv > reports/testpbx.log 2>&1 & timeout 60 bash -c 'until curl -sf http://localhost:3001/api/health >/dev/null; do sleep 2; done' - name: load test env: K6_PROMETHEUS_RW_SERVER_URL: http://localhost:9091/api/v1/write K6_FEATURES: native-histograms run: | echo "START=$(date +%s)" >> "$GITHUB_ENV" ./bin/k6 run -o experimental-prometheus-rw --tag testid="$TESTID" --tag pbx_version=testpbx \ -e VUS=20 -e DURATION=2m -e HOLD=5 -e AUDIO=1 -e SIP_METRICS_ADDR=0.0.0.0:6566 \ --summary-export reports/summary.json examples/call.js | tee reports/k6.txt - name: dashboard report if: always() run: | sleep 15 # the last remote write and scrape bash monitoring/report.sh "$TESTID" $((START - 30)) $(($(date +%s) - 10)) reports/dashboard.png
TESTID равен ci-<номер сборки>: по нему прогон находится на дашборде, а если мониторинг общий для нескольких сборок, прогоны не смешиваются. Метрики теста k6 отдаёт через remote write, а метрики процесса k6 и машины Prometheus забирает сам; как это устроено, подробно разобрано в статье про мониторинг.
Отчёты: сводка прогона, JUnit и дашборд
Так выглядит зелёный прогон в GitHub Actions: пять job за 3,5 минуты, нагрузочный — самый долгий. Отчёты лежат в двух артефактах сборки: functional-reports и load-report.

Сводка на странице прогона
Первое, что видно после сборки, — не артефакты, а сводка прямо на странице прогона. Скачивать zip, чтобы узнать «что упало», — лишний шаг, и его никто не делает. GitHub Actions даёт для этого файл $GITHUB_STEP_SUMMARY: всё, что job записал туда в Markdown, показывается на вкладке Summary.
У обоих job со звонками последний шаг — summary с if: always(): он работает и на красной сборке, когда сводка нужнее всего. Короткий скрипт на Python без зависимостей, .github/scripts/summary.py, собирает её из того, что k6 уже написал: из JUnit-отчётов сценариев и из summary.json нагрузки.
Функциональные тесты — одна строка на сценарий: результат, число шагов и упавший шаг.

Под таблицей — шаги каждого сценария в свёрнутых блоках <details>. У прошедшего сценария блок свёрнут, у упавшего раскрыт сразу и показывает, что успело пройти до поломки и где именно сломалось. Вот как выглядит сводка, если задрать порог оценки звука до 0,999 (вывод скрипта на настоящем провале):
Scenario |
Result |
Steps |
Failed step |
|---|---|---|---|
basic-call |
✅ |
9 |
|
audio-quality |
❌ |
6 of 7 passed |
B hears A's phrase clearly (score 0.993 >= 0.999) |
Step |
|
|---|---|
✅ |
A calls B |
✅ |
B gets the call from A |
✅ |
A connected |
✅ |
B hears A |
✅ |
A hears B |
✅ |
B audio compared |
❌ |
B hears A's phrase clearly (score 0.993 >= 0.999) |
❌ |
threshold checks: rate==1 |
Звонок соединился, звук идёт в обе стороны, а фраза A дошла с оценкой 0,993 при требовании 0,999. Всё это видно без лога и без артефактов. В логе job печатается та же сводка, но с шагами только упавших сценариев, чтобы она не тонула в зелёных строках.
Код шага в job функциональных тестов — три строки:
- name: summary if: always() run: | python3 .github/scripts/summary.py functional reports $SCENARIOS python3 .github/scripts/summary.py functional --all-steps reports $SCENARIOS > reports/summary.md cat reports/summary.md >> "$GITHUB_STEP_SUMMARY"
Первая строка печатает короткую сводку в лог, вторая сохраняет полную в артефакт, третья выводит её на страницу прогона.
Нагрузка. Здесь сводка отвечает на два вопроса сразу: прошли ли пороги и насколько далеко метрики от них. Скрипт берёт цифры из summary.json, который k6 пишет по флагу --summary-export, и ставит рядом с каждой метрикой её порог из скрипта. Заголовок — итог одной строкой: «✅ Load: 4 thresholds passed» или «❌ Load: 1 of 4 thresholds crossed».
В таблице два вида строк:
С порогом — quality gate сборки: успешные звонки, setup time p95, доля плеч, услышавших звук, оценка звука p95. Рядом ✅ или ❌.
Без порога — контекст, который объясняет красную строку: число звонков и CAPS, post-dial delay, первый ответ на INVITE, джиттер, принятые и потерянные RTP-пакеты, проверки. Если упал порог оценки звука, строка «RTP packets received / lost» сразу покажет, потери это или что-то другое.
Так сводка выглядит на последнем прогоне:

Первая строка — ещё и проверка самого теста: 3,18 CAPS сходятся с расчётными 3,2 по закону Литтла, значит генератор дал заказанную нагрузку. Если бы общий раннер захлебнулся, CAPS упал бы раньше, чем покраснели пороги. Любой порог, которого нет в списке метрик, скрипт добавит строкой сам: нарушенный порог не потеряется, даже если его добавили позже. Строка в конце сводки указывает на dashboard.png в артефакте: сводка говорит «что», дашборд — «когда и как».
Шаг в job нагрузки — такой же, с проверкой, что k6 вообще успел написать итоги:
- name: summary if: always() run: | if [ -f reports/summary.json ]; then python3 .github/scripts/summary.py load reports/summary.json \ "20 VUs for 2 minutes against the test PBX, audio compared on every call." > reports/summary.md cat reports/summary.md cat reports/summary.md >> "$GITHUB_STEP_SUMMARY" fi
JUnit: при чём он здесь
JUnit XML — формат отчёта о тестах, который понимает практически любая CI и система тест-менеджмента. Название осталось от Java-фреймворка, но сам формат давно общий: набор тестов (testsuite), в нём тест-кейсы (testcase), у упавшего — failure с причиной. Именно из JUnit скрипт строит сводку функциональных тестов, и именно его заберут другие системы, если ваш CI — не GitHub.
В нашем отчёте каждый step() сценария — отдельный тест-кейс, с фактическим значением в имени:
<testsuite name="reject-cancel" tests="14" failures="0"> <testcase name="A calls B" classname="reject-cancel"/> <testcase name="B gets the call" classname="reject-cancel"/> <testcase name="A gets 486 (got 486)" classname="reject-cancel"/> ... <testcase name="A gets 404 (got 404)" classname="reject-cancel"/> <testcase name="threshold checks: rate==1" classname="reject-cancel"/> </testsuite>
Ловушка стандартной функции. В первой версии handleSummary() звал jUnit() из официальной библиотеки k6-summary, и отчёты выглядели так:
<testsuite name="k6 thresholds" tests="1" failures="0"> <testcase name="checks - rate==1" classname="Unnamed folder" /> </testsuite>
Один кейс на весь сценарий: jUnit() делает тест-кейсы из порогов, а не из проверок. Если бы такой отчёт упал, он сказал бы только «что-то не так». Заметили это не сразу: пока всё зелёное, файл никто не открывает. Теперь JUnit пишет сам lib.js, примерно 30 строк: обходит data.root_group.checks из итогов k6 в порядке выполнения и добавляет по кейсу на каждый порог.
Кто ещё покажет этот отчёт. Сводка — это наш вариант для GitHub. Другие системы читают тот же XML сами, и каждый шаг звонка там становится отдельным тестом:
Где |
Как показывает |
Что нужно |
|---|---|---|
GitLab CI |
вкладка Tests в пайплайне и виджет в merge request |
|
Jenkins |
история каждого кейса по сборкам, график «прошло / упало» |
плагин JUnit: |
GitHub, с аннотациями в PR |
упавшие шаги отдельным отчётом проверки |
сторонний action ( |
TestRail, Xray, Zephyr |
прогон в системе тест-менеджмента |
импорт JUnit XML |
Именно поэтому фактическое значение стоит писать в имя шага. В GitLab или TestRail лога k6 не будет, а строка «A gets 404 (got 480)» объясняет провал сама.
Дашборд за окно прогона
Пороги отвечают на вопрос «прошло или нет», а дашборд — на вопрос «что происходило». Но Prometheus и Grafana на раннере исчезают вместе с ним, поэтому дашборд нужно успеть сохранить. Это делает скрипт monitoring/report.sh: он открывает дашборд в headless Chrome с нужным testid и окном времени и снимает его целиком одним изображением.
# высота страницы — из разметки дашборда: клетка сетки Grafana 30 px + 8 px отступ height=$(python3 -c 'import json,sys d = json.load(open(sys.argv[1], encoding="utf-8")) print(max(p["gridPos"]["y"] + p["gridPos"]["h"] for p in d["panels"]) * 38 + 80)' "$dashboard") # kiosk — без меню; testid и from/to (в миллисекундах) выбирают прогон и его окно url="$grafana/d/xk6-sip/xk6-sip?orgId=1&kiosk&var-testid=$testid&var-window=30s&from=${from}000&to=${to}000" # --window-size: окно высотой во весь дашборд — Grafana рисует только видимые панели # --run-all-compositor-stages-before-draw: снимок только полностью отрисованной страницы # --virtual-time-budget=60000: до 60 с виртуального времени на запросы к Prometheus; # без него Chrome снимает сразу после загрузки, и панели оказываются пустыми "$chrome" --headless=new --disable-gpu --hide-scrollbars --window-size="1600,$height" \ --run-all-compositor-stages-before-draw --virtual-time-budget=60000 \ --screenshot="$out" "$url"
Две детали, без которых скриншот получается пустым. Grafana дорисовывает панели лениво, только в видимой части окна, поэтому окно браузера должно быть высотой во весь дашборд. А --virtual-time-budget даёт запросам к Prometheus время завершиться до снимка. Окно времени берётся от момента запуска k6 минус 30 секунд до конца прогона, с паузой 15 секунд на последнюю отправку метрик.
Итог — картинка 1600 × 3956 со всеми 39 панелями в артефактах сборки. Разберём два блока из неё.

Верхний блок — 12 индикаторов за последнее окно: ASR и SEER 100%, one-way audio 0%, setup p95 1,1 мс, все звонки в SLA, ретрансмиссий нет. «Calls in progress 0» и CAPS 2,3 вместо 3,15 — не ошибка: плитки показывают самую свежую точку, а она приходится на завершение теста. Средние за весь прогон — в итогах k6.

Нижний блок отвечает на главный вопрос нагрузки на общем раннере: не упёрся ли сам тест. Dropped iterations — 0, длительность итерации ровная, 6–6,5 с. Процесс k6 занял около 6% одного ядра и 70 МБ памяти, куча Go идёт пилой без роста, горутин около 125. CPU машины после старта — несколько процентов; всплеск до 70% в начале — это запуск контейнеров и k6. Значит, цифрам сигнализации и звука можно верить.
Сеть машины при этом показывает килобайты, а сеть процесса k6 — 310 кБ/с RTP. Тестовая АТС работает на той же машине, и звонки идут через loopback, который не проходит через сетевую карту. Счётчики на сокетах xk6-sip видят его, а экспортёр машины — нет.
И целиком — так отчёт выглядит в артефактах сборки:

Грабли
Настройка заняла шесть прогонов CI. Ни одна ошибка не была в самих тестах — все в окружении вокруг них.
Симптом |
Причина |
Исправление |
|---|---|---|
Все шесть сценариев падают: REGISTER без ответа, 10 ретрансмиссий, |
|
бинарники в |
|
|
|
|
скрипт закоммичен с Windows без бита исполняемости |
|
Панели процесса k6 пустые на Linux, хотя на Windows работают |
Prometheus в Docker ходит к k6 через шлюз хоста, а эндпоинт на |
|
Панель доли ошибок с осью до 10 000% |
если все значения нулевые, Grafana сама выбирает диапазон оси |
мягкий максимум оси ( |
Первая строка — самая поучительная. Симптомы выглядели как сетевая проблема или ошибка SIP-стека, а причина нашлась в первой строке лога АТС, который мы сохраняем в артефактах: ./testpbx: Is a directory. Правило: логи тестируемой системы идут в артефакты с if: always() с первого дня. И стоит проверять, что система поднялась, прежде чем давать на неё нагрузку: для Grafana это уже сделано через /api/health, а для АТС пока стоит sleep 1.
Ещё одна тонкость относится не к CI, а к метрикам: нативные гистограммы Prometheus искажают величины, которые скапливаются у единицы. Оценка звука 0,993 превращалась на дашборде в 0,958. Подробно — в статье про проверку качества звука.
Что дальше
То, что описано выше, закрывает первые два уровня проверок. Дальше — то, что превращает CI с нагрузкой в полноценный процесс нагрузочного тестирования:
Настоящая АТС вместо тестовой. Сценарии не меняются: абоненты передаются через
-e REGISTRAR=... -e A_USER=...или CSV. АТС можно поднять контейнером прямо в CI (Asterisk, FreeSWITCH) или гонять тесты против стенда с self-hosted раннера в его сети.Длинные прогоны по расписанию. Ночной job (
on: schedule) на выделенном генераторе: поиск предела открытой моделью нагрузки (ramping-arrival-rateступенями) и soak-тесты на часы. Закрытая модель с фиксированным числом VU, как в CI, потолок не находит: замедление АТС само снижает CAPS.Сравнение с базовой линией. Сейчас пороги абсолютные. Следующий шаг — сравнивать
summary.jsonс прогоном основной ветки и валить PR, если setup p95 или оценка звука ухудшились сверх допуска. На выделенном стенде с постоянным Prometheus то же делает дашборд: выбор несколькихpbx_versionи сравнение версий на одном графике.Результат прямо в PR. Комментарий с таблицей ключевых метрик и превью отчёта, чтобы ревьюер видел влияние изменения на производительность, не скачивая артефакты.
Всё описанное лежит в репозитории github.com/Dmitry-Fedotov-Dev/xk6-sip: *
скрипт отчёта и дашборд Grafana.
Предыдущие статьи серии:
xk6-sip: мониторинг нагрузочного тестирования VoIP/SIP‑звонков
xk6-sip: проверка качества звука в нагрузочных и автоматизированных функциональных тестах VoIP/SIP
Если вы тестируете свою АТС — Asterisk, FreeSWITCH, Kamailio, OpenSIPS или собственную, — попробуйте эти сценарии на ней: для старта достаточно двух абонентов и адреса регистратора. Результаты, падения и нехватающие сценарии жду в issues репозитория. А если подход пригодился — поставьте звезду на GitHub: так инструмент быстрее найдут те, кому он нужен.
Granulex
Раз в релиз инженер запускает прогон, смотрит на графики и пишет в отчёте "нормально" – самый дорогой способ узнать, что три недели назад всё ещё работало. Автоматические пороги придают графику память: "нормально" перестаёт быть мнением смотрящего. Отдельно порадовали WAV проваленных проверок – спор "было эхо или почудилось" закрывается артефактом за секунды.