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 метрики. График становится фильтруемым, но перестаёт быть хорошим графиком. Цена ошибки — не абстрактная «плохая наблюдаемость». Команда смешивает счётчик с идентичностью одного запроса, раздувает число временных рядов и принимает решение по данным, которые не отвечают на вопрос о причине.

\n

Тезис простой: общий идентификатор нужен для корреляции trace и log/event record, а labels метрики должны описывать небольшой набор групп, по которым допустима агрегация. Один и тот же атрибут может встретиться в нескольких сигналах, но его роль не становится одинаковой. Сначала определите вопрос сигнала. Потом выбирайте поле.

\n

Три сигнала, три вопроса

\n

Trace описывает путь операции. Его узлы — spans: например, gateway, вызов каталога и шаг оплаты. У trace есть TraceId, а у каждого span — собственный SpanId. Так можно связать дочернюю операцию с родительской и пройти от общего маршрута к конкретному шагу.

\n

Metric описывает измеряемый класс поведения во времени. Для неё важны имя инструмента, значение, единица, временной ряд и attributes, которые разделяют поток на dimensions. Вопрос метрики звучит как «сколько отказов было на этом маршруте?» или «какое распределение задержки видит этот класс операций?». Вопрос «какой именно request упал?» относится к другой записи.

\n

Log или 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Становиться единственным источником агрегации
\n
\"Учебная
Учебная иллюстрация разделяет поля для агрегации и поля для корреляции. Она не показывает данные конкретного сервиса, backend или production-нагрузку.
\n

Механизм: корреляция отдельно, агрегация отдельно

\n

Представим учебный маршрут checkout. Gateway принимает запрос и создаёт корневой span. Дочерний span вызывает оплату. Оплата отклоняет авторизацию. Во всех трёх шагах используется один учебный trace ID, но gateway и payment имеют разные span ID. Event об отказе ссылается на payment span. Metric считает класс route=checkout, outcome=authorization_rejected. Идентификатор конкретного пути остаётся в trace и event.

\n
// Учебный пример. Он не создаёт 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 «на всякий случай».

\n

Trace ID не запрещён во всех местах метрики. OpenTelemetry описывает exemplars как механизм, который может связать измеренное значение с trace и span. Это другой канал связи, не обычная dimension временного ряда. Нельзя заменить exemplar добавлением идентификатора в каждый label и объявить задачи одинаковыми. Конкретная поддержка exemplars зависит от инструмента и backend, поэтому её нужно проверять отдельно.

\n

Симптом → причина → проверка → действие

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

Проверка должна включать отрицательный путь

\n

Положительный пример легко обманчив. Он показывает, что два объекта можно связать одинаковым ID, но не показывает, что система отвергает неверную связь. Минимальный учебный тест должен принимать согласованный trace и отклонять четыре случая: другой trace ID в event, другой span ID, trace_id в labels и новый неизвестный label. Тест проверяет форму договора. Он не проверяет экспорт, collector, индексацию, sampling, storage или реальную cardinality.

\n
// Учебный псевдокод. Вызовы не обращаются к 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

Порядок действий

\n
  1. Назовите симптом. Запишите, какой вопрос остался без ответа: класс отказов, путь одного запроса или контекст события. Не начинайте с названия инструмента.
  2. Разложите объекты. Для trace выпишите spans и родительские связи. Для metric — имя, value, unit и labels. Для event — имя события, trace ID, span ID и attributes.
  3. Составьте словарь labels. Для каждого dimension укажите допустимые значения и владельца. Отдельно отметьте поля, которые меняются почти на каждый запрос.
  4. Проведите корреляцию. На безопасном учебном сценарии проверьте общий trace ID, соответствующий span ID и путь родитель-потомок. Несовпадение должно быть видимым отказом.
  5. Проверьте отрицательный путь. Добавьте per-request ID в копию metric и измените ID в event. Проверка должна отклонить оба случая по разным причинам.
  6. Проверьте реальный контур отдельно. Уточните, как конкретный SDK переносит context, где backend хранит attributes, поддерживает ли он exemplars и какие ограничения действует для series. Учебный код этого не делает.
  7. Зафиксируйте границу. Запишите, какой сигнал отвечает на какой вопрос, кто владеет схемой и как откатывается изменение instrumentation. Не называйте label budget соблюдённым без измерения в выбранной среде.
\n

Ограничения и отрицательный путь

\n

В статье нет production-телеметрии, реального counter, latency, trace, log, dashboard или запроса к backend. Все значения с префиксом synthetic- служат для объяснения связей. Учебная metric point не измеряет количество отказов. Согласованный trace не доказывает, что propagation работает в приложении. Пройденная функция не доказывает, что exporter доставит запись, collector не изменит её и backend покажет её пользователю.

\n

Высокая cardinality тоже не вычисляется по числу labels в примере. Влияние зависит от множества значений, сочетаний dimensions, периода хранения, агрегации и конкретной платформы. Поэтому отрицательный путь должен продолжаться за пределами кода: измерьте число временных рядов и стоимость выбранного набора в разрешённой среде. Если такой проверки нет, формулировка должна быть «контракт не разрешает поле», а не «система доказанно экономна».

\n

Если общий trace ID не проходит границу процесса, не компенсируйте это копированием идентификатора в каждую метрику. Сначала проверьте propagation, формат записи и доступность корреляции. Если event содержит чувствительные данные, отдельно решите redaction, retention и права доступа. Связь между сигналами не отменяет требований к данным.

\n

Критерий готовности

\n

Работа готова, когда на одном контролируемом сценарии видны четыре результата: агрегатная метрика содержит только согласованные dimensions; trace показывает ожидаемый путь и разные span ID; event ссылается на тот же trace и правильный span; неверный trace, неверный span и per-request label получают отдельный отказ. Для реального контура дополнительно есть проверка propagation и измерение series в конкретном backend. Если можно показать только зелёный учебный пример, готова модель договора, но не production-настройка наблюдаемости.

\n

Проверяемые источники

\n"} + "contentHtml": "

Симптом появляется во время расследования отказа: график показывает рост ошибок, но по точке на графике нельзя найти конкретный запрос. В ответ хочется добавить trace_id или request_id в labels метрики. Фильтр действительно станет точнее, но каждая новая строка начнёт описывать отдельный запрос. Команда получит много временных рядов, а метрика перестанет отвечать на вопрос о тенденции.

\n

Рабочее правило проще сформулировать через задачу сигнала: metric агрегирует класс поведения, trace показывает путь операции, а log или event сохраняет контекст отдельного события. Общий идентификатор нужен для корреляции trace и записи события. Он не обязан становиться dimension метрики. Ниже — модель, пример и проверка, которую можно воспроизвести без SDK и доступа к backend.

\n

Сначала разделите три вопроса

\n

Трасса отвечает на вопрос «какой путь прошла операция?». Trace состоит из связанных spans: корневой span описывает вход в операцию, дочерние — вызовы сервиса, базы или внешнего API. У trace есть общий TraceId, а каждый span получает собственный SpanId. Поэтому один запрос можно проследить от gateway до шага оплаты, не смешивая соседние операции.

\n

Метрика отвечает на вопрос «как ведёт себя класс операций во времени?». Её точка имеет имя, значение и набор атрибутов. Например, счётчик может считать отказы для service=checkout-api, route=checkout и outcome=authorization_rejected. Такой набор пригоден для группировки: можно сравнить маршруты или исходы, не перечисляя каждый запрос.

\n

Log или event отвечает на вопрос «что произошло в конкретный момент?». В запись можно положить имя события, класс ошибки, trace_id, span_id и разрешённый контекст. Это помогает перейти от агрегата к расследованию. При этом запись события не заменяет счётчик: поиск по свободному тексту и уникальным идентификаторам плохо подходит для долгого тренда.

\n
Роль одного и того же идентификатора в сигналах
СигналГлавный вопросЧто хранитьЧего не требовать
TraceКак прошла операция?TraceId, SpanId, parent и имя операцииЗаменять им агрегированную статистику
MetricКак меняется класс поведения?Стабильные service, route, outcome и environmentИдентичность каждого request
Log/eventЧто случилось на одном шаге?Имя события, trace/span ID и проверенные attributesИспользовать как единственный источник тренда
\n
\"Схема
Корреляция ведёт от агрегата к конкретному событию, но идентификатор операции не входит в набор labels метрики. Иллюстрация показывает учебный контракт, а не данные реального сервиса.
\n

Почему label меняет стоимость метрики

\n

В Prometheus временной ряд однозначно задаётся именем метрики и набором пар «label — value». Изменение значения label создаёт новый ряд. В OpenTelemetry metric stream также идентифицируется набором attributes, а модель поддерживает последующую агрегацию с меньшим числом attributes. Это полезные механизмы, но они не делают идентификатор запроса хорошим dimension: стоимость и объём уже возникших комбинаций никуда не исчезают автоматически.

\n

Рассмотрим два запроса одного маршрута. В первом варианте labels описывают класс результата:

\n
checkout_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 и того, как инструмент экспортирует данные.

\n

Имена маршрутов тоже требуют осторожности. В label должен попадать шаблон маршрута вроде /orders/{orderId} или заранее согласованное имя операции, а не полный URL с идентификатором заказа. Иначе в метрику попадёт та же проблема высокой cardinality — число уникальных комбинаций dimensions.

\n

Корреляция проходит через context, а не через новый ряд

\n

В OpenTelemetry дочерний span с родителем сохраняет тот же TraceId, но получает собственный SpanId. Это даёт точный путь: gateway и payment принадлежат одной трассе, а их spans различаются. Если контекст передаётся между процессами, instrumentation или propagator должен извлечь его на входе и использовать при создании следующего span.

\n

Запись отказа должна ссылаться на тот 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.

\n

Exemplar — отдельная связь для точки измерения

\n

Иногда расследователю полезно перейти с точки метрики к одной трассе. Для такого сценария OpenTelemetry описывает exemplar — записанное значение, связанное с context метрики; в нём могут присутствовать trace_id и span_id. Exemplar не становится label и не создаёт по одному временному ряду на каждую операцию. Это принципиально другой канал: агрегат сохраняет свою размерность, а отдельная точка получает ссылку на trace.

\n

Поддержка exemplars и переход по ним зависит от SDK, exporter и системы хранения. Поэтому нельзя обещать рабочую ссылку только по факту добавления поля в объект. Проверьте документацию конкретного стека, формат экспорта и то, отображает ли выбранный интерфейс exemplar. Если такой цепочки нет, сохраняйте ID в структурированном log/event с учётом доступа, redaction и retention.

\n

Диагностика: симптом, причина, проверка, действие

\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
Нужен поиск по IDMetric выполняет роль индекса событийСформулировать запрос, который должен отвечать на агрегатЕсли нужен один request, искать trace или event
\n

Проверка контракта с отрицательными случаями

\n

Зелёный happy path показывает только согласованный объект. Для полезной проверки нужны намеренно неверные входы: другой trace ID, другой span ID и запрещённый label. Следующая функция не использует OpenTelemetry SDK; она фиксирует минимальное правило учебного договора.

\n
const 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

Порядок внедрения и границы вывода

\n
  1. Назовите вопрос. Решите, нужен тренд по классу ошибок, путь одного запроса или контекст события. Один сигнал может ссылаться на другой, но не обязан хранить его поля в одинаковой роли.
  2. Нормализуйте dimensions. Для каждого label зафиксируйте словарь значений и запретите полный URL, user ID, order ID, request ID и текст исключения, если это per-request данные.
  3. Проверьте путь. На контролируемом запросе сравните общий trace ID, собственные span IDs и parent-child связь. Для границы процесса отдельно проверьте извлечение и инъекцию context.
  4. Выберите корреляцию. Если стек поддерживает exemplars, проверьте полный переход metric → trace. Если нет, запишите разрешённые IDs в структурированное событие и документируйте поиск.
  5. Прогоните отрицательные случаи. Сломайте trace ID, span ID и набор labels по очереди. Проверка должна отличать три причины, а не принимать любой объект с непустым ID.
  6. Измерьте реальную cardinality. Снимите число временных рядов до и после изменения в выбранном backend и на согласованном интервале. Без такого замера нельзя заявлять экономию или безопасную стоимость.
  7. Зафиксируйте данные. Для trace ID и event attributes проверьте redaction, retention и права чтения. Корреляция не отменяет требований к чувствительным данным.
\n

Что именно доказывает пример

\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

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/156.json b/editorial/agent-rewrites/156.json index e00b6b6..f67feb9 100644 --- a/editorial/agent-rewrites/156.json +++ b/editorial/agent-rewrites/156.json @@ -1,7 +1,7 @@ { "index": 156, "slug": "editorial-2023-09-practice-telemetry-signals", - "title": "Trace ID, метрика и лог: как связать сигналы без высокой cardinality", - "excerpt": "Практический договор для распределённого запроса: trace ID связывает путь и событие, метрика считает малый набор классов, а лог сохраняет контекст отказа.", - "contentHtml": "

На графике выросли ошибки авторизации. В журнале есть сообщения об отказе. В трассировке виден тот же endpoint, но инженер не может доказать, что три наблюдения относятся к одному запросу. Он тратит время на ручной поиск и может увеличить timeout или retry, не устранив причину. Цена ошибки — лишняя нагрузка, задержка расследования и решение по несвязанным данным.

\n

Быстрый ремонт — добавить trace ID во все метрики, а в лог оставить длинный текст. Связь одного запроса с графиком станет возможной, но метрика перестанет быть хорошим агрегатом. Число уникальных series будет расти вместе с числом запросов. Корреляция должна жить в trace и логах, а метрика должна отвечать на вопрос о классе событий.

\n

Тезис: каждому сигналу — свой вопрос

\n

Trace описывает путь операции через компоненты. Metric показывает число, долю или распределение во времени. Log фиксирует отдельное событие и его контекст. OpenTelemetry называет их разными сигналами, потому что у них разные модели данных и способы поиска.

\n

Общий trace ID связывает span одного распределённого пути. Span ID уточняет конкретный шаг. Лог может содержать оба значения и имя события. Метрика должна использовать поля с небольшим заранее известным набором значений: шаблон маршрута, результат и имя сервиса. Идентификатор запроса, пользователя, заказа и необработанный URL в этот набор обычно не входят.

\n

Механизм связи

\n

Клиент отправляет запрос в gateway. Gateway принимает или создаёт trace context и передаёт его дальше. Сервис оплаты создаёт дочерний span. При отказе сервис записывает событие в лог с trace ID и span ID шага оплаты. Отдельно он увеличивает счётчик отказов с labels service, route и outcome. По метрике видно, что класс отказа растёт. По trace ID можно найти конкретный путь. По записи события можно понять, что произошло внутри шага.

\n

Сигналы не связываются автоматически. Пропагатор может быть не настроен, лог может потерять контекст, sampling может не сохранить нужный trace, а индекс может скрыть поле поиска. Договор задаёт ожидаемую связь. Проверка должна показать, где она рвётся.

\n
СигналВопросДопустимые поляНе следует добавлять
TraceКакой путь прошёл запрос?trace ID, span ID, service, operationСвободный текст вместо структуры
MetricКак меняется класс событий?service, route template, outcometrace ID, request ID, user ID, order ID
LogЧто произошло на одном шаге?trace ID, span ID, event name, безопасные attributesСекреты, токены и лишние персональные данные
\n
\"Схема
Общий trace ID связывает путь и событие, а метрика сохраняет только агрегируемые labels.
\n

Учебный пример

\n

Ниже показана форма данных для одного учебного отказа. Имена с префиксом demo- не представляют реальные запросы, пользователей, задержки или результаты работы сервиса. Пример проверяет только границы между сигналами.

\n
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 и не доказывает работу экспортера.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
График показывает всплеск, но запрос не найтиНет устойчивого перехода от 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
\n

Порядок внедрения

\n
  1. Назовите один пользовательский путь и один отказ. Не начинайте с полного набора endpoint.
  2. Для каждого сигнала запишите его вопрос. Поле без ясного вопроса уберите из договора.
  3. Определите trace ID и span ID, которые должны попасть в контекст и событие. Проверьте, что они не содержат пользовательских данных.
  4. Составьте список metric labels. Для каждой укажите допустимый словарь или правило нормализации. Используйте шаблон маршрута вместо сырого URL.
  5. Проверьте отрицательные случаи: другой trace ID, неверный span ID, отсутствующее обязательное поле и попытка добавить уникальный ID в метрику.
  6. Запустите проверку в разрешённой среде и сохраните ссылку на trace, запись события и график. Учебный объект подтверждает только форму данных; рабочее подтверждение требует реальной системы.
\n

Отрицательный путь важнее счастливого

\n

Если gateway передал trace context, а payment создал новый trace вместо дочернего span, оба сигнала выглядят корректно по отдельности. Поиск по одному ID ничего не даст. Если лог записал trace ID, но указывает span gateway вместо span с отказом, инженер попадёт в начало пути и пропустит причину. Если метрика получила user_id, она может показать нужный случай, но ценой неконтролируемого числа комбинаций и лишнего раскрытия данных.

\n

Проверяйте эти случаи специально. Сравните входной и исходящий context на каждой границе. Сопоставьте span ID события с операцией, где произошёл отказ. Отдельно проверьте отказ без trace: пустая строка не должна смешивать разные случаи. Если связь потеряна, исправьте propagation. Не маскируйте пробел новой label.

\n

Ограничения

\n

Небольшой набор labels не гарантирует низкую стоимость хранения. Итог зависит от числа сервисов, маршрутов, окружений, времени хранения и запросов к backend. Удаление trace ID из метрики не решает проблему, если в labels остаются сырые URL, тексты ошибок или идентификаторы заказов. Нужен отдельный обзор cardinality и доступа к данным.

\n

Trace sampling может сохранить не каждый запрос. Логирование тоже может быть ограничено уровнем, фильтрами или политикой персональных данных. Поэтому отсутствие trace по графику не доказывает отсутствие ошибки. В критичном потоке метрика должна фиксировать класс отказа, а лог — безопасный контекст для следующей проверки.

\n

Критерий готовности

\n

Договор готов, когда для выбранного пути выполнены четыре условия: один запрос сохраняет общий trace ID через нужные границы; событие отказа содержит тот же trace ID и span ID правильного шага; метрика группируется только по описанным labels; отрицательная проверка обнаруживает mismatch и уникальные идентификаторы в labels. Результат должен воспроизводиться по ссылкам на конкретный trace, лог и график в разрешённой среде. Если условие не выполнено, связь сигналов ещё не доказана.

\n

Проверяемые источники

\n" + "title": "Логи, метрики и трассы: как связать один сбой без взрыва cardinality", + "excerpt": "Пошаговая схема корреляции для распределённого запроса: метрика показывает класс отказа, trace — путь, а структурированный лог — причину конкретного события.", + "contentHtml": "

После релиза возникает сбой в авторизации: график показывает рост отказов, но по нему нельзя найти конкретный запрос. В логах сообщения есть, однако они не связаны с трассировкой. Инженер вручную перебирает временной интервал и рискует исправить не ту границу. Лишний retry увеличивает нагрузку, а необоснованный timeout прячет задержку.

\n

Рабочая схема разделяет три роли. Метрика отвечает, как часто возникает класс событий. Trace, то есть распределённая трасса, показывает путь одного запроса через сервисы. Структурированный лог фиксирует событие и его безопасный контекст. Один trace ID связывает trace и лог, но не должен становиться label метрики: иначе корреляция создаст неконтролируемое число временных рядов.

\n

Начните с вопроса расследования

\n

До настройки SDK и дашборда запишите, какой факт требуется получить. Для всплеска ошибок авторизации вопрос звучит так: «какие операции и на каких границах отклоняют запросы?». У каждого сигнала будет свой ответ, поэтому один идентификатор нельзя механически разложить по всем полям.

\n
СигналВопросПример поляОграничение
МетрикаКак меняется частота класса?outcome=rejectedНе указывает запрос
TraceКакие шаги прошёл запрос?trace_id, span_idНе сохраняет каждый запрос
ЛогЧто произошло на шаге?event_name, reason_classНе заменяет агрегат
\n

OpenTelemetry описывает trace как путь запроса, metric как измерение во время работы, а log как запись события. Сигнал выбирают по вопросу, а не по открытому у инженера хранилищу.

\n

Опишите контракт на границах сервисов

\n

Рассмотрим запрос checkout. Gateway принимает HTTP-запрос, передаёт контекст сервису оплаты, а payment создаёт дочерний span authorize. При отказе payment пишет событие и увеличивает счётчик. Контракт проверяется по пяти переходам:

\n
  1. На входе gateway прочитать или создать trace context.
  2. Передать контекст на исходящем вызове в payment.
  3. Создать дочерний span вокруг авторизации.
  4. Записать из активного контекста тот же trace ID и span ID фактической причины.
  5. Увеличить метрику с labels сервиса, нормализованного маршрута и класса результата.
\n

Для HTTP таким переносом обычно служит заголовок traceparent, определённый W3C Trace Context. Он не является пользовательским request ID и должен проходить проверку формата. На каждой границе сравнивайте trace ID: downstream продолжает тот же trace, а не начинает новый. Если клиент не поддерживает propagation, исправляйте интеграцию, а не добавляйте trace ID в metric.

\n
\"Учебная
Trace и лог связываются по контексту запроса; метрика сохраняет только класс события и остаётся агрегируемой.
\n

Оставьте уникальные значения вне labels

\n

В модели Prometheus каждый уникальный набор значений labels создаёт отдельный временной ряд. Если добавить к счётчику trace_id, почти каждый запрос создаст новый ряд. Тот же риск несут user_id, order_id, email и сырой URL с идентификаторами. Backend может принять такие значения, но стоимость хранения и запросов растёт вместе с комбинациями.

\n

В label оставляйте поля с ограниченным словарём. Вместо /orders/8472 используйте /orders/:id; вместо текста исключения — класс limit, invalid_input или upstream_timeout. Список классов — часть контракта и должен быть согласован с владельцем дашборда.

\n
ПолеTrace или logMetric labelПричина
trace_idДа, для перехода к путиНетПочти неограниченное множество
route_templateДаДаОграниченный словарь
reason_classДаДа, если согласованГруппирует причины
order_idТолько при разрешённом доступеНетВысокая cardinality и чувствительность
\n

Проверьте договор на учебных данных

\n

Это форма договора, а не готовый OpenTelemetry exporter. Идентификаторы с префиксом demo- вымышлены. Фрагмент проверяет совпадение trace ID в trace и логе и отсутствие уникального поля в metric sample.

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

Воспроизведите связь через HTTP

\n

Если тестовый gateway слушает localhost:8080, передайте ему фиксированный учебный контекст. Заголовок соответствует формату W3C и предназначен для лабораторной проверки. Замените URL и имя файла на настройки своей среды.

\n
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 не создаёт отказ, используйте тестовый сценарий с известным ответом.

\n

Разберите симптом по таблице

\n
НаблюдениеГипотезаПроверкаДействие
График растёт, trace не находитсяНет перехода к контексту или trace отброшен samplingВзять лог отказа и найти его trace IDНастроить переход; sampling проверить отдельно
Новая series появляется почти на каждый запросВ label попал ID или сырой URLПосчитать значения label за окноУдалить уникальное поле и нормализовать маршрут
Trace общий, span указывает gatewayЛог пишется вне активного spanСопоставить span ID с операцией отказаПисать событие внутри нужного контекста
Payment видит новый traceНе сработал propagatorСравнить входящий и исходящий traceparentИсправить middleware или клиент
\n

Таблица отделяет неисправность контекста от sampling и плохой схемы labels. После исправления повторите тот же тестовый запрос.

\n

Проверьте отрицательные сценарии

\n

Счастливый путь доказывает лишь то, что корреляция иногда работает. Подмените span ID в логе и убедитесь, что проверка указывает на неверный шаг. Уберите trace context и проверьте, что запись без trace ID не смешивается с другой трассой. Запустите два параллельных запроса: одинаковая временная метка не может быть единственным ключом связи.

\n

Отдельно проверьте рост словаря labels. В тестовом инструменте добавьте уникальный ID намеренно, посчитайте новые серии, затем удалите его и сравните число рядов с исходным диапазоном. Для этого достаточно изолированного Prometheus-compatible backend; production-трафик не нужен.

\n

Зафиксируйте границы применимости

\n

Схема не гарантирует trace для каждого отказа. Sampling может сохранить только часть запросов, сборщик — потерять данные, а политика хранения — удалить старые записи. Метрика показывает агрегированный класс, но не полный список причин. Для критичных операций заранее определите sampling и срок хранения.

\n

Trace ID не является разрешением на доступ к данным. Ссылка из метрики в trace должна учитывать права пользователя. Логи с trace ID всё равно могут содержать персональные данные, токены или платёжные реквизиты; корреляция не отменяет маскирование и ограничение доступа.

\n

Низкая cardinality не означает низкую стоимость для любого backend. Prometheus описывает labels как измерения временных рядов и предупреждает о high-cardinality values. Для другой системы уточните модель хранения, индексацию и sampling. Имена полей зависят от языка и SDK.

\n

Критерий готовности

\n

Договор проверен, когда тестовый запрос проходит нужные границы, downstream сохраняет общий trace ID, лог содержит trace ID и span ID фактической причины, а метрика группируется по ограниченным labels. Зафиксируйте также тест потери контекста, проверку cardinality и правило доступа к логам и трассам. Иначе dashboard показывает сигнал, но не даёт воспроизводимого маршрута расследования.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/157.json b/editorial/agent-rewrites/157.json index 0669d71..46a836c 100644 --- a/editorial/agent-rewrites/157.json +++ b/editorial/agent-rewrites/157.json @@ -2,6 +2,6 @@ "index": 157, "slug": "editorial-2023-08-field-e2e-stability", "title": "Flaky e2e-тест: как найти причину и сохранить сигнал", - "excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.", - "contentHtml": "

В CI тест оформления платежа падает на click. Повторный запуск проходит. Через день тот же тест снова красный, но уже на проверке результата. Команда увеличивает timeout, добавляет ещё один retry и получает более длинную очередь. Ошибка не исчезает: тест лишь дольше скрывает нарушение пользовательского сценария.

\n

Цена такого решения измерима. Разработчик ждёт обратную связь дольше. Красный запуск перестаёт отличать дефект продукта от дефекта теста. Если retry маскирует настоящий сбой оплаты, команда может пропустить проблему до релиза. Если виноваты общие данные, каждый тест с большим timeout платит за чужую гонку.

\n

Тезис простой: flaky — это сигнал для разбора, а не причина менять настройки вслепую. Сначала разделите locator, actionability, readiness, данные и внешние зависимости. Затем привяжите evidence к конкретной попытке. После этого выбирайте маленькую правку с owner и понятным rollback.

\n

Что происходит между двумя попытками

\n

Статус failed → passed сообщает только о разных исходах запусков. Он не называет причину. Retry не продолжает ту же страницу с того же места. Runner создаёт новую попытку и может использовать другой worker. Меняются cookies, storage, порядок тестов, состояние базы, очистка данных и доступность внешнего сервиса.

\n

У действия есть несколько независимых условий. Locator должен указывать ровно на один элемент. Перед click элемент должен быть видимым, стабильным, доступным для событий и активным. После действия интерфейс должен показать пользовательский результат. Успешный click доказывает готовность действия. Он не доказывает, что платёж подтверждён.

\n

Ожидание networkidle не равно готовому экрану. Исчезнувший spinner не равен успешной операции. Прошедший retry не равен воспроизводимому тесту. Trace помогает увидеть ход одной попытки, но не подменяет контракт результата.

\n

Минимальный контракт теста

\n

Зафиксируйте четыре факта до изменения конфигурации: чем пользователь находит control, какой результат он должен увидеть, что произошло в initial и retry, и какой артефакт относится к каждой попытке. Не называйте доказательством файл без номера попытки. Trace retry не объясняет автоматически initial failure.

\n
import { 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

Симптом → причина → проверка → действие

\n
Как сузить область исправления
СимптомПричинаПроверкаДействие
Ноль или несколько совпадений locatorSelector не описывает одну цельПроверьте роль, имя и область контейнераИсправьте 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
\n

Locator и readiness проверяйте отдельно

\n

Начните с 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: такая форма сначала получает снимок состояния и теряет встроенное ожидание.

\n

Retry должен сохранять сигнал

\n

Retry полезен как диагностический слой и как защита от краткого сбоя инфраструктуры. Он опасен, когда превращается в разрешение на merge. Запишите номер попытки, worker, используемые данные и исходную ошибку. Слово «flaky» описывает классификацию запусков. Оно не заменяет root cause.

\n

Не смешивайте классы причин. Ошибка locator требует проверки DOM. Ошибка actionability требует проверки overlay, animation и disabled state. Отсутствие readiness требует проверки UI и ответа операции. Разные данные требуют проверки setup и cleanup. Внешний сервис требует отдельной политики зависимости. Один глобальный timeout не лечит все случаи.

\n
use: { trace: 'on-first-retry', screenshot: 'only-on-failure' }, retries: process.env.CI ? 1 : 0 // Учебная конфигурация. Значения зависят от цены очереди и среды.
\n

Фрагмент показывает форму, а не готовую политику. Trace на первом retry даёт контекст повторной попытки и экономит место. Он не создаёт trace initial failure. Если первичный контекст критичен, настройте отдельный способ его сохранить и явно подпишите артефакт.

\n

Trace отвечает на узкий вопрос

\n

Откройте trace с одним вопросом: locator указывал на одну кнопку перед click или после click появился нужный status? Trace Viewer позволяет сопоставить timeline, DOM snapshot, action log и сетевые запросы. Это помогает сузить гипотезу. Но trace показывает конкретный запуск. Он не знает, был ли результат бизнес-успешным, пока тест не проверяет assertion.

\n

Если артефакта нет, запишите evidence: absent. Не заменяйте отсутствие данных уверенным объяснением. Сначала сверяйте имя теста, проект, commit и номер попытки. Затем смотрите один слой. Широкий запрос «найти причину по trace» часто приводит к непроверенной версии.

\n
Цикл разбора flaky e2e-теста: сравнение initial и retry, проверка locator и readiness, затем малая правка с rollback
Схема задаёт порядок разбора: сначала фиксируются две попытки, затем отдельно проверяются цель действия и пользовательский результат. Это иллюстрация процедуры, а не реальный trace или отчёт CI.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Сохраните initial error, retry outcome, номер попытки и ссылку на отчёт. Не сокращайте запись до «иногда падает».
  2. Назовите причины. Разделите locator, actionability, readiness, изоляцию данных и внешнюю зависимость.
  3. Проверьте locator. Убедитесь, что он находит ровно один пользовательский control в нужной области.
  4. Проверьте readiness. Сопоставьте assertion с финальным фактом интерфейса. Уберите sleep и ожидание вторичного сигнала.
  5. Сравните попытки. Проверьте worker, cookies, storage, входные данные, cleanup и порядок запуска.
  6. Привяжите evidence. Свяжите trace, screenshot или log с attempt и задайте артефакту один вопрос.
  7. Сделайте малый diff. Меняйте только подтверждённый слой: locator, assertion, данные или политику артефактов.
  8. Опишите rollback. Укажите владельца, что вернуть, каким запуском проверить результат и когда снять временное исключение.
\n

Проверьте отрицательный путь

\n

Проверяйте не только успешную оплату. Добавьте сценарий, в котором сервер возвращает отказ или данные невалидны. Убедитесь, что тест видит сообщение об ошибке и не принимает disabled control, spinner или старый status за успех. Если отрицательный путь ломается из-за случайного текста, проблема может быть в контракте интерфейса, а не в retry.

\n

Проверка изоляции тоже должна иметь отрицательный путь. Запустите тест отдельно и в другом порядке. Используйте новый идентификатор данных. Удалите запись после сценария. Если результат меняется, не маскируйте гонку timeout. Найдите владельца состояния и границу cleanup.

\n

Ограничения

\n

Ни один locator не защищает от неверного продукта. Auto-waiting ждёт actionability, но не исправляет серверный ответ. Web-first assertion ждёт условие, но не делает условие правильным. Retry может уменьшить шум инфраструктуры, но может и скрыть редкую ошибку. Trace полезен только там, где его записали и правильно связали с попыткой.

\n

Учебные фрагменты не дают production-результатов. Они не измеряют flake rate, длительность очереди, совместимость браузеров или качество данных. Версию Playwright, project config и окружение нужно сверять отдельно. Повышение timeout допустимо только после доказанной верхней границы задержки и проверки, что ожидание относится к нужному событию.

\n

Проверяемый критерий готовности

\n

Разбор готов, если команда может показать карточку запуска и ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какой evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.

\n

Для временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry не проходит этот критерий. Проходит повторяемый контракт, в котором тест отличает готовое действие от подтверждённого результата.

\n

Проверяемые источники

\n" + "excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, actionability, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.", + "contentHtml": "

В CI тест оформления платежа падает на click, а повторная попытка проходит. На следующий день тот же сценарий становится красным уже на проверке результата. Команда увеличивает timeout и добавляет ещё один retry, но получает только более длинную очередь: причина остаётся неизвестной, а настоящий сбой оплаты может потеряться среди зелёных повторов.

\n

Такой тест называют flaky, когда он при одинаковом заявленном сценарии иногда проходит, а иногда нет. Это описание наблюдения, а не диагноз. Чтобы вернуть тесту ценность, нужно сохранить исходную ошибку, разделить слои отказа и доказать маленьким экспериментом, какой слой меняется между попытками.

\n

Ниже — рабочая схема для Playwright Test. Маршрут, тексты, фикстуры и способ подготовки платежа в примерах условны: их нужно заменить контрактом конкретного приложения. Статья не обещает нулевой flake rate и не разрешает автоматически скрывать дефекты retry-настройкой.

\n

Сначала зафиксируйте симптом

\n

Начните с карточки одного запуска. Запишите commit, проект браузера, worker, номер попытки, входные данные и точное место падения. Не заменяйте initial failure результатом retry. В Playwright Test значение testInfo.retry показывает номер повторной попытки, а testInfo.workerIndex помогает связать запуск с worker.

\n
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 объясняет первый сбой.

\n

Что меняется при retry

\n

Retry не продолжает тот же браузер с места ошибки. Когда тест падает, Playwright Test удаляет worker вместе с браузером и запускает новый worker; при включённых retry тест начинается заново в новом процессе. Поэтому между initial и retry могут отличаться cookies, storage, состояние фикстур, worker, порядок подготовки данных и доступность внешней зависимости.

\n

Playwright классифицирует результаты так: passed — первый запуск прошёл; flaky — первый запуск упал, но retry прошёл; failed — упали первый запуск и все retry. Метка flaky полезна для очереди разбора, но не доказывает, что продукт исправен или что причина относится к инфраструктуре.

\n
Как читать пару initial/retry
НаблюдениеЧто уже известноЧего ещё нельзя утверждатьСледующая проверка
Обе попытки падают на одном locatorСбой воспроизводится в этом запускеЧто виноват только selectorПроверить число совпадений и actionability
Initial падает, retry проходит до clickМежду попытками изменилось состояние или времяЧто нужен больший timeoutСравнить DOM, overlay, animation и данные
Click проходит, assertion результата падаетДействие принято браузеромЧто операция завершилась успешноПроверить финальный UI-state и ответ операции
Тест проходит отдельно, но падает в пачкеЕсть зависимость от порядка или общего состоянияЧто проблема в браузереЗапустить с новым id данных и другим порядком
Есть trace только у retryВидна одна повторная попыткаЧто она показывает initialВключить симметричный сбор первого failure
\n

Разделите locator и actionability

\n

Для locator.click() Playwright ждёт, пока locator разрешится ровно в один элемент, элемент станет видимым, стабильным, доступным для событий и активным. Это несколько разных проверок. Таймаут на невидимой кнопке, перекрытие модальным слоем и два совпавших элемента требуют разных исправлений, хотя в отчёте могут выглядеть как один TimeoutError.

\n

Сначала проверьте cardinality — количество совпадений. Локатор должен описывать одну пользовательскую цель в нужной области страницы. Предпочтительны роль и доступное имя; цепочка CSS-классов связывает тест с реализацией DOM. getByTestId допустим, если команда поддерживает test id как стабильный технический контракт. Нельзя объявлять locator надёжным только потому, что он зелёный в одном браузере.

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

\n

Готовность экрана не равна успеху операции

\n

После click нужен бизнес-результат, который видит пользователь: подтверждённый статус, новая запись или переход на согласованный маршрут. Spinner, исчезновение skeleton и завершение отдельного сетевого запроса — промежуточные признаки. Они не заменяют assertion финального состояния.

\n
await pay.click();\nawait expect(page.getByRole('status')).toHaveText('Платёж подтверждён');\nawait expect(page).toHaveURL(/\\/checkout\\/success$/);
\n

Два assertion должны соответствовать контракту приложения. Если статус появляется раньше фактического сохранения, тест обязан ждать более точный сигнал или проверять запись через контролируемый API. Если URL и статус намеренно не меняются, не добавляйте их ради формы — зафиксируйте один действительно наблюдаемый результат.

\n

Случайная задержка waitForTimeout не объясняет, какое событие делает страницу готовой. Ожидание networkidle тоже не является универсальным признаком готовности: аналитика, polling и WebSocket могут не завершаться, а нужный UI уже может быть готов. Выбирайте состояние, принадлежащее пользовательскому сценарию.

\n

Проверьте данные и границы внешних систем

\n

Тест может быть стабильным, а данные — нет. Используйте уникальный идентификатор заказа, подготавливайте его перед тестом и удаляйте после него. Запуск отдельно и запуск в пачке должны получать независимые записи. Если тест зависит от общей корзины, аккаунта или очереди, это состояние нужно назвать владельцем и включить в fixture либо изменить контракт сценария.

\n

Внешний платёжный провайдер, email-шлюз и сторонняя аналитика находятся вне контроля e2e-команды. Полный путь к провайдеру проверяйте отдельным контрактным или интеграционным набором с его политикой доступности. В пользовательском e2e-тесте контролируйте внешний ответ через API-мок, если задача теста — проверить собственный UI. Иначе смена ответа третьей стороны будет ошибочно классифицирована как flaky интерфейса.

\n
Цикл разбора flaky e2e-теста: сравнить initial и retry, проверить locator, готовность интерфейса и evidence, затем внести малый diff с rollback
Порядок triage: сначала карточка двух попыток, затем отдельно selector и readiness, после этого — данные и внешние зависимости. Схема описывает процедуру и не является trace конкретного CI-запуска.
\n

Настройте артефакты так, чтобы они сохраняли сигнал

\n

Для CI разумно собирать trace на первом retry: это даёт подробный контекст повторной попытки и не записывает тяжёлый trace для каждого зелёного теста. Если retry выключены, используйте retain-on-failure, чтобы сохранить trace неудачного запуска. Режим on удобен для локального расследования, но в большом CI увеличивает стоимость и объём артефактов.

\n
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, затем откройте отчёт:

\n
pnpm 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

Порядок расследования

\n
  1. Сохраните исходный факт. Запишите initial error, результат retry, commit, project, worker, attempt и входные данные.
  2. Разнесите гипотезы. Отдельно назовите locator, actionability, readiness, изоляцию данных и внешнюю зависимость.
  3. Повторите без retry. Используйте тот же тест, свежие данные и trace, чтобы не смешивать диагностику с автоматической маскировкой.
  4. Проверьте цель действия. Убедитесь, что locator находит ровно один control в правильном контейнере.
  5. Проверьте финальный контракт. Assertion должен ждать пользовательский результат, а не случайный промежуточный сигнал.
  6. Сравните окружения. Сверьте worker, cookies, storage, браузер, порядок запуска, fixture setup/cleanup и ответы контролируемых API.
  7. Измените один слой. Исправьте selector, предусловие, assertion, данные или сбор артефактов — только тот слой, для которого есть evidence.
  8. Проверьте отрицательный путь. Отказ операции должен показывать ожидаемую ошибку, а старый status не должен приниматься за новый успех.
  9. Оставьте критерий снятия. Для временного retry укажите owner и срок пересмотра; для постоянного diff — команду проверки и rollback.
\n

Как отличить исправление от маскировки

\n

После правки прогоните тест отдельно и в пачке, с новым идентификатором данных и в поддерживаемых проектах браузера. Сравнивайте не только итоговый exit code, но и место assertion, длительность действия и наличие артефактов для каждой попытки. Один зелёный запуск ничего не доказывает; полезнее серия запусков с одинаковым контрактом и независимым setup.

\n

Повышать timeout можно только когда trace показывает допустимую задержку конкретного события, а данные подтверждают её верхнюю границу. Даже в этом случае изменяйте локальный timeout нужного действия и фиксируйте причину. Глобальное увеличение времени скрывает регрессии производительности и заставляет все тесты ждать чужую проблему.

\n

Ограничения применимости

\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 становится диагностическим инструментом, а не зелёной маской.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/158.json b/editorial/agent-rewrites/158.json index 58b2658..a3f2f40 100644 --- a/editorial/agent-rewrites/158.json +++ b/editorial/agent-rewrites/158.json @@ -3,5 +3,5 @@ "slug": "editorial-2023-08-mechanism-e2e-stability", "title": "Почему e2e-тест проходит на retry: четыре контракта устойчивого сценария", "excerpt": "Timeout в e2e-тесте не объясняет причину. Разбираем locator, actionability, пользовательский результат и retry, чтобы отличать настоящий дефект от замаскированного падения.", - "contentHtml": "

В CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда повышает timeout и закрывает задачу. Через неделю тот же сценарий снова падает на другом шаге. Цена ошибки — не только красный pipeline. Команда теряет время на повторы, пропускает дефект интерфейса или данных и привыкает считать зелёный retry доказательством исправности.

\n

Тезис простой: устойчивость e2e-теста нельзя свести к одному timeout. В сценарии действуют отдельные контракты. Locator должен выбрать нужный элемент. Actionability должна разрешить действие. Assertion должен дождаться пользовательского результата. Retry должен описать факт повторного запуска, но не объяснить его причину.

\n

Симптом начинается раньше TimeoutError

\n

Один текст ошибки скрывает разные события. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может пройти, но сервер вернёт ошибку. Сохранение может завершиться, а тест будет ждать исчезновения spinner, который не связан с итоговым состоянием. Retry может запуститься в новом worker с другим состоянием данных.

\n

Первый вопрос звучит не «какой timeout поставить?», а «какой контракт не выполнен?». Разделите фазу поиска locator, фазу действия, фазу ожидания результата и фазу retry. Это сразу сужает область правки.

\n

Четыре контракта одного теста

\n
КонтрактЧто он гарантируетЧто он не гарантирует
LocatorТест обращается к нужному пользовательскому элементу и ожидает понятную cardinality.Что операция завершилась успешно.
ActionabilityЭлемент допустимо использовать: он найден, видим, стабилен, принимает события и включён.Что приложение приняло действие или сохранило данные.
ReadinessПосле действия появился наблюдаемый пользовательский результат.Что причина результата находится в DOM.
RetryТест повторился и получил новый outcome.Что первая ошибка была случайной или устранена.
\n

Playwright автоматически ждёт actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Это защищает от клика по исчезнувшему или перекрытому control. Но проверка заканчивается, когда click допустим. Библиотека не знает, должен ли после него появиться статус «Сохранено», новая строка или ошибка.

\n

Readiness принадлежит пользовательскому сценарию. Для профиля это status с текстом «Сохранено» и новое значение поля. Для импорта — строка с terminal state. Spinner, enabled-кнопка и network idle могут быть промежуточными признаками. Они не заменяют бизнес-результат.

\n

Учебный пример: действие и постусловие

\n

Фрагмент ниже учебный. Он не утверждает, что такой locator или текст существуют в вашем приложении. Он показывает разделение действия и постусловия.

\n
import { 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 стал неоднозначным, ошибка должна указывать на выбор элемента. Эти отказы требуют разных исправлений.

\n

Не подменяйте постусловие ручной паузой. waitForTimeout(2000) иногда скрывает медленный UI, а иногда просто откладывает отказ. Не подменяйте результат исчезновением spinner, если spinner исчезает и при ошибке. Не используйте force: true, чтобы обойти перекрытие, пока не доказано, что перекрытие не является дефектом интерфейса.

\n

Retry меняет условия запуска

\n

В Playwright retry выключен по умолчанию. Если он включён, упавший тест запускается снова. Runner работает с worker-процессами. После отказа worker может быть отброшен, а повтор начнётся в новом процессе. Retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это наблюдение о двух запусках, а не доказательство случайности.

\n

Статус flaky полезен как сигнал: первая попытка не прошла, повторная прошла. Он не отвечает на вопросы «почему упало», «исправили ли причину» и «будет ли проходить другой браузер». Trace, записанный через on-first-retry, относится к повторной попытке. Он показывает конкретный запуск, но не восстанавливает контекст первой ошибки.

\n

Отрицательный путь важен не меньше зелёного. Если click прошёл, но readiness не наступил, тест должен закончиться понятной ошибкой на assertion результата. Если retry затем проходит, сохраняйте обе попытки. Нельзя заменить историю фразой «flaky исчез» без проверки данных, состояния и UI-сигнала.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Timeout на clickLocator пустой, множественный, перекрыт или disabled.Проверьте cardinality, видимость, стабильность и получение событий.Уточните scope и locator; исправьте UI или данные, если control недоступен.
Click прошёл, тест ждёт до timeoutAssertion ждёт не тот результат или UI не сообщает terminal state.Назовите факт, который должен увидеть пользователь.Добавьте web-first assertion на этот факт или согласуйте UI-контракт.
Первый запуск failed, retry passedУтечка данных, порядок тестов, внешний сервис или скрытая готовность.Сравните входы, worker, cleanup, номер попытки и evidence.Изолируйте данные или исправьте ожидание. Не увеличивайте retry.
Тест проходит только с waitForTimeoutСценарий не ждёт наблюдаемое событие.Уберите паузу в учебной ветке и найдите первый пользовательский факт.Замените паузу на locator/assertion с ясным сообщением.
Trace ничего не объясняетАртефакт относится к retry, а вопрос слишком широк.Проверьте attempt, тест, проект и момент записи.Используйте trace как evidence одного запуска и соберите контекст initial failure.
\n
\"Схема
Locator выбирает control, actionability разрешает действие, readiness подтверждает пользовательский результат, а retry фиксирует повтор. Asset показывает модель, а не trace реального теста.
\n

Порядок разбора

\n
  1. Зафиксируйте симптом. Запишите тест, браузер, шаг, номер попытки, исходную ошибку и артефакт. Формулировки «иногда падает» недостаточно.
  2. Определите фазу. Отделите поиск locator, actionability, assertion результата, подготовку данных и retry.
  3. Проверьте locator. Убедитесь, что он выражает пользовательский смысл, работает в нужном scope и возвращает ожидаемое число элементов.
  4. Назовите readiness. Запишите terminal state, который доказывает успех. Сверьте его с тем, что увидит пользователь.
  5. Сравните попытки. Сопоставьте входные данные, worker, cleanup, конфигурацию и порядок действий. Retry рассматривайте как новый запуск.
  6. Проверьте evidence. Свяжите trace, screenshot или log с попыткой. Если артефакт отсутствует, так и запишите.
  7. Сделайте один diff. Меняйте один слой: locator, assertion, изоляцию данных или evidence. Укажите owner и rollback. Timeout меняйте только после доказательства нужного события.
  8. Проверьте отрицательный путь. Ошибка сервера, отсутствие readiness и неоднозначный locator должны давать разные понятные отказы. Зелёный retry сам по себе проверкой не считается.
\n

Ограничения

\n

Модель не делает любой тест стабильным. Locator с role/name может быть корректным, но приложение может показывать неверное состояние. Assertion может быть семантическим, но тестовые данные могут пересекаться. Retry помогает обнаружить flake, но не заменяет изоляцию и диагностику. Trace фиксирует только записанный запуск. Отдельно проверяйте версии Playwright, браузеры, сеть и внешний сервис.

\n

Учебный код не запускает реальный профиль, не измеряет flake rate и не доказывает production-результаты. Для рабочего теста подставьте реальные роли, данные и terminal state, затем проверьте их на целевой конфигурации.

\n

Критерий готовности

\n

Разбор готов, когда для одного сценария записаны четыре вещи: уникальный locator, ожидаемый пользовательский результат, evidence с номером попытки и действие с понятным rollback. На контролируемом отрицательном пути тест должен падать на соответствующем контракте, а не на случайном timeout. На повторном запуске команда должна видеть, что изменилось: locator, readiness, данные или окружение. Только после этого статус flaky становится входом для проверки, а не заменой объяснения.

\n

Проверяемые источники

\n" + "contentHtml": "

В CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда увеличивает timeout и закрывает задачу. Через неделю тот же сценарий падает уже на другом шаге. Цена ошибки — не только красный pipeline: команда теряет время на повторы, пропускает дефект интерфейса или данных и начинает считать зелёный retry доказательством исправности.

\n

Устойчивость e2e-сценария нельзя свести к одному таймауту. В нём действуют четыре разных контракта: locator выбирает нужный элемент, actionability разрешает действие, assertion подтверждает пользовательский результат, а retry описывает повторный запуск. Если смешать эти уровни, диагностика превращается в перебор чисел. Если разделить их, место отказа становится проверяемым.

\n

Симптом начинается раньше TimeoutError

\n

Одинаковая ошибка ожидания может возникнуть по разным причинам. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может успешно отправить событие, а сервер — вернуть ошибку. Сохранение может завершиться, но тест ждёт исчезновения spinner, который скрывается и при успехе, и при отказе. При retry меняются worker, состояние браузера и иногда подготовленные данные.

\n

Первый вопрос здесь не «какой timeout поставить?», а «какой контракт не выполнен?». Отделите поиск элемента, проверку готовности действия, ожидание результата, подготовку данных и повтор. У каждого слоя должна быть своя ошибка и свой следующий шаг.

\n

Четыре контракта одного теста

\n
КонтрактЧто он проверяетЧего он не доказывает
LocatorТест обращается к нужному пользовательскому элементу и ожидает понятное число совпадений.Что операция завершилась успешно.
ActionabilityЭлемент найден, видим, стабилен, получает события и включён для выбранного действия.Что приложение приняло действие или записало данные.
ReadinessПосле действия появился наблюдаемый terminal state: статус, строка результата или изменённое значение.Что именно этот locator был причиной успеха.
RetryRunner повторил тест и получил новый outcome в другом запуске.Что первая ошибка была случайной, а исправление найдено.
\n

Playwright автоматически ждёт проверки actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Стабильность означает, что геометрия элемента не меняется на последовательных кадрах. Получение событий означает, что другой элемент, например overlay, не перехватит клик. Это защищает от клика по исчезнувшему или перекрытому control, но не знает смысла продукта.

\n

После успешного click() библиотека не может сама решить, что считать сохранением. Для профиля это может быть сообщение «Сохранено» и новое значение поля. Для импорта — строка с terminal state и числом обработанных записей. Spinner, enabled-кнопка и отсутствие сетевой активности могут быть промежуточными признаками. Их нельзя выдавать за бизнес-результат без проверки сценария.

\n

Locator должен описывать интерфейс, а не разметку

\n

Хороший locator связан с тем, как пользователь воспринимает control: ролью, доступным именем, label или устойчивым тестовым идентификатором. Селектор по случайному классу или позиции в списке может пройти сегодня и сломаться после перестановки DOM. Но и role-based locator не магический: две кнопки с одним доступным именем — это неоднозначный контракт, а не повод добавить nth(0).

\n

Проверяйте cardinality отдельно, когда она важна. await expect(save).toHaveCount(1) сообщает, что на странице ровно одна кнопка с выбранным locator. После этого click() может всё ещё не пройти: control способен быть disabled или закрыт overlay. Разные отказы оставляют разную подсказку для исправления.

\n

Учебный пример: действие и postcondition

\n

Фрагмент ниже самодостаточен как форма теста, но не подключён к реальному профилю. Пути, label и тексты — проектные значения; в рабочем тесте их нужно заменить на фактический пользовательский контракт. Важен порядок: ввод, проверка уникальности locator, действие, затем ожидание результата.

\n
import { 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});
\n

getByLabel() и getByRole() выражают пользовательское представление интерфейса. toHaveCount() ловит неоднозначный locator до действия. click() ждёт техническую готовность кнопки. toHaveText() — web-first assertion: он повторяет проверку, пока условие не выполнено или не истечёт timeout. Если сервер вернул ошибку, тест должен упасть на postcondition, а не пройти потому, что кнопка была кликабельной.

\n

Не подменяйте postcondition ручной паузой. waitForTimeout(2000) иногда маскирует медленный UI, а иногда лишь откладывает отказ. Не ждите исчезновения spinner, если он исчезает и при ошибке. Не используйте force: true, пока не доказано, что перекрытие не является дефектом интерфейса: этот флаг отключает часть проверок actionability и может превратить реальную проблему в зелёный тест.

\n

Retry создаёт новый запуск, а не новое доказательство

\n

По умолчанию Playwright Test не повторяет упавшие тесты. При включённых retry runner запускает тест снова до достижения лимита. Playwright Test работает с worker-процессами. Если тест падает, worker вместе с браузером отбрасывается; повтор начинается в новом worker, где hooks могут выполниться заново. Поэтому retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это объясняет различие условий, но не выбирает причину автоматически.

\n

Категория flaky означает только последовательность «первая попытка упала, повторная прошла». Она не означает «дефект случайный», «сеть была медленной» или «правка сработала». Сохраните initial error, retry outcome, входные данные, worker и cleanup. Иначе отчёт оставит только удобный ярлык.

\n

Для CI удобно записывать trace на первой повторной попытке. Такой trace содержит историю именно записанного запуска: действия, снимки DOM и сетевой контекст. Он полезен для анализа retry, но не восстанавливает отсутствующий trace initial attempt. Если причина могла проявиться только в первой попытке, включите режим, который сохраняет evidence и для неё, либо соберите отдельный воспроизводимый запуск.

\n

Минимальная конфигурация и команды

\n

В конфигурации ниже оставлена одна повторная попытка: этого достаточно, чтобы увидеть разницу между initial и retry, но недостаточно, чтобы объявить тест стабильным. Значение retries — политика запуска, а не лечение теста.

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

Запустите один тест без параллельного шума и сохраните отчёт:

\n
npx 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, но режим записи каждого запуска дороже по времени и месту.

\n

Номер попытки можно добавить в evidence:

\n
test('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

Симптом → причина → проверка → действие

\n
СимптомВерсия причиныПроверкаБезопасное действие
Timeout на clickLocator пустой, множественный, перекрыт или 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.
\n

Данные и окружение часто меняются вместе с retry

\n

Свежий browser context не делает внешние данные свежими. Если тест меняет одну и ту же учётную запись, записи могут пересекаться между worker и параллельными запусками. Если cleanup выполняется только после успеха, retry стартует с другим состоянием. Если серверная очередь или партнёрский API отвечает асинхронно, DOM может быть готов раньше результата операции.

\n

Сначала зафиксируйте границы изоляции. Для каждого теста задайте уникальный идентификатор сущности или подготовьте её через API; cleanup сделайте идемпотентным; состояние, которое нельзя очищать, проверяйте перед повтором. В лог запишите correlation id, но не секреты и персональные данные. Отдельно сравните локальный запуск, CI worker и browser project: одинаковый код не означает одинаковое окружение.

\n

Если тест зависит от внешнего сервиса, разделите два вопроса. UI-тест проверяет пользовательский контракт на контролируемом ответе, а интеграционный сценарий отдельно проверяет реальное взаимодействие. Один retry не отличает дефект приложения от временной недоступности партнёра.

\n

Порядок разбора

\n
  1. Зафиксируйте симптом. Сохраните test title, browser project, шаг, initial error, retry outcome и ссылку на отчёт. Формулировки «иногда падает» недостаточно.
  2. Определите фазу. Отделите locator, actionability, postcondition, подготовку данных, внешний сервис и retry.
  3. Проверьте locator. Убедитесь, что он выражает пользовательский смысл, работает в нужном scope и возвращает ожидаемое число элементов.
  4. Назовите readiness. Запишите terminal state, который доказывает успех. Сверьте его с тем, что увидит пользователь при ошибке сервера.
  5. Сравните попытки. Сопоставьте данные, worker, cleanup, browser project, конфигурацию и порядок действий. Retry рассматривайте как новый запуск.
  6. Свяжите evidence с attempt. Trace, screenshot, log и network запись должны иметь понятный test title и номер попытки. Отсутствующий artefact не заменяйте предположением.
  7. Сделайте один diff. Меняйте один слой: locator, assertion, изоляцию данных или политику evidence. Зафиксируйте owner и rollback.
  8. Проверьте отрицательный путь. Ошибка сервера, неоднозначный locator и отсутствие readiness должны давать разные понятные отказы.
  9. Повторите на свежих данных. Прогоните сценарий с той же конфигурацией, а затем отдельно проверьте другой browser project или внешний dependency, если они входят в область риска.
\n
\"Схема
Каждый следующий слой проверяет отдельный факт. Evidence привязан к конкретной попытке и не превращает успешный retry в доказательство причины.
\n

Ограничения применимости

\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

Критерий готовности

\n

Разбор готов, когда команда может ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какое evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.

\n

Для временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry этому критерию не соответствует. Соответствует контракт, в котором тест различает готовое действие, подтверждённый результат, состояние данных и условия повторного запуска.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/159.json b/editorial/agent-rewrites/159.json index 15fd378..fa1265d 100644 --- a/editorial/agent-rewrites/159.json +++ b/editorial/agent-rewrites/159.json @@ -1,7 +1,7 @@ { "index": 159, "slug": "editorial-2023-08-practice-e2e-stability", - "title": "Почему e2e-тест проходит со второго раза и что проверять вместо retry", - "excerpt": "Flaky после retry — это симптом, а не причина. Разбираем границы locator, готовности интерфейса, изоляции данных и evidence на примере Playwright.", - "contentHtml": "

Тест нажимает «Оплатить», получает ошибку на первой попытке и проходит на повторной. 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 одновременно: после такой правки исчезает связь между симптомом и действием.

Симптом → причина → проверка → действие

Короткая карта разбора нестабильного e2e-теста
СимптомПричинаПроверкаДействие
Locator не найденРазметка не появилась или селектор зависит от CSSСнять число совпадений и посмотреть DOM первой попыткиВыбрать user-facing locator и сузить scope
Клик проходит, статус не меняетсяНет readiness condition, ошибка API или промежуточное состояние принято за успехПроверить итоговый сигнал и ответ операцииЖдать terminal state; отдельно исправить приложение или данные
Первый запуск failed, retry passedГонка, утечка данных, порядок тестов или новый workerСопоставить данные, worker, шаг ошибки и evidence обеих попытокИзолировать данные и подтвердить один источник различия
Тест проходит после роста timeoutОжидание было коротким или assertion смотрит не на тот фактИзмерить время наступления названного состоянияМенять timeout только вместе с контрактом и лимитом
Trace есть, причина неяснаАртефакт относится к одной попыткеПроверить номер попытки и последний шагСформулировать гипотезу и проверить её отдельно

Почему retry меняет картину

Retry запускает тест снова после сбоя. В Playwright повтор может выполняться в новом worker. Это полезно для изоляции, но одновременно меняет окружение: новый браузерный контекст, новое состояние фикстур, другой порядок подготовки. Если первая попытка получила пользователя от соседнего теста, повтор может пройти только потому, что набор данных изменился.

Статус flaky описывает сочетание результатов «первая попытка не прошла, повторная прошла». Он не доказывает случайность. Он не говорит, что сеть была медленной, selector неверен или приложение сломалось. Для каждой гипотезы нужны свои признаки.

Trace, screenshot и action log тоже имеют границу. Они показывают, что происходило в записанном запуске. Trace на on-first-retry полезен для повторной попытки, но не превращается в запись первого отказа. Сохраняйте номер попытки рядом с артефактом и не называйте trace root-cause analysis.

Иллюстрация разбора

Схема разбора e2e-сбоя через locator, readiness, retry и evidence
Схема разделяет четыре вопроса: найден ли элемент, наступил ли пользовательский результат, что изменил retry и к какой попытке относится evidence. Изображение иллюстрирует порядок проверки и не содержит измерений реального проекта.

Если locator не уникален, не обсуждают timeout. Если locator стабилен, но состояние не наступает, смотрят на readiness и ответ операции. Если обе части верны, сравнивают данные и окружение initial/retry. Только после этого решают, нужна ли настройка retry или дополнительные артефакты.

Порядок действий

  1. Зафиксируйте симптом: какая попытка упала, какая прошла, на каком шаге и с каким текстом ошибки.
  2. Проверьте locator. Он должен выбирать ожидаемый пользовательский элемент в нужной области. Не принимайте first() как доказательство правильного выбора.
  3. Назовите readiness condition одним предложением. Она должна описывать terminal state, а не длительность ожидания.
  4. Сверьте assertion с этим условием. Уберите sleep, если он маскирует отсутствие постусловия. Оставьте timeout как верхнюю границу.
  5. Сравните данные initial и retry: идентификаторы, cookies, storage, записи на сервере и порядок подготовки.
  6. Прочитайте evidence по номеру попытки. Отделите факт от гипотезы: trace показывает шаг, но не объясняет причину состояния.
  7. Внесите одну правку в один слой. После неё повторите сценарий в тех же условиях и сохраните критерий отката.
  8. Если причина не подтверждена, оставьте тест в очереди разбора. Не объявляйте повышение timeout исправлением.

Ограничения

Эта схема не гарантирует отсутствие flaky-тестов. Она не заменяет проверку backend, браузеров, сети, CI-ресурсов и тестовых данных. Playwright может ждать actionability и web-first assertion, но не может выбрать бизнес-сигнал за команду. Для сложного процесса readiness может включать несколько состояний, однако каждое должно иметь понятное сообщение об ошибке.

Учебный пример не запускался против браузера и не измеряет flake rate, latency или совместимость платформ. Синтетическая модель попыток не является production evidence. Официальная документация меняется вместе с версиями Playwright, поэтому поведение конкретного runner проверяют по версии в проекте.

Проверяемый критерий готовности

Разбор можно считать завершённым, если для выбранного теста записаны пять вещей: уникальный locator, наблюдаемый readiness condition, входные данные каждой попытки, evidence с номером попытки и одна подтверждённая причина различия. После правки тест проходит несколько независимых запусков без изменения timeout как единственного изменения, а отрицательный сценарий всё ещё падает на неверном результате. Если пункт отсутствует, стабильность не доказана — есть только удачный retry.

Проверяемые источники

" + "title": "E2E без лишних retry: ждать факт, а не тишину интерфейса", + "excerpt": "Почему retry не лечит e2e-падение: отделяем locator, условие готовности, повторный запуск и evidence, а timeout меняем только после проверки контракта.", + "contentHtml": "

Тест нажимает кнопку «Оплатить», получает ошибку на первой попытке и проходит на повторной. В отчёте остаётся статус flaky, а в pull request появляется короткое предложение: поднять timeout и идти дальше. Проблема в том, что этим действием смешиваются четыре разных объекта: selector для действия, условие готовности интерфейса, retry тест-раннера и evidence из конкретного запуска. Пока они смешаны, команда лечит паузу, а не контракт.

\n

Цена такой правки не сводится к лишним секундам CI. Реальная ошибка может стать «шумом»: повторный запуск проходит, скрывает первый отказ и откладывает разбор. Обратная цена тоже заметна: бесконечный trace на каждый тест раздувает артефакты, но не отвечает, какой результат должен увидеть пользователь. Здесь нужен короткий порядок: сначала назвать факт после действия, затем выбрать наблюдение, только потом решать, нужен ли retry и какой evidence сохранять.

\n

Четыре объекта, которые нельзя называть одним ожиданием

\n

Selector отвечает на вопрос «куда направить действие». Для Playwright 1.37.0 locator с role и name — это способ найти пользовательский элемент. Перед click() Playwright проверяет, что элемент attached, visible, stable, receives events и enabled. Эти проверки полезны: они не дают кликнуть в скрытый или перекрытый control. Но они не знают, завершилась ли оплата, сохранился ли профиль или пришёл ли пользовательский статус.

\n

Readiness condition отвечает на другой вопрос: какой наблюдаемый продуктовый факт должен появиться после действия. Это может быть текст в role=status, появление записи в таблице или смена доступного пользователю состояния. Условие не должно быть «страница немного успокоилась» или «кнопка стала disabled», если бизнес-операция ещё не подтверждена. Retry запускает тест повторно после failure. Evidence — trace, snapshot, action log или другой артефакт отдельной попытки. Ни один из них не является синонимом selector или готовности.

\n
Граница каждого слоя в e2e-сценарии
СлойНа какой вопрос отвечаетЧто может подтвердитьЧего не подтверждаетПервое действие
SelectorКак найти control?locator разрешается ровно в один ожидаемый элементчто операция завершиласьиспользовать role/name или явный test id
Readiness conditionКакой факт увидел пользователь после действия?конкретный status, текст, запись или состояниечто locator был уникален до clickсформулировать ожидаемый продуктовый результат
RetryЧто сделал runner после failure?первая попытка не прошла, следующая прошла или нетпочему попытки различаютсясохранить статус первой и повторной попытки
EvidenceЧто можно изучить в одном запуске?действия, snapshots, log и network log, если они записаныкорневую причину без дополнительной проверкисвязать артефакт с номером попытки
TimeoutКаков верхний предел ожидания?когда ожидание остановится ошибкойкакой факт надо дождатьсяне менять, пока не назван readiness contract
\n

Сначала формулируем результат после click

\n

Практический контракт выглядит короче, чем кажется. После нажатия нужно назвать одно состояние, которое экран обязан показать. Например: «платёж подтверждён», а не «кнопка уже недоступна». Если интерфейс не имеет такого состояния, это не повод ждать произвольную паузу. Это повод вместе с владельцем экрана выбрать доступный сигнал: status region, итоговую строку, новый route или ответственный элемент в UI. Контракт должен быть проверяемым пользователем, а не внутренним CSS-классом без смысла вне реализации.

\n

Ниже — проектный образец. getByRole() выбирает кнопку, а toHaveText() ждёт текст результата. Web-first assertion в Playwright умеет повторять проверку до timeout; это не означает, что любой текст подходит. Значение confirmed здесь специально условное: в своём продукте его заменяют на реальный, устойчивый и доступный пользователю результат. Фрагмент не доказывает, что страница, backend или browser уже проверены.

\n
// Иллюстрация контракта: 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 должны соответствовать контракту конкретного продукта.
\n

Sleep или network idle без связи с результатом делают запуск длиннее, но не объясняют, что делать при ошибке экрана после успешного запроса. Actionability делает действие допустимым, readiness делает завершение сценария наблюдаемым. Тогда timeout — параметр договора, а не способ спрятать расхождение.

\n

Учебный fixture: только synthetic попытки в памяти

\n

Пакет содержит исполнимую модель, но не e2e-запуск. Она создаёт две synthetic попытки: первая не достигает payment-state=confirmed, вторая достигает его после retry. Selector в обеих уникален, поэтому fixture отделяет readiness от selector и retry. Он не открывает URL, не читает тест, не запускает Playwright и не производит trace.zip или video.

\n
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 готов.
\n

PASS проверяет восемнадцать утверждений: вход помечен как memory-only, браузер не запускается, первая и повторная попытки различаются ровно теми полями, которые заданы в модели, а увеличение timeout отклоняется учебным планом. Отдельная отрицательная ветка делает selector множественным и получает другую классификацию. Это важно: даже одинаковый финальный статус «flaky» не даёт права без проверки переписать locator, readiness condition и retry policy одной правкой.

\n
\"Схема
Схема задаёт порядок разбора. Она не показывает настоящий trace, браузер, длительности, количество флаков или совместимость платформ. Каждый прямоугольник — вопрос к одному слою теста.
\n

Маршрут: симптом → причина → проверка → действие

\n
  1. Симптом. Первый запуск failed, retry passed, а отчёт пометил тест flaky. Сохраните номер попытки, исходный текст ошибки и ссылку на evidence, если ваш runner его записал. Не называйте это доказанной причиной.
  2. Причина. Обычно в одном ожидании оказались selector, техническая готовность control и результат операции. Иногда к ним добавляют общий timeout, поэтому ошибка становится длиннее, но не яснее.
  3. Проверка selector. Убедитесь, что locator на обеих попытках разрешается ровно в один пользовательский элемент. Если нет, это отдельная задача: сузить role/name, scope или test id; не менять пока бизнес-assert.
  4. Проверка readiness. Выпишите факт, который обязан увидеть пользователь после действия. Сверьте, что assertion ждёт именно его, а не disabled button, исчезновение spinner или окончание произвольной паузы.
  5. Проверка retry и evidence. Сопоставьте initial и retry как два запуска. Для Playwright 1.37.0 retry выполняется в новом worker; trace с on-first-retry, если он настроен, относится к первой повторной попытке. Он помогает задать вопрос, но не возвращает автоматически evidence первого отказа.
  6. Действие. Внесите минимальную правку в один слой: locator, semantic assertion, изоляцию данных или явный проектный контракт. Не повышайте timeout, пока не можете назвать, что именно должно стать ready.
  7. Rollback. До merge запишите прежний assertion и критерий возврата. Если новый сигнал оказался неверным, откатите только test diff и повторите разбор; не стирайте историю первой неудачной попытки комментарием «пофиксили flaky».
\n

Retry полезен как граница сбора evidence, а не как индульгенция

\n

В Playwright retries выключены по умолчанию. При включении runner повторно запускает упавший тест; документация v1.37.0 называет flaky тот случай, когда первая попытка не прошла, а повторная прошла. Это удобный сигнал для очереди разбора, но не диагноз. Повтор может дать другой worker и чистое состояние, а значит скрыть утечку тестовых данных, зависимость от порядка или неявную готовность экрана. Он не доказывает, что первая ошибка была случайной, внешний сервис был медленным или selector корректен.

\n

Конфигурация trace: on-first-retry привязывает артефакт к первому повтору, а не ко всей истории. Это evidence retry, не объяснение initial failure. Если нужен контекст первого отказа, проекту требуется отдельный способ его сохранить с учётом чувствительности данных и цены артефактов.

\n
// Иллюстрация для 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

Ограничения и следующий проверяемый шаг

\n

Заметка не измеряет flake rate, не сравнивает браузеры, не обещает устойчивость любого getByRole() и не предлагает общий timeout. Trace фиксирует один запуск, а причина может лежать в приложении, сети, окружении или данных. Рамка ограничена Playwright v1.37.0 от 10 августа 2023; другую версию проверяют по её документации.

\n

Следующий шаг — взять один тест со статусом flaky и заполнить короткую карточку из пяти строк: selector, readiness condition, initial outcome, retry outcome и доступный evidence. Затем выбрать один слой для изменения и указать rollback. Если карточку нельзя заполнить без догадок, не увеличивайте timeout. Сначала добавьте недостающий наблюдаемый результат или изоляцию данных. Так retry перестаёт превращать ошибку в шум и становится точкой, где начинается инженерный разбор.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/160.json b/editorial/agent-rewrites/160.json index dd1f0a4..44bf6bb 100644 --- a/editorial/agent-rewrites/160.json +++ b/editorial/agent-rewrites/160.json @@ -1 +1 @@ -{"index":160,"slug":"editorial-2023-07-field-contract-tests","title":"Контракт API прошёл schema-проверку, но сломал сценарий: как найти расхождение смысла","excerpt":"Валидный JSON не доказывает совместимость consumer и provider. Разбираем учебный случай с nullable-полем, отделяем форму ответа от его смысла и задаём проверяемый шлюз перед выпуском.","contentHtml":"

После выкладки экран может получить ответ со статусом 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Клиент не знает новое значениеПроверить все поддерживаемые версииСохранить обратную ветку или добавить поле

Учебный пример: active без даты

Это 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, результат должен попасть в матрицу, по которой релиз принимает решение.

\"Шлюз
Interaction, provider state и версия provider должны привести к результату verification. Отсутствующий результат не превращается в зелёное разрешение.

Не смешивайте статусы. Schema PASS означает совпадение с формой. Semantic FAIL означает, что конкретный consumer не может продолжить сценарий. Provider verification not-run означает, что совместимость с исполняемой версией ещё не доказана.

Порядок действий

  1. Зафиксируйте симптом. Сохраните один request, response, пользовательский эффект, время и доступные версии. Не называйте виновника заранее.
  2. Опишите expectation. Назовите поле и состояние, нужные consumer. Добавьте ветку для null, неизвестного enum и отсутствующей даты.
  3. Проверьте форму. Сопоставьте schema, статус, media type, required, enum и типы. Проверьте допустимость null в нужной версии схемы.
  4. Проверьте сценарий. Запустите interaction с названным provider state и точной версией provider. Не подменяйте слой, который должен формировать ответ.
  5. Выберите обратимое действие. При неизвестном результате остановите выпуск, сохраните старое поведение или включите согласованный fallback. Не объявляйте совместимость по schema PASS.
  6. Закройте цепочку. Опубликуйте результат с версиями и окружением. Перед deploy проверьте матрицу совместимости, после deploy запишите фактическую версию и среду.

Откат и отрицательный путь

Откат оправдан, если новый 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 до решения о выпуске.

Проверяемые источники

"} +{"index":160,"slug":"editorial-2023-07-field-contract-tests","title":"Контракт API прошёл schema-проверку, но сломал сценарий: как найти расхождение смысла","excerpt":"Валидный JSON не доказывает совместимость consumer и provider. Разбираем случай с nullable-полем, отделяем форму ответа от его смысла и задаём проверяемый шлюз перед выпуском.","contentHtml":"

Представим знакомый симптом: frontend отправляет запрос, получает 200, ответ проходит проверку OpenAPI-схемы, но на экране не появляется действие, ради которого пользователь открыл страницу. В ответе есть state: "active", а renewalAt равен null. С точки зрения формы JSON корректен. С точки зрения экрана продления дата обязательна, поэтому кнопка остаётся недоступной.

Цена такой ошибки — не только сломанный экран. Команда видит зелёный schema-check и может решить, что provider совместим с клиентом. Затем она либо выпускает несовместимую версию, либо откатывает полезное изменение, не установив причину. Разберём, какую проверку добавить между «ответ имеет правильную форму» и «пользовательский сценарий может продолжиться».

Ниже не отчёт о конкретном production-инциденте, а воспроизводимый учебный сценарий. Он отделяет три утверждения: схема допускает тело, consumer умеет обработать тело, а provider действительно возвращает его при нужном состоянии данных. Только последнее проверяется на работающем provider.

Что именно проверяет schema

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 PASSProvider ответил ожидаемым образом в заданном состоянииВсе сценарии и окружения покрытыПроверить список consumer, версии и матрицу
can-i-deploy PASSBroker нашёл совместимые версии в окруженииБизнес-правило не ошибочно и не забыты внешние зависимостиОставить функциональные, 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 должен выбрать договорённость:

  1. Разные состояния. Provider возвращает active только вместе с датой, а отсутствие даты получает состояние, например pending_renewal.
  2. Явная возможность действия. Ответ содержит canRenew: true|false, а дата обязательна только при canRenew: true.
  3. Безопасная ветка consumer. null остаётся допустимым, но consumer скрывает кнопку, показывает объяснение и не подставляет текущую дату.

Выбор зависит от смысла поля, обратной совместимости и того, какие старые версии consumer уже работают с provider. Удобство валидатора не является бизнес-правилом.

Как зафиксировать interaction

Для 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 должна уметь подготовить каждое состояние независимо.

Как проверить настоящий provider

Consumer test работает с mock provider и формирует pact-файл. Он отвечает на вопрос «понимает ли consumer ожидаемый обмен и формирует ли правильный запрос?». Следующий шаг — provider verification: Pact воспроизводит interaction против настоящего provider и сравнивает фактический ответ с минимальным ожидаемым.

Перед каждой interaction provider должен попасть в названное состояние: запись подписки существует, дата рассчитана, авторизация разрешена. State setup не должен зависеть от порядка других тестов. Если provider использует базу или downstream-сервис, настройте изолированный fixture или стабилизируйте зависимость в рамках тестовой архитектуры.

\"Шлюз
Форма ответа — только первый шлюз. Для решения о выпуске нужны конкретная interaction, состояние provider, версии приложений и опубликованный результат verification.

Лог с телом 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. Идите от наблюдаемого эффекта к самому узкому доказательству.

  1. Зафиксируйте ответ. Сохраните URL, метод, статус, Content-Type, тело, версии consumer и provider. Секреты и персональные данные удалите.
  2. Назовите потребность. Запишите действие пользователя, нужные поля и ветки для null, неизвестного enum, 4xx и 5xx.
  3. Разделите проверки. Запустите schema validator, затем тест API-клиента на mock provider. Schema PASS не равен пользовательскому PASS.
  4. Сверьте provider state. Убедитесь, что verification создаёт именно данные из interaction. Неверное состояние даёт зелёный результат для другого сценария.
  5. Сопоставьте версии. Проверьте публикацию pact и verification для конкретных версий и фактическое состояние целевого окружения в Broker.
  6. Выберите минимальное изменение. При нарушении формы исправляйте схему или provider. При допустимой форме и незафиксированном смысле уточняйте interaction, поле состояния или обработку consumer.

Ограничения и безопасный вывод

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 с корректно подготовленным состоянием.

Проверяемые источники

"}