diff --git a/editorial/agent-rewrites/155.json b/editorial/agent-rewrites/155.json index a911a91..c05903e 100644 --- a/editorial/agent-rewrites/155.json +++ b/editorial/agent-rewrites/155.json @@ -3,4 +3,5 @@ "slug": "editorial-2023-09-mechanism-telemetry-signals", "title": "Почему trace ID не должен становиться label метрики", "excerpt": "Trace, metric и log отвечают на разные вопросы. Разбираем, как сохранить корреляцию одного запроса, не превратить метрику в журнал событий и проверить отрицательный путь.", - "contentHtml": "
Симптом знаком: график отказов растёт, но инженер не может назвать конкретный запрос и этап, на котором он сломался. В логах есть похожие сообщения, а трасса либо не находится, либо не связана с ними. Первый быстрый ремонт — добавить trace_id или request_id в labels метрики. График становится фильтруемым, но перестаёт быть хорошим графиком. Цена ошибки — не абстрактная «плохая наблюдаемость». Команда смешивает счётчик с идентичностью одного запроса, раздувает число временных рядов и принимает решение по данным, которые не отвечают на вопрос о причине.
Тезис простой: общий идентификатор нужен для корреляции trace и log/event record, а labels метрики должны описывать небольшой набор групп, по которым допустима агрегация. Один и тот же атрибут может встретиться в нескольких сигналах, но его роль не становится одинаковой. Сначала определите вопрос сигнала. Потом выбирайте поле.
\nTrace описывает путь операции. Его узлы — spans: например, gateway, вызов каталога и шаг оплаты. У trace есть TraceId, а у каждого span — собственный SpanId. Так можно связать дочернюю операцию с родительской и пройти от общего маршрута к конкретному шагу.
Metric описывает измеряемый класс поведения во времени. Для неё важны имя инструмента, значение, единица, временной ряд и attributes, которые разделяют поток на dimensions. Вопрос метрики звучит как «сколько отказов было на этом маршруте?» или «какое распределение задержки видит этот класс операций?». Вопрос «какой именно request упал?» относится к другой записи.
\nLog или event record фиксирует конкретное событие и его контекст. В него можно положить имя события, класс ошибки, span ID и trace ID, если это разрешено политикой хранения. Такая запись помогает объяснить один отказ. Она не заменяет агрегированную метрику, потому что свободный текст и уникальные идентификаторы плохо отвечают на вопрос о тренде.
\n| Сигнал | Основной вопрос | Подходящие данные | Чего не следует требовать |
|---|---|---|---|
| Trace | Какой путь прошла операция? | trace_id, span_id, родительский span, имя операции | Считать все ошибки и строить долгий тренд |
| Metric | Как меняется класс результата? | route template, service, outcome, environment | Хранить идентичность каждого запроса |
| Log/event | Что произошло на одном шаге? | event name, trace ID, span ID, проверенные attributes | Становиться единственным источником агрегации |
Представим учебный маршрут checkout. Gateway принимает запрос и создаёт корневой span. Дочерний span вызывает оплату. Оплата отклоняет авторизацию. Во всех трёх шагах используется один учебный trace ID, но gateway и payment имеют разные span ID. Event об отказе ссылается на payment span. Metric считает класс route=checkout, outcome=authorization_rejected. Идентификатор конкретного пути остаётся в trace и event.
// Учебный пример. Он не создаёт telemetry и не сообщает о production.\nconst trace = {\n traceId: 'synthetic-trace-2023-09-A',\n spans: [\n { spanId: 'synthetic-span-gateway-A', name: 'checkout', parent: null },\n { spanId: 'synthetic-span-payment-A', name: 'payment.authorize',\n parent: 'synthetic-span-gateway-A' },\n ],\n};\n\nconst metricPoint = {\n name: 'checkout.authorization.rejected.total',\n value: 1,\n labels: {\n service: 'checkout-api',\n route: 'checkout',\n outcome: 'authorization_rejected',\n },\n // trace_id намеренно не является label.\n};\n\nconst event = {\n name: 'payment.authorization.rejected',\n traceId: trace.traceId,\n spanId: 'synthetic-span-payment-A',\n attributes: { failureClass: 'declined' },\n};\nВ примере три labels имеют небольшой словарь только по замыслу. Учебные строки не доказывают, что такой набор безопасен для любого backend. Реальный владелец метрики должен знать допустимые значения, объём данных, правила retention и способ измерения series. Но граница уже видна: trace_id, request_id, user_id, order_id, полный URL и текст исключения описывают отдельные случаи. Их нельзя добавлять в metric labels «на всякий случай».
Trace ID не запрещён во всех местах метрики. OpenTelemetry описывает exemplars как механизм, который может связать измеренное значение с trace и span. Это другой канал связи, не обычная dimension временного ряда. Нельзя заменить exemplar добавлением идентификатора в каждый label и объявить задачи одинаковыми. Конкретная поддержка exemplars зависит от инструмента и backend, поэтому её нужно проверять отдельно.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| График есть, виновный запрос не находится | Метрика должна была заменить trace | Проверить, есть ли рабочая связь от точки измерения к trace или event | Оставить labels агрегируемыми и настроить отдельную корреляцию |
| Число series растёт вместе с трафиком | В labels попал per-request ID или свободный текст | Выписать словарь значений каждого label и найти значения, уникальные для запросов | Убрать поле из labels; перенести его в event attributes или корреляционный механизм |
| Log найден, но относится к другому span | Контекст потерялся на границе процесса или записан вручную | Сравнить trace ID, span ID и родительский путь на одном учебном сценарии | Исправить propagation и формат записи; mismatch считать отрицательным результатом |
| Одна ошибка попала в несколько групп | Названия outcome и route не имеют единого договора | Сопоставить значения с владельцем маршрута и схемой агрегации | Зафиксировать малый словарь и версию изменения |
| Новый label нужен только для поиска | Metric используют как индекс событий | Сформулировать вопрос, который этот label должен отвечать в агрегате | Если вопрос про один запрос, использовать trace/log, а не новую dimension |
Положительный пример легко обманчив. Он показывает, что два объекта можно связать одинаковым ID, но не показывает, что система отвергает неверную связь. Минимальный учебный тест должен принимать согласованный trace и отклонять четыре случая: другой trace ID в event, другой span ID, trace_id в labels и новый неизвестный label. Тест проверяет форму договора. Он не проверяет экспорт, collector, индексацию, sampling, storage или реальную cardinality.
// Учебный псевдокод. Вызовы не обращаются к SDK или сети.\nfunction checkScenario({ traceId, spanId, labels }) {\n if (traceId !== 'synthetic-trace-2023-09-A') return 'reject: trace mismatch';\n if (spanId !== 'synthetic-span-payment-A') return 'reject: span mismatch';\n const allowed = ['service', 'route', 'outcome'];\n if (Object.keys(labels).some((key) => !allowed.includes(key))) {\n return 'reject: metric label contract';\n }\n return 'accept: synthetic correlation contract';\n}\n\ncheckScenario({\n traceId: 'synthetic-trace-2023-09-A',\n spanId: 'synthetic-span-payment-A',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'authorization_rejected' },\n});\n// accept\n\ncheckScenario({\n traceId: 'synthetic-trace-2023-09-A',\n spanId: 'synthetic-span-payment-A',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'authorization_rejected', trace_id: '...' },\n});\n// reject: metric label contract\nОтрицательный результат не означает, что любой trace ID в любом представлении запрещён. Он означает, что именно этот учебный contract не разрешает использовать его как dimension метрики. В рабочей системе правило должно жить рядом с инструментированием, а не только в статье. Иначе следующий разработчик изменит labels, а проверка останется зелёной на старом примере.
\nВ статье нет production-телеметрии, реального counter, latency, trace, log, dashboard или запроса к backend. Все значения с префиксом synthetic- служат для объяснения связей. Учебная metric point не измеряет количество отказов. Согласованный trace не доказывает, что propagation работает в приложении. Пройденная функция не доказывает, что exporter доставит запись, collector не изменит её и backend покажет её пользователю.
Высокая cardinality тоже не вычисляется по числу labels в примере. Влияние зависит от множества значений, сочетаний dimensions, периода хранения, агрегации и конкретной платформы. Поэтому отрицательный путь должен продолжаться за пределами кода: измерьте число временных рядов и стоимость выбранного набора в разрешённой среде. Если такой проверки нет, формулировка должна быть «контракт не разрешает поле», а не «система доказанно экономна».
\nЕсли общий trace ID не проходит границу процесса, не компенсируйте это копированием идентификатора в каждую метрику. Сначала проверьте propagation, формат записи и доступность корреляции. Если event содержит чувствительные данные, отдельно решите redaction, retention и права доступа. Связь между сигналами не отменяет требований к данным.
\nРабота готова, когда на одном контролируемом сценарии видны четыре результата: агрегатная метрика содержит только согласованные dimensions; trace показывает ожидаемый путь и разные span ID; event ссылается на тот же trace и правильный span; неверный trace, неверный span и per-request label получают отдельный отказ. Для реального контура дополнительно есть проверка propagation и измерение series в конкретном backend. Если можно показать только зелёный учебный пример, готова модель договора, но не production-настройка наблюдаемости.
\nSpanContext, TraceId, SpanId и span tree.Симптом появляется во время расследования отказа: график показывает рост ошибок, но по точке на графике нельзя найти конкретный запрос. В ответ хочется добавить trace_id или request_id в labels метрики. Фильтр действительно станет точнее, но каждая новая строка начнёт описывать отдельный запрос. Команда получит много временных рядов, а метрика перестанет отвечать на вопрос о тенденции.
Рабочее правило проще сформулировать через задачу сигнала: metric агрегирует класс поведения, trace показывает путь операции, а log или event сохраняет контекст отдельного события. Общий идентификатор нужен для корреляции trace и записи события. Он не обязан становиться dimension метрики. Ниже — модель, пример и проверка, которую можно воспроизвести без SDK и доступа к backend.
\nТрасса отвечает на вопрос «какой путь прошла операция?». Trace состоит из связанных spans: корневой span описывает вход в операцию, дочерние — вызовы сервиса, базы или внешнего API. У trace есть общий TraceId, а каждый span получает собственный SpanId. Поэтому один запрос можно проследить от gateway до шага оплаты, не смешивая соседние операции.
Метрика отвечает на вопрос «как ведёт себя класс операций во времени?». Её точка имеет имя, значение и набор атрибутов. Например, счётчик может считать отказы для service=checkout-api, route=checkout и outcome=authorization_rejected. Такой набор пригоден для группировки: можно сравнить маршруты или исходы, не перечисляя каждый запрос.
Log или event отвечает на вопрос «что произошло в конкретный момент?». В запись можно положить имя события, класс ошибки, trace_id, span_id и разрешённый контекст. Это помогает перейти от агрегата к расследованию. При этом запись события не заменяет счётчик: поиск по свободному тексту и уникальным идентификаторам плохо подходит для долгого тренда.
| Сигнал | Главный вопрос | Что хранить | Чего не требовать |
|---|---|---|---|
| Trace | Как прошла операция? | TraceId, SpanId, parent и имя операции | Заменять им агрегированную статистику |
| Metric | Как меняется класс поведения? | Стабильные service, route, outcome и environment | Идентичность каждого request |
| Log/event | Что случилось на одном шаге? | Имя события, trace/span ID и проверенные attributes | Использовать как единственный источник тренда |
В Prometheus временной ряд однозначно задаётся именем метрики и набором пар «label — value». Изменение значения label создаёт новый ряд. В OpenTelemetry metric stream также идентифицируется набором attributes, а модель поддерживает последующую агрегацию с меньшим числом attributes. Это полезные механизмы, но они не делают идентификатор запроса хорошим dimension: стоимость и объём уже возникших комбинаций никуда не исчезают автоматически.
\nРассмотрим два запроса одного маршрута. В первом варианте labels описывают класс результата:
\ncheckout_authorization_total{\n service=\"checkout-api\",\n route=\"checkout\",\n outcome=\"authorization_rejected\"\n} 1\nВо втором к тем же labels добавляют trace_id. Если за интервал пришло 100 000 запросов и каждый получил новый идентификатор, появится до 100 000 комбинаций только для этого маршрута и исхода. Это иллюстрация верхней границы при условии, что все IDs различны и система принимает их без дополнительной агрегации. Реальное число рядов зависит от backend, других labels, срока хранения, sampling и того, как инструмент экспортирует данные.
Имена маршрутов тоже требуют осторожности. В label должен попадать шаблон маршрута вроде /orders/{orderId} или заранее согласованное имя операции, а не полный URL с идентификатором заказа. Иначе в метрику попадёт та же проблема высокой cardinality — число уникальных комбинаций dimensions.
В OpenTelemetry дочерний span с родителем сохраняет тот же TraceId, но получает собственный SpanId. Это даёт точный путь: gateway и payment принадлежат одной трассе, а их spans различаются. Если контекст передаётся между процессами, instrumentation или propagator должен извлечь его на входе и использовать при создании следующего span.
Запись отказа должна ссылаться на тот span, где отказ наблюдался. Ссылка только на trace без span оставляет расследование слишком широким; новый случай trace/span mismatch должен быть виден в проверке. Нельзя «чинить» потерю context копированием ID во все metrics labels: сначала проверяют propagation и границу записи, затем исправляют контракт.
\n// Учебные данные: код не обращается к сети и не создаёт telemetry.\nconst trace = {\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spans: {\n gateway: { spanId: '00f067aa0ba902b7', parent: null },\n payment: { spanId: 'b7ad6b7169203331', parent: '00f067aa0ba902b7' },\n },\n};\n\nconst metric = {\n name: 'checkout_authorization_total',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected' },\n};\n\nconst event = {\n name: 'payment.authorization.rejected',\n traceId: trace.traceId,\n spanId: trace.spans.payment.spanId,\n attributes: { failureClass: 'declined' },\n};\nДлины IDs в примере выбраны по формату, описанному в спецификации OpenTelemetry: hex-представление TraceId содержит 32 символа, SpanId — 16. Это проверяет форму учебного объекта, но не доказывает, что ваш SDK корректно передаст context через HTTP, очередь или фоновой worker.
Иногда расследователю полезно перейти с точки метрики к одной трассе. Для такого сценария OpenTelemetry описывает exemplar — записанное значение, связанное с context метрики; в нём могут присутствовать trace_id и span_id. Exemplar не становится label и не создаёт по одному временному ряду на каждую операцию. Это принципиально другой канал: агрегат сохраняет свою размерность, а отдельная точка получает ссылку на trace.
Поддержка exemplars и переход по ним зависит от SDK, exporter и системы хранения. Поэтому нельзя обещать рабочую ссылку только по факту добавления поля в объект. Проверьте документацию конкретного стека, формат экспорта и то, отображает ли выбранный интерфейс exemplar. Если такой цепочки нет, сохраняйте ID в структурированном log/event с учётом доступа, redaction и retention.
\n| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| График есть, trace не находится | Метрику использовали как журнал | Проверить exemplar или связь с event по одному сценарию | Оставить labels агрегируемыми, восстановить отдельную корреляцию |
| Число рядов растёт вместе с трафиком | В dimension попал per-request ID | Выгрузить словарь значений label за короткий интервал | Убрать поле из labels и перенести его в event/context |
| Log найден, но span другой | Context потерян на границе процесса | Сравнить trace ID, span ID и parent на одном запросе | Проверить propagator, middleware и формат записи |
| Маршрут дробится по заказам | В label попал полный URL | Сопоставить значения route с шаблонами маршрутов | Записывать нормализованный route template |
| Нужен поиск по ID | Metric выполняет роль индекса событий | Сформулировать запрос, который должен отвечать на агрегат | Если нужен один request, искать trace или event |
Зелёный happy path показывает только согласованный объект. Для полезной проверки нужны намеренно неверные входы: другой trace ID, другой span ID и запрещённый label. Следующая функция не использует OpenTelemetry SDK; она фиксирует минимальное правило учебного договора.
\nconst allowedLabels = new Set(['service', 'route', 'outcome']);\n\nfunction validate({ traceId, spanId, labels }) {\n if (traceId !== '4bf92f3577b34da6a3ce929d0e0e4736') {\n return 'reject: trace mismatch';\n }\n if (spanId !== 'b7ad6b7169203331') {\n return 'reject: span mismatch';\n }\n if (Object.keys(labels).some((key) => !allowedLabels.has(key))) {\n return 'reject: metric label contract';\n }\n return 'accept: educational contract';\n}\n\nvalidate({\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spanId: 'b7ad6b7169203331',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected' },\n});\n// accept: educational contract\n\nvalidate({\n traceId: '4bf92f3577b34da6a3ce929d0e0e4736',\n spanId: 'b7ad6b7169203331',\n labels: { service: 'checkout-api', route: 'checkout', outcome: 'rejected', trace_id: '...' },\n});\n// reject: metric label contract\nЗапустите этот фрагмент в Node.js, сохранив его как обычный JavaScript-файл, или перенесите правило в тест своего instrumentation. В production-тесте добавьте проверку реального экспортированного payload: учебная функция не знает о collector, sampling, exporter, индексации и правах доступа.
\nПример доказывает только структуру договора: labels описывают небольшой набор классов, а trace и event могут иметь общий trace ID и точный span ID. Он не доказывает, что выбранный SDK создаёт такие spans, что HTTP-заголовок не теряется, что sampling сохранит нужную трассу или что backend поддерживает переход по exemplar.
\nТакже нельзя выводить стоимость по числу трёх labels. Cardinality зависит от числа значений и их сочетаний, а не только от количества ключей. Даже нормализованные route и outcome требуют словаря, владельца и наблюдения за ростом рядов. Если данные о backend недоступны, честный результат проверки — «контракт запрещает per-request label», а не «система доказанно экономна».
\nКритерий готовности для реального изменения состоит из четырёх наблюдений: metric строится по согласованным dimensions; trace показывает ожидаемый путь; event ссылается на правильный trace и span; неверные IDs и per-request label отвергаются. После этого отдельно проверяют экспорт, хранение, sampling, безопасность и число временных рядов.
\nНа графике выросли ошибки авторизации. В журнале есть сообщения об отказе. В трассировке виден тот же endpoint, но инженер не может доказать, что три наблюдения относятся к одному запросу. Он тратит время на ручной поиск и может увеличить timeout или retry, не устранив причину. Цена ошибки — лишняя нагрузка, задержка расследования и решение по несвязанным данным.
\nБыстрый ремонт — добавить trace ID во все метрики, а в лог оставить длинный текст. Связь одного запроса с графиком станет возможной, но метрика перестанет быть хорошим агрегатом. Число уникальных series будет расти вместе с числом запросов. Корреляция должна жить в trace и логах, а метрика должна отвечать на вопрос о классе событий.
\nTrace описывает путь операции через компоненты. Metric показывает число, долю или распределение во времени. Log фиксирует отдельное событие и его контекст. OpenTelemetry называет их разными сигналами, потому что у них разные модели данных и способы поиска.
\nОбщий trace ID связывает span одного распределённого пути. Span ID уточняет конкретный шаг. Лог может содержать оба значения и имя события. Метрика должна использовать поля с небольшим заранее известным набором значений: шаблон маршрута, результат и имя сервиса. Идентификатор запроса, пользователя, заказа и необработанный URL в этот набор обычно не входят.
\nКлиент отправляет запрос в gateway. Gateway принимает или создаёт trace context и передаёт его дальше. Сервис оплаты создаёт дочерний span. При отказе сервис записывает событие в лог с trace ID и span ID шага оплаты. Отдельно он увеличивает счётчик отказов с labels service, route и outcome. По метрике видно, что класс отказа растёт. По trace ID можно найти конкретный путь. По записи события можно понять, что произошло внутри шага.
Сигналы не связываются автоматически. Пропагатор может быть не настроен, лог может потерять контекст, sampling может не сохранить нужный trace, а индекс может скрыть поле поиска. Договор задаёт ожидаемую связь. Проверка должна показать, где она рвётся.
\n| Сигнал | Вопрос | Допустимые поля | Не следует добавлять |
|---|---|---|---|
| Trace | Какой путь прошёл запрос? | trace ID, span ID, service, operation | Свободный текст вместо структуры |
| Metric | Как меняется класс событий? | service, route template, outcome | trace ID, request ID, user ID, order ID |
| Log | Что произошло на одном шаге? | trace ID, span ID, event name, безопасные attributes | Секреты, токены и лишние персональные данные |
Ниже показана форма данных для одного учебного отказа. Имена с префиксом demo- не представляют реальные запросы, пользователей, задержки или результаты работы сервиса. Пример проверяет только границы между сигналами.
const trace = { traceId: 'demo-trace-001', spans: [{ spanId: 'demo-span-gateway', service: 'gateway', operation: 'checkout' }, { spanId: 'demo-span-payment', service: 'payment', operation: 'authorize' }] }; const logEvent = { traceId: trace.traceId, spanId: 'demo-span-payment', eventName: 'payment.authorization.rejected', attributes: { reasonClass: 'demo-limit', retryable: false } }; const metric = { name: 'payment_authorization_total', value: 1, labels: { service: 'payment', route: 'checkout', outcome: 'rejected' } };\nВ примере trace ID повторяется в trace и log. Он не попадает в labels метрики. Поля reasonClass и retryable объясняют событие, но не становятся dimensions автоматически. В рабочей системе имена и состав полей нужно согласовать с владельцами сервиса, требованиями безопасности и возможностями хранилища. Этот фрагмент не создаёт telemetry и не доказывает работу экспортера.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| График показывает всплеск, но запрос не найти | Нет устойчивого перехода от metric к trace | Выбрать один отказ и проверить его trace ID в логах | Оставить labels агрегируемыми, а корреляцию добавить в log/span |
| Число series растёт почти с каждым запросом | В labels попал trace ID или другой уникальный идентификатор | Посчитать словари значений по каждой label и сравнить с числом запросов | Удалить request-like label и заменить его малым классом |
| Trace есть, но событие не объясняет отказ | Лог содержит только текст или другой span ID | Сверить trace ID, span ID, имя события и обязательные attributes | Записывать структурированное событие на том же шаге |
| Связь работает только иногда | Контекст теряется на границе сервиса или при sampling | Проверить propagation на входе и выходе | Исправить границу передачи; не маскировать пробел новой label |
Если gateway передал trace context, а payment создал новый trace вместо дочернего span, оба сигнала выглядят корректно по отдельности. Поиск по одному ID ничего не даст. Если лог записал trace ID, но указывает span gateway вместо span с отказом, инженер попадёт в начало пути и пропустит причину. Если метрика получила user_id, она может показать нужный случай, но ценой неконтролируемого числа комбинаций и лишнего раскрытия данных.
Проверяйте эти случаи специально. Сравните входной и исходящий context на каждой границе. Сопоставьте span ID события с операцией, где произошёл отказ. Отдельно проверьте отказ без trace: пустая строка не должна смешивать разные случаи. Если связь потеряна, исправьте propagation. Не маскируйте пробел новой label.
\nНебольшой набор labels не гарантирует низкую стоимость хранения. Итог зависит от числа сервисов, маршрутов, окружений, времени хранения и запросов к backend. Удаление trace ID из метрики не решает проблему, если в labels остаются сырые URL, тексты ошибок или идентификаторы заказов. Нужен отдельный обзор cardinality и доступа к данным.
\nTrace sampling может сохранить не каждый запрос. Логирование тоже может быть ограничено уровнем, фильтрами или политикой персональных данных. Поэтому отсутствие trace по графику не доказывает отсутствие ошибки. В критичном потоке метрика должна фиксировать класс отказа, а лог — безопасный контекст для следующей проверки.
\nДоговор готов, когда для выбранного пути выполнены четыре условия: один запрос сохраняет общий trace ID через нужные границы; событие отказа содержит тот же trace ID и span ID правильного шага; метрика группируется только по описанным labels; отрицательная проверка обнаруживает mismatch и уникальные идентификаторы в labels. Результат должен воспроизводиться по ссылкам на конкретный trace, лог и график в разрешённой среде. Если условие не выполнено, связь сигналов ещё не доказана.
\nПосле релиза возникает сбой в авторизации: график показывает рост отказов, но по нему нельзя найти конкретный запрос. В логах сообщения есть, однако они не связаны с трассировкой. Инженер вручную перебирает временной интервал и рискует исправить не ту границу. Лишний retry увеличивает нагрузку, а необоснованный timeout прячет задержку.
\nРабочая схема разделяет три роли. Метрика отвечает, как часто возникает класс событий. Trace, то есть распределённая трасса, показывает путь одного запроса через сервисы. Структурированный лог фиксирует событие и его безопасный контекст. Один trace ID связывает trace и лог, но не должен становиться label метрики: иначе корреляция создаст неконтролируемое число временных рядов.
\nДо настройки SDK и дашборда запишите, какой факт требуется получить. Для всплеска ошибок авторизации вопрос звучит так: «какие операции и на каких границах отклоняют запросы?». У каждого сигнала будет свой ответ, поэтому один идентификатор нельзя механически разложить по всем полям.
\n| Сигнал | Вопрос | Пример поля | Ограничение |
|---|---|---|---|
| Метрика | Как меняется частота класса? | outcome=rejected | Не указывает запрос |
| Trace | Какие шаги прошёл запрос? | trace_id, span_id | Не сохраняет каждый запрос |
| Лог | Что произошло на шаге? | event_name, reason_class | Не заменяет агрегат |
OpenTelemetry описывает trace как путь запроса, metric как измерение во время работы, а log как запись события. Сигнал выбирают по вопросу, а не по открытому у инженера хранилищу.
\nРассмотрим запрос checkout. Gateway принимает HTTP-запрос, передаёт контекст сервису оплаты, а payment создаёт дочерний span authorize. При отказе payment пишет событие и увеличивает счётчик. Контракт проверяется по пяти переходам:
Для HTTP таким переносом обычно служит заголовок traceparent, определённый W3C Trace Context. Он не является пользовательским request ID и должен проходить проверку формата. На каждой границе сравнивайте trace ID: downstream продолжает тот же trace, а не начинает новый. Если клиент не поддерживает propagation, исправляйте интеграцию, а не добавляйте trace ID в metric.
В модели Prometheus каждый уникальный набор значений labels создаёт отдельный временной ряд. Если добавить к счётчику trace_id, почти каждый запрос создаст новый ряд. Тот же риск несут user_id, order_id, email и сырой URL с идентификаторами. Backend может принять такие значения, но стоимость хранения и запросов растёт вместе с комбинациями.
В label оставляйте поля с ограниченным словарём. Вместо /orders/8472 используйте /orders/:id; вместо текста исключения — класс limit, invalid_input или upstream_timeout. Список классов — часть контракта и должен быть согласован с владельцем дашборда.
| Поле | Trace или log | Metric label | Причина |
|---|---|---|---|
trace_id | Да, для перехода к пути | Нет | Почти неограниченное множество |
route_template | Да | Да | Ограниченный словарь |
reason_class | Да | Да, если согласован | Группирует причины |
order_id | Только при разрешённом доступе | Нет | Высокая cardinality и чувствительность |
Это форма договора, а не готовый OpenTelemetry exporter. Идентификаторы с префиксом demo- вымышлены. Фрагмент проверяет совпадение trace ID в trace и логе и отсутствие уникального поля в metric sample.
const trace = {\n traceId: 'demo-trace-001',\n spans: [\n { spanId: 'demo-gateway', service: 'gateway', operation: 'checkout' },\n { spanId: 'demo-payment', service: 'payment', operation: 'authorize' },\n ],\n};\nconst logEvent = {\n trace_id: trace.traceId,\n span_id: 'demo-payment',\n event_name: 'payment.authorization.rejected',\n reason_class: 'limit',\n};\nconst metricSample = {\n name: 'payment_authorization_total',\n labels: { service: 'payment', route: 'checkout', outcome: 'rejected' },\n value: 1,\n};\nconsole.assert(logEvent.trace_id === trace.traceId);\nconsole.assert(!Object.hasOwn(metricSample.labels, 'trace_id'));\nПроверки подтверждают только форму объекта. В рабочем сервисе данные должны быть результатом инструментирования и экспортироваться в выбранный backend. Demo-данные не показывают реальную задержку, частоту отказов или полноту sampling.
\nЕсли тестовый gateway слушает localhost:8080, передайте ему фиксированный учебный контекст. Заголовок соответствует формату W3C и предназначен для лабораторной проверки. Замените URL и имя файла на настройки своей среды.
curl -sS -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' http://localhost:8080/checkout\njq 'select(.trace_id == \"4bf92f3577b34da6a3ce929d0e0e4736\") | {trace_id, span_id, event_name}' app.log\nСверьте три наблюдения: в trace появился путь gateway → payment; в логе есть тот же trace_id и span оплаты; график показывает серию service=payment, route=checkout, outcome=rejected. HTTP 200 этого не доказывает. Если endpoint не создаёт отказ, используйте тестовый сценарий с известным ответом.
| Наблюдение | Гипотеза | Проверка | Действие |
|---|---|---|---|
| График растёт, trace не находится | Нет перехода к контексту или trace отброшен sampling | Взять лог отказа и найти его trace ID | Настроить переход; sampling проверить отдельно |
| Новая series появляется почти на каждый запрос | В label попал ID или сырой URL | Посчитать значения label за окно | Удалить уникальное поле и нормализовать маршрут |
| Trace общий, span указывает gateway | Лог пишется вне активного span | Сопоставить span ID с операцией отказа | Писать событие внутри нужного контекста |
| Payment видит новый trace | Не сработал propagator | Сравнить входящий и исходящий traceparent | Исправить middleware или клиент |
Таблица отделяет неисправность контекста от sampling и плохой схемы labels. После исправления повторите тот же тестовый запрос.
\nСчастливый путь доказывает лишь то, что корреляция иногда работает. Подмените span ID в логе и убедитесь, что проверка указывает на неверный шаг. Уберите trace context и проверьте, что запись без trace ID не смешивается с другой трассой. Запустите два параллельных запроса: одинаковая временная метка не может быть единственным ключом связи.
\nОтдельно проверьте рост словаря labels. В тестовом инструменте добавьте уникальный ID намеренно, посчитайте новые серии, затем удалите его и сравните число рядов с исходным диапазоном. Для этого достаточно изолированного Prometheus-compatible backend; production-трафик не нужен.
\nСхема не гарантирует trace для каждого отказа. Sampling может сохранить только часть запросов, сборщик — потерять данные, а политика хранения — удалить старые записи. Метрика показывает агрегированный класс, но не полный список причин. Для критичных операций заранее определите sampling и срок хранения.
\nTrace ID не является разрешением на доступ к данным. Ссылка из метрики в trace должна учитывать права пользователя. Логи с trace ID всё равно могут содержать персональные данные, токены или платёжные реквизиты; корреляция не отменяет маскирование и ограничение доступа.
\nНизкая cardinality не означает низкую стоимость для любого backend. Prometheus описывает labels как измерения временных рядов и предупреждает о high-cardinality values. Для другой системы уточните модель хранения, индексацию и sampling. Имена полей зависят от языка и SDK.
\nДоговор проверен, когда тестовый запрос проходит нужные границы, downstream сохраняет общий trace ID, лог содержит trace ID и span ID фактической причины, а метрика группируется по ограниченным labels. Зафиксируйте также тест потери контекста, проверку cardinality и правило доступа к логам и трассам. Иначе dashboard показывает сигнал, но не даёт воспроизводимого маршрута расследования.
\nTraceId и SpanId.В CI тест оформления платежа падает на click. Повторный запуск проходит. Через день тот же тест снова красный, но уже на проверке результата. Команда увеличивает timeout, добавляет ещё один retry и получает более длинную очередь. Ошибка не исчезает: тест лишь дольше скрывает нарушение пользовательского сценария.
Цена такого решения измерима. Разработчик ждёт обратную связь дольше. Красный запуск перестаёт отличать дефект продукта от дефекта теста. Если retry маскирует настоящий сбой оплаты, команда может пропустить проблему до релиза. Если виноваты общие данные, каждый тест с большим timeout платит за чужую гонку.
\nТезис простой: flaky — это сигнал для разбора, а не причина менять настройки вслепую. Сначала разделите locator, actionability, readiness, данные и внешние зависимости. Затем привяжите evidence к конкретной попытке. После этого выбирайте маленькую правку с owner и понятным rollback.
\nСтатус failed → passed сообщает только о разных исходах запусков. Он не называет причину. Retry не продолжает ту же страницу с того же места. Runner создаёт новую попытку и может использовать другой worker. Меняются cookies, storage, порядок тестов, состояние базы, очистка данных и доступность внешнего сервиса.
У действия есть несколько независимых условий. Locator должен указывать ровно на один элемент. Перед click элемент должен быть видимым, стабильным, доступным для событий и активным. После действия интерфейс должен показать пользовательский результат. Успешный click доказывает готовность действия. Он не доказывает, что платёж подтверждён.
Ожидание networkidle не равно готовому экрану. Исчезнувший spinner не равен успешной операции. Прошедший retry не равен воспроизводимому тесту. Trace помогает увидеть ход одной попытки, но не подменяет контракт результата.
Зафиксируйте четыре факта до изменения конфигурации: чем пользователь находит control, какой результат он должен увидеть, что произошло в initial и retry, и какой артефакт относится к каждой попытке. Не называйте доказательством файл без номера попытки. Trace retry не объясняет автоматически initial failure.
\nimport { expect, test } from '@playwright/test'; test('confirms payment', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Оплатить' }).click(); await expect(page.getByRole('status')).toHaveText('Платёж подтверждён'); }); // Учебный пример: текст, маршрут и данные замените на контракт конкретного приложения.\nВ примере locator описывает действие языком пользователя. Assertion ждёт смысловой результат, а не случайную задержку. Это учебный фрагмент: он не подтверждает работу платёжного контура и не заменяет проверку в вашем окружении. В реальном тесте задайте независимые данные и очистку, иначе зелёный запуск может зависеть от предыдущего теста.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ноль или несколько совпадений locator | Selector не описывает одну цель | Проверьте роль, имя и область контейнера | Исправьте locator; timeout не меняйте |
| Click ждёт и завершается timeout | Элемент скрыт, перекрыт, движется или disabled | Смотрите actionability и состояние экрана | Исправьте предусловие или selector |
| Click прошёл, результата нет | Неверный readiness contract, данные или ответ сервиса | Сверьте assertion с пользовательским итогом и входом | Уточните UI-контракт или изоляцию данных |
| Initial failed, retry passed | Гонка, загрязнение данных или внешний сбой | Сравните worker, данные, порядок и evidence | Создайте triage; не добавляйте retry автоматически |
| Trace есть только у retry | Контекст попыток собран несимметрично | Проверьте attempt, тест и проект в отчёте | Добавьте путь сбора initial evidence |
Начните с cardinality. Locator должен находить ровно один элемент в момент действия. Если кнопок несколько, сузьте область диалога или списка. Если совпадений нет, разберитесь с состоянием страницы и текстом. Увеличение timeout не делает неоднозначную цель однозначной.
\nПредпочитайте роль, доступное имя и label. CSS-цепочка по классам связывает тест со строением DOM, а не с поведением интерфейса. Это не абсолютный запрет на test id или CSS. Test id полезен для стабильного технического контракта. CSS оправдан, когда команда сознательно поддерживает его как API. Важно назвать владельца и смысл locator.
\nПосле click проверяйте факт, который видит пользователь: сообщение об успехе, новую запись, смену статуса или подтверждённый маршрут. Не используйте spinner как финальный результат. Не делайте expect(await locator.isVisible()).toBe(true), если нужен web-first assertion: такая форма сначала получает снимок состояния и теряет встроенное ожидание.
Retry полезен как диагностический слой и как защита от краткого сбоя инфраструктуры. Он опасен, когда превращается в разрешение на merge. Запишите номер попытки, worker, используемые данные и исходную ошибку. Слово «flaky» описывает классификацию запусков. Оно не заменяет root cause.
\nНе смешивайте классы причин. Ошибка locator требует проверки DOM. Ошибка actionability требует проверки overlay, animation и disabled state. Отсутствие readiness требует проверки UI и ответа операции. Разные данные требуют проверки setup и cleanup. Внешний сервис требует отдельной политики зависимости. Один глобальный timeout не лечит все случаи.
\nuse: { trace: 'on-first-retry', screenshot: 'only-on-failure' }, retries: process.env.CI ? 1 : 0 // Учебная конфигурация. Значения зависят от цены очереди и среды.\nФрагмент показывает форму, а не готовую политику. Trace на первом retry даёт контекст повторной попытки и экономит место. Он не создаёт trace initial failure. Если первичный контекст критичен, настройте отдельный способ его сохранить и явно подпишите артефакт.
\nОткройте trace с одним вопросом: locator указывал на одну кнопку перед click или после click появился нужный status? Trace Viewer позволяет сопоставить timeline, DOM snapshot, action log и сетевые запросы. Это помогает сузить гипотезу. Но trace показывает конкретный запуск. Он не знает, был ли результат бизнес-успешным, пока тест не проверяет assertion.
\nЕсли артефакта нет, запишите evidence: absent. Не заменяйте отсутствие данных уверенным объяснением. Сначала сверяйте имя теста, проект, commit и номер попытки. Затем смотрите один слой. Широкий запрос «найти причину по trace» часто приводит к непроверенной версии.
Проверяйте не только успешную оплату. Добавьте сценарий, в котором сервер возвращает отказ или данные невалидны. Убедитесь, что тест видит сообщение об ошибке и не принимает disabled control, spinner или старый status за успех. Если отрицательный путь ломается из-за случайного текста, проблема может быть в контракте интерфейса, а не в retry.
\nПроверка изоляции тоже должна иметь отрицательный путь. Запустите тест отдельно и в другом порядке. Используйте новый идентификатор данных. Удалите запись после сценария. Если результат меняется, не маскируйте гонку timeout. Найдите владельца состояния и границу cleanup.
\nНи один locator не защищает от неверного продукта. Auto-waiting ждёт actionability, но не исправляет серверный ответ. Web-first assertion ждёт условие, но не делает условие правильным. Retry может уменьшить шум инфраструктуры, но может и скрыть редкую ошибку. Trace полезен только там, где его записали и правильно связали с попыткой.
\nУчебные фрагменты не дают production-результатов. Они не измеряют flake rate, длительность очереди, совместимость браузеров или качество данных. Версию Playwright, project config и окружение нужно сверять отдельно. Повышение timeout допустимо только после доказанной верхней границы задержки и проверки, что ожидание относится к нужному событию.
\nРазбор готов, если команда может показать карточку запуска и ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какой evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.
\nДля временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry не проходит этот критерий. Проходит повторяемый контракт, в котором тест отличает готовое действие от подтверждённого результата.
\nВ CI тест оформления платежа падает на click, а повторная попытка проходит. На следующий день тот же сценарий становится красным уже на проверке результата. Команда увеличивает timeout и добавляет ещё один retry, но получает только более длинную очередь: причина остаётся неизвестной, а настоящий сбой оплаты может потеряться среди зелёных повторов.
Такой тест называют flaky, когда он при одинаковом заявленном сценарии иногда проходит, а иногда нет. Это описание наблюдения, а не диагноз. Чтобы вернуть тесту ценность, нужно сохранить исходную ошибку, разделить слои отказа и доказать маленьким экспериментом, какой слой меняется между попытками.
\nНиже — рабочая схема для Playwright Test. Маршрут, тексты, фикстуры и способ подготовки платежа в примерах условны: их нужно заменить контрактом конкретного приложения. Статья не обещает нулевой flake rate и не разрешает автоматически скрывать дефекты retry-настройкой.
\nНачните с карточки одного запуска. Запишите commit, проект браузера, worker, номер попытки, входные данные и точное место падения. Не заменяйте initial failure результатом retry. В Playwright Test значение testInfo.retry показывает номер повторной попытки, а testInfo.workerIndex помогает связать запуск с worker.
import { test } from '@playwright/test';\n\ntest('confirms payment', async ({ page }, testInfo) => {\n console.log({\n retry: testInfo.retry,\n worker: testInfo.workerIndex,\n project: testInfo.project.name,\n });\n\n await page.goto('/checkout');\n await page.getByRole('button', { name: 'Оплатить' }).click();\n});\nЛог не заменяет отчёт: в нём должны остаться текст ошибки, URL или идентификатор тестовых данных и ссылка на артефакт именно этой попытки. Если trace был собран только на retry, пометьте initial как evidence: absent, а не делайте вид, что повторный trace объясняет первый сбой.
Retry не продолжает тот же браузер с места ошибки. Когда тест падает, Playwright Test удаляет worker вместе с браузером и запускает новый worker; при включённых retry тест начинается заново в новом процессе. Поэтому между initial и retry могут отличаться cookies, storage, состояние фикстур, worker, порядок подготовки данных и доступность внешней зависимости.
\nPlaywright классифицирует результаты так: passed — первый запуск прошёл; flaky — первый запуск упал, но retry прошёл; failed — упали первый запуск и все retry. Метка flaky полезна для очереди разбора, но не доказывает, что продукт исправен или что причина относится к инфраструктуре.
| Наблюдение | Что уже известно | Чего ещё нельзя утверждать | Следующая проверка |
|---|---|---|---|
| Обе попытки падают на одном locator | Сбой воспроизводится в этом запуске | Что виноват только selector | Проверить число совпадений и actionability |
| Initial падает, retry проходит до click | Между попытками изменилось состояние или время | Что нужен больший timeout | Сравнить DOM, overlay, animation и данные |
| Click проходит, assertion результата падает | Действие принято браузером | Что операция завершилась успешно | Проверить финальный UI-state и ответ операции |
| Тест проходит отдельно, но падает в пачке | Есть зависимость от порядка или общего состояния | Что проблема в браузере | Запустить с новым id данных и другим порядком |
| Есть trace только у retry | Видна одна повторная попытка | Что она показывает initial | Включить симметричный сбор первого failure |
Для locator.click() Playwright ждёт, пока locator разрешится ровно в один элемент, элемент станет видимым, стабильным, доступным для событий и активным. Это несколько разных проверок. Таймаут на невидимой кнопке, перекрытие модальным слоем и два совпавших элемента требуют разных исправлений, хотя в отчёте могут выглядеть как один TimeoutError.
Сначала проверьте cardinality — количество совпадений. Локатор должен описывать одну пользовательскую цель в нужной области страницы. Предпочтительны роль и доступное имя; цепочка CSS-классов связывает тест с реализацией DOM. getByTestId допустим, если команда поддерживает test id как стабильный технический контракт. Нельзя объявлять locator надёжным только потому, что он зелёный в одном браузере.
const dialog = page.getByRole('dialog', { name: 'Оплата' });\nconst pay = dialog.getByRole('button', { name: 'Оплатить' });\n\nawait expect(dialog).toBeVisible();\nawait expect(pay).toHaveCount(1);\nawait pay.click();\nПроверка toHaveCount(1) сама ожидает условие, поэтому не превращается в мгновенный снимок состояния. Но она не доказывает, что платёж принят: она лишь делает цель действия явной. Если элемент появляется после запроса, ищите причину отсутствия или перекрытия, а не маскируйте её глобальным timeout.
После click нужен бизнес-результат, который видит пользователь: подтверждённый статус, новая запись или переход на согласованный маршрут. Spinner, исчезновение skeleton и завершение отдельного сетевого запроса — промежуточные признаки. Они не заменяют assertion финального состояния.
\nawait pay.click();\nawait expect(page.getByRole('status')).toHaveText('Платёж подтверждён');\nawait expect(page).toHaveURL(/\\/checkout\\/success$/);\nДва assertion должны соответствовать контракту приложения. Если статус появляется раньше фактического сохранения, тест обязан ждать более точный сигнал или проверять запись через контролируемый API. Если URL и статус намеренно не меняются, не добавляйте их ради формы — зафиксируйте один действительно наблюдаемый результат.
\nСлучайная задержка waitForTimeout не объясняет, какое событие делает страницу готовой. Ожидание networkidle тоже не является универсальным признаком готовности: аналитика, polling и WebSocket могут не завершаться, а нужный UI уже может быть готов. Выбирайте состояние, принадлежащее пользовательскому сценарию.
Тест может быть стабильным, а данные — нет. Используйте уникальный идентификатор заказа, подготавливайте его перед тестом и удаляйте после него. Запуск отдельно и запуск в пачке должны получать независимые записи. Если тест зависит от общей корзины, аккаунта или очереди, это состояние нужно назвать владельцем и включить в fixture либо изменить контракт сценария.
\nВнешний платёжный провайдер, email-шлюз и сторонняя аналитика находятся вне контроля e2e-команды. Полный путь к провайдеру проверяйте отдельным контрактным или интеграционным набором с его политикой доступности. В пользовательском e2e-тесте контролируйте внешний ответ через API-мок, если задача теста — проверить собственный UI. Иначе смена ответа третьей стороны будет ошибочно классифицирована как flaky интерфейса.
\nДля CI разумно собирать trace на первом retry: это даёт подробный контекст повторной попытки и не записывает тяжёлый trace для каждого зелёного теста. Если retry выключены, используйте retain-on-failure, чтобы сохранить trace неудачного запуска. Режим on удобен для локального расследования, но в большом CI увеличивает стоимость и объём артефактов.
import { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 1 : 0,\n use: {\n trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure',\n screenshot: 'only-on-failure',\n },\n});\nПроверить initial и retry можно явно. В локальной диагностике запустите один тест без повторов и с полным trace, затем откройте отчёт:
\npnpm exec playwright test tests/checkout.spec.ts --project=chromium --retries=0 --trace on\npnpm exec playwright show-report\n# Для сохранённого архива: pnpm exec playwright show-trace path/to/trace.zip\nВ Trace Viewer смотрите один вопрос за раз: какой locator использовался, что было в DOM до и после действия, какие запросы ушли, какой ответ пришёл и какой статус получил assertion. Viewer показывает timeline, snapshots, action log, network и metadata конкретного запуска. Он не сообщает, корректен ли бизнес-контракт, и не восстанавливает отсутствующий initial trace.
\nПосле правки прогоните тест отдельно и в пачке, с новым идентификатором данных и в поддерживаемых проектах браузера. Сравнивайте не только итоговый exit code, но и место assertion, длительность действия и наличие артефактов для каждой попытки. Один зелёный запуск ничего не доказывает; полезнее серия запусков с одинаковым контрактом и независимым setup.
\nПовышать timeout можно только когда trace показывает допустимую задержку конкретного события, а данные подтверждают её верхнюю границу. Даже в этом случае изменяйте локальный timeout нужного действия и фиксируйте причину. Глобальное увеличение времени скрывает регрессии производительности и заставляет все тесты ждать чужую проблему.
\nЭта схема рассчитана на Playwright Test и его worker/retry/trace-модель. В Cypress, Selenium Grid или самописном runner жизненный цикл попытки и набор артефактов могут отличаться. Проверки actionability не исправляют серверный ответ, а web-first assertion не делает неверное ожидание правильным.
\nМок внешнего сервиса повышает повторяемость собственного UI, но не проверяет реальную интеграцию. Изоляция тестовых данных не устраняет дефекты параллельной обработки в production. Trace может содержать чувствительные URL, заголовки и данные страницы, поэтому задайте срок хранения и права доступа по правилам своей CI-системы.
\nРазбор можно считать законченным, когда команда отвечает на пять вопросов: что произошло в initial, что изменилось в retry, какой locator выполнял действие, какой финальный результат ожидался и какой evidence относится к каждой попытке. Только после этого retry становится диагностическим инструментом, а не зелёной маской.
\nclick — уникальность, видимость, стабильность, получение событий и enabled state.passed/flaky/failed и testInfo.retry.В CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда повышает timeout и закрывает задачу. Через неделю тот же сценарий снова падает на другом шаге. Цена ошибки — не только красный pipeline. Команда теряет время на повторы, пропускает дефект интерфейса или данных и привыкает считать зелёный retry доказательством исправности.
\nТезис простой: устойчивость e2e-теста нельзя свести к одному timeout. В сценарии действуют отдельные контракты. Locator должен выбрать нужный элемент. Actionability должна разрешить действие. Assertion должен дождаться пользовательского результата. Retry должен описать факт повторного запуска, но не объяснить его причину.
\nОдин текст ошибки скрывает разные события. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может пройти, но сервер вернёт ошибку. Сохранение может завершиться, а тест будет ждать исчезновения spinner, который не связан с итоговым состоянием. Retry может запуститься в новом worker с другим состоянием данных.
\nПервый вопрос звучит не «какой timeout поставить?», а «какой контракт не выполнен?». Разделите фазу поиска locator, фазу действия, фазу ожидания результата и фазу retry. Это сразу сужает область правки.
\n| Контракт | Что он гарантирует | Что он не гарантирует |
|---|---|---|
| Locator | Тест обращается к нужному пользовательскому элементу и ожидает понятную cardinality. | Что операция завершилась успешно. |
| Actionability | Элемент допустимо использовать: он найден, видим, стабилен, принимает события и включён. | Что приложение приняло действие или сохранило данные. |
| Readiness | После действия появился наблюдаемый пользовательский результат. | Что причина результата находится в DOM. |
| Retry | Тест повторился и получил новый outcome. | Что первая ошибка была случайной или устранена. |
Playwright автоматически ждёт actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Это защищает от клика по исчезнувшему или перекрытому control. Но проверка заканчивается, когда click допустим. Библиотека не знает, должен ли после него появиться статус «Сохранено», новая строка или ошибка.
Readiness принадлежит пользовательскому сценарию. Для профиля это status с текстом «Сохранено» и новое значение поля. Для импорта — строка с terminal state. Spinner, enabled-кнопка и network idle могут быть промежуточными признаками. Они не заменяют бизнес-результат.
\nФрагмент ниже учебный. Он не утверждает, что такой locator или текст существуют в вашем приложении. Он показывает разделение действия и постусловия.
\nimport { test, expect } from '@playwright/test';\n\ntest('user saves profile', async ({ page }) => {\n await page.goto('/profile');\n\n const email = page.getByLabel('Почта');\n const save = page.getByRole('button', { name: 'Сохранить' });\n const status = page.getByRole('status');\n\n await email.fill('user@example.test');\n await expect(save).toBeEnabled();\n await save.click();\n\n await expect(status).toHaveText('Сохранено');\n});\nВ примере getByLabel() и getByRole() описывают пользовательский интерфейс. click() ждёт техническую готовность кнопки. toHaveText() ждёт наблюдаемый итог. Если сервер отвечает ошибкой, тест должен упасть на постусловии. Если locator стал неоднозначным, ошибка должна указывать на выбор элемента. Эти отказы требуют разных исправлений.
Не подменяйте постусловие ручной паузой. waitForTimeout(2000) иногда скрывает медленный UI, а иногда просто откладывает отказ. Не подменяйте результат исчезновением spinner, если spinner исчезает и при ошибке. Не используйте force: true, чтобы обойти перекрытие, пока не доказано, что перекрытие не является дефектом интерфейса.
В Playwright retry выключен по умолчанию. Если он включён, упавший тест запускается снова. Runner работает с worker-процессами. После отказа worker может быть отброшен, а повтор начнётся в новом процессе. Retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это наблюдение о двух запусках, а не доказательство случайности.
\nСтатус flaky полезен как сигнал: первая попытка не прошла, повторная прошла. Он не отвечает на вопросы «почему упало», «исправили ли причину» и «будет ли проходить другой браузер». Trace, записанный через on-first-retry, относится к повторной попытке. Он показывает конкретный запуск, но не восстанавливает контекст первой ошибки.
Отрицательный путь важен не меньше зелёного. Если click прошёл, но readiness не наступил, тест должен закончиться понятной ошибкой на assertion результата. Если retry затем проходит, сохраняйте обе попытки. Нельзя заменить историю фразой «flaky исчез» без проверки данных, состояния и UI-сигнала.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Timeout на click | Locator пустой, множественный, перекрыт или disabled. | Проверьте cardinality, видимость, стабильность и получение событий. | Уточните scope и locator; исправьте UI или данные, если control недоступен. |
| Click прошёл, тест ждёт до timeout | Assertion ждёт не тот результат или UI не сообщает terminal state. | Назовите факт, который должен увидеть пользователь. | Добавьте web-first assertion на этот факт или согласуйте UI-контракт. |
| Первый запуск failed, retry passed | Утечка данных, порядок тестов, внешний сервис или скрытая готовность. | Сравните входы, worker, cleanup, номер попытки и evidence. | Изолируйте данные или исправьте ожидание. Не увеличивайте retry. |
| Тест проходит только с waitForTimeout | Сценарий не ждёт наблюдаемое событие. | Уберите паузу в учебной ветке и найдите первый пользовательский факт. | Замените паузу на locator/assertion с ясным сообщением. |
| Trace ничего не объясняет | Артефакт относится к retry, а вопрос слишком широк. | Проверьте attempt, тест, проект и момент записи. | Используйте trace как evidence одного запуска и соберите контекст initial failure. |
Модель не делает любой тест стабильным. Locator с role/name может быть корректным, но приложение может показывать неверное состояние. Assertion может быть семантическим, но тестовые данные могут пересекаться. Retry помогает обнаружить flake, но не заменяет изоляцию и диагностику. Trace фиксирует только записанный запуск. Отдельно проверяйте версии Playwright, браузеры, сеть и внешний сервис.
\nУчебный код не запускает реальный профиль, не измеряет flake rate и не доказывает production-результаты. Для рабочего теста подставьте реальные роли, данные и terminal state, затем проверьте их на целевой конфигурации.
\nРазбор готов, когда для одного сценария записаны четыре вещи: уникальный locator, ожидаемый пользовательский результат, evidence с номером попытки и действие с понятным rollback. На контролируемом отрицательном пути тест должен падать на соответствующем контракте, а не на случайном timeout. На повторном запуске команда должна видеть, что изменилось: locator, readiness, данные или окружение. Только после этого статус flaky становится входом для проверки, а не заменой объяснения.
\nВ CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда увеличивает timeout и закрывает задачу. Через неделю тот же сценарий падает уже на другом шаге. Цена ошибки — не только красный pipeline: команда теряет время на повторы, пропускает дефект интерфейса или данных и начинает считать зелёный retry доказательством исправности.
\nУстойчивость e2e-сценария нельзя свести к одному таймауту. В нём действуют четыре разных контракта: locator выбирает нужный элемент, actionability разрешает действие, assertion подтверждает пользовательский результат, а retry описывает повторный запуск. Если смешать эти уровни, диагностика превращается в перебор чисел. Если разделить их, место отказа становится проверяемым.
\nОдинаковая ошибка ожидания может возникнуть по разным причинам. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может успешно отправить событие, а сервер — вернуть ошибку. Сохранение может завершиться, но тест ждёт исчезновения spinner, который скрывается и при успехе, и при отказе. При retry меняются worker, состояние браузера и иногда подготовленные данные.
\nПервый вопрос здесь не «какой timeout поставить?», а «какой контракт не выполнен?». Отделите поиск элемента, проверку готовности действия, ожидание результата, подготовку данных и повтор. У каждого слоя должна быть своя ошибка и свой следующий шаг.
\n| Контракт | Что он проверяет | Чего он не доказывает |
|---|---|---|
| Locator | Тест обращается к нужному пользовательскому элементу и ожидает понятное число совпадений. | Что операция завершилась успешно. |
| Actionability | Элемент найден, видим, стабилен, получает события и включён для выбранного действия. | Что приложение приняло действие или записало данные. |
| Readiness | После действия появился наблюдаемый terminal state: статус, строка результата или изменённое значение. | Что именно этот locator был причиной успеха. |
| Retry | Runner повторил тест и получил новый outcome в другом запуске. | Что первая ошибка была случайной, а исправление найдено. |
Playwright автоматически ждёт проверки actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Стабильность означает, что геометрия элемента не меняется на последовательных кадрах. Получение событий означает, что другой элемент, например overlay, не перехватит клик. Это защищает от клика по исчезнувшему или перекрытому control, но не знает смысла продукта.
После успешного click() библиотека не может сама решить, что считать сохранением. Для профиля это может быть сообщение «Сохранено» и новое значение поля. Для импорта — строка с terminal state и числом обработанных записей. Spinner, enabled-кнопка и отсутствие сетевой активности могут быть промежуточными признаками. Их нельзя выдавать за бизнес-результат без проверки сценария.
Хороший locator связан с тем, как пользователь воспринимает control: ролью, доступным именем, label или устойчивым тестовым идентификатором. Селектор по случайному классу или позиции в списке может пройти сегодня и сломаться после перестановки DOM. Но и role-based locator не магический: две кнопки с одним доступным именем — это неоднозначный контракт, а не повод добавить nth(0).
Проверяйте cardinality отдельно, когда она важна. await expect(save).toHaveCount(1) сообщает, что на странице ровно одна кнопка с выбранным locator. После этого click() может всё ещё не пройти: control способен быть disabled или закрыт overlay. Разные отказы оставляют разную подсказку для исправления.
Фрагмент ниже самодостаточен как форма теста, но не подключён к реальному профилю. Пути, label и тексты — проектные значения; в рабочем тесте их нужно заменить на фактический пользовательский контракт. Важен порядок: ввод, проверка уникальности locator, действие, затем ожидание результата.
\nimport { test, expect } from '@playwright/test';\n\ntest('user saves profile', async ({ page }) => {\n await page.goto('/profile');\n\n const email = page.getByLabel('Почта');\n const save = page.getByRole('button', { name: 'Сохранить' });\n const status = page.getByRole('status');\n\n await email.fill('user@example.test');\n await expect(save).toHaveCount(1);\n await expect(save).toBeEnabled();\n await save.click();\n\n await expect(status).toHaveText('Сохранено');\n});\ngetByLabel() и getByRole() выражают пользовательское представление интерфейса. toHaveCount() ловит неоднозначный locator до действия. click() ждёт техническую готовность кнопки. toHaveText() — web-first assertion: он повторяет проверку, пока условие не выполнено или не истечёт timeout. Если сервер вернул ошибку, тест должен упасть на postcondition, а не пройти потому, что кнопка была кликабельной.
Не подменяйте postcondition ручной паузой. waitForTimeout(2000) иногда маскирует медленный UI, а иногда лишь откладывает отказ. Не ждите исчезновения spinner, если он исчезает и при ошибке. Не используйте force: true, пока не доказано, что перекрытие не является дефектом интерфейса: этот флаг отключает часть проверок actionability и может превратить реальную проблему в зелёный тест.
По умолчанию Playwright Test не повторяет упавшие тесты. При включённых retry runner запускает тест снова до достижения лимита. Playwright Test работает с worker-процессами. Если тест падает, worker вместе с браузером отбрасывается; повтор начинается в новом worker, где hooks могут выполниться заново. Поэтому retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это объясняет различие условий, но не выбирает причину автоматически.
\nКатегория flaky означает только последовательность «первая попытка упала, повторная прошла». Она не означает «дефект случайный», «сеть была медленной» или «правка сработала». Сохраните initial error, retry outcome, входные данные, worker и cleanup. Иначе отчёт оставит только удобный ярлык.
Для CI удобно записывать trace на первой повторной попытке. Такой trace содержит историю именно записанного запуска: действия, снимки DOM и сетевой контекст. Он полезен для анализа retry, но не восстанавливает отсутствующий trace initial attempt. Если причина могла проявиться только в первой попытке, включите режим, который сохраняет evidence и для неё, либо соберите отдельный воспроизводимый запуск.
\nВ конфигурации ниже оставлена одна повторная попытка: этого достаточно, чтобы увидеть разницу между initial и retry, но недостаточно, чтобы объявить тест стабильным. Значение retries — политика запуска, а не лечение теста.
import { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 1 : 0,\n use: {\n trace: 'on-first-retry',\n },\n});\nЗапустите один тест без параллельного шума и сохраните отчёт:
\nnpx playwright test tests/profile.spec.ts --workers=1 --retries=1\nnpx playwright show-report\n# Если trace сохранён в test-results, откройте конкретный архив:\nnpx playwright show-trace test-results/<test-name>/trace.zip\nПуть test-results/<test-name>/trace.zip в последней команде условный: имя каталога зависит от проекта и репортера. Команда воспроизводима после подстановки фактического пути из отчёта. Для локального расследования, когда retry не нужен, можно временно запустить npx playwright test tests/profile.spec.ts --workers=1 --trace on, но режим записи каждого запуска дороже по времени и месту.
Номер попытки можно добавить в evidence:
\ntest('user saves profile', async ({ page }, testInfo) => {\n console.log(JSON.stringify({\n retry: testInfo.retry,\n worker: testInfo.workerIndex,\n title: testInfo.title,\n }));\n\n await page.goto('/profile');\n await page.getByRole('button', { name: 'Сохранить' }).click();\n await expect(page.getByRole('status')).toHaveText('Сохранено');\n});\nЛог не доказывает причину отказа, но связывает наблюдение с попыткой. Это особенно полезно, когда одинаковый тест выполняется в нескольких browser project или на разных shard.
\n| Симптом | Версия причины | Проверка | Безопасное действие |
|---|---|---|---|
| Timeout на click | Locator пустой, множественный, перекрыт или disabled. | Проверьте count, видимость, стабильность и получение событий. | Уточните scope или исправьте UI/данные, если control недоступен. |
| Click прошёл, assertion истёк | Ожидается не тот результат или UI не сообщает terminal state. | Назовите факт, который должен увидеть пользователь после операции. | Добавьте assertion на этот факт или согласуйте UI-контракт. |
| Initial failed, retry passed | Утечка данных, порядок тестов, внешний сервис или скрытая готовность. | Сравните входы, worker, cleanup, browser project и attempt. | Изолируйте данные и устраните причину; не увеличивайте retry. |
| Тест проходит только с waitForTimeout | Сценарий не ждёт наблюдаемое событие. | Уберите паузу в изолированной ветке и найдите первый terminal state. | Замените паузу на locator/assertion с ясным сообщением. |
| Trace ничего не объясняет | Артефакт относится к retry, а вопрос относится к initial. | Проверьте attempt, test title, project и момент записи. | Соберите evidence обеих попыток или отдельный повтор с нужным trace mode. |
Свежий browser context не делает внешние данные свежими. Если тест меняет одну и ту же учётную запись, записи могут пересекаться между worker и параллельными запусками. Если cleanup выполняется только после успеха, retry стартует с другим состоянием. Если серверная очередь или партнёрский API отвечает асинхронно, DOM может быть готов раньше результата операции.
\nСначала зафиксируйте границы изоляции. Для каждого теста задайте уникальный идентификатор сущности или подготовьте её через API; cleanup сделайте идемпотентным; состояние, которое нельзя очищать, проверяйте перед повтором. В лог запишите correlation id, но не секреты и персональные данные. Отдельно сравните локальный запуск, CI worker и browser project: одинаковый код не означает одинаковое окружение.
\nЕсли тест зависит от внешнего сервиса, разделите два вопроса. UI-тест проверяет пользовательский контракт на контролируемом ответе, а интеграционный сценарий отдельно проверяет реальное взаимодействие. Один retry не отличает дефект приложения от временной недоступности партнёра.
\nМодель не делает любой тест стабильным. User-facing locator может быть корректным, но приложение — показывать неверный статус. Web-first assertion может ждать правильный DOM-факт, но серверная запись ещё не завершится. Изоляция browser context не очищает базу, очередь или внешний сервис. Retry помогает обнаружить различие между запусками, но не заменяет расследование.
\nПримеры используют API Playwright Test и синтетические значения. Они не измеряют flake rate, latency, стоимость CI, совместимость браузеров или качество тестовых данных. Версии Playwright, Node.js, браузеров, project config и runner нужно проверять в своём lockfile и CI-образе. Документация меняется, поэтому для исторического разбора закрепляйте версию пакета и сверяйте поведение с release notes этой версии.
\nНе объявляйте тест исправленным после одного зелёного повтора. Нужны как минимум повторяемый сценарий, понятный отрицательный путь, evidence initial/retry и проверка того слоя, который был изменён. Если данных для вывода нет, корректный результат расследования — «причина не установлена», а не повышение timeout.
\nРазбор готов, когда команда может ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какое evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.
\nДля временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry этому критерию не соответствует. Соответствует контракт, в котором тест различает готовое действие, подтверждённый результат, состояние данных и условия повторного запуска.
\nТест нажимает «Оплатить», получает ошибку на первой попытке и проходит на повторной. CI помечает его как flaky. Команда поднимает timeout, добавляет retry и закрывает pull request. Через неделю тот же тест снова падает, но уже дольше занимает очередь.
Цена ошибки — не только медленный pipeline. Retry может скрыть настоящий отказ: грязные данные, зависимость от порядка тестов, гонку после ответа API или неверный locator. Прошедшая повторная попытка создаёт ощущение исправления, хотя первый запуск так и остался необъяснённым.
Тезис: стабильность e2e строится не вокруг большого timeout. Сначала нужно разделить selector, техническую готовность элемента, продуктовый результат, состояние данных и evidence конкретной попытки. Retry помогает классифицировать запуск. Он не объясняет причину.
У одного сценария несколько ожиданий. Locator отвечает на вопрос «какой элемент нужно найти». Для click() Playwright дополнительно проверяет actionability: элемент должен существовать, быть видимым, стабильным, доступным для события и включённым. Это защищает от клика по скрытой или перекрытой кнопке.
Эти проверки не знают, завершилась ли операция. Кнопка может принять клик, пока запрос ещё идёт. Сервер может вернуть ошибку. Экран может показать промежуточный spinner. Поэтому после действия нужен отдельный readiness contract: наблюдаемый факт, который означает завершение сценария для пользователя.
Для платежа таким фактом может быть статус «Подтверждено». Для сохранения профиля — сообщение об успехе и новое значение в карточке. Для импорта — строка с терминальным состоянием. disabled и исчезновение spinner подходят только тогда, когда продуктовый контракт прямо говорит, что они означают успех.
Есть и граница данных. Если тест создаёт пользователя с фиксированным логином и не удаляет его, результат зависит от предыдущего запуска. Если два теста меняют одну корзину, retry получает другое начальное состояние. Locator и assertion могут быть правильными, а падает изоляция.
Ниже учебный фрагмент для Playwright. Названия кнопки и статуса условны. Код не доказывает готовность конкретного продукта и не сообщает production-результаты. Он показывает границу между действием и постусловием.
import { expect, test } from '@playwright/test';\n\ntest('подтверждает оплату', async ({ page }) => {\n const payButton = page.getByRole('button', { name: 'Оплатить' });\n const paymentState = page.getByTestId('payment-state');\n\n await payButton.click();\n await expect(paymentState).toHaveText('confirmed');\n});getByRole() выбирает пользовательское действие. toHaveText() ждёт смысловой результат и повторяет проверку до assertion timeout. Такой код лучше, чем waitForTimeout(1000): пауза ничего не говорит о состоянии приложения и одинаково плохо работает для быстрого и медленного ответа.
Если locator совпадает с несколькими кнопками, сначала исправляют область поиска или контракт доступной разметки. Если кнопка одна, но payment-state не меняется, проверяют продуктовый поток, API и состояние данных. Нельзя менять locator, assertion и timeout одновременно: после такой правки исчезает связь между симптомом и действием.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Locator не найден | Разметка не появилась или селектор зависит от CSS | Снять число совпадений и посмотреть DOM первой попытки | Выбрать user-facing locator и сузить scope |
| Клик проходит, статус не меняется | Нет readiness condition, ошибка API или промежуточное состояние принято за успех | Проверить итоговый сигнал и ответ операции | Ждать terminal state; отдельно исправить приложение или данные |
| Первый запуск failed, retry passed | Гонка, утечка данных, порядок тестов или новый worker | Сопоставить данные, worker, шаг ошибки и evidence обеих попыток | Изолировать данные и подтвердить один источник различия |
| Тест проходит после роста timeout | Ожидание было коротким или assertion смотрит не на тот факт | Измерить время наступления названного состояния | Менять timeout только вместе с контрактом и лимитом |
| Trace есть, причина неясна | Артефакт относится к одной попытке | Проверить номер попытки и последний шаг | Сформулировать гипотезу и проверить её отдельно |
Retry запускает тест снова после сбоя. В Playwright повтор может выполняться в новом worker. Это полезно для изоляции, но одновременно меняет окружение: новый браузерный контекст, новое состояние фикстур, другой порядок подготовки. Если первая попытка получила пользователя от соседнего теста, повтор может пройти только потому, что набор данных изменился.
Статус flaky описывает сочетание результатов «первая попытка не прошла, повторная прошла». Он не доказывает случайность. Он не говорит, что сеть была медленной, selector неверен или приложение сломалось. Для каждой гипотезы нужны свои признаки.
Trace, screenshot и action log тоже имеют границу. Они показывают, что происходило в записанном запуске. Trace на on-first-retry полезен для повторной попытки, но не превращается в запись первого отказа. Сохраняйте номер попытки рядом с артефактом и не называйте trace root-cause analysis.
Если locator не уникален, не обсуждают timeout. Если locator стабилен, но состояние не наступает, смотрят на readiness и ответ операции. Если обе части верны, сравнивают данные и окружение initial/retry. Только после этого решают, нужна ли настройка retry или дополнительные артефакты.
first() как доказательство правильного выбора.Эта схема не гарантирует отсутствие flaky-тестов. Она не заменяет проверку backend, браузеров, сети, CI-ресурсов и тестовых данных. Playwright может ждать actionability и web-first assertion, но не может выбрать бизнес-сигнал за команду. Для сложного процесса readiness может включать несколько состояний, однако каждое должно иметь понятное сообщение об ошибке.
Учебный пример не запускался против браузера и не измеряет flake rate, latency или совместимость платформ. Синтетическая модель попыток не является production evidence. Официальная документация меняется вместе с версиями Playwright, поэтому поведение конкретного runner проверяют по версии в проекте.
Разбор можно считать завершённым, если для выбранного теста записаны пять вещей: уникальный locator, наблюдаемый readiness condition, входные данные каждой попытки, evidence с номером попытки и одна подтверждённая причина различия. После правки тест проходит несколько независимых запусков без изменения timeout как единственного изменения, а отрицательный сценарий всё ещё падает на неверном результате. Если пункт отсутствует, стабильность не доказана — есть только удачный retry.
Тест нажимает кнопку «Оплатить», получает ошибку на первой попытке и проходит на повторной. В отчёте остаётся статус flaky, а в pull request появляется короткое предложение: поднять timeout и идти дальше. Проблема в том, что этим действием смешиваются четыре разных объекта: selector для действия, условие готовности интерфейса, retry тест-раннера и evidence из конкретного запуска. Пока они смешаны, команда лечит паузу, а не контракт.
\nЦена такой правки не сводится к лишним секундам CI. Реальная ошибка может стать «шумом»: повторный запуск проходит, скрывает первый отказ и откладывает разбор. Обратная цена тоже заметна: бесконечный trace на каждый тест раздувает артефакты, но не отвечает, какой результат должен увидеть пользователь. Здесь нужен короткий порядок: сначала назвать факт после действия, затем выбрать наблюдение, только потом решать, нужен ли retry и какой evidence сохранять.
\nSelector отвечает на вопрос «куда направить действие». Для Playwright 1.37.0 locator с role и name — это способ найти пользовательский элемент. Перед click() Playwright проверяет, что элемент attached, visible, stable, receives events и enabled. Эти проверки полезны: они не дают кликнуть в скрытый или перекрытый control. Но они не знают, завершилась ли оплата, сохранился ли профиль или пришёл ли пользовательский статус.
Readiness condition отвечает на другой вопрос: какой наблюдаемый продуктовый факт должен появиться после действия. Это может быть текст в role=status, появление записи в таблице или смена доступного пользователю состояния. Условие не должно быть «страница немного успокоилась» или «кнопка стала disabled», если бизнес-операция ещё не подтверждена. Retry запускает тест повторно после failure. Evidence — trace, snapshot, action log или другой артефакт отдельной попытки. Ни один из них не является синонимом selector или готовности.
| Слой | На какой вопрос отвечает | Что может подтвердить | Чего не подтверждает | Первое действие |
|---|---|---|---|---|
| Selector | Как найти control? | locator разрешается ровно в один ожидаемый элемент | что операция завершилась | использовать role/name или явный test id |
| Readiness condition | Какой факт увидел пользователь после действия? | конкретный status, текст, запись или состояние | что locator был уникален до click | сформулировать ожидаемый продуктовый результат |
| Retry | Что сделал runner после failure? | первая попытка не прошла, следующая прошла или нет | почему попытки различаются | сохранить статус первой и повторной попытки |
| Evidence | Что можно изучить в одном запуске? | действия, snapshots, log и network log, если они записаны | корневую причину без дополнительной проверки | связать артефакт с номером попытки |
| Timeout | Каков верхний предел ожидания? | когда ожидание остановится ошибкой | какой факт надо дождаться | не менять, пока не назван readiness contract |
Практический контракт выглядит короче, чем кажется. После нажатия нужно назвать одно состояние, которое экран обязан показать. Например: «платёж подтверждён», а не «кнопка уже недоступна». Если интерфейс не имеет такого состояния, это не повод ждать произвольную паузу. Это повод вместе с владельцем экрана выбрать доступный сигнал: status region, итоговую строку, новый route или ответственный элемент в UI. Контракт должен быть проверяемым пользователем, а не внутренним CSS-классом без смысла вне реализации.
\nНиже — проектный образец. getByRole() выбирает кнопку, а toHaveText() ждёт текст результата. Web-first assertion в Playwright умеет повторять проверку до timeout; это не означает, что любой текст подходит. Значение confirmed здесь специально условное: в своём продукте его заменяют на реальный, устойчивый и доступный пользователю результат. Фрагмент не доказывает, что страница, backend или browser уже проверены.
// Иллюстрация контракта: locator выбирает действие, expect проверяет результат.\nimport { expect, test } from '@playwright/test';\n\ntest('подтверждает оплату', async ({ page }) => {\n await page.getByRole('button', { name: 'Оплатить' }).click();\n await expect(page.getByTestId('payment-state')).toHaveText('confirmed');\n});\n\n// getByRole — selector. toHaveText — readiness condition.\n// Текст и test id должны соответствовать контракту конкретного продукта.\nSleep или network idle без связи с результатом делают запуск длиннее, но не объясняют, что делать при ошибке экрана после успешного запроса. Actionability делает действие допустимым, readiness делает завершение сценария наблюдаемым. Тогда timeout — параметр договора, а не способ спрятать расхождение.
\nПакет содержит исполнимую модель, но не e2e-запуск. Она создаёт две synthetic попытки: первая не достигает payment-state=confirmed, вторая достигает его после retry. Selector в обеих уникален, поэтому fixture отделяет readiness от selector и retry. Он не открывает URL, не читает тест, не запускает Playwright и не производит trace.zip или video.
node web/scripts/upgrade-2023-08.mjs --verify-fixture\n\n# Команда детерминированно создаёт marked synthetic attempts только в памяти.\n# Она не открывает браузер, не читает проект, не запускает Playwright,\n# не записывает trace/video, не измеряет duration и не проверяет compatibility.\n# PASS проверяет разделение selector, readiness, retry и synthetic evidence.\n# PASS не означает, что настоящий тест flaky, что причина найдена или что UI готов.\nPASS проверяет восемнадцать утверждений: вход помечен как memory-only, браузер не запускается, первая и повторная попытки различаются ровно теми полями, которые заданы в модели, а увеличение timeout отклоняется учебным планом. Отдельная отрицательная ветка делает selector множественным и получает другую классификацию. Это важно: даже одинаковый финальный статус «flaky» не даёт права без проверки переписать locator, readiness condition и retry policy одной правкой.
\non-first-retry, если он настроен, относится к первой повторной попытке. Он помогает задать вопрос, но не возвращает автоматически evidence первого отказа.В Playwright retries выключены по умолчанию. При включении runner повторно запускает упавший тест; документация v1.37.0 называет flaky тот случай, когда первая попытка не прошла, а повторная прошла. Это удобный сигнал для очереди разбора, но не диагноз. Повтор может дать другой worker и чистое состояние, а значит скрыть утечку тестовых данных, зависимость от порядка или неявную готовность экрана. Он не доказывает, что первая ошибка была случайной, внешний сервис был медленным или selector корректен.
\nКонфигурация trace: on-first-retry привязывает артефакт к первому повтору, а не ко всей истории. Это evidence retry, не объяснение initial failure. Если нужен контекст первого отказа, проекту требуется отдельный способ его сохранить с учётом чувствительности данных и цены артефактов.
// Иллюстрация для Playwright v1.37.0; этот фрагмент не запускается fixture.\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 1 : 0,\n use: { trace: 'on-first-retry' },\n});\n\n// retry сохраняет evidence первого повторного запуска.\n// Он не заменяет явное условие готовности после click.\nЗаметка не измеряет flake rate, не сравнивает браузеры, не обещает устойчивость любого getByRole() и не предлагает общий timeout. Trace фиксирует один запуск, а причина может лежать в приложении, сети, окружении или данных. Рамка ограничена Playwright v1.37.0 от 10 августа 2023; другую версию проверяют по её документации.
Следующий шаг — взять один тест со статусом flaky и заполнить короткую карточку из пяти строк: selector, readiness condition, initial outcome, retry outcome и доступный evidence. Затем выбрать один слой для изменения и указать rollback. Если карточку нельзя заполнить без догадок, не увеличивайте timeout. Сначала добавьте недостающий наблюдаемый результат или изоляцию данных. Так retry перестаёт превращать ошибку в шум и становится точкой, где начинается инженерный разбор.
\nПосле выкладки экран может получить ответ со статусом 200, пройти проверку схемы и всё равно не показать пользователю нужное действие. Например, provider возвращает state: "active" и renewalAt: null. OpenAPI допускает такое тело. Consumer видит active, но строит экран продления по дате и оставляет кнопку недоступной.
Симптом наблюдаем: запрос успешен, JSON корректен, а пользовательский сценарий остановился. Цена ошибки — сломанный экран и неверное решение о релизе. Команда может откатить полезное изменение без доказательства или оставить несовместимость до следующей выкладки.
Тезис: schema проверяет форму, а contract interaction проверяет конкретное использование API. Решение о выпуске должно связывать consumer, provider, версии, состояние provider и результат verification. Один зелёный schema-check не доказывает совместимость всех клиентов.
Schema описывает типы, обязательность, enum, media type и допустимость null. Она ловит удалённое поле, неверный тип и неожиданный статус. Но она не знает, какую кнопку должен показать конкретный consumer.
Consumer contract фиксирует более узкий вопрос: какой запрос отправляет клиент и какой ответ нужен его сценарию. Экран продления может принимать только active вместе с будущей датой. Другой consumer может законно использовать active без даты: ему достаточно показать состояние подписки. Поэтому правило renewalAt нельзя молча объявить глобальным правилом API.
Provider verification проверяет interaction на стороне provider. Для неё нужно назвать provider state: подписка активна, продление разрешено, дата существует или дата отсутствует. Фраза «вернулся active» недостаточна. Иначе тест закрепляет удобный ответ, а не сценарий пользователя.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
200, schema PASS, действие недоступно | Consumer ждёт смысл, которого ответ не обещает | Сопоставить scenario, поле и ветку интерфейса | Уточнить expectation или добавить явное поле |
Допустимый request получает 400 | Разошлись enum, required или provider state | Сверить interaction, schema и состояние данных | Исправить контракт или совместимость provider |
| Verification не запускается | Нет версии contract/provider или результата | Найти точный artifact и PASS/FAIL | Остановить шлюз до появления evidence |
| Старый consumer падает после нового enum | Клиент не знает новое значение | Проверить все поддерживаемые версии | Сохранить обратную ветку или добавить поле |
Это synthetic response, а не production-данные и не отчёт об инциденте. Он показывает границу между формой и смыслом.
const response = { status: 200, body: { state: "active", renewalAt: null } }; const schemaResult = response.status === 200 && response.body.state === "active"; const semanticResult = response.body.state !== "active" || response.body.renewalAt !== null; console.log({ schemaResult, semanticResult }); // { schemaResult: true, semanticResult: false }Учебная проверка специально строже для одного сценария. Она не говорит, что null запрещён в API. Она говорит только: экрану продления нужна дата. В реальном проекте это правило подтверждает владелец сценария и фиксирует рядом с consumer contract.
Отрицательный путь важнее зелёного. Если дата отсутствует, consumer не должен подставлять текущий день, показывать фиктивную дату или бесконечно повторять запрос. Он должен скрыть действие, объяснить недоступность или выбрать безопасную ветку. Это часть контракта, которую одна JSON Schema не описывает.
Сначала сохраните исходный contract до изменения provider. Запишите consumer, provider, scenario, request, ожидаемый response, версию contract и версию provider. Не переписывайте contract под новый ответ: иначе пропадёт точка сравнения.
Отдельно проверьте форму: статус, Content-Type, required, enum, типы и null. Если форма не совпала, это самостоятельная причина отказа. Если совпала, проверьте semantic expectation: какое действие принимает consumer и какое условие ему нужно.
Затем provider выполняет interaction в контролируемом provider state. Результат связывается с точной версией provider. Ответ из лога не заменяет verification: он может относиться к другой сборке, данным или consumer. Если используется Pact Broker, результат должен попасть в матрицу, по которой релиз принимает решение.
Не смешивайте статусы. Schema PASS означает совпадение с формой. Semantic FAIL означает, что конкретный consumer не может продолжить сценарий. Provider verification not-run означает, что совместимость с исполняемой версией ещё не доказана.
null, неизвестного enum и отсутствующей даты.null в нужной версии схемы.Откат оправдан, если новый provider уже влияет на поддерживаемый consumer, а совместимого поведения нет. Сначала определите границу: версия provider обратима, а данные, созданные новым consumer, могут быть необратимы. Проверьте миграции, записи и feature flags отдельно.
Semantic FAIL не всегда означает дефект provider. Возможно, provider всегда считал active широким состоянием, а consumer ошибочно использовал его как гарантию даты. Тогда исправление нужно в consumer. Возможны также явное поле canRenew, временная поддержка двух форм или обновление старого consumer до изменения provider.
Если неизвестны consumer, provider state, версия или verification result, шлюз не должен трактовать неизвестность как PASS. Выпуск останавливается с объяснимой причиной. Молчаливое разрешение создаёт ложную уверенность.
Consumer-driven contract покрывает зафиксированные interactions, а не все ответы provider. Он не заменяет интеграционные, компонентные и end-to-end проверки. Он также не определяет бизнес-смысл сам: ошибочное expectation может надёжно защищать неправильное решение.
Contract test не проверяет автоматически auth, feature flags, миграции, лимиты, retries и downstream-зависимости. Их включают в provider state или проверяют отдельно, если они меняют ответ. Учебный пример из статьи не запускает сеть, broker, CI или provider. Его значения нельзя выдавать за измерение совместимости или за основание production rollback.
OpenAPI описывает интерфейс, но не знает, какую кнопку показать consumer. Pact связывает consumer expectations с provider verification, но требует дисциплины версий и окружений. Verification без точной версии provider или с неверным состоянием данных создаёт видимость доказательства.
Изменение готово к выпуску, когда для каждого затронутого consumer есть versioned contract и scenario; schema-check прошёл; отрицательная ветка описана; provider verification выполнилась в нужном provider state; PASS связан с точной версией provider; решение о deploy проверено по актуальной матрице. Если любой пункт неизвестен, статус — «не готово к выпуску».
В учебном случае ответ 200 с active и renewalAt: null проходит формальную схему, но не проходит ожидание экрана продления. Это не доказывает дефект provider. Это требует уточнить семантику и выполнить настоящую provider verification до решения о выпуске.
Представим знакомый симптом: frontend отправляет запрос, получает 200, ответ проходит проверку OpenAPI-схемы, но на экране не появляется действие, ради которого пользователь открыл страницу. В ответе есть state: "active", а renewalAt равен null. С точки зрения формы JSON корректен. С точки зрения экрана продления дата обязательна, поэтому кнопка остаётся недоступной.
Цена такой ошибки — не только сломанный экран. Команда видит зелёный schema-check и может решить, что provider совместим с клиентом. Затем она либо выпускает несовместимую версию, либо откатывает полезное изменение, не установив причину. Разберём, какую проверку добавить между «ответ имеет правильную форму» и «пользовательский сценарий может продолжиться».
Ниже не отчёт о конкретном production-инциденте, а воспроизводимый учебный сценарий. Он отделяет три утверждения: схема допускает тело, consumer умеет обработать тело, а provider действительно возвращает его при нужном состоянии данных. Только последнее проверяется на работающем provider.
Schema описывает структуру сообщения: обязательные свойства, типы, перечисления, формат, статус ответа и допустимость null. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12 и может описывать входные и выходные данные. Если контракт допускает renewalAt: null, валидатор не обязан знать, что конкретному экрану для этого значения нужна отдельная ветка.
Это полезная, но ограниченная гарантия. Схема отвечает на вопрос «можно ли представить этот JSON в объявленной форме?», а не «сможет ли каждый consumer выполнить свой сценарий?». Одно и то же поле может быть необязательным для списка подписок и обязательным для экрана продления.
openapi: 3.1.0\ncomponents:\n schemas:\n Subscription:\n type: object\n required: [state, renewalAt]\n properties:\n state:\n type: string\n enum: [active, paused]\n renewalAt:\n type: [string, 'null']\n format: date-timeВ этом фрагменте свойство обязательно как ключ, но его значение может быть null. Такая модель честно описывает форму. Она не говорит, что при state: active дата обязана существовать. Если это бизнес-правило, его нужно выразить отдельным условием, полем вроде canRenew или interaction конкретного consumer.
В HTTP-интеграции consumer — приложение, которое отправляет запрос, а provider — приложение, которое возвращает ответ. Consumer-driven contract фиксирует не всю модель provider, а минимальный обмен, который нужен конкретному consumer: запрос, ожидаемый ответ и, если требуется, состояние provider.
Допустим, frontend строит экран по правилу: при state: active и строковой renewalAt показываем кнопку продления; при null показываем состояние «дата недоступна» и не создаём действие с выдуманной датой. Это expectation одного сценария, а не общая истина обо всех клиентах API.
Плохой контракт проверяет только state === "active". Он пропустит ответ, с которым реальный экран не может продолжить работу. Слишком строгий контракт тоже вреден: если consumer не использует displayName, требование этого поля запретит provider безопасно убрать ненужную деталь. Контракт должен фиксировать используемое поведение, а не копировать весь ответ.
| Наблюдение | Что доказано | Чего ещё нет | Следующий шаг |
|---|---|---|---|
200 и schema PASS | Ответ соответствует объявленной форме | Consumer может выполнить сценарий | Проверить expectation поля и ветки UI |
| Consumer test PASS | Клиент отправляет запрос и понимает ожидаемый пример | Работающая версия provider отдаёт его | Опубликовать contract и запустить provider verification |
| Provider verification PASS | Provider ответил ожидаемым образом в заданном состоянии | Все сценарии и окружения покрыты | Проверить список consumer, версии и матрицу |
can-i-deploy PASS | Broker нашёл совместимые версии в окружении | Бизнес-правило не ошибочно и не забыты внешние зависимости | Оставить функциональные, e2e и операционные проверки |
Сначала проверим расхождение без сети и тестового фреймворка. Команда создаёт тот же ответ, который валиден по форме, и сравнивает две разные проверки. Она запускается в Node.js 18 или новее.
node - <<'NODE'\nconst response = {\n status: 200,\n body: { state: 'active', renewalAt: null },\n};\n\nconst schemaAccepts =\n response.status === 200 &&\n response.body.state === 'active' &&\n 'renewalAt' in response.body &&\n (response.body.renewalAt === null ||\n typeof response.body.renewalAt === 'string');\n\nconst renewalScenarioAccepts =\n schemaAccepts && typeof response.body.renewalAt === 'string';\n\nconsole.log({ schemaAccepts, renewalScenarioAccepts });\n// { schemaAccepts: true, renewalScenarioAccepts: false }\nNODEРезультат не доказывает дефект provider. Он доказывает несовпадение между формулировками «null разрешён типом» и «экрану продления нужна дата». Владелец consumer должен выбрать договорённость:
active только вместе с датой, а отсутствие даты получает состояние, например pending_renewal.canRenew: true|false, а дата обязательна только при canRenew: true.null остаётся допустимым, но consumer скрывает кнопку, показывает объяснение и не подставляет текущую дату.Выбор зависит от смысла поля, обратной совместимости и того, какие старые версии consumer уже работают с provider. Удобство валидатора не является бизнес-правилом.
Для contract test опишите один сценарий как самостоятельную interaction. Название должно объяснять состояние и потребность, а не только HTTP-метод: «активная подписка с доступным продлением возвращает дату». В запросе оставьте только те заголовки, параметры и поля, которые действительно формирует клиент.
given('active subscription with renewal date')\nuponReceiving('request for renewal details')\n .withRequest('GET', '/subscriptions/42')\nwillRespondWith(200, {\n state: 'active',\n renewalAt: '2030-07-25T10:00:00Z',\n});Синтаксис зависит от языка и версии Pact, поэтому фрагмент показывает структуру, а не готовый файл для любого проекта. Настоящий тест обязан вызывать ваш API-клиент, а не повторять запрос через случайный fetch из теста. Иначе контракт может быть зелёным, хотя рабочий клиент отправляет другой URL или неверно разбирает ответ.
Добавьте отдельную interaction для недоступного продления, если consumer должен её обрабатывать: например, canRenew: false и renewalAt: null. Не смешивайте состояния в одном тесте и не делайте тесты зависимыми друг от друга. Provider verification должна уметь подготовить каждое состояние независимо.
Consumer test работает с mock provider и формирует pact-файл. Он отвечает на вопрос «понимает ли consumer ожидаемый обмен и формирует ли правильный запрос?». Следующий шаг — provider verification: Pact воспроизводит interaction против настоящего provider и сравнивает фактический ответ с минимальным ожидаемым.
Перед каждой interaction provider должен попасть в названное состояние: запись подписки существует, дата рассчитана, авторизация разрешена. State setup не должен зависеть от порядка других тестов. Если provider использует базу или downstream-сервис, настройте изолированный fixture или стабилизируйте зависимость в рамках тестовой архитектуры.
Лог с телом state: active не заменяет verification: он может относиться к другой сборке, среде или данным. В результат включите имя consumer, provider, версию pact, версию provider, provider state, окружение и ссылку на результат. Так failure можно связать с изменением, а не искать его по времени.
Broker связывает версии. Consumer публикует contract, provider получает его и публикует verification. Затем шлюз проверяет версию, которую собираются выпустить, против версий интеграций, уже находящихся в окружении.
# перед deploy: VERSION — конкретная версия приложения\npact-broker can-i-deploy \\\n --pacticipant WebApp \\\n --version VERSION \\\n --to-environment staging \\\n --broker-base-url \"$PACT_BROKER_BASE_URL\"\n\n# после успешного deploy в staging\npact-broker record-deployment \\\n --pacticipant WebApp \\\n --version VERSION \\\n --environment stagingПодставляйте реальный идентификатор сборки и окружение команды. Конкретная версия предпочтительнее latest: результат не меняется из-за гонки параллельных сборок. Для старых Broker документация описывает tag-based режим, но его нельзя смешивать с режимом environments без единой договорённости о том, что означает окружение.
В pipeline разделите статусы: schema failure останавливает проверку формы; consumer failure означает проблему клиента; provider failure — несовместимость provider с interaction; отсутствие результата — not proven, а не PASS. Выпуск разрешается только после успешной проверки нужных версий.
Когда экран сломался после успешной schema-проверки, не начинайте с обвинения backend или frontend. Идите от наблюдаемого эффекта к самому узкому доказательству.
Content-Type, тело, версии consumer и provider. Секреты и персональные данные удалите.null, неизвестного enum, 4xx и 5xx.Contract test покрывает только зафиксированные interactions. Он не перечисляет все ответы provider и не доказывает корректность бизнес-решения. Если consumer забыл проверить отсутствие даты, зелёный contract закрепит неполный сценарий. Это ошибка покрытия.
Тесты также не заменяют проверки авторизации, feature flags, миграций, таймаутов, повторов, лимитов, производительности и доступности downstream-систем. UI-тест остаётся полезен для композиции экрана, но не должен превращать каждую пиксельную деталь в API interaction.
Semantic failure не означает автоматически, что provider нужно откатить. Provider мог сохранить корректную широкую семантику, а consumer ошибочно трактовал active как гарантию даты. Сначала установите владельца правила и обратимость изменения. Если consumer не известен, provider state отсутствует или verification не опубликована, выпуск блокируется как недоказанный.
Изменение готово к выпуску, когда для каждого затронутого consumer названы положительные и отрицательные сценарии, schema соответствует фактической модели, API-клиент проверен на mock provider, verification прошла в требуемом состоянии, результат связан с конкретными версиями, а Broker подтверждает совместимость с целевым окружением. Любой неизвестный пункт получает статус «не доказано» и явное действие.
В нашем примере тело с active и renewalAt: null проходит широкую schema-проверку, но не удовлетворяет сценарию, которому нужна дата. Это сигнал уточнить контракт, но ещё не доказательство поломки provider. Доказательство появится после воспроизведения interaction на provider с корректно подготовленным состоянием.