diff --git a/editorial/agent-rewrites/047.json b/editorial/agent-rewrites/047.json index 67baa93..87b7440 100644 --- a/editorial/agent-rewrites/047.json +++ b/editorial/agent-rewrites/047.json @@ -2,6 +2,6 @@ "index": 47, "slug": "editorial-2026-09-mechanism-frontend-backend-boundary", "title": "Состояние экрана не угадывают по статусу: разделяем query, command и ошибку", - "excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.", - "contentHtml": "

Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красный toast «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.

\n

Причина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение representation, отправку команды и объяснение отказа. Надёжная граница оставляет право менять screen state только за проверенной моделью. Query читает данные. Command просит изменить состояние. Error envelope сообщает, почему переход не состоялся и какой следующий шаг допустим.

\n

Тезис: статус не является моделью экрана

\n

Статус HTTP сужает множество возможных решений, но не выбирает состояние компонента в одиночку. Ответ 200 может содержать неизвестный для клиента статус. Ответ 204 подтверждает отсутствие тела, но не говорит, какую локальную модель нужно строить. Ответ 409 может требовать перечитать ресурс. Ответ 422 может подсветить поле формы. Ответ 429 может разрешать повтор только после паузы. Универсальный обработчик «не 2xx — ошибка, 2xx — успех» стирает эти различия.

\n

Разделите контекст по последствиям. Query не должен менять доменное состояние. Command не должен объявлять новую screen model только потому, что сервер принял запрос. Error envelope должен содержать машинный тип проблемы и безопасные данные для следующего шага. Текст для пользователя — ответственность адаптера UI, а не строка, которую компонент извлекает из свободного detail.

\n
\"Матрица
Матрица связывает результат HTTP с разрешённым действием UI. Она не доказывает корректность конкретного API и не заменяет тесты.
\n

Механизм границы

\n

У каждой операции должны быть названы вход, форма результата и отрицательные переходы. Для query это обычно валидная representation или ошибка чтения. Для command возможны новая representation, 202 Accepted с идентификатором операции или 204 No Content. Эти ответы нельзя обрабатывать одной функцией. В 202 результат команды ещё не равен готовому состоянию ресурса. В 204 тела нет, поэтому запуск JSON parser — уже ошибка клиента.

\n

409 Conflict означает конфликт текущего состояния ресурса с запросом. Если версия записи устарела, UI может предложить перечитать данные и выбрать действие заново. Не стоит без изменения входа отправлять команду снова. 422 Unprocessable Content означает, что запрос синтаксически понятен, но содержимое не прошло прикладную проверку. Это путь к конкретному полю или правилу, а не сетевой сбой.

\n

429 Too Many Requests и 5xx могут быть временными, но их нельзя объединять в бесконечный retry. Политика зависит от идемпотентности команды, бюджета попыток, Retry-After и того, известен ли исход операции. Timeout особенно опасен: сервер мог принять команду, а ответ мог потеряться. В этом случае клиент не имеет права считать операцию не выполненной и безопасно повторять POST без договорённости о ключе идемпотентности.

\n

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

\n

Ниже — ограниченный учебный пример. Он не обращается к сети, не проверяет реальную схему и не объявляет production-операцию успешной. Его задача — показать место, где transport result превращается в решение UI. В настоящем клиенте список допустимых статусов, problem types и действий должен следовать конкретному контракту API.

\n
type UiDecision =\n  | { kind: 'render'; model: ScreenModel }\n  | { kind: 'read-again'; problemType: string }\n  | { kind: 'field-error'; fields: Record<string, string> }\n  | { kind: 'retry-later'; retryAfterSeconds?: number }\n  | { kind: 'unknown'; reason: string };\n\nfunction decide(response: {\n  status: number;\n  body: unknown;\n  retryAfter?: number;\n}): UiDecision {\n  if (response.status === 204) {\n    return { kind: 'read-again', problemType: 'no-representation' };\n  }\n\n  if (response.status === 409 && isProblem(response.body)) {\n    return { kind: 'read-again', problemType: response.body.type };\n  }\n\n  if (response.status === 422 && isValidationError(response.body)) {\n    return { kind: 'field-error', fields: response.body.fields };\n  }\n\n  if (response.status === 429 || response.status >= 500) {\n    return { kind: 'retry-later', retryAfterSeconds: response.retryAfter };\n  }\n\n  if (response.status === 200 && isScreenModel(response.body)) {\n    return { kind: 'render', model: response.body };\n  }\n\n  return { kind: 'unknown', reason: 'response-does-not-match-contract' };\n}
\n

Важен отрицательный путь в конце. Неизвестный статус или форма тела не должны попадать в ветку render по умолчанию. Сгенерированный TypeScript-тип тоже не даёт такой гарантии: он описывает ожидаемый payload, но не проверяет фактический JSON во время выполнения. Runtime-проверка должна отделять корректную модель от данных, которые нельзя безопасно показать.

\n

Query, command и ошибка на одном сценарии

\n

Представим экран редактирования адреса. Query GET /orders/42 возвращает модель заказа и разрешённые действия. Пользователь отправляет command POST /orders/42/address. Если сервер возвращает 200 с новой моделью, адаптер может передать её в store. Если сервер возвращает 202, store получает состояние «операция принята» и идентификатор отслеживания. Если сервер возвращает 204, клиент перечитывает заказ по правилу, которое явно указано контрактом.

\n

Если версия заказа устарела, API возвращает 409 с problem type order.version-conflict. UI показывает, что данные изменились, и предлагает перечитать их. Если индекс адреса неверен, API возвращает 422 и привязку ошибки к полю. Если ограничение частоты сработало, UI не очищает форму и не создаёт вторую команду: он показывает ограниченное сообщение и ждёт разрешённый момент повтора. Во всех трёх случаях компонент не придумывает доменное состояние из одного числа.

\n

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

\n
Диагностика границы между UI и API
СимптомПричинаПроверкаДействие
После 2xx экран показывает новое состояние, но после reload данные старыеCommand принята или завершилась без representationСверить статус, тело и момент повторного чтенияРазделить подтверждение команды и query
409 показывает общий сетевой toastВсе 4xx сведены к одной веткеПроверить problem type и текущую версию ресурсаПредложить перечитать или разрешить конфликт
422 не подсвечивает полеАдаптер читает только HTTP statusПроверить структуру ошибок и указатель поляПреобразовать код поля в модель формы
После 429 запросы идут без остановкиRetry стал общей реакцией на отказПосчитать попытки и проверить Retry-AfterВвести бюджет повторов и паузу
После timeout пользователь повторяет команду вручнуюНеизвестный исход назван отрицательнымПроверить idempotency key и способ узнать результатПеречитать операцию или показать безопасное ожидание
Неизвестный JSON отображается как готовый экранВалидация формы отсутствует или стоит после renderПроверить runtime schema до записи в storeОстановить render и передать техническую ошибку
\n

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

\n
  1. Опишите наблюдаемый симптом и цену ошибочного перехода.
  2. Назовите операцию: query, command или получение результата фоновой операции.
  3. Зафиксируйте метод, маршрут, статус, Content-Type и безопасный request id.
  4. Определите, есть ли в ответе representation, problem detail или только подтверждение приёма.
  5. Опишите переходы для 2xx, 204, 409, 422, 429, 5xx и неизвестного ответа.
  6. Проверьте тело до того, как записывать его в screen state.
  7. Для command отдельно проверьте повтор после timeout и правило идемпотентности.
  8. Для 202 и 204 укажите, как клиент узнает актуальную модель.
  9. Добавьте тест на отрицательный путь: неизвестный статус, неверную форму или отсутствующий обязательный тип.
  10. Сопоставьте UI-действие с одним машинным кодом, а не со свободной строкой сообщения.
\n

Ограничения

\n

Эта схема не назначает единственный статус для каждой бизнес-операции. Один API может использовать 409 для конфликта версии, другой — отдельный прикладной код внутри 409. Решение должно быть закреплено в контракте и одинаково понято всеми клиентами. Problem Details задаёт форму переносимого описания ошибки, но не выбирает локализацию, право доступа, retry policy или безопасное содержание полей.

\n

Граница также не решает проблему stale data сама по себе. Кэш, очередь, реплика и фоновая обработка требуют своих версий и сигналов. Если command запускает асинхронную работу, одной HTTP-карточки мало: нужны идентификатор операции, статус её обработки и путь к итоговой representation. Не маскируйте очередь под мгновенный 200.

\n

Не всякое различие нужно превращать в новый тип. Для простого чтения достаточно строгой схемы и понятного error path. Но если UI должен показать разные действия, контракт обязан назвать эти действия или стабильные коды, а не заставлять клиента разбирать английский текст detail. Учебный классификатор выше не заменяет security review, нагрузочное испытание и проверку реальной реализации.

\n

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

\n

Граница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, а неизвестный ответ останавливает render. Для command после timeout должен существовать отдельный путь узнать фактический результат.

\n

Если команда не может ответить на эти вопросы по контракту и тесту, исправление не завершено. Нельзя закрывать пробел общим toast или локальным флагом isSuccess. Готовность — это совпадение HTTP-семантики, проверенной модели и следующего действия пользователя. Только после этого компонент получает право менять экран.

\n

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

" + "excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 202, 204, 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.", + "contentHtml": "

Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красное сообщение «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.

\n

Причина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение представления ресурса, отправку команды и объяснение отказа. Надёжная граница оставляет право менять модель экрана только за проверенным payload. Query читает данные. Command просит изменить состояние. Ответ об ошибке сообщает, почему переход не состоялся и какой следующий шаг допустим. Статус помогает выбрать ветку, но не заменяет проверку тела и контракта.

\n

Главный вопрос: что именно подтверждено

\n

До написания обработчика нужно закончить фразу «после этого ответа сервер подтвердил…». Для GET это может быть представление заказа на момент чтения. Для POST — факт принятия команды, созданный ресурс или новое представление. Для PUT — результат замены при соблюдении условий. Для фоновой операции — только постановка в обработку. Если команда не вернула представление, локальный флаг isSuccess не становится новой доменной моделью.

\n

У ответа есть как минимум четыре независимые части: HTTP-статус, заголовки, тип содержимого и тело. Успешный статус не гарантирует, что тело подходит экрану. Отсутствие тела не говорит, нужно ли очищать форму, показывать старую модель или делать query заново. Error response тоже не следует превращать в произвольную строку: UI должен получить стабильный код и локализовать его в своём слое.

\n
Схема разделяет query и command и показывает, как статус, заголовки и тело ответа разрешают или запрещают переход UI
Схема показывает границу ответственности: транспортный ответ сначала проверяет адаптер, затем UI выбирает разрешённое действие. Сам рисунок не описывает конкретный API и не заменяет его контракт.
\n

Что означает каждый ответ

\n

200 OK. В ответе обычно есть представление, но клиент всё равно проверяет Content-Type и форму JSON. Только после runtime-проверки данные можно записать в store. Если API договорилось о пустом 200, это должно быть явно описано; иначе пустое тело — не повод угадывать состояние.

\n

202 Accepted. Запрос принят для обработки, но обработка ещё не завершена. Даже успешный 202 не означает, что заказ уже изменён и его можно рисовать как изменённый. Контракт должен дать идентификатор операции, ссылку на её статус или правило повторного чтения. Без этого UI показывает «запрос принят», а не «результат готов».

\n

204 No Content. Запрос успешно выполнен, но в ответе нет дополнительного содержимого. JSON-парсер здесь запускать нельзя. После 204 приложение может оставить известную модель, инвалидировать её или выполнить query — выбор зависит от операции и должен быть записан в контракте. HTTP сам по себе не выбирает поведение экрана.

\n

409 Conflict. Запрос не завершён из-за конфликта с текущим состоянием ресурса. Типичный случай — клиент отправил старую версию заказа после того, как его изменил другой участник. Безопасное действие — перечитать ресурс и дать пользователю осознанно выбрать дальнейший шаг. Автоматическая повторная отправка того же payload сохраняет конфликт и может скрыть чужое изменение.

\n

422 Unprocessable Content. Сервер понял тип содержимого и синтаксис запроса, но не смог выполнить содержащуюся инструкцию. Для формы это обычно прикладная ошибка: значение допустимо по JSON-схеме, но нарушает правило предметной области. Ошибка должна попасть в модель полей или в понятный код правила, а не в общий сетевой toast.

\n

429 Too Many Requests. Сервер ограничил частоту запросов. Ответ может содержать Retry-After, причём это либо количество секунд, либо HTTP-дата. Клиент не должен запускать бесконечный retry: он ограничивает число попыток, учитывает паузу и сохраняет введённые данные. Для команды с неизвестным исходом повтор разрешён только при отдельном договоре об идемпотентности.

\n

5xx. Ошибка сервера или посредника не доказывает, что команда не была выполнена. После timeout, 502 или 504 результат POST может быть неизвестен. Повтор возможен для операции, которая идемпотентна по контракту и имеет бюджет попыток. 501 или 505 не следует автоматически считать временным сбоем. В сомнении UI показывает ожидание или предлагает узнать результат, а не создаёт вторую команду.

\n

Контракт должен описывать переход, а не только статус

\n

Удобный контракт для каждой операции отвечает на пять вопросов: какой метод и ресурс участвуют, какой payload считается валидным, какая модель приходит при каждом поддержанном успехе, какие коды ошибки может обработать UI и как узнать итог после потери ответа. В OpenAPI это выражается операцией с перечисленными responses, схемами содержимого и описанием заголовков. Документ не делает runtime-проверку, но не оставляет варианты ответа невидимыми для команды.

\n

Для редактирования заказа полезно отделить версию от полей формы. Query возвращает order и version. Command отправляет изменённые поля вместе с условием, например If-Match или числовой версией. Backend сравнивает условие с текущим ресурсом. Если оно устарело, он возвращает конфликт; frontend не затирает store ответом, который относится к старой версии.

\n

Problem Details даёт переносимую форму ошибки: type, title, status, detail и расширения. Главным идентификатором проблемы служит type, а status внутри JSON носит справочный характер и не должен переопределять реальный HTTP-статус. Поле detail предназначено для объяснения конкретного случая; его нельзя без фильтра показывать пользователю и нельзя использовать как стабильный ключ локализации.

\n

Воспроизводимый адаптер ответа

\n

Ниже — небольшой TypeScript-подобный адаптер без сети. В нём специально оставлены функции isOrder, isProblem и isValidationProblem: их нужно реализовать схемой конкретного API. Пример проверяет тип содержимого, различает принятую и завершённую команду и закрывает неизвестную ветку. Он не объявляет операцию успешной по одному числу.

\n
type Decision =\n  | { kind: 'render'; order: Order }\n  | { kind: 'accepted'; operationId: string }\n  | { kind: 'read-again'; reason: string }\n  | { kind: 'field-error'; fields: Record<string, string> }\n  | { kind: 'retry-later'; waitSeconds?: number }\n  | { kind: 'unknown'; reason: string };\n\nfunction retryDelay(value: string | null): number | undefined {\n  if (!value) return undefined;\n  if (/^\\d+$/.test(value)) return Number(value);\n  const timestamp = Date.parse(value);\n  return Number.isNaN(timestamp)\n    ? undefined\n    : Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));\n}\n\nasync function decide(response: Response): Promise<Decision> {\n  const type = response.headers.get('content-type') ?? '';\n  const body = response.status === 204 ? null : await response.json();\n\n  if (response.status === 200 && type.includes('application/json')\n      && isOrder(body)) {\n    return { kind: 'render', order: body };\n  }\n\n  if (response.status === 202 && isAccepted(body)) {\n    return { kind: 'accepted', operationId: body.operationId };\n  }\n\n  if (response.status === 204) {\n    return { kind: 'read-again', reason: 'no-representation' };\n  }\n\n  if (response.status === 409 && isProblem(body)) {\n    return { kind: 'read-again', reason: body.type };\n  }\n\n  if (response.status === 422 && isValidationProblem(body)) {\n    return { kind: 'field-error', fields: body.fields };\n  }\n\n  if (response.status === 429 || [502, 503, 504].includes(response.status)) {\n    return {\n      kind: 'retry-later',\n      waitSeconds: retryDelay(response.headers.get('retry-after')),\n    };\n  }\n\n  return { kind: 'unknown', reason: 'response-does-not-match-contract' };\n}
\n

В настоящем коде чтение тела тоже должно учитывать невалидный JSON: ошибка парсинга — отдельная техническая ветка, а не повод отдать исключение в общий рендер. Аналогично, application/problem+json нужно проверять перед разбором Problem Details. Если сервер прислал HTML от прокси вместо ожидаемого JSON, адаптер сохраняет техническое событие с request id и не записывает ответ в доменную модель.

\n

Один сценарий от клика до результата

\n

Возьмём экран редактирования адреса заказа. При открытии query GET /orders/42 возвращает адрес, доступные действия и версию ресурса. Store помечает модель как полученную в момент t0. Пользователь меняет индекс, а на сервере другой клиент сохраняет новый адрес. Наша форма всё ещё содержит старую версию.

\n

Команда POST /orders/42/address отправляет поля и условие версии. Backend возвращает 409 с типом https://api.example.test/problems/order-version-conflict. UI сохраняет введённое значение, перечитывает заказ и показывает различие между серверной и локальной версиями. Он не заменяет экран молча и не повторяет POST с теми же данными.

\n

Теперь сервер отвечает 202. UI показывает «изменение принято», блокирует повторную отправку и хранит operationId. Отдельный query статуса возвращает pending, затем completed с ссылкой на актуальный заказ или failed с Problem Details. Только после валидного представления заказа store переходит в состояние «готово». Если статус операции не описан контрактом, клиент не может безопасно изобрести его.

\n

Наконец, сеть обрывается после отправки POST. Это не то же самое, что 4xx: сервер мог сохранить изменение. Кнопка получает состояние «результат неизвестен», а клиент использует idempotency key или запрос статуса, если такой механизм предусмотрен. Если механизма нет, пользователю предлагают проверить заказ и принять осознанное решение о повторе. Это медленнее мгновенного retry, но не создаёт дубликат автоматически.

\n

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

\n
Как найти ошибочную границу между UI и API
СимптомПричинаПроверкаДействие
После 2xx экран новый, после reload данные старыеПринятие команды перепутано с готовой модельюСверить статус, тело и момент queryРазвести accepted и render
После 202 сразу показывается «готово»Асинхронная обработка скрыта от UIНайти operation id и финальный статусПоказать pending и путь к результату
После 204 падает JSON-парсерОдин обработчик читает тело для всех ответовПроверить ветку до чтения bodyИнвалидировать или перечитать по контракту
409 превращается в сетевое сообщениеВсе 4xx сведены к одной веткеПроверить версию ресурса и problem typeПеречитать и разрешить конфликт
422 не подсвечивает полеАдаптер читает только HTTP statusПроверить указатели полей и прикладной кодСобрать модель ошибок формы
После 429 идут запросы без остановкиRetry стал реакцией на любой отказПосчитать попытки и разобрать Retry-AfterВвести бюджет, паузу и отмену
После timeout пользователь создаёт второй заказНеизвестный исход назван отрицательнымПроверить idempotency key или status endpointСначала узнать результат
Незнакомый JSON рисуется как экранRuntime-проверка отсутствуетПроверить схему до записи в storeОстановить render и записать событие
\n

Порядок проверки в проекте

\n
  1. Опишите наблюдаемый симптом и цену ошибочного перехода: дубликат, потеря ввода, устаревший экран или неверное сообщение.
  2. Назовите операцию: query, command или получение результата фоновой операции.
  3. Зафиксируйте метод, маршрут, статус, Content-Type, безопасный request id и версию ресурса.
  4. Для каждого успеха укажите, что подтверждено: представление, создание, принятие в очередь или отсутствие содержимого.
  5. Опишите схему тела до и после выполнения: модель, accepted-ответ и Problem Details.
  6. Составьте таблицу для 200, 202, 204, 409, 422, 429, выбранных 5xx и неизвестного ответа.
  7. Проверьте body до записи в screen state; отдельно протестируйте неверный JSON и неожиданный Content-Type.
  8. Для конфликта проверьте условие версии: ETag/If-Match либо поле версии в payload.
  9. Для timeout опишите, как узнать результат и почему повтор безопасен или запрещён.
  10. Ограничьте retry бюджетом и паузой; обработайте отмену, закрытие вкладки и повторный клик.
  11. Сопоставьте UI-действие со стабильным машинным кодом, а не со свободной строкой detail.
  12. Добавьте контрактные и интеграционные тесты на каждый поддержанный переход, включая отрицательный.
\n

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

\n

Эта схема не назначает один обязательный статус каждой бизнес-операции. Один API использует 409 для конфликта версии, другой — 412 при нарушении If-Match; оба варианта требуют точной документации. 422 не является универсальной ошибкой валидации, а 429 не говорит, каким именно способом сервер считает лимит. Это решения конкретного API, а не вывод из названия статуса.

\n

HTTP не стандартизирует общий заголовок idempotency key. Если он нужен для POST, его формат, время хранения ключа, область уникальности и ответ при повторе должны быть частью прикладного контракта. Нельзя обещать безопасность повтора только потому, что клиент передал похожий заголовок. Для финансовой, складской или иной необратимой операции это проверяется тестом на потерю ответа и повтор.

\n

Проблема stale data также не исчезает от одного runtime-валидатора. Кэш, реплика, очередь и несколько вкладок требуют своих версий и правил согласования. ETag защищает только тот ресурс и тот сценарий, для которых сервер его проверяет. Если команда меняет несколько ресурсов атомарно, одной версии заказа может быть недостаточно.

\n

Наконец, UI не должен показывать пользователю внутренний URL типа ошибки, стек, request id вместо объяснения или текст от прокси. Стабильный код помогает маршрутизации, а локализованный текст и допустимое действие выбирает клиент. Диагностические данные остаются в защищённом журнале с нужными ограничениями доступа.

\n

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

\n

Граница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 202 остаётся accepted, 204 не читает JSON, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, 429 учитывает ограничение, а неизвестный ответ останавливает render.

\n

Для команды после timeout должен существовать отдельный путь узнать фактический результат. Если такого пути нет, состояние «неизвестно» должно быть видимым и обратимым: пользователь может проверить ресурс, отменить повтор или обратиться к оператору. Компонент получает право менять экран не тогда, когда promise завершился без исключения, а когда transport result прошёл проверку и соответствует договорённому переходу.

\n

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

" } diff --git a/editorial/agent-rewrites/048.json b/editorial/agent-rewrites/048.json index 4c8534c..7109514 100644 --- a/editorial/agent-rewrites/048.json +++ b/editorial/agent-rewrites/048.json @@ -2,6 +2,6 @@ "index": 48, "slug": "editorial-2026-09-practice-frontend-backend-boundary", "title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API", - "excerpt": "Клиент может получить успешный HTTP-ответ и всё равно показать неверное состояние. Разбираем порядок проверок, runtime-контракт и отрицательные пути для screen model.", - "contentHtml": "

Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.

\n

Цена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии.

\n

Тезис статьи прост: статус HTTP сообщает результат обмена, но не доказывает, что тело подходит конкретному экрану. Клиент должен проверить HTTP, определить наличие representation, разобрать JSON, проверить минимальную screen model и только потом передать данные компоненту. Для каждого шага нужен отрицательный путь.

\n

Что именно считается успехом

\n

У ответа есть несколько уровней смысла. Код 200 сообщает, что сервер обработал запрос успешно на уровне операции. Заголовок Content-Type заявляет формат тела. JSON parser проверяет синтаксис. Runtime-валидатор проверяет поля, которые нужны экрану. Ни один предыдущий шаг не заменяет следующий.

\n

Тело {\"status\":\"done\",\"allowedActions\":\"edit\"} может быть корректным JSON. Но экран, который знает только ready, pending и blocked, не может безопасно выбрать состояние для done. Строка вместо массива также не становится моделью от того, что в ней записано знакомое слово.

\n

Граница принадлежит адаптеру данных, а не JSX-компоненту. Компонент получает проверенную модель или явный результат ошибки. Он не должен угадывать неизвестный статус, подставлять пустой массив и считать это подтверждённым состоянием.

\n
\"Поток
Проверки идут от транспорта к экрану. Ошибка на любом шаге останавливает передачу данных в render state.
\n

Минимальная модель экрана

\n

Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решение. Для экрана заказа это состояние, набор известных действий и код сообщения. У каждого поля есть тип, допустимые значения и действие при нарушении.

\n
Минимальная screen model для состояния заказа
ПолеДопустимое значениеРешение UIОтрицательный путь
statusready | pending | blockedвыбрать состояние экранаостановить render
allowedActionsмассив известных командпоказать разрешённые кнопкине создавать кнопку
messageCodeнепустая строкавыбрать локализованный текстпоказать безопасную ошибку
HTTP statusстатус операцииотделить успех от отказане строить модель только по числу
\n

Словарь статусов должен быть закрытым, пока команда не описала новый переход. Это не запрет на развитие API. Новый статус должен менять контракт, обработчик и тест. Молчаливый fallback скрывает изменение API и превращает его в случайный UI-дефект.

\n

Рабочий пример: проверяем ответ до render

\n

Ниже учебный пример на JavaScript. Списки статусов и действий выбраны для демонстрации. Они не описывают production API и не обещают production-результат. В реальном проекте их заменяет контракт конкретного endpoint.

\n
const statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\nfunction validateScreenModel(value) {\n  const errors = [];\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return { valid: false, errors: ['body-must-be-object'] };\n  if (!statuses.has(value.status)) errors.push('status-is-unknown');\n  if (!Array.isArray(value.allowedActions)) errors.push('allowedActions-must-be-array');\n  else if (value.allowedActions.some((item) => !actions.has(item))) errors.push('allowedActions-contains-unknown-action');\n  if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) errors.push('messageCode-must-be-non-empty');\n  return { valid: errors.length === 0, errors };\n}\nasync function readScreen(response) {\n  if (response.status === 204) return { kind: 'empty-success', next: 'refetch-screen' };\n  if (!response.ok) return { kind: 'http-error', status: response.status };\n  if (!response.headers.get('content-type')?.includes('application/json')) return { kind: 'wrong-media-type' };\n  let body;\n  try { body = await response.json(); } catch { return { kind: 'invalid-json' }; }\n  const result = validateScreenModel(body);\n  return result.valid ? { kind: 'screen-model-ready', model: body } : { kind: 'invalid-screen-model', errors: result.errors };\n}
\n

Функция возвращает классификацию, а не случайную строку из parser. Такой результат связывается с error boundary, логом и безопасным состоянием компонента. В технический канал передавайте код нарушения и request id. Не отправляйте весь payload: он может содержать персональные или доменные данные.

\n

Порядок проверок важен. Для 204 нельзя вызывать response.json(): успешное выполнение не означает наличие representation. Для неверного Content-Type повтор запроса обычно не исправит формат. Для невалидной модели retry тоже не является решением: сервер может стабильно возвращать тот же payload.

\n

Симптомы и действия на границе

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Экран показал «Готово», следующий клик падает200 приняли за модельсравнить тело с runtime-схемойзапретить запись в state до валидации
DELETE вызывает ошибку parserhelper ждёт JSON после 204проверить статус до json()вернуть empty-success и перечитать ресурс
Появилась кнопка неизвестной командыUI доверяет строке actionпроверить каждый элемент словарёмотклонить модель и не создавать кнопку
422 превратился в общий toastприкладной отказ назвали сетьюпрочитать machine code и полеподсветить поле, если контракт это разрешает
После timeout команда выполнилась дваждыклиент повторил запрос вслепуюпроверить idempotency key и статус операциине повторять; перечитать результат
Типы проходят, внешний сервис прислал другой JSONтип сборки не проверяет сетьвоспроизвести фактический ответдобавить contract или runtime test
\n

Query, command и пустой результат

\n

Чтение и изменение состояния требуют разных правил. Query получает representation и обновляет экран после проверки. Command просит сервер изменить состояние. Ответ 202 означает принятие асинхронной работы, а не завершённый переход. Ответ 204 означает успешную операцию без тела. В обоих случаях клиенту нужен путь к актуальной модели.

\n

Не превращайте ответ команды в локальный флаг isSuccess. Если запрос оборвался после отправки, клиент не знает, успел ли сервер изменить ресурс. Повтор POST может создать вторую операцию. Безопасный вариант зависит от API: idempotency key, endpoint статуса или повторное чтение. Если контракт ничего не даёт, UI не может честно обещать результат после timeout.

\n

Ошибки 409, 422, 429 и 5xx нельзя свести к одному toast. 409 может требовать перечитать конфликтующий ресурс. 422 может вернуть ошибки полей. 429 может содержать ограничение частоты и время следующей попытки. 5xx допускает повтор только при известной идемпотентности и ограниченном бюджете. Код статуса сужает выбор, но не заменяет error detail и правило следующего действия.

\n

Как внедрить границу

\n
  1. Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.
  2. Зафиксируйте intent: query, command или получение результата фоновой операции.
  3. Выпишите минимальную screen model и закройте словари статусов, действий и кодов сообщений.
  4. Разделите обработку HTTP, media type, JSON parse и runtime validation. Для 204 задайте результат без тела.
  5. Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx и timeout.
  6. Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело и неверный media type.
  7. Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.
  8. Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.
\n

Ограничения

\n

Runtime-валидатор проверяет форму данных, но не доказывает бизнес-истину. status: ready может быть формально корректным и устареть через секунду. Для этого нужны версия ресурса, контроль конкуренции, кэш-политика или повторное чтение. Валидация не заменяет authorization: наличие действия в JSON не выдаёт право на серверную операцию.

\n

OpenAPI и сгенерированные TypeScript-типы описывают договорённость, но тип на этапе сборки не проверяет байты из сети. При независимом выпуске сервисов оставьте тест фактического ответа или runtime-схему. Ручная функция подходит для маленькой модели. Для сложных вложенных структур используйте schema validator и измерьте стоимость на реальном размере payload.

\n

Учебный пример не описывает конкретную бизнес-модель. Он показывает место проверки и решения, которые нужно подтвердить контрактом проекта. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект бесконечно. Назовите отдельные read model или версионируйте ответ.

\n

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

\n

Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.

\n

Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы или строит кнопку из свободной строки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки.

\n

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

" + "excerpt": "Успешный HTTP-ответ ещё не означает, что тело подходит экрану. Разбираем четыре проверки, отрицательные пути и безопасное поведение для 200, 202, 204 и ошибок API.", + "contentHtml": "

Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.

\n

Цена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии и будет выглядеть как случайный дефект кнопки.

\n

Главный вопрос — не «успешен ли запрос», а «можно ли этот ответ безопасно превратить в модель конкретного экрана». Код статуса, заголовок формата, синтаксис JSON и поля модели отвечают на разные вопросы. Клиент должен пройти их последовательно и иметь явный отрицательный путь для каждого шага.

\n

Ответ и модель — разные утверждения

\n

HTTP 200 сообщает об успешной обработке запроса. Он не знает, какое состояние должен показать конкретный компонент и какие действия разрешены бизнес-правилом. Даже если в ответе есть JSON, это только синтаксически разобранное тело. JSON с полем {\"status\":\"done\",\"allowedActions\":\"edit\"} корректен как JSON, но может быть непригоден для экрана, который понимает только ready, pending и blocked.

\n

Успешный транспортный ответ и пригодная screen model — разные утверждения. Первое проверяется статусом и доступностью ответа. Второе требует знания контракта потребителя: обязательных полей, типов, закрытых словарей и правила для неизвестного значения. Поэтому типы TypeScript, скомпилированные вместе с клиентом, не заменяют проверку данных, пришедших по сети.

\n

Граница проходит в адаптере данных. Компонент получает проверенную модель или состояние ошибки с понятным кодом. Он не угадывает неизвестный статус, не подставляет пустой массив вместо сломанного allowedActions и не объявляет операцию завершённой только потому, что response.ok вернул true.

\n
\"Поток
Транспортный успех проходит несколько независимых проверок. Только после проверки модели данные становятся входом для рендера.
\n

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

\n

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

\n
Уровни границы ответа
УровеньВопросПроверкаОтрицательный путь
ТранспортЗапрос принят сервером?status, response.okклассифицировать HTTP-ошибку
Наличие телаЕсть ли representation?204, 202 и заголовки ответаперечитать ресурс или ждать статус
ФорматКак читать тело?Content-Typeне запускать JSON parser вслепую
МодельПодходит ли тело экрану?типы, обязательные поля и словарине передавать payload в render state
\n

Для 204 тело отсутствует по семантике HTTP. Вызов response.json() в таком пути не доказывает успех: он пытается разобрать пустой поток и обычно заканчивается ошибкой разбора. Ветку 204 нужно обработать раньше parser и вернуть результат вроде empty-success с действием refetch-screen, если экрану требуется актуальное состояние.

\n

Заголовок Content-Type: application/json тоже не доказывает форму данных. Он говорит, как интерпретировать representation, но не гарантирует поля status или allowedActions. Для ошибки действует отдельный media type application/problem+json: его стоит разбирать как problem details, а не пытаться превращать в обычную модель экрана.

\n

Минимальная модель экрана

\n

Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решения, и отдельно зафиксируйте, что делать при нарушении. Ниже — учебный контракт экрана заказа; названия статусов и команд проектные, их нельзя переносить в API без согласования.

\n
Минимальная screen model для состояния заказа
ПолеДопустимое значениеРешение UIЧто проверяет тест
statusready | pending | blockedвыбрать состояние экрананеизвестное значение отклоняется
allowedActionsмассив известных командпоказать разрешённые кнопкистрока и неизвестная команда отклоняются
messageCodeнепустой код сообщениявыбрать локализованный текстпустое или нестроковое поле отклоняется
revisionнеотрицательное целоесравнить версию ресурсаустаревшее состояние не затирает новое
\n

Закрытый словарь статусов — это защита от тихого изменения API, а не запрет на эволюцию. Когда сервер вводит новый переход, меняются контракт, адаптер, визуальное состояние и тест. До этого момента безопаснее показать нейтральную ошибку и записать код нарушения, чем выбрать знакомый fallback.

\n

Авторизация остаётся на сервере. Наличие edit в JSON может управлять видимостью кнопки, но не выдаёт право на операцию. Перед командой сервер заново проверяет права, актуальность версии и допустимость перехода. Клиентская screen model — описание отображения, не источник истины для доступа.

\n

Воспроизводимый пример на JavaScript

\n

Следующий пример намеренно мал. Он показывает место границы, а не библиотеку валидации. Множества статусов и действий выбраны для демонстрации; в рабочем проекте их заменяют сгенерированный контракт, схема или ручной адаптер с такими же отрицательными путями.

\n
const statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\n\nfunction validateScreenModel(value) {\n  const errors = [];\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return { valid: false, errors: ['body-must-be-object'] };\n  }\n  if (!statuses.has(value.status)) errors.push('status-is-unknown');\n  if (!Array.isArray(value.allowedActions)) {\n    errors.push('allowedActions-must-be-array');\n  } else if (value.allowedActions.some((item) => !actions.has(item))) {\n    errors.push('allowedActions-contains-unknown-action');\n  }\n  if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) {\n    errors.push('messageCode-must-be-non-empty');\n  }\n  if (!Number.isInteger(value.revision) || value.revision < 0) {\n    errors.push('revision-must-be-non-negative-integer');\n  }\n  return { valid: errors.length === 0, errors };\n}\n\nasync function readProblem(response) {\n  const type = response.headers.get('content-type') || '';\n  if (!type.includes('application/problem+json')) return null;\n  try {\n    const value = await response.json();\n    return value && typeof value === 'object'\n      ? { type: value.type, title: value.title, detail: value.detail }\n      : null;\n  } catch {\n    return null;\n  }\n}\n\nasync function readScreen(response) {\n  if (!response.ok) {\n    return { kind: 'http-error', status: response.status, problem: await readProblem(response) };\n  }\n  if (response.status === 204) {\n    return { kind: 'empty-success', next: 'refetch-screen' };\n  }\n  if (response.status === 202) {\n    return {\n      kind: 'accepted',\n      next: response.headers.get('location') ? 'poll-location' : 'query-status',\n    };\n  }\n  const mediaType = response.headers.get('content-type') || '';\n  if (!mediaType.includes('application/json')) return { kind: 'wrong-media-type' };\n  let body;\n  try {\n    body = await response.json();\n  } catch {\n    return { kind: 'invalid-json' };\n  }\n  const result = validateScreenModel(body);\n  return result.valid\n    ? { kind: 'screen-model-ready', model: body }\n    : { kind: 'invalid-screen-model', errors: result.errors };\n}
\n

Сначала проверяется response.ok, то есть диапазон успешных 2xx-ответов в Fetch API. Ошибка не смешивается с невалидной моделью: у неё свой kind и, если сервер прислал problem details, короткая диагностическая часть. Затем отдельно обрабатываются 204 и 202. В первом случае нет тела, во втором работа принята, но ещё не завершена.

\n

Только обычный успешный ответ с ожидаемым media type доходит до response.json(). После parser запускается проверка полей. Компонент может отобразить screen-model-ready, а для других результатов выбрать безопасный экран, обновить ресурс или показать прикладную ошибку. Полный payload в логи отправлять не следует: он может содержать персональные и доменные данные.

\n

Проверить функцию можно без браузера, подставив небольшие моки Response или объект с теми же свойствами. Набор тестов должен включать валидный JSON, неизвестный статус, строку вместо массива, пустое тело 204, 202 с Location, неверный media type и 422 с application/problem+json. Ожидаемый результат — конкретный kind, а не случайное исключение parser.

\n

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

\n
Диагностика рассогласования UI и API
СимптомВероятная причинаПроверкаДействие
Экран показал «Готово», следующий клик падает200 приняли за модельсравнить тело с runtime-схемойзапретить запись в state до валидации
DELETE вызывает ошибку parserhelper ждёт JSON после 204проверить статус до json()вернуть empty-success и перечитать ресурс
Появилась кнопка неизвестной командыUI доверяет свободной строкепроверить каждый элемент словарёмотклонить модель и не создавать кнопку
422 превратился в общий toastприкладной отказ назвали сетьюпроверить media type и поля problem detailsсопоставить machine-readable ошибку с полем
После timeout команда выполнилась дваждыклиент повторил запрос вслепуюпроверить идемпотентность и статус операциине повторять без гарантии; перечитать результат
Типы проходят, внешний сервис прислал другой JSONтип сборки не проверяет сетьсохранить фактический ответ и request idдобавить contract или runtime test
\n

Таблица задаёт порядок разговора на инциденте. Сначала фиксируется наблюдаемый ответ, затем проверяется слой, на котором возникло расхождение. Не стоит начинать с переписывания JSX: пока неизвестно, нарушены транспорт, формат или форма данных, изменение компонента может только спрятать причину.

\n

202, 204 и обрыв после команды

\n

Query и command нельзя обрабатывать одинаково. Query получает representation и после проверки обновляет экран. Command просит сервер изменить состояние. Ответ 202 означает, что запрос принят в обработку, но она не завершена; клиенту нужен адрес проверки, идентификатор операции или другой явно описанный способ узнать результат. Не следует показывать финальное «Готово» на основании одного 202.

\n

Ответ 204 означает успешное выполнение без дополнительного содержимого. После него экран обычно перечитывает ресурс, обновляет локальную модель по известному результату команды или закрывает экран — выбор зависит от контракта. Ни один из вариантов нельзя вывести из кода 204 без знания того, какая модель считается актуальной.

\n

Timeout после отправки команды — неопределённый результат, а не доказательство неуспеха. Сервер мог применить операцию, пока клиент ждал. Для повторяемого запроса нужен idempotency key, идемпотентная семантика метода или способ проверить состояние. Для POST без такой защиты слепой retry может создать вторую операцию. Если API не предоставляет проверку результата, UI должен честно показать неопределённость и дать безопасный следующий шаг.

\n

Ошибки API и повтор запросов

\n

Статусы 409, 422, 429 и 5xx сужают выбор, но не заменяют тело ошибки. 409 часто требует перечитать конфликтующий ресурс. 422 может содержать ошибки полей. Для 429 нужно учитывать лимит и, если он прислан, Retry-After. 5xx допускает повтор только при известной идемпотентности, ограниченном числе попыток и контроле нагрузки.

\n

Problem details позволяют передать машинный тип проблемы, заголовок, подробность и расширения вроде указателя на поле. Клиент не обязан показывать пользователю сырые detail или instance: адаптер выбирает локализованный текст по безопасному коду, а диагностические поля отправляет в защищённый канал с ограниченным составом данных.

\n

Кэш и конкуренция добавляют ещё один слой. Формально валидный status: ready может устареть через секунду. Для изменения состояния нужны версия ресурса, ETag или другой механизм, предусмотренный API. Runtime-валидация отвечает на вопрос «форма подходит?», но не на вопросы «данные свежие?» и «операция разрешена?».

\n

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

\n
  1. Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.
  2. Зафиксируйте intent: query, command или получение результата фоновой операции.
  3. Выпишите минимальную screen model: обязательные поля, типы, закрытые словари и версию ресурса.
  4. Разделите обработку HTTP, отсутствия тела, media type, JSON parse и runtime validation.
  5. Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx, 202, 204 и timeout.
  6. Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело, неверный media type и problem details.
  7. Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.
  8. Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.
\n

Для воспроизводимости сохраните в тесте не только ожидаемый экран, но и входной HTTP-контекст: статус, media type, тело и способ обработки. Иначе тест может продолжать проверять старый мок с правильными типами, пока реальный сервис отдаёт другую representation.

\n

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

\n

Этот подход не заменяет серверный контракт, авторизацию, бизнес-валидацию и проверку свежести. Он защищает границу от того, чтобы случайный payload стал UI-состоянием. Для сложных вложенных моделей ручная функция быстро станет второй схемой; используйте согласованный schema validator и измерьте стоимость на реальном размере ответа.

\n

Проверка media type не гарантирует, что промежуточный proxy не изменил тело, а runtime-схема не гарантирует, что поле означает правильный бизнес-факт. Для независимых релизов нужны contract-тесты или тесты фактических ответов. Для асинхронных операций нужен API статуса, callback или другой документированный способ узнать завершение; один 202 этого не создаёт.

\n

Пример не описывает конкретную бизнес-модель и не даёт права копировать названия статусов. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект до бесконечности. Разделите read model, версионируйте ответ или добавьте адаптер на стороне потребителя.

\n

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

\n

Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Минимальный набор должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 202 не маскируется под завершение, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.

\n

Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы, строит кнопку из свободной строки или показывает общий toast вместо различимой прикладной ошибки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки и с понятным следующим шагом.

\n

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

" } diff --git a/editorial/agent-rewrites/049.json b/editorial/agent-rewrites/049.json index f1b849f..b3df061 100644 --- a/editorial/agent-rewrites/049.json +++ b/editorial/agent-rewrites/049.json @@ -1,7 +1,7 @@ { "index": 49, "slug": "editorial-2026-08-field-end-to-end-observability", - "title": "Сквозная наблюдаемость без ложной связи: как довести сигнал от UI до worker", - "excerpt": "E2E-тест падает, API пишет лог, worker считает задачу, но причина теряется между границами. Разбираем correlation context, роли trace, log и metric, отрицательный путь и критерий готовности.", - "contentHtml": "

E2E-тест сообщает об ошибке, но в панели нельзя быстро найти соответствующий backend-запрос. В логах есть время и название операции. Worker показывает метрику. UI показывает упавший шаг. Однако эти записи нельзя надёжно связать. Инженер тратит часы на ручное сравнение временных окон и похожих идентификаторов.

\n

Цена ошибки выше времени расследования. Команда может исправить worker, хотя проблема возникла при передаче контекста из API в очередь. Может включить повтор, хотя первая команда уже была принята. Может добавить в логи email, полный URL или сырой идентификатор пользователя. Тогда диагностика ускорится на один случай, но данные станут чувствительнее, а метрики — бесполезнее из-за высокой кардинальности.

\n

Тезис статьи простой: end-to-end наблюдаемость начинается с проверяемой связи между границами, а не с количества панелей. Сначала нужно назвать один путь, один технический context и вопрос для каждого сигнала. Затем нужно запретить поля, которые не нужны этому вопросу. Если связь или границу данных нельзя доказать, система должна вернуть точный stop, а не дорисовать причинную историю по похожему имени.

\n

Что именно связывает сквозной сигнал

\n

Рассмотрим путь ui.checkout.submit → API → worker. UI создаёт операцию и отправляет запрос. API принимает запрос и ставит работу в очередь. Worker получает сообщение и выполняет работу. Это три разные границы. Между ними передаётся не весь объект операции, а небольшой технический контекст, который позволяет понять: записи относятся к одному пути.

\n

Correlation context не отвечает на вопрос «какой пользователь это сделал». Он отвечает на вопрос «какие сигналы относятся к одной технической цепочке». Это различие важно для доступа и хранения. Идентификатор пользователя, email, текст формы и полный query string не становятся допустимыми только потому, что их удобно искать. Для расследования отдельной операции может потребоваться другой защищённый процесс. Его нельзя незаметно встроить в общий label или trace attribute.

\n

У trace, log и metric разные задачи. Span показывает последовательность и границы операции. Structured log объясняет решение в конкретной ветке: например, API принял задачу или отклонил её по известному классу. Metric агрегирует повторяющиеся события: число jobs по ограниченному job-kind и outcome-class. Один context может связать сигналы, но не превращает их в один и тот же тип данных.

\n
\"Цикл
Схема показывает порядок проверки. Сначала формулируется вопрос, затем проверяются связь и состав полей. Цикл заканчивается проверяемым hand-off или точным stop, а не выводом о production-системе.
\n

Механизм: один context, три семантики

\n

Начните с одного технического значения, например trace-7f в учебной модели. UI, API и worker должны явно показать это значение в своей записи. В настоящей системе формат и перенос определяет конкретный контракт, например W3C Trace Context. Важно не название стандарта, а инвариант: каждая граница либо несёт допустимый context, либо end-to-end вывод прекращается.

\n

API не должен искать «ближайший» trace по времени. Worker не должен присоединяться к trace только потому, что совпал job-kind. Такие эвристики создают убедительную, но недоказанную историю. При пропавшем или некорректном context нужно сохранить локальный сигнал и отдельно отметить, что сквозная связь не подтверждена.

\n

Асинхронная очередь добавляет смысловую границу. Принятие задачи и её выполнение не являются одной операцией по умолчанию. Для них нужно описать carrier, место извлечения, место вставки и поведение при ошибке. Нельзя считать, что SDK автоматически сохранит родительскую связь через любую очередь. Это должно следовать из контракта message boundary и проверки конкретной реализации.

\n

Время требует такой же аккуратности. UI waiting, время обработки API, задержка очереди и worker execution — разные интервалы. Их нельзя складывать без источника времени, правил для retry и определения начала и конца каждого участка. Один root span может скрыть задержку очереди. Три коротких span могут скрыть потерянную связь. Поэтому сначала фиксируют границы и допустимый вопрос, а измерение добавляют после этого.

\n

Учебный пример: проверка карты сигналов

\n

Ниже приведён ограниченный учебный пример. Он не обращается к браузеру, API, очереди или telemetry backend. Он не доказывает, что в production есть нужный context. Его задача — показать fail-closed правило: validator принимает только три named signals с одним context и отклоняет запрещённое поле.

\n
type Signal = {\n  component: 'ui' | 'api' | 'worker';\n  name: string;\n  context: string;\n  fields: string[];\n};\n\nfunction checkMap(signals: Signal[], forbidden: Set<string>) {\n  const contexts = new Set(signals.map((signal) => signal.context));\n  const forbiddenFields = signals\n    .flatMap((signal) => signal.fields)\n    .filter((field) => forbidden.has(field));\n\n  if (signals.length !== 3 || contexts.size !== 1 || signals.some((s) => !s.context)) {\n    return { status: 'stop-broken-correlation-context' };\n  }\n\n  if (forbiddenFields.length > 0) {\n    return {\n      status: 'stop-forbidden-signal-field',\n      fields: [...new Set(forbiddenFields)],\n    };\n  }\n\n  return { status: 'synthetic-map-ready-for-review' };\n}\n\nconst result = checkMap([\n  { component: 'ui', name: 'span: ui.checkout.submit', context: 'trace-7f', fields: ['route-template'] },\n  { component: 'api', name: 'log: api.accepted', context: 'trace-7f', fields: ['outcome-class'] },\n  { component: 'worker', name: 'metric: worker.jobs', context: 'trace-7f', fields: ['job-kind'] },\n], new Set(['email', 'raw-user-id', 'request-url-with-query']));\n\nconsole.log(result.status);\n// synthetic-map-ready-for-review
\n

Этот код проверяет структуру входного объекта в памяти. Он не создаёт trace header и не отправляет данные. Если у worker поставить пустой context, результат станет stop-broken-correlation-context. Если в UI добавить email, результат станет stop-forbidden-signal-field. Это полезный отрицательный путь: отсутствие доказательства не превращается в успешную связь.

\n

В реальной системе такой validator не заменяет SDK, интеграционный тест, контроль доступа и проверку схемы сообщений. Он задаёт только минимальное правило, которое можно проверять отдельно от транспорта. Полезность примера ограничена именно этим.

\n

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

\n
Диагностика разрыва сквозной наблюдаемости
СимптомПричинаПроверкаДействие
E2E-тест и API-лог нельзя связатьUI не передал технический context или лог потерял егоСверить context на входе API и в span операцииНазвать carrier и остановить cross-boundary вывод при его отсутствии
API и worker выглядят связанными по времени, но причина спорнаяСвязь построена эвристикой по timestamp или job nameПроверить точное значение context и parent/message boundaryУбрать эвристику; вернуть stop для неподтверждённой цепочки
Metric содержит trace id или user idИндивидуальный идентификатор стал labelПосчитать уникальные значения и проверить список attributesОставить low-cardinality class; индивидуальный след вынести в отдельный доступный канал
Лог помогает одному расследованию, но быстро растётВ log попал свободный payload или полный текст ошибкиПроверить schema, размер записи и наличие query, email, телефонаОставить operation name и outcome class; payload удалить или ограничить policy
После sampling «всё равно» видны чувствительные поляSampling перепутали с разрешением на сборРазделить sampling rule и data allow-listСначала убрать запрещённое поле, затем отдельно обсуждать объём traces
Worker показывает успешную metric, а пользователь получил ошибкуMetric измеряет получение job, а не итог операцииСверить смысл outcome-class и место инкрементаРазвести accepted, processing и completed; не называть одно другим
\n

Почему sampling не решает cardinality

\n

Cardinality описывает число разных комбинаций значений в измерении. Sampling выбирает, какие события или traces сохранять. Это разные решения. Если label содержит email, сохранение одного из двадцати событий не делает поле low-cardinality и не меняет его смысл. Если outcome-class имеет небольшой закрытый словарь, ему не нужен trace id в качестве дополнительного измерения.

\n

Сначала определите вопрос метрики. Для worker это может быть количество jobs по классу работы и ограниченному исходу. job-kind должен приходить из закрытой taxonomy. Новое значение должно пройти изменение схемы, а не появиться из свободного текста сообщения. Не используйте текст исключения, полный URL, request id или сырые идентификаторы как metric dimension.

\n

Sampling тоже требует причины и границы. Например, правило может отдельно обсуждать ошибки и обычный путь. Но статья не может назвать coverage, стоимость или процент потерь без реального измерения. Учебное правило — это только параметр дизайна. Оно не доказывает, что выбранный объём достаточен для SLA или расследования.

\n

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

\n
  1. Запишите наблюдаемый симптом и цену неверного вывода: потеря времени, повтор операции, лишние данные или неправильный fix.
  2. Сузьте сценарий до одного пути, например ui.checkout.submit → API → worker.
  3. Для UI, API и worker назовите главный signal и вопрос, на который он отвечает.
  4. Опишите technical correlation context, carrier, точки extraction и injection.
  5. Зафиксируйте поведение при пустом, неверном или отсутствующем context.
  6. Составьте allow-list полей и отдельно forbidden-list: email, phone, raw user id, свободный payload и URL с query.
  7. Разведите accepted, processing и completed, если путь содержит очередь или retry.
  8. Проверьте cardinality до обсуждения sampling. Для metric оставьте только закрытые классы.
  9. Проверьте отрицательные варианты: worker без context, запрещённое поле и неизвестный outcome.
  10. Добавьте интеграционный тест на реальную границу сообщения и отдельный тест на безопасную схему сигналов.
  11. Передайте результат как карту вопроса, границ, полей и stop. Не называйте её incident report или production evidence без соответствующих данных.
\n

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

\n

Описанный механизм не доказывает, что конкретный SDK корректно переносит context через браузер, HTTP-клиент или очередь. Необходимы тесты с реальным carrier и версиями библиотек. Стандарт задаёт формат и семантику контекста, но не выбирает права доступа, retention, список разрешённых бизнес-полей или способ обработки customer data.

\n

Механизм также не отвечает на вопрос, действительно ли пользователь увидел результат. Наличие span до worker не доказывает доставку UI-ответа. Metric worker.jobs не доказывает завершение операции. Для этого нужны отдельные сигналы и договорённость о состоянии команды. Не смешивайте техническую связь с бизнес-подтверждением.

\n

Если context потерян, не восстанавливайте его по времени, имени операции или ближайшей записи. Сохраните локальный сигнал, обозначьте границу и верните stop. Если schema неизвестна, не принимайте свободный JSON как допустимый payload. Если outcome не входит в закрытый словарь, классифицируйте его как unknown и передайте владельцу taxonomy. Такой результат выглядит менее удобным, но его можно проверить.

\n

Статья не описывает production-исследование, не сообщает latency, error rate, sampling coverage или экономию времени. Все значения в коде и примере учебные. Их можно использовать как форму проверки границ, но нельзя цитировать как результат запуска.

\n

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

\n

Сценарий готов к следующему техническому review, если другой инженер без устного объяснения может ответить на четыре вопроса: какой путь проверяется, какой context связывает границы, какие поля разрешены и что произойдёт при нарушении. Проверка должна показать один named context на UI, API и worker; раздельную семантику span, log и metric; отсутствие запрещённых полей; закрытый словарь outcome-class; и точный stop для разрыва связи.

\n

Для реальной системы добавьте доказательство транспорта: интеграционный тест передаёт context через HTTP и message boundary, worker сохраняет ожидаемую связь, а неизвестный или пустой input не получает искусственный идентификатор. Отдельно проверьте, что metric не принимает trace id и user id как labels. Пока эти проверки не пройдены, готова только схема расследования, а не end-to-end наблюдаемость.

\n

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

" + "title": "Сквозная наблюдаемость без ложной связи: UI, API и worker", + "excerpt": "E2E-тест падает, API пишет лог, worker считает задачу, но причина теряется между границами. Разбираем trace context, роли trace, log и metric, проверку очереди и отрицательный путь.", + "contentHtml": "

E2E-тест сообщает об ошибке, но в панели нельзя быстро найти соответствующий backend-запрос. В логах есть время и название операции. Worker показывает метрику. UI показывает упавший шаг. Однако эти записи нельзя надёжно связать. Инженер тратит часы на ручное сравнение временных окон и похожих идентификаторов.

\n

Цена ошибки выше времени расследования. Команда может исправить worker, хотя проблема возникла при передаче контекста из API в очередь. Может включить повтор, хотя первая команда уже была принята. Может добавить в логи email, полный URL или сырой идентификатор пользователя. Тогда диагностика ускорится на один случай, но данные станут чувствительнее, а метрики — бесполезнее из-за высокой кардинальности.

\n

Тезис статьи простой: end-to-end наблюдаемость начинается с проверяемой связи между границами, а не с количества панелей. Сначала нужно назвать один путь, один технический context и вопрос для каждого сигнала. Затем нужно запретить поля, которые не нужны этому вопросу. Если связь или границу данных нельзя доказать, система должна вернуть точный stop, а не дорисовать причинную историю по похожему имени.

\n

Что именно связывает сквозной сигнал

\n

Рассмотрим путь ui.checkout.submit → API → worker. UI создаёт операцию и отправляет запрос. API принимает запрос и ставит работу в очередь. Worker получает сообщение и выполняет работу. Это три разные границы. Между ними передаётся не весь объект операции, а небольшой технический контекст, который позволяет понять: записи относятся к одному пути.

\n

Correlation context не отвечает на вопрос «какой пользователь это сделал». Он отвечает на вопрос «какие сигналы относятся к одной технической цепочке». Это различие важно для доступа и хранения. Идентификатор пользователя, email, текст формы и полный query string не становятся допустимыми только потому, что их удобно искать. Для расследования отдельной операции может потребоваться другой защищённый процесс. Его нельзя незаметно встроить в общий label или trace attribute.

\n

У trace, log и metric разные задачи. Span показывает последовательность и границы операции. Structured log объясняет решение в конкретной ветке: например, API принял задачу или отклонил её по известному классу. Metric агрегирует повторяющиеся события: число jobs по ограниченному job-kind и outcome-class. Один context может связать сигналы, но не превращает их в один и тот же тип данных.

\n
\"Цикл
Схема показывает порядок проверки. Сначала формулируется вопрос, затем проверяются связь и состав полей. Цикл заканчивается проверяемым hand-off или точным stop, а не выводом о production-системе.
\n

Механизм: переносимый context и локальные роли

\n

Слово «correlation» часто скрывает две разные задачи. Trace context строит отношение между операциями распределённой трассировки. Прикладной correlation ID может связывать запись заказа, команду или обращение в поддержку. Они могут жить рядом, но один идентификатор не обязан заменять другой. В этой статье context относится только к технической цепочке ui.checkout.submit → API → worker.

\n

Для HTTP существует стандартизированный формат W3C Trace Context. В нём traceparent переносит положение запроса в trace-графе, а необязательный tracestate предназначен для данных поставщиков. Это не разрешение пересылать в заголовке пользовательские поля. На каждой границе нужно проверить формат, доверие к входу и список допустимых атрибутов.

\n

В терминах OpenTelemetry propagator извлекает context из входного carrier и вставляет его в исходящий carrier. Для HTTP carrier обычно представлен заголовками. Для очереди нужен адаптер конкретного транспорта: он должен назвать поле сообщения или headers, точку извлечения и точку вставки. Сам факт наличия SDK не доказывает, что произвольный брокер сохранит родительскую связь.

\n

После очереди меняется и семантика времени. Принятие команды, ожидание в очереди и выполнение worker — разные участки. Retry может породить несколько попыток одной команды. Поэтому в логах и метриках полезно разделить accepted, processing и completed, а в trace явно обозначить связь с сообщением и попыткой. Нельзя назвать worker «успешным» только потому, что он получил сообщение.

\n

Учебный пример: проверка карты сигналов

\n

Ниже приведён ограниченный учебный пример. Он не обращается к браузеру, API, очереди или telemetry backend. Он не доказывает, что в production есть нужный context. Его задача — показать fail-closed правило: validator принимает только три named signals с одним context и отклоняет запрещённое поле.

\n
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';\n\nfunction injectTraceparent(context, carrier) {\n  if (!context?.traceparent) throw new Error('missing context');\n  carrier.traceparent = context.traceparent;\n}\n\nfunction extractTraceparent(carrier) {\n  const value = carrier?.traceparent;\n  const validShape = /^\\w{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/.test(value || '');\n  return validShape ? { traceparent: value } : null;\n}\n\nconst httpHeaders = {};\nconst messageHeaders = {};\ninjectTraceparent({ traceparent }, httpHeaders);\nconst apiContext = extractTraceparent(httpHeaders);\nif (!apiContext) throw new Error('stop: invalid HTTP context');\ninjectTraceparent(apiContext, messageHeaders);\n\nconst workerContext = extractTraceparent(messageHeaders);\nconsole.log(workerContext?.traceparent === traceparent); // true\nconsole.log(extractTraceparent({ traceparent: 'trace-7f' })); // null
\n

Положительный результат означает только то, что учебный carrier сохранил строку и worker смог проверить её форму. Отрицательный результат для trace-7f показывает нужное поведение при повреждённом значении. Код не создаёт span, не отправляет запрос и не доказывает работу конкретной библиотеки.

\n

В интеграционном тесте замените обычные объекты реальным HTTP-клиентом и тестовым message broker. Проверьте значение на входе API, значение в опубликованном сообщении и значение после извлечения worker. Отдельно проверьте, что сообщение без context не получает новый идентификатор «для удобства».

\n

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

\n
Диагностика разрыва сквозной наблюдаемости
СимптомПричинаПроверкаДействие
E2E-тест и API-лог нельзя связатьUI не передал технический context или лог потерял егоСверить context на входе API и в span операцииНазвать carrier и остановить cross-boundary вывод при его отсутствии
API и worker выглядят связанными по времени, но причина спорнаяСвязь построена эвристикой по timestamp или job nameПроверить точное значение context и parent/message boundaryУбрать эвристику; вернуть stop для неподтверждённой цепочки
Metric содержит trace id или user idИндивидуальный идентификатор стал labelПосчитать уникальные значения и проверить список attributesОставить low-cardinality class; индивидуальный след вынести в отдельный доступный канал
Лог помогает одному расследованию, но быстро растётВ log попал свободный payload или полный текст ошибкиПроверить schema, размер записи и наличие query, email, телефонаОставить operation name и outcome class; payload удалить или ограничить policy
После sampling «всё равно» видны чувствительные поляSampling перепутали с разрешением на сборРазделить sampling rule и data allow-listСначала убрать запрещённое поле, затем отдельно обсуждать объём traces
Worker показывает успешную metric, а пользователь получил ошибкуMetric измеряет получение job, а не итог операцииСверить смысл outcome-class и место инкрементаРазвести accepted, processing и completed; не называть одно другим
\n

Почему sampling не решает cardinality

\n

Cardinality описывает число разных комбинаций значений в измерении. Sampling выбирает, какие события или traces сохранять. Это разные решения. Если label содержит email, сохранение одного из двадцати событий не делает поле low-cardinality и не меняет его смысл. Если outcome-class имеет небольшой закрытый словарь, ему не нужен trace id в качестве дополнительного измерения.

\n

Сначала определите вопрос метрики. Для worker это может быть количество jobs по классу работы и ограниченному исходу. job-kind должен приходить из закрытой taxonomy. Новое значение должно пройти изменение схемы, а не появиться из свободного текста сообщения. Не используйте текст исключения, полный URL, request id или сырые идентификаторы как metric dimension.

\n

Sampling тоже требует причины и границы. Например, правило может отдельно обсуждать ошибки и обычный путь. Но статья не может назвать coverage, стоимость или процент потерь без реального измерения. Учебное правило — это только параметр дизайна. Оно не доказывает, что выбранный объём достаточен для SLA или расследования.

\n

Очередь, retry и состояние операции

\n

Асинхронная граница ломается не только из-за потерянного заголовка. Сообщение может быть принято брокером, доставлено дважды, обработано после задержки или завершиться ошибкой после записи результата. Один trace ID не отвечает на все эти вопросы. Добавьте в технический контекст идентификатор операции и номер попытки только тогда, когда это разрешено контрактом; не путайте их с пользовательскими данными.

\n

Для каждой команды полезно зафиксировать переходы: accepted — API принял запрос и создал сообщение; processing — worker начал попытку; completed — бизнес-операция завершилась по определённому результату. Повторная доставка должна иметь понятное поведение. Идемпотентный ключ может защитить эффект от дубля, но его формат, срок хранения и границы действия зависят от конкретного хранилища.

\n

Проверяйте не только счастливый путь. Нужны случаи: API не смог опубликовать сообщение, сообщение пришло без context, worker упал после начала обработки, retry получил старую версию данных, а UI потерял ответ после успешного завершения. Для каждого случая назовите локальный сигнал, ожидаемый статус и допустимость сквозного вывода. Если доказательства не хватает, результат должен быть «связь не подтверждена».

\n

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

\n
  1. Запишите наблюдаемый симптом и цену неверного вывода: потеря времени, повтор операции, лишние данные или неправильный fix.
  2. Сузьте сценарий до одного пути, например ui.checkout.submit → API → worker.
  3. Для UI, API и worker назовите главный signal и вопрос, на который он отвечает.
  4. Опишите technical correlation context, carrier, точки extraction и injection.
  5. Зафиксируйте поведение при пустом, неверном или отсутствующем context.
  6. Составьте allow-list полей и отдельно forbidden-list: email, phone, raw user id, свободный payload и URL с query.
  7. Разведите accepted, processing и completed, если путь содержит очередь или retry.
  8. Проверьте cardinality до обсуждения sampling. Для metric оставьте только закрытые классы.
  9. Проверьте отрицательные варианты: worker без context, запрещённое поле и неизвестный outcome.
  10. Добавьте интеграционный тест на реальную границу сообщения и отдельный тест на безопасную схему сигналов.
  11. Передайте результат как карту вопроса, границ, полей и stop. Не называйте её incident report или production evidence без соответствующих данных.
\n

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

\n

Описанный механизм не доказывает, что конкретный SDK корректно переносит context через браузер, HTTP-клиент или очередь. Необходимы тесты с реальным carrier и версиями библиотек. Стандарт задаёт формат и семантику контекста, но не выбирает права доступа, retention, список разрешённых бизнес-полей или способ обработки customer data.

\n

Механизм также не отвечает на вопрос, действительно ли пользователь увидел результат. Наличие span до worker не доказывает доставку UI-ответа. Metric worker.jobs не доказывает завершение операции. Для этого нужны отдельные сигналы и договорённость о состоянии команды. Не смешивайте техническую связь с бизнес-подтверждением.

\n

Если context потерян, не восстанавливайте его по времени, имени операции или ближайшей записи. Сохраните локальный сигнал, обозначьте границу и верните stop. Если schema неизвестна, не принимайте свободный JSON как допустимый payload. Если outcome не входит в закрытый словарь, классифицируйте его как unknown и передайте владельцу taxonomy. Такой результат выглядит менее удобным, но его можно проверить.

\n

Статья не описывает production-исследование, не сообщает latency, error rate, sampling coverage или экономию времени. Все значения в коде и примере учебные. Их можно использовать как форму проверки границ, но нельзя цитировать как результат запуска.

\n

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

\n

Сценарий готов к следующему техническому review, если другой инженер без устного объяснения может ответить на четыре вопроса: какой путь проверяется, какой context связывает границы, какие поля разрешены и что произойдёт при нарушении. Проверка должна показать один named context на UI, API и worker; раздельную семантику span, log и metric; отсутствие запрещённых полей; закрытый словарь outcome-class; и точный stop для разрыва связи.

\n

Для реальной системы добавьте доказательство транспорта: интеграционный тест передаёт context через HTTP и message boundary, worker сохраняет ожидаемую связь, а неизвестный или пустой input не получает искусственный идентификатор. Отдельно проверьте, что metric не принимает trace id и user id как labels. Пока эти проверки не пройдены, готова только схема расследования, а не end-to-end наблюдаемость.

\n

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

" } diff --git a/editorial/agent-rewrites/050.json b/editorial/agent-rewrites/050.json index dd46785..b6081a9 100644 --- a/editorial/agent-rewrites/050.json +++ b/editorial/agent-rewrites/050.json @@ -3,5 +3,5 @@ "slug": "editorial-2026-08-mechanism-end-to-end-observability", "title": "End-to-end наблюдаемость: как не перепутать сигналы с доказательством", "excerpt": "Когда e2e-тест падает, совпадение имён в логах не связывает UI, API и worker. Разбираем propagation, смысл span/log/metric, отрицательный путь и критерий, при котором сквозной вывод можно считать проверяемым.", - "contentHtml": "

В e2e-тесте упала отправка заказа. Браузер показал таймаут, API записал ошибку, worker продолжил обрабатывать очередь. Все три записи содержат checkout. Но инженер не знает, относятся ли они к одной попытке. Он тратит время на поиск «медленного сервиса», хотя разрыв мог произойти в propagation. Цена ошибки — ложный вывод, лишний rollback и повтор инцидента после следующего релиза.

\n

End-to-end наблюдаемость начинается не с дашборда. Она начинается с проверяемого контракта связи. UI, API и worker должны передать один контекст по названным границам. Каждая граница должна описывать свой сигнал. Если контекст потерян, система должна остановить сквозной вывод. Похожее имя, соседнее время и одинаковый тип операции не заменяют корреляцию.

\n

Что именно связывает сквозной сигнал

\n

Контекст отвечает на вопрос «к какой цепочке относится операция». В стандарте W3C для этого есть переносимый traceparent с trace-id и parent-id. На практике важен не сам заголовок, а договор: кто его извлекает, кто передаёт дальше, кто создаёт новую связь и что происходит с пустым или неверным значением.

\n

У разных сигналов разные задачи. Span показывает участок операции и его границы. Log объясняет событие и его исход. Metric считает повторяющиеся события по небольшому набору признаков. Общий context помогает перейти от одного объекта к другому, но не делает эти объекты взаимозаменяемыми. Нельзя считать metric доказательством конкретной попытки. Нельзя читать один log как полную историю запроса.

\n

Асинхронная очередь добавляет отдельную границу. API может принять сообщение в одной операции, а worker обработать его позже и повторить несколько раз. Время API, задержка очереди и время worker нельзя сложить без явных часов и правил retry. Если carrier сообщения не определён, связь с worker остаётся гипотезой.

\n
\"Схема
Иллюстрация разделяет две независимые задачи: sampling выбирает наблюдаемые traces, а cardinality ограничивает форму агрегируемых признаков. Одно не исправляет другое.
\n

Сигнал должен отвечать на один вопрос

\n

Перед добавлением поля сформулируйте вопрос. Для span это может быть «какая операция заняла участок пути». Для log — «какой ограниченный исход получил API». Для metric — «сколько задач класса payment завершилось исходом timeout». Если вопрос требует email, полного URL, текста запроса или случайного идентификатора, поле нельзя добавлять в metric label. Такие значения раздувают число series и смешивают диагностику с хранением данных.

\n

Ошибка тоже требует словаря. Transport failure означает, что граница вызова не завершилась ожидаемо. Domain rejection означает, что правило продукта отклонило операцию. Retryable failure означает, что worker может попробовать ещё раз. Одно error=true скроет разницу между ними. Храните ограниченный outcome_class, а подробности оставляйте в защищённом событии с отдельными правилами доступа и хранения.

\n
const signal = {\n  context: 'trace-7f',\n  operation: 'checkout.submit',\n  outcome_class: 'transport_failure',\n  route_template: '/orders/{id}'\n};\n\nconst allowed = new Set([\n  'operation', 'outcome_class', 'route_template'\n]);\n\nfunction accept(fields) {\n  return Object.keys(fields).every((name) => allowed.has(name));\n}\n\nif (!accept(signal)) {\n  throw new Error('stop: forbidden signal field');\n}
\n

Это учебный JavaScript-пример. Он не подключается к браузеру, HTTP-клиенту, очереди или telemetry backend и не доказывает свойства production-системы. Его задача — показать fail-closed правило: неизвестное поле не проходит молча, а пустой контекст не получает новый идентификатор только ради красивой связи.

\n

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

\n
Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
e2e упал, backend-запрос не находитсяUI не передал context или query скрывает егоСравнить carrier на запросе с context в spanОстановить вывод и назначить owner propagation
API и worker имеют одинаковый job nameИмя операции приняли за correlationПроверить trace-id и parent relation, а не текст имениНе связывать события эвристикой
Metric содержит много уникальных labelsВ label попали id, URL или свободный текстПосчитать допустимые значения каждой dimensionsОставить named low-cardinality class или убрать поле
После retry длительность выглядит вдвое большеСложили API, очередь и повтор workerРазделить сегменты и проверить источники времениНе делать latency-вывод до полной модели границ
Trace иногда есть, иногда исчезаетSampling или async carrier не описаныПроверить policy, message headers и absent-context branchНазвать правило отбора и fail-closed поведение
\n

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

\n

Представим учебный маршрут: UI создаёт trace-7f, API получает тот же контекст, а worker получает сообщение без carrier. В этой точке нельзя подставить «ближайший» trace и нельзя связать worker по имени задачи. Правильный результат — stop-broken-correlation-context. Он не сообщает причину сбоя в production. Он сообщает, какого факта не хватает для сквозного вывода.

\n

Другой отрицательный путь возникает, когда UI добавляет email в список полей, а API и worker остаются корректными. Проверка должна остановиться на запрещённом поле. Sampling не исправляет нарушение: меньший объём trace не меняет характер персонального значения. Переагрегация тоже не оправдывает сбор лишнего поля задним числом.

\n

Третий путь — неизвестный исход. Если API записал свободный текст исключения, metric не должна превращать его в новую series. Сначала ограничьте taxonomy: например, ok, domain_rejected, transport_failure, retryable_failure. Если новый исход нельзя отнести к классу, запишите unknown и отправьте вопрос владельцу словаря. Это сохраняет честность сигнала.

\n

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

\n
  1. Опишите один пользовательский путь: UI → API → очередь → worker. Укажите владельца каждой границы.
  2. Назовите carrier, точки extraction и injection, а также реакцию на отсутствующий и неверный context.
  3. Разведите span, log и metric по вопросам. Не переносите trace-id в metric label.
  4. Составьте allow-list полей и закрытый словарь outcome-классов. Уберите identity и свободный payload.
  5. Разделите UI time, API processing, queue delay и worker execution. Не складывайте интервалы без общей модели часов.
  6. Назовите sampling policy и её границу. Не объявляйте coverage, latency или стоимость без измерения.
  7. Прогоните положительный и три отрицательных варианта: потерянный context, запрещённое поле и неизвестный исход.
  8. Сохраните результат как проверяемый контракт. Если любой stop сработал, не публикуйте end-to-end причину.
\n

Ограничения

\n

Наличие одинакового trace-id ещё не доказывает, что пользователь получил успешный результат. Span может быть экспортирован с задержкой. Log может отсутствовать из-за уровня записи. Metric агрегирует множество операций. Sampling может не сохранить нужную попытку. Прокси может удалить заголовок, а очередь — не поддержать выбранный carrier.

\n

Стандарты задают модели и форматы, но не выбирают taxonomy конкретного продукта, retention, доступ, redaction или стоимость telemetry. Учебная схема не доказывает compliance и не заменяет нагрузочное измерение. Перед внедрением нужен отдельный контракт для каждой границы и проверка реального SDK, collector и хранилища.

\n

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

\n

Механизм готов к следующему инженерному шагу, если независимая проверка получает один и тот же результат: для выбранного пути назван carrier, UI, API и worker несут один контекст, каждый сигнал отвечает на свой вопрос, поля проходят allow-list, sampling описан без выдуманной эффективности, а все отрицательные варианты дают именованный stop. При этом нет заявления о production-латентности, покрытии, релизе или устранённой аварии.

\n

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

" + "contentHtml": "

В e2e-тесте упала отправка заказа. Браузер показал таймаут, API записал ошибку, worker продолжил обрабатывать очередь. Все три записи содержат checkout. Но инженер не знает, относятся ли они к одной попытке. Он тратит время на поиск «медленного сервиса», хотя разрыв мог произойти в propagation. Цена ошибки — ложный вывод, лишний rollback и повтор инцидента после следующего релиза.

\n

End-to-end наблюдаемость начинается не с дашборда. Она начинается с проверяемого контракта связи. UI, API и worker должны передать один контекст по названным границам. Каждая граница должна описывать свой сигнал. Если контекст потерян, система должна остановить сквозной вывод. Похожее имя, соседнее время и одинаковый тип операции не заменяют корреляцию.

\n

Сквозная цепочка — это контракт

\n

У цепочки есть три разных объекта: операция, контекст и запись о результате. Операция — действие пользователя или сервиса, например checkout.submit. Контекст сообщает, к какой цепочке относится текущий участок. Запись о результате говорит, что именно произошло на этом участке. Если в лог попал только текст ошибки, он не превращается в доказательство связи с конкретным запросом.

\n

Стандарт W3C Trace Context описывает HTTP-перенос контекста через traceparent. В формате версии 00 заголовок содержит версию, trace-id, parent-id и flags. trace-id идентифицирует весь trace, а parent-id — родительский участок. Нулевые идентификаторы и неверный формат недействительны: принимающая сторона не должна считать такой заголовок доказательством связи.

\n

Это не означает, что один заголовок автоматически пройдёт через всю систему. HTTP-прокси, клиентская библиотека, API gateway и consumer должны иметь договор extraction/injection. Для очереди нужен carrier сообщения: например, отдельное поле headers или metadata. Название carrier, допустимый размер, способ сериализации и поведение при потере должны быть частью контракта, а не устной договорённостью.

\n
Схема разделяет низкокардинальные классы и индивидуальные поля, а sampling показывает как отдельное правило выбора traces
Иллюстрация разделяет две независимые задачи: cardinality ограничивает форму агрегируемых признаков, а sampling выбирает наблюдаемые traces. Меньшая выборка не делает персональное поле допустимым.
\n

Три сигнала — три разных вопроса

\n

В OpenTelemetry trace описывает путь запроса через приложение, metric — измерение во времени, а log — запись события. Их можно связать общим контекстом, но нельзя подменять один другим. Trace помогает найти участок цепочки. Log объясняет локальный исход. Metric показывает масштаб повторяющегося явления. Ни один из них в одиночку не доказывает, что пользователь увидел ошибку или что worker обработал именно этот заказ.

\n

Сначала сформулируйте вопрос, затем выберите сигнал. Для span вопрос звучит так: «какой участок пути занял время или завершился ошибкой?». Для log: «какой ограниченный исход получил API?». Для metric: «сколько операций класса payment завершилось исходом timeout?». Если ответ требует email, полного URL, свободного текста или случайного идентификатора, такое значение нельзя превращать в metric label.

\n

У исходов должен быть небольшой словарь. transport_failure означает отказ границы вызова, domain_rejected — отказ правила продукта, retryable_failure — возможность повторной обработки. Одно error=true скроет важное различие. Подробности исключения оставляйте в защищённом событии с правилами доступа и хранения, а в агрегате сохраняйте ограниченный outcome_class.

\n

Propagation через HTTP и очередь

\n

Проверяйте не наличие похожих строк, а переход между границами. На входе API нужно зафиксировать, какой carrier извлечён и какой trace-id получен. На исходящем запросе API должен создать дочерний span и передать контекст дальше. При постановке сообщения в очередь тот же контекст нужно положить в согласованный carrier. Worker извлекает его и создаёт свой участок обработки; он не должен искать ближайший trace по имени задачи или времени.

\n

Асинхронная граница меняет смысл времени. API может завершиться через 80 мс, сообщение ждать 2 секунды, а worker выполнить повтор через 300 мс. Это минимум три интервала: обработка API, задержка очереди и выполнение worker. Повтор создаёт новый участок работы, но не обязательно новый пользовательский trace. Если не записать номер попытки и причину retry, суммарная длительность будет выглядеть как один длинный вызов и приведёт к неверной оптимизации.

\n

Проверка должна различать отсутствие контекста и отказ telemetry backend. В первом случае система не знает, к чему привязать событие. Во втором контекст мог быть корректным, но запись не дошла до хранилища. Обе ситуации ухудшают расследование, однако исправляются на разных границах: в первом ищут extraction/injection, во втором — экспорт, очередь telemetry и retention.

\n

Воспроизводимая проверка формата и словаря

\n

Ниже — самостоятельный JavaScript-пример без SDK. Он проверяет только две вещи: минимальную структуру W3C-заголовка версии 00 и закрытый словарь исходов. Значения идентификаторов взяты из примера спецификации; они не являются идентификатором реального пользователя или запроса.

\n
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';\nconst allowedOutcomes = new Set([\n  'ok', 'domain_rejected', 'transport_failure', 'retryable_failure'\n]);\n\nfunction nonZero(value) {\n  return /[1-9a-f]/.test(value);\n}\n\nfunction validTraceparent(value) {\n  const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(value);\n  return Boolean(match) && nonZero(match[1]) && nonZero(match[2]);\n}\n\nfunction inspectSignal(signal) {\n  if (!validTraceparent(signal.traceparent)) {\n    return { ok: false, reason: 'stop-broken-correlation-context' };\n  }\n  if (!allowedOutcomes.has(signal.outcome_class)) {\n    return { ok: false, reason: 'stop-unknown-outcome-class' };\n  }\n  return { ok: true, traceparent: signal.traceparent,\n    operation: signal.operation, outcome_class: signal.outcome_class };\n}\n\nconsole.log(inspectSignal({\n  traceparent,\n  operation: 'checkout.submit',\n  outcome_class: 'transport_failure'\n}));
\n

После декодирования HTML этот код можно запустить в обычном Node.js. Валидный пример возвращает ok: true. Если заменить последний фрагмент заголовка на 00, формат останется допустимым, но sampled-флаг будет снят; это не доказательство отсутствия trace. Если удалить один символ из trace-id, функция вернёт именованный stop. Если подставить свободный текст вместо outcome_class, сработает второй stop. Так проверяется не «красивый лог», а заранее названное правило.

\n

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

\n
Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
e2e упал, backend-запрос не находитсяUI не передал context или query скрывает егоСравнить carrier на запросе с context в spanОстановить вывод и назначить владельца propagation
API и worker имеют одинаковый job nameИмя операции приняли за correlationПроверить trace-id, parent relation и attemptНе связывать события эвристикой
Metric содержит много уникальных labelsВ label попали id, URL или свободный текстПосчитать допустимые значения каждой dimensionОставить low-cardinality class или убрать поле
После retry длительность выглядит вдвое большеСложили API, очередь и повтор workerРазделить сегменты и проверить источники времениНе делать latency-вывод без модели границ
Trace иногда есть, иногда исчезаетSampling или async carrier не описаныПроверить policy, message headers и absent-context branchНазвать правило отбора и fail-closed поведение
\n

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

\n

Представим учебный маршрут: UI создаёт корректный traceparent, API получает его и публикует сообщение, а worker получает сообщение без carrier. В этой точке нельзя подставить «ближайший» trace и нельзя связать worker по имени задачи. Правильный результат — stop-broken-correlation-context. Он не утверждает причину сбоя в продукте; он сообщает, какого факта не хватает для сквозного вывода.

\n

Второй отрицательный путь — посредник удалил заголовок, но API создал новый trace и продолжил работу. Для локальной диагностики это может быть допустимым решением, но в отчёте нужно отметить разрыв: новый trace не доказывает продолжение исходного. Если граница критична, лучше сохранить отдельное событие о потере связи и передать в доменную команду только явно разрешённые признаки.

\n

Третий путь — неизвестный исход. Если API записал свободный текст исключения, metric не должна превращать каждую новую строку в series. Сначала ограничьте taxonomy. Если новый исход нельзя отнести к классу, запишите unknown или остановите публикацию метрики по правилу команды, а затем обновите словарь. Нельзя ретроспективно выдавать неизвестное значение за известный класс.

\n

Sampling и cardinality не заменяют корреляцию

\n

Sampling отвечает на вопрос «какие traces записывать или экспортировать». Cardinality отвечает на вопрос «сколько различных значений может иметь признак в агрегате». Это разные оси стоимости и качества. Можно выбрать только один trace из двадцати и всё равно создать опасную series для каждого email. Можно ограничить label словарём и всё равно потерять нужную попытку из-за sampling.

\n

Флаг sampled в traceparent сообщает о решении caller записывать trace, но не превращается в гарантию, что все downstream-системы сохранили данные. Компонент может изменить решение из-за своей нагрузки или политики. Поэтому в расследовании проверяйте фактическое наличие span и конфигурацию экспортёра, а не только значение флага.

\n

Не обещайте покрытие или экономию без измерения. Для решения нужны хотя бы объём входных операций, доля сохранённых traces, число series по каждой dimension, размер событий и срок хранения. Эти значения зависят от SDK, collector, backend, нагрузки и правил redaction. Перенос цифры из чужого окружения не делает её результатом вашего измерения.

\n

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

\n
  1. Опишите один путь: UI → API → очередь → worker. Укажите владельца каждой границы и отдельно обозначьте синхронные и асинхронные участки.
  2. Назовите carrier, точки extraction и injection, формат значения и реакцию на отсутствующий или неверный context.
  3. Для одной тестовой попытки выпишите trace-id, parent-id, attempt и outcome. Сверьте их в запросе, сообщении и записи worker.
  4. Разведите span, log и metric по вопросам. Не переносите trace-id, email, raw id или свободный payload в metric label.
  5. Составьте allow-list полей и закрытый словарь outcome-классов. Зафиксируйте, где хранится подробное исключение и кто имеет к нему доступ.
  6. Разделите UI time, API processing, queue delay и worker execution. Для retry показывайте номер попытки и не складывайте интервалы без общей модели часов.
  7. Назовите sampling policy, границу её применения и способ проверки фактической записи. Не называйте флаг sampled доказательством сохранения.
  8. Прогоните положительный и три отрицательных варианта: потерянный carrier, неверный заголовок, запрещённое поле и неизвестный исход.
  9. Сохраните результат как проверяемый контракт. Если любой stop сработал, не публикуйте end-to-end причину и не заменяйте её догадкой.
\n

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

\n

Одинаковый trace-id не доказывает, что пользователь получил успешный результат. Span может быть экспортирован с задержкой, log — отброшен уровнем записи, metric — агрегировать множество операций, а sampling — не сохранить нужную попытку. Прокси может удалить заголовок, очередь — не поддержать выбранный carrier, а retry — породить несколько обработок одного сообщения.

\n

W3C задаёт формат trace context, а OpenTelemetry — модель сигналов и контекстов; эти документы не выбирают taxonomy продукта, retention, redaction, права доступа, SLA freshness или стоимость telemetry. Учебный код не подключается к браузеру, HTTP-клиенту, очереди или backend. Он проверяет локальный инвариант и не заменяет интеграционный тест с реальным SDK и collector.

\n

Для защищённых данных корреляция не должна становиться способом передать персональные сведения. Trace-id обычно безопаснее email, но он всё равно может связать записи между системами. Опишите срок хранения, доступ, маскирование и процедуру удаления отдельно. Если эти правила не определены, полезность подробного контекста не оправдывает его сбор.

\n

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

\n

Механизм готов к следующему инженерному шагу, если независимая проверка получает один и тот же результат: для выбранного пути назван carrier, UI, API и worker несут проверяемый контекст, каждый сигнал отвечает на свой вопрос, поля проходят allow-list, sampling описан без выдуманной эффективности, а отрицательные варианты дают именованный stop. При этом нет заявления о production-латентности, покрытии, релизе или устранённой аварии без соответствующего измерения.

\n

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

" } diff --git a/editorial/agent-rewrites/051.json b/editorial/agent-rewrites/051.json index 1f4cfe9..4e990e3 100644 --- a/editorial/agent-rewrites/051.json +++ b/editorial/agent-rewrites/051.json @@ -2,6 +2,6 @@ "index": 51, "slug": "editorial-2026-08-practice-end-to-end-observability", "title": "Сквозная наблюдаемость без ложных связей: UI, API и worker", - "excerpt": "Как сохранить correlation context между UI, API и worker, выбрать отдельный смысл для span, log и metric и остановиться, если связь или граница данных нарушена.", - "contentHtml": "

End-to-end тест падает на кнопке оформления заказа. В браузере виден timeout. В API-логе есть принятый запрос. В метрике worker растёт число задач. Но найти один backend-запрос по этому тесту нельзя. Инженер не знает, где оборвалась цепочка: в браузере, proxy, API, очереди или worker. Он меняет timeout и повторяет запуск. Иногда тест проходит. Причина остаётся.

Цена ошибки — не только лишние минуты расследования. Команда может увеличить таймаут, скрыть повторную работу или добавить второй диагностический канал. Если для связи в telemetry попадают email, полный URL или raw user id, локальное удобство превращается в проблему данных и кардинальности. Тезис статьи простой: сквозной сигнал начинается с одного технического context и явных границ. Он не начинается с дашборда и не требует передавать весь payload.

Механизм: один путь, три разных сигнала

Возьмём один учебный сценарий: пользователь нажал «Оплатить», API принял команду, worker обработал задание. UI создаёт span ui.checkout.submit. API пишет структурированный log api.accepted. Worker увеличивает metric worker.jobs. Все три записи получают технический correlation context fixed-trace-7f.

Context связывает позиции в одной операции. Он не превращает сигналы в один формат и не отвечает на все вопросы сразу. Span показывает ход ограниченной операции и её длительность. Log объясняет одно событие и его outcome-class. Metric считает повторяющиеся события по небольшому словарю классов. Если записать trace id как label метрики, агрегат начнёт хранить идентификаторы отдельных операций. Это уже не полезная группировка.

У асинхронной границы нужен отдельный контракт. API должен решить, что именно передаётся в сообщение, кто создаёт дочернюю операцию и что делать при отсутствии context. Worker не может считать, что очередь сама сохранила parent relation. Если context пуст, разбор заканчивается статусом stop-broken-correlation-context. Нельзя дорисовывать связь по одинаковому имени job или времени запуска.

\"Карта
Учебная карта показывает владельца каждого перехода. Она не изображает работающую telemetry-систему, не содержит production trace и не доказывает propagation через конкретную очередь.

Что разрешает context

Correlation и identity решают разные задачи. Correlation отвечает: относятся ли записи к одному пути. Identity отвечает: кто совершил действие. Для первой задачи достаточно технического идентификатора внутри разрешённого контура. Добавлять в span или log пользовательский email «для удобства» нельзя без отдельного назначения, доступа и срока хранения.

Полезно заранее записать allow-list. Для UI это могут быть route-template и request-kind. Для API — operation-name и outcome-class. Для worker — job-kind и outcome-class. Вне списка остаются raw user id, email, phone, свободный текст, полный URL с query и текст исключения. Название поля само по себе ничего не гарантирует. Без ограниченного словаря outcome станет свободным текстом.

const path = [{ component: 'ui', signal: 'span', context: 'fixed-trace-7f', fields: ['route-template', 'request-kind'] }, { component: 'api', signal: 'log', context: 'fixed-trace-7f', fields: ['operation-name', 'outcome-class'] }, { component: 'worker', signal: 'metric', context: 'fixed-trace-7f', fields: ['job-kind', 'outcome-class'] }];\nconst forbidden = ['email', 'raw-user-id', 'free-text-query'];\nconst sameContext = new Set(path.map((step) => step.context)).size === 1;\nconst safe = path.every((step) => step.fields.every((field) => !forbidden.includes(field)));\nif (!sameContext || !safe) return 'stop: review the boundary';\nreturn 'synthetic-observability-plan-hand-off';

Это учебный JavaScript-пример. Он проверяет заранее заданный объект в памяти. Он не создаёт HTTP-заголовок, не подключает SDK, не отправляет telemetry и не подтверждает состояние production. Его задача — не дать назвать схему готовой, если worker потерял context или поле нарушило границу.

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

Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
Тест видит timeout, backend-запрос не находитсяContext не дошёл до API или не попал в logСравнить наличие одного технического context на UI и APIИсправить propagation или остановить расследование на границе
UI и API связаны, worker выглядит отдельнымMessage boundary не описывает перенос и parent relationПроверить контракт сообщения и значение context перед обработкойНазначить владельца перехода; при пустом значении вернуть stop
Metric имеет почти отдельную series на каждую операциюВ label попал trace id, raw id или свободный текстСверить labels с allow-list и посчитать классы, а не значенияУбрать identity; оставить низкокардинальный class
Все отказы помечены одинаковоTransport failure и domain rejection смешаны в error=trueПроверить словарь outcome-classРазделить transport, domain и retryable processing outcome
Есть sampling rule, но нет уверенности в покрытииПлан выдаётся за измерениеНайти реальные данные о rate, collector и retentionНазвать правило планом; не делать вывод о latency или стоимости

Почему нельзя смешивать ошибку и результат

Transport failure означает, что граница вызова не завершилась ожидаемо. Domain rejection означает, что запрос дошёл до правила и получил допустимый отрицательный исход. Retryable processing failure означает, что worker может повторить работу. Эти состояния требуют разных действий. Одно поле error=true не говорит, искать ли сеть, бизнес-правило или повтор.

Для учебной схемы достаточно небольшого словаря: transport-unavailable, domain-rejected, retryable-processing, accepted. Не следует добавлять в metric текст исключения. Он может содержать input, URL, имя клиента или случайный идентификатор. Подробный event, если он действительно нужен, должен иметь отдельный канал, доступ и retention. Trace context не является разрешением на хранение payload.

Sampling не исправляет плохую схему

Sampling выбирает объём trace-наблюдений. Cardinality определяет, сколько отдельных серий создаёт metric. Если metric получила raw user id, правило sampling для trace не уменьшает проблему metric. Если worker context потерян, двадцать процентов сохранённых trace не докажут связь с worker. Поэтому sampling записывают рядом с причиной, границей и ожидаемым вопросом. Формулировка «ошибка или 1 из 20» здесь только учебная. Она не сообщает реальную долю, стоимость хранения или полноту покрытия.

Время также нельзя складывать без границ. UI wait, server processing, queue delay и worker execution — разные сегменты. Один root span может скрыть ожидание очереди и retry. Три коротких span могут не показать путь, если context оборвался. Пока не определены часы, события и повторные попытки, статья не делает вывода о bottleneck. Это отрицательный путь: отсутствие данных о границе запрещает уверенный performance claim.

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

  1. Выбрать один пользовательский путь и назвать три границы: UI, API, worker.
  2. Для каждой границы задать главный сигнал и один вопрос, на который он отвечает.
  3. Назначить технический context и проверить, что он одинаково представлен на каждом переходе.
  4. Описать message boundary: что переносится, кто создаёт новую операцию и что происходит при пустом context.
  5. Составить allow-list полей и отдельный список запретов для identity, свободного текста и query.
  6. Разделить outcome-class для transport, domain и retryable processing.
  7. Проверить учебным validator-ом положительный hand-off и три отрицательных случая: нет context, запрещённое поле, operational verb вместо plan.
  8. Только после этого согласовать реальный collector, access, retention, sampling и тест в разрешённой среде.

Ограничения

Один и тот же context в трёх литералах не доказывает, что заголовок дойдёт через browser, proxy и очередь. SVG не доказывает наличие SDK. Учебный код не измеряет latency, throughput, error rate или стоимость telemetry. OpenTelemetry и W3C задают терминологию и форматы, но не назначают словарь вашей команды, права доступа, срок хранения и правила редактирования данных.

Нельзя объявлять проблему решённой только потому, что тест снова прошёл. Повторный запуск мог попасть в другую ветку, а retry мог скрыть отказ. Нельзя объявлять metric безопасной только по короткому имени label. Нужны допустимые values и проверка неизвестного значения. Если вопрос требует индивидуального payload, его нельзя протащить в агрегат под видом «диагностики».

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

Материал готов к отдельному design review, когда для одного пути есть карта UI → API → worker, названный владелец каждой границы, один technical context, allow-list полей и словарь outcome-class. Validator должен вернуть только synthetic-observability-plan-hand-off для полного учебного объекта. Для пустого context он обязан вернуть stop-broken-correlation-context; для forbidden field — stop-forbidden-signal-field. Ни один результат не должен называться deploy, rollout или production success.

После этого готовность системы проверяют уже другими средствами: разрешённым тестом propagation, проверкой редактирования данных, контролем доступа, измерением cardinality и сопоставлением реального trace с запросом. Пока этих доказательств нет, корректный итог — ограниченный hand-off и точный stop, а не красивая легенда о сквозной наблюдаемости.

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

" + "excerpt": "Как сохранить trace context между UI, API и worker, не превращать trace id в измерение метрики и проверять асинхронную границу по воспроизводимому контракту.", + "contentHtml": "

End-to-end тест падает на кнопке оформления заказа. В браузере виден timeout. В API-логе есть принятый запрос. В метрике worker растёт число задач. Но найти один backend-запрос по этому тесту нельзя. Инженер не знает, где оборвалась цепочка: в браузере, proxy, API, очереди или worker. Он меняет timeout и повторяет запуск. Иногда тест проходит. Причина остаётся.

Цена ошибки — не только лишние минуты расследования. Команда может увеличить таймаут, скрыть повторную работу или добавить второй диагностический канал. Если для связи в telemetry попадают email, полный URL или raw user id, локальное удобство превращается в проблему данных и кардинальности. Тезис статьи простой: сквозной сигнал начинается с одного технического context и явных границ. Он не начинается с дашборда и не требует передавать весь payload.

Механизм: один путь, три разных сигнала

Возьмём один учебный сценарий: пользователь нажал «Оплатить», API принял команду, worker обработал задание. UI и API создают операции с техническим correlation context fixed-trace-7f, worker продолжает его на обработке сообщения. Worker увеличивает metric worker.jobs с низкокардинальными attributes; связь отдельной точки с trace возможна через exemplar, а не через label.

Context связывает позиции в одной операции. Он не превращает сигналы в один формат и не отвечает на все вопросы сразу. Span показывает ход ограниченной операции и её длительность. Log объясняет одно событие и его outcome-class. Metric считает повторяющиеся события по небольшому словарю классов; связь отдельной точки метрики с trace оформляется exemplar-ом, а не новым измерением на каждый trace id. Если записать trace id как label метрики, агрегат начнёт хранить идентификаторы отдельных операций. Это уже не полезная группировка.

У асинхронной границы нужен отдельный контракт. API должен решить, что именно передаётся в сообщение, кто создаёт дочернюю операцию и что делать при отсутствии context. Для producer и consumer надо проверить реальное извлечение и вложение metadata: очередь не обязана сама сохранить parent relation. Если context пуст, разбор заканчивается статусом stop-broken-correlation-context. Нельзя дорисовывать связь по одинаковому имени job или времени запуска.

\"Карта
Учебная карта показывает владельца каждого перехода. Она не изображает работающую telemetry-систему, не содержит production trace и не доказывает propagation через конкретную очередь.

Что разрешает context

Correlation и identity решают разные задачи. Correlation отвечает: относятся ли записи к одному пути. Identity отвечает: кто совершил действие. Для первой задачи достаточно технического идентификатора внутри разрешённого контура. Добавлять в span или log пользовательский email «для удобства» нельзя без отдельного назначения, доступа и срока хранения.

Полезно заранее записать allow-list. Для UI это могут быть route-template и request-kind. Для API — operation-name и outcome-class. Для worker — job-kind и outcome-class. Вне списка остаются raw user id, email, phone, свободный текст, полный URL с query и текст исключения. Название поля само по себе ничего не гарантирует. Без ограниченного словаря outcome станет свободным текстом.

const forbidden = new Set(['trace-id', 'email', 'raw-user-id', 'free-text-query']);\nconst traceIds = ['4bf92f3577b34da6a3ce929d0e0e4736', '4bf92f3577b34da6a3ce929d0e0e4736'];\nconst metricAttributes = { job_kind: 'checkout.capture', outcome_class: 'accepted' };\nconst validOutcomes = new Set(['accepted', 'domain-rejected', 'transport-unavailable', 'retryable-processing']);\nconst sameTrace = new Set(traceIds).size === 1;\nconst safeFields = Object.keys(metricAttributes).every((field) => !forbidden.has(field));\nconst knownOutcome = validOutcomes.has(metricAttributes.outcome_class);\nconst result = sameTrace && safeFields && knownOutcome ? 'context-ok-metric-via-exemplar' : 'stop-at-boundary';\nconsole.log(result);

Это учебный JavaScript-пример. Он проверяет заранее заданный объект в памяти: одинаковый trace id только у операций, низкокардинальные metric attributes и закрытый словарь outcome. Он не создаёт HTTP-заголовок, не подключает SDK, не отправляет telemetry и не подтверждает состояние production. Его задача — не дать назвать схему готовой, если worker потерял context или поле нарушило границу.

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

Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
Тест видит timeout, backend-запрос не находитсяContext не дошёл до API или не попал в logСравнить наличие одного технического context на UI и APIИсправить propagation или остановить расследование на границе
UI и API связаны, worker выглядит отдельнымMessage boundary не описывает перенос и parent relationПроверить контракт сообщения и значение context перед обработкойНазначить владельца перехода; при пустом значении вернуть stop
Metric имеет почти отдельную series на каждую операциюВ label попал trace id, raw id или свободный текстСверить labels с allow-list и посчитать классы, а не значенияУбрать identity; оставить низкокардинальный class
Все отказы помечены одинаковоTransport failure и domain rejection смешаны в error=trueПроверить словарь outcome-classРазделить transport, domain и retryable processing outcome
Есть sampling rule, но нет уверенности в покрытииПлан выдаётся за измерениеНайти реальные данные о rate, collector и retentionНазвать правило планом; не делать вывод о latency или стоимости

Почему нельзя смешивать ошибку и результат

Transport failure означает, что граница вызова не завершилась ожидаемо. Domain rejection означает, что запрос дошёл до правила и получил допустимый отрицательный исход. Retryable processing failure означает, что worker может повторить работу. Эти состояния требуют разных действий. Одно поле error=true не говорит, искать ли сеть, бизнес-правило или повтор.

Для учебной схемы достаточно небольшого словаря: transport-unavailable, domain-rejected, retryable-processing, accepted. Не следует добавлять в metric текст исключения. Он может содержать input, URL, имя клиента или случайный идентификатор. Подробный event, если он действительно нужен, должен иметь отдельный канал, доступ и retention. Trace context не является разрешением на хранение payload.

Sampling не исправляет плохую схему

Sampling выбирает объём trace-наблюдений. Cardinality определяет, сколько отдельных серий создаёт metric. Если metric получила raw user id, правило sampling для trace не уменьшает проблему metric. Если worker context потерян, двадцать процентов сохранённых trace не докажут связь с worker. Поэтому sampling записывают рядом с причиной, границей и ожидаемым вопросом. Формулировка «ошибка или 1 из 20» здесь только учебная. Она не сообщает реальную долю, стоимость хранения или полноту покрытия.

Время также нельзя складывать без границ. UI wait, server processing, queue delay и worker execution — разные сегменты. Один root span может скрыть ожидание очереди и retry. Три коротких span могут не показать путь, если context оборвался. Пока не определены часы, события и повторные попытки, статья не делает вывода о bottleneck. Это отрицательный путь: отсутствие данных о границе запрещает уверенный performance claim.

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

  1. Выбрать один пользовательский путь и назвать три границы: UI, API, worker.
  2. Для каждой границы задать главный сигнал и один вопрос, на который он отвечает.
  3. Назначить технический context и проверить, что он одинаково представлен на каждом переходе.
  4. Описать message boundary: что переносится, кто создаёт новую операцию и что происходит при пустом context.
  5. Составить allow-list полей и отдельный список запретов для identity, свободного текста и query.
  6. Разделить outcome-class для transport, domain и retryable processing.
  7. Проверить учебным validator-ом положительный hand-off и три отрицательных случая: нет context, запрещённое поле, operational verb вместо plan.
  8. Только после этого согласовать реальный collector, access, retention, sampling и тест в разрешённой среде.

Ограничения

Один и тот же context в трёх литералах не доказывает, что заголовок дойдёт через browser, proxy и очередь. SVG не доказывает наличие SDK. Учебный код не измеряет latency, throughput, error rate или стоимость telemetry. OpenTelemetry и W3C задают терминологию и форматы, но не назначают словарь вашей команды, права доступа, срок хранения и правила редактирования данных.

Нельзя объявлять проблему решённой только потому, что тест снова прошёл. Повторный запуск мог попасть в другую ветку, а retry мог скрыть отказ. Нельзя объявлять metric безопасной только по короткому имени label. Нужны допустимые values и проверка неизвестного значения. Если вопрос требует индивидуального payload, его нельзя протащить в агрегат под видом «диагностики».

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

Материал готов к отдельному design review, когда для одного пути есть карта UI → API → worker, названный владелец каждой границы, один technical context, allow-list полей и словарь outcome-class. Validator должен вернуть только synthetic-observability-plan-hand-off для полного учебного объекта. Для пустого context он обязан вернуть stop-broken-correlation-context; для forbidden field — stop-forbidden-signal-field. Ни один результат не должен называться deploy, rollout или production success.

После этого готовность системы проверяют уже другими средствами: разрешённым тестом propagation, проверкой редактирования данных, контролем доступа, измерением cardinality и сопоставлением реального trace с запросом. Пока этих доказательств нет, корректный итог — ограниченный hand-off и точный stop, а не красивая легенда о сквозной наблюдаемости.

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

" } diff --git a/editorial/agent-rewrites/052.json b/editorial/agent-rewrites/052.json index 9d7ddc9..4bfce91 100644 --- a/editorial/agent-rewrites/052.json +++ b/editorial/agent-rewrites/052.json @@ -2,6 +2,6 @@ "index": 52, "slug": "editorial-2026-07-field-migration-playbook", "title": "Безопасный переход между старой и новой схемой", - "excerpt": "Как перенести поле или формат данных без разрыва совместимости: разделить чтение и запись, назвать состояние отката и остановиться при неполной проверке.", - "contentHtml": "

Запросы к старой версии API проходят, а часть новых клиентов получает 500 после записи нового поля. Через несколько минут становится непонятно, что возвращать: трафик на старый endpoint или данные к прежнему формату. Если откатить только маршрут, старый код может прочитать уже изменённую запись. Если откатить только схему, новый код потеряет обязательное поле. Цена ошибки — потерянные обновления, повторные операции и ручное восстановление согласованности.

\n

Безопасный переход начинается с совместимости, а не с переключателя. Старая и новая версии должны некоторое время читать общий набор данных. Каждое изменение делят на независимые состояния: код, маршрут и данные. Для каждого состояния называют условие возврата. Тогда отказ возвращает систему в известное состояние, а не просто включает старый URL.

\n

Механизм совместимого перехода

\n

Рассмотрим поле display_name, которое нужно заменить на объект profile_name. Старый клиент ожидает строку. Новый клиент ожидает объект с языком и значением. Удалять строку сразу нельзя: старый reader ещё может работать после переключения части трафика.

\n
  1. Добавьте новую форму данных как необязательную.
  2. На записи временно сохраняйте старую и новую формы из одного входного значения.
  3. На чтении нового клиента сначала используйте новую форму, затем совместимый fallback.
  4. Переключайте трафик только после проверки чтения и записи обеих версий.
  5. Удаляйте старую форму только после измеримого сигнала, что старые readers больше её не запрашивают.
\n

Эти шаги защищают только совместимость формата. Они не гарантируют правильность бизнес-правил, отсутствие дублей или сохранность данных после ошибочного повторного запроса. Такие свойства проверяют отдельно.

\n
\"Схема
Сначала сосуществуют две формы записи. Переключение трафика и возврат данных имеют разные условия.
\n

Пример записи и чтения

\n

Ниже учебный фрагмент на TypeScript. Он не подключается к базе и не показывает результат конкретного сервиса. Его задача — сделать порядок совместимости явным.

\n
type LegacyRecord = { display_name: string };\ntype MigratedRecord = LegacyRecord & {\n  profile_name?: { value: string; locale: string };\n};\n\nfunction writeBoth(input: string): MigratedRecord {\n  return {\n    display_name: input,\n    profile_name: { value: input, locale: 'ru-RU' },\n  };\n}\n\nfunction readForNewClient(record: MigratedRecord): string {\n  return record.profile_name?.value ?? record.display_name;\n}\n\nfunction canRemoveLegacyField(state: {\n  legacyReads: number;\n  newReads: number;\n  dataBackfillComplete: boolean;\n}): boolean {\n  return state.legacyReads === 0\n    && state.newReads > 0\n    && state.dataBackfillComplete;\n}
\n

В этом примере запись остаётся совместимой, пока существуют старые readers. Fallback защищает новую версию от неполной миграции данных, но не исправляет пустое или неверное значение. Функция удаления требует трёх наблюдаемых условий: старые чтения не встречаются, новая форма действительно читается, перенос данных завершён. В настоящей системе пороги и окно наблюдения задаёт владелец данных.

\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Старый клиент получает 500 после записиНовая форма стала обязательной для старого readerСравнить payload старого клиента и схему ответаВернуть optional-поле и сохранить старую форму
Новый клиент видит пустое имяНет fallback или запись прошла только в старую формуПроверить обе формы одной записиДобавить fallback и двойную запись
После возврата маршрута данные не совпадаютОткатили traffic state, но не определили data stateСопоставить версию reader с формой записиОстановить переключение и назвать восстановление данных
Старая колонка остаётся востребованнойВ системе есть старый consumer или кешПосчитать обращения по имени поля и версии клиентаНе удалять колонку; найти consumer
Повторная запись создаёт разные значенияДвойная запись неидемпотентнаПовторить request с тем же idempotency keyСделать запись идемпотентной
\n

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

\n

Возврат трафика меняет адрес или долю запросов. Он не возвращает уже записанные значения. Возврат данных меняет записи, журнал преобразований или источник чтения. Он не гарантирует, что запросы снова попадут в старый код. Эти операции имеют разные условия и риски.

\n

Например, новая версия записала profile_name, а старый reader читает display_name. Маршрут можно вернуть, только если старая форма сохраняется и содержит корректное значение. Если двойной записи не было, переключатель маршрута скрывает проблему до следующего чтения. Поэтому «можно откатить» — неполная формулировка. Нужно назвать объект отката, триггер и состояние после него.

\n

Отрицательный путь: нет условия восстановления

\n

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

\n
const migration = {\n  trafficState: 'candidate-25-percent',\n  dataState: 'dual-write',\n  rollback: {\n    trigger: '',\n    trafficState: 'legacy-100-percent',\n    dataState: '',\n  },\n};\n\nconst safeToSwitch = Boolean(\n  migration.rollback.trigger\n  && migration.rollback.trafficState\n  && migration.rollback.dataState\n);\n\nif (!safeToSwitch) {\n  throw new Error('rollback state is incomplete');\n}
\n

Этот код — учебная проверка структуры, а не механизм управления трафиком или базой. Он намеренно возвращает отрицательный путь. Пустой триггер и пустое состояние данных нельзя заменить общим словом «ошибка»: разные сбои требуют разных условий и действий.

\n

Ограничения

\n

Совместимая схема не решает проблему удаления данных, если старый и новый формат имеют разную семантику. Fallback может скрыть неполный перенос. Двойная запись может удвоить побочный эффект. Кеш может отдавать старую форму после изменения источника. Очередь может повторить сообщение. Для этих границ нужны идемпотентность, версия события, контроль источника и явное состояние обработки.

\n

Учебные значения не описывают пропускную способность, ошибки или поведение конкретной среды. Нельзя по ним утверждать, что переход завершился, что откат безопасен или что данные согласованы. Такие утверждения требуют наблюдений из системы за определённое окно и с понятной выборкой.

\n

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

\n

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

\n

После окончания перехода удаляйте старую форму в отдельном изменении. Сначала зафиксируйте нулевое чтение старого поля за согласованное окно, затем отключите запись старой формы, после этого удалите consumer и только потом меняйте схему. Каждый шаг должен иметь обратимое предыдущее состояние или явно названную причину необратимости.

\n

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

" + "excerpt": "Как перенести поле или формат данных без разрыва совместимости: разделить чтение и запись, проверить реальные границы и заранее назвать состояние отката.", + "contentHtml": "

После релиза старый клиент продолжает отправлять display_name, а новый уже ожидает объект profile_name. Часть запросов проходит чтение, но запись нового поля заканчивается ошибкой в старом обработчике. Команда возвращает трафик на прежний маршрут и видит зелёный статус, хотя несколько записей уже изменились. Цена такой путаницы — потерянные обновления, повторные операции и ручное восстановление согласованности.

\n

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

\n

Сначала зафиксируйте границу перехода

\n

Запишите один конкретный объект, а не «миграцию сервиса целиком». Для поля это могут быть таблица profiles, запись с идентификатором 42, endpoint PUT /profiles/42 и два consumer: старое мобильное приложение и новая web-версия. Такая запись позволяет связать симптом с запросом и данными. Если объект, владелец или потребитель не названы, причина ещё не проверяема.

\n

Разделите четыре состояния. Code state описывает версии reader и writer. Traffic state показывает, какой маршрут получает запросы. Data state говорит, заполнены ли обе формы и чем их сравнивают. Recovery state объясняет, что можно вернуть после частичной записи. Один флаг migrated не заменяет эту карту: он не говорит, кто уже читает новую форму.

\n
СостояниеЧто фиксируемНаблюдаемый сигналГраница решения
КодВерсия reader и writer, поддерживаемые поляОтвет старого и нового клиента на одной записиНовая версия не делает старую форму обязательной
ТрафикМаршрут, доля, control и candidateВерсия обработчика в журнале запросаСравниваются одинаковые route boundary
ДанныеИсточник, целевая форма, правило сверкиСчётчик несовпадений и выборка записейНет скрытого расхождения между формами
ВосстановлениеТриггер, владелец, traffic return и data returnЗафиксированный результат возвратаПонятно, что произойдёт после частичного успеха
\n

Control и candidate нужны даже при малой доле нового трафика. Без control новая версия сравнивается сама с собой. Доля 10% — это только распределение запросов, а не доказательство совместимости. Владелец перехода также должен различать «можно продолжать наблюдение» и «можно менять схему»: это разные решения.

\n

Расширяйте схему в несколько фаз

\n

Для замены строки на объект используйте расширение и последующее сужение. Сначала новая форма существует рядом со старой и остаётся необязательной. Затем writer формирует обе формы из одного входа. После заполнения старых записей readers переходят на новую форму с безопасным fallback. Лишь после окна наблюдения отключают старую запись и удаляют старый consumer.

\n
  1. Инвентаризируйте все readers и writers выбранного поля, включая фоновые задачи, кеши и повторную доставку сообщений.
  2. Добавьте profile_name без немедленного требования для старых записей. Новая запись должна сохранять и display_name, и объект.
  3. Заполните пропуски отдельной процедурой. На каждой порции считайте ошибки преобразования и несовпадения, а не только число обработанных строк.
  4. Переключите новый reader на новую форму, но оставьте fallback для записи, которая ещё не прошла backfill.
  5. Подавайте трафик ступенями. После каждой ступени проверяйте код ответа, расхождения данных, повторы записи и ошибки конкретного consumer.
  6. Отключите старую запись отдельным изменением. Удаляйте старую колонку и контракт только после нулевого чтения за согласованное окно и проверки восстановления.
\n

Такой порядок называется expand-and-contract, но название не является гарантией. Если новая и старая формы имеют разную семантику, автоматическое копирование строки в объект может создать корректный по типу, но неверный по смыслу результат. В этом случае сначала нужно определить правило преобразования и список значений, которые нельзя преобразовать автоматически.

\n
\"Схема
Переход проходит от конкретной записи и маршрута к проверяемому критерию. Неполное условие останавливает следующую ступень.
\n

Сделайте чтение и запись совместимыми

\n

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

\n
type LegacyRecord = { display_name: string };\ntype ProfileName = { value: string; locale: string };\ntype RecordV2 = LegacyRecord & { profile_name?: ProfileName };\n\nfunction normalize(input: string): ProfileName {\n  return { value: input.trim(), locale: 'ru-RU' };\n}\n\nfunction writeBoth(input: string): RecordV2 {\n  const profileName = normalize(input);\n  return {\n    display_name: profileName.value,\n    profile_name: profileName,\n  };\n}\n\nfunction readForNewClient(record: RecordV2): ProfileName | null {\n  if (record.profile_name) return record.profile_name;\n  if (record.display_name.trim() === '') return null;\n  return normalize(record.display_name);\n}\n\nfunction hasConflict(record: RecordV2): boolean {\n  return Boolean(\n    record.profile_name\n    && record.profile_name.value !== record.display_name,\n  );\n}
\n

В примере normalize — проектное правило, а не свойство PostgreSQL или HTTP. Его нужно согласовать с предметной областью: пробелы, регистр и локаль могут быть значимыми. Функция hasConflict намеренно не выбирает «более новую» форму. При конфликте безопаснее остановить запись и сохранить исходные значения для расследования, чем потерять одно из них.

\n

Для writer отдельно задайте повторную обработку. Если запрос оборвался после записи старой формы, но до записи новой, повтор может завершить операцию. Если повтор создаёт новую побочную запись или меняет значение ещё раз, одного HTTP-метода недостаточно. Храните ключ операции, результат и версию входа там, где это требуется вашему хранилищу.

\n

Проверьте одну запись и одну повторную попытку

\n

До переключения трафика возьмите одну тестовую запись и прогоните полный цикл: старый запрос, новый запрос, двойная запись, чтение обеими версиями и повтор после искусственного обрыва ответа. В реальной базе сначала проверьте план и блокировки на копии или в тестовой среде. В PostgreSQL добавление необязательной колонки и заполнение строк — разные операции, поэтому их и измеряют отдельно.

\n
-- Фаза expand: новая форма пока допускает NULL.\nALTER TABLE profiles ADD COLUMN profile_name jsonb;\n\n-- Проверка согласованности после backfill.\nSELECT count(*) AS conflicts\nFROM profiles\nWHERE profile_name IS NOT NULL\n  AND profile_name->>'value' <> display_name;\n\n-- Фаза contract выполняется только после нулевого conflicts\n-- и подтверждения, что старые readers больше не обращаются к колонке.\n-- ALTER TABLE profiles DROP COLUMN display_name;
\n

Этот SQL не является готовой миграцией для любого проекта. В нём нет блокировок, размера таблицы, индексов, прав, времени выполнения и правила обработки NULL. Запрос сверяет только одно поле и может быть недостаточен для реального контракта. Перед DROP COLUMN сохраните экспорт или другой согласованный способ восстановления: удалённое значение не вернётся от одного отката приложения.

\n

Повторную запись проверяйте тем же ключом операции. Если в API используется POST, не называйте его идемпотентным только из-за того, что сервер старается распознать повторы. Идемпотентность — свойство намеренного эффекта при нескольких одинаковых запросах; его нужно реализовать и проверить на уровне приложения. Для каждой операции зафиксируйте: ключ, вход, первую запись, ответ и результат повторной попытки.

\n

Разделяйте возврат трафика и возврат данных

\n

Возврат трафика меняет, какой код получает следующий запрос. Возврат данных меняет записи, журнал преобразований или источник чтения. Эти действия могут выполняться в разное время и иметь разного владельца. Если новая версия уже сохранила profile_name, переключение маршрута на старый reader не отменит эту запись.

\n

На уровне Kubernetes команда kubectl rollout undo возвращает Deployment к прежней ревизии Pod template. Это полезный способ вернуть контейнер и его конфигурацию, но он не восстанавливает строки в базе и не меняет внешний балансировщик, если тот управляется отдельно. После команды проверяйте фактическое состояние Deployment и отдельно — состояние записи, очереди и кеша.

\n
kubectl rollout history deployment/profile-api\nkubectl rollout undo deployment/profile-api --to-revision=12\nkubectl rollout status deployment/profile-api\n\n# Отдельно проверяем данные через приложение или read-only запрос:\ncurl -sS https://api.example.test/profiles/42 -H 'X-Client-Version: legacy'
\n

Команды требуют подходящих прав и контекста кластера; URL и номер ревизии здесь учебные. Не запускайте их в production по копированию из статьи. Сначала определите, кто владеет маршрутизацией, кто владеет схемой и какой сигнал подтверждает результат каждого возврата.

\n

Остановитесь по конкретному симптому

\n
СимптомПроверкаВероятная границаДействие
Старый клиент получает 500 после записиСравнить его payload, схему ответа и версию writerНовая форма стала обязательной слишком раноВернуть optional-поле и двойную запись
Новый клиент видит старое имяПроверить приоритет reader и наличие новой формы у записиBackfill не завершён или кеш не инвалидированОставить fallback и найти источник устаревшего ответа
Формы одной записи расходятсяСравнить нормализованный вход, время записи и ключ операцииДве записи не были атомарными или повторилисьОстановить ступень и запустить reconciliation
После rollback приложения данные остались новымиСопоставить revision Deployment и data state записиВозвращён только Pod templateОтдельно выбрать restore или новый reader с fallback
Старая колонка всё ещё читаетсяПосчитать обращения по consumer, кешу и фоновой задачеНе найден параллельный readerНе удалять колонку и продолжить инвентаризацию
Повтор запроса создаёт второй эффектПовторить тот же input и ключ после обрыва ответаОперация не идемпотентна на уровне приложенияДобавить дедупликацию и проверку результата повтора
\n

Остановка должна оставлять диагностический артефакт: идентификатор записи, request id, версию consumer, обе формы данных и причину остановки. Общий статус «ошибка миграции» не помогает восстановить порядок событий. Чем меньше вход фиксирован, тем быстрее можно отличить ошибку преобразования от ошибки маршрутизации.

\n

Критерий готовности следующей ступени

\n

Переход можно расширять только после проверки на выбранном срезе. Для каждой ступени сохраните значения следующих полей: одинаковая route boundary у control и candidate; версия reader и writer; окно наблюдения; число записей с обеими формами; число конфликтов; количество повторов; trigger возврата; traffic return; data return; владелец решения. Нулевой конфликт без указанного окна не является универсальным доказательством: он может означать, что выборка не охватила нужные записи.

\n
  1. Проверьте, что старый reader получает допустимый ответ до переключения.
  2. Проверьте, что новый reader одинаково обрабатывает новую форму и fallback.
  3. Повторите запись с тем же ключом и сравните побочный эффект.
  4. Намеренно остановите тестовый переход и подтвердите оба результата: маршрут вернулся, а данные либо восстановлены, либо явно остались в новой форме, которую умеет читать старый клиент.
  5. Только после этого увеличивайте долю candidate и записывайте решение в журнал изменения.
\n

После достижения 100% нового reader не переходите сразу к удалению. Оставьте отдельное окно для фоновых задач, кешей, отложенных сообщений и клиентов, которые обновляются медленнее сервера. Старую форму отключайте отдельным релизом, чтобы при проблеме можно было вернуть writer без смешения двух причин.

\n

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

\n

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

\n

Учебные идентификаторы, доли трафика и ответы не являются измерениями конкретной системы. Нельзя по ним утверждать отсутствие потерь, безопасное время выполнения SQL или успешный rollout. Kubernetes, PostgreSQL и HTTP имеют свои версии, настройки и права. Проверяйте команды на версии вашего кластера, размер таблицы, политики кеширования и фактический контракт API.

\n

Fallback тоже имеет цену. Он может скрыть незаполненную новую форму, а двойная запись — увеличить задержку и число мест отказа. Если читатель не может отличить «нового значения нет» от «новое значение не удалось сохранить», fallback маскирует дефект. Введите отдельный сигнал для пропуска, конфликта и технической ошибки, иначе решение будет принято по неполному наблюдению.

\n

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

\n" }