diff --git a/editorial/agent-rewrites/357.json b/editorial/agent-rewrites/357.json index be9297e..61e43af 100644 --- a/editorial/agent-rewrites/357.json +++ b/editorial/agent-rewrites/357.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-02-practice-php-diagnostics", "title": "PHP-интеграция вернула 500: как сохранить причину сбоя", "excerpt": "Разбираем, почему одного set_error_handler недостаточно для диагностики PHP-интеграции, как связать ошибку с операцией и где проходит граница shutdown-обработчика.", - "contentHtml": "
Интеграционный endpoint вернул 500. В журнале остались только время и путь к скрипту. Партнёр повторил запрос позже, но уже с другим идентификатором. Вторая попытка прошла, поэтому по журналу нельзя понять, где остановился первый запрос.
\nЦена такой ошибки — не сам HTTP-статус. Команда теряет исходные факты и повторяет расследование вслепую. Она проверяет сеть, базу и чужой API в случайном порядке. Иногда разработчик добавляет ещё один try/catch, но фатальная ошибка PHP может не дойти до этого блока.
Тезис статьи простой: диагностике нужен короткий контекст операции и отдельная ветка для каждого класса сбоя. set_error_handler() фиксирует обрабатываемые ошибки. set_exception_handler() фиксирует непойманный Throwable. Shutdown-функция проверяет последнюю ошибку после завершения скрипта. Ни одна из веток не заменяет остальные.
Пользовательский обработчик ошибок не получает E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR и E_COMPILE_WARNING. Он также не видит ошибку, которая произошла до его регистрации. Это граница API, а не случайная особенность конкретного проекта.
Обработчик исключений вызывается, если исключение не поймал try/catch. В PHP 7 и новее его аргументом может быть любой Throwable: и Exception, и Error. После вызова обработчика выполнение запроса заканчивается, поэтому сам обработчик не должен бросать новое исключение.
Shutdown-функция выполняется после завершения скрипта или после exit(). В ней можно вызвать error_get_last() и проверить тип последней ошибки. Это страховочный слой для части фатальных ошибок. Он не ловит синтаксическую ошибку в файле, который не дал приложению запуститься, и не спасает процесс, убитый сигналом.
Логируйте не весь запрос, а минимальный контекст: идентификатор операции, имя интеграции, внешний ID, текущий этап, тип сбоя, файл и строку. Тело запроса, токены, cookies и ответы партнёра могут содержать секреты и персональные данные. Их нельзя добавлять в журнал только ради удобства расследования.
\n| Поле | Пример | Вопрос |
|---|---|---|
request_id | sync-4f2a | С какой записью веб-сервера связать событие? |
operation | order_export | Какой сценарий выполнялся? |
external_id | ORD-9182 | Какой объект повторить в тестовой среде? |
stage | partner_called | Успел ли код вызвать партнёра? |
kind | fatal_error | Какая ветка диагностики сработала? |
file, line | путь и номер строки | Где открыть ту версию кода? |
Этап меняйте до побочного эффекта, а не после него. Перед запросом поставьте request_prepared, перед отправкой — partner_called, после сохранения ответа — response_saved. Если процесс оборвался между двумя метками, журнал всё равно покажет последнюю завершённую границу.
Ниже минимальный пример для одного HTTP-запроса. Он показывает механизм, а не готовый production-логгер. В реальном приложении запись должна использовать общий структурированный лог, маскирование полей и защиту от повторной записи. Учебные значения идентификаторов вымышлены.
\n<?php function writeDiagnostic(array $record) { error_log(json_encode($record, JSON_UNESCAPED_UNICODE)); } function installDiagnostics($requestId, $operation, $externalId) { $context = array('request_id' => $requestId, 'operation' => $operation, 'external_id' => $externalId, 'stage' => 'started'); $setStage = function ($stage) use (&$context) { $context['stage'] = $stage; }; set_error_handler(function ($severity, $message, $file, $line) use (&$context) { if (!(error_reporting() & $severity)) { return false; } writeDiagnostic($context + array('kind' => 'php_error', 'error_type' => $severity, 'message' => $message, 'file' => $file, 'line' => $line)); return false; }); set_exception_handler(function (Throwable $error) use (&$context) { writeDiagnostic($context + array('kind' => 'uncaught_throwable', 'class' => get_class($error), 'message' => $error->getMessage(), 'file' => $error->getFile(), 'line' => $error->getLine())); }); register_shutdown_function(function () use (&$context) { $last = error_get_last(); $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR); if ($last === null || !in_array($last['type'], $fatalTypes, true)) { return; } writeDiagnostic($context + array('kind' => 'fatal_error', 'error_type' => $last['type'], 'message' => $last['message'], 'file' => $last['file'], 'line' => $last['line'])); }); return $setStage; } $setStage = installDiagnostics('sync-4f2a', 'order_export', 'ORD-9182'); $setStage('request_prepared'); // Здесь находится вызов клиента партнёра. $setStage('partner_called');\nВ примере обработчик ошибки возвращает false. Это передаёт событие стандартному обработчику PHP и не меняет поведение приложения незаметно. Если проект хочет превращать предупреждения в исключения, это нужно сделать отдельным решением и проверить для каждого типа ошибки.
Shutdown-функция не должна отправлять сетевой запрос. При падении сети такой вызов может зависнуть и потерять исходную запись. Используйте локальный канал логирования, который уже принят в приложении. Если запись сама завершилась ошибкой, диагностический слой добавит шум вместо факта.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В журнале только 500 | Контекст не создаётся до интеграции | Найти место регистрации обработчиков и первую метку этапа | Создать контекст до подготовки запроса |
| Предупреждение есть, штатная запись исчезла | Обработчик не вернул false | Проверить возврат из callback | Вернуть false или явно принять новую политику |
| Исключение не связано с операцией | Обработчик установлен после внешнего вызова | Сравнить порядок регистрации и вызова клиента | Установить его в точке входа |
| Фатальная ошибка не записалась | Ошибка произошла до регистрации или процесс убит сигналом | Проверить начало выполнения и код завершения процесса | Добавить раннюю регистрацию и смотреть журналы окружения |
| Одна ошибка записалась дважды | Её обработали и exception-handler, и shutdown-handler | Сравнить kind, время и ID операции | Добавить признак уже записанного события |
| В журнале оказался токен | Записали входной запрос целиком | Проверить схему полей и тестовые записи | Оставить allowlist полей и маскировать секреты |
request_id, имя операции и внешний ID. Запишите только разрешённые поля.trigger_error(). Проверьте поля записи и сохранение штатного поведения.RuntimeException или Error. Убедитесь, что запись содержит класс и последнюю метку.Схема не исправляет причину сбоя. Она только сохраняет факт, который иначе исчезнет. Она не перехватывает синтаксическую ошибку в файле, который не был выполнен. Она не гарантирует запись при SIGKILL, аварии хоста или потере диска. Для этих случаев нужны проверки сборки, журналы PHP-FPM или веб-сервера и мониторинг инфраструктуры.
Она также не даёт права регистрировать секреты. Сообщение исключения может содержать URL с query-параметрами, SQL-фрагмент или ответ внешнего сервиса. Перед записью задайте allowlist полей и правило маскирования. Если сомневаетесь, сохраните тип, этап и внутренний код ошибки, а не исходный текст.
\nНе ставьте shutdown-обработчик единственным способом узнать о 500. Обычные исключения должны обрабатываться на своём уровне, а HTTP-статус и трассировка должны оставаться в системах наблюдаемости. Этот слой нужен для связи ошибки с конкретной бизнес-операцией.
\nДиагностика готова, если в изолированных тестах каждая из трёх веток оставляет одну запись с request_id, операцией, внешним ID, последним этапом и местом ошибки. Предупреждение сохраняет ожидаемое штатное поведение. Непойманный Throwable не создаёт вторую ошибку. Фатальный тест не меняет данные внешней системы. В записи нет токенов, паролей и полного тела запроса.
После этого проверьте отрицательный путь: ошибка до регистрации, принудительное завершение процесса, недоступный логгер и повторная запись. Для каждого случая должно быть понятно, какой внешний журнал или контрольный сигнал принимает эстафету. Если такого ответа нет, схема ещё не описывает реальные границы системы.
\nThrowableИнтеграционный endpoint вернул 500. В журнале остались только время и путь к скрипту. Партнёр повторил запрос позже, но уже с другим идентификатором. Вторая попытка прошла, поэтому по журналу нельзя понять, где остановился первый запрос.
\nЦена такой ошибки — не сам HTTP-статус. Команда теряет исходные факты и повторяет расследование вслепую. Она проверяет сеть, базу и чужой API в случайном порядке. Иногда разработчик добавляет ещё один try/catch, но фатальная ошибка PHP может не дойти до этого блока.
Тезис статьи простой: диагностике нужен короткий контекст операции и отдельная ветка для каждого класса сбоя. set_error_handler() фиксирует обрабатываемые ошибки. set_exception_handler() фиксирует непойманный Throwable. Shutdown-функция проверяет последнюю ошибку после завершения скрипта. Ни одна из веток не заменяет остальные.
Пользовательский обработчик ошибок не получает E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR и E_COMPILE_WARNING. Он также не видит ошибку, которая произошла до его регистрации. Это граница API, а не случайная особенность конкретного проекта. Если не передать свой список error_levels, переданная функция вызывается для каждого уровня ошибки независимо от настройки error_reporting(); в примере обработчик проверяет текущий уровень перед записью и возвращает false, если уровень отключён.
Обработчик исключений вызывается, если исключение не поймал try/catch. В PHP 7 и новее его аргументом может быть любой Throwable: и Exception, и Error. После вызова обработчика выполнение запроса заканчивается, поэтому сам обработчик не должен бросать новое исключение.
Shutdown-функция выполняется после завершения скрипта или после exit(). В ней можно вызвать error_get_last() и проверить тип последней ошибки. Это страховочный слой для части фатальных ошибок. Он не ловит синтаксическую ошибку в файле, который не дал приложению запуститься, и не спасает процесс, убитый сигналом.
Логируйте не весь запрос, а минимальный контекст: идентификатор операции, имя интеграции, внешний ID, текущий этап, тип сбоя, файл и строку. Тело запроса, токены, cookies и ответы партнёра могут содержать секреты и персональные данные. Их нельзя добавлять в журнал только ради удобства расследования.
\n| Поле | Пример | Вопрос |
|---|---|---|
request_id | sync-4f2a | С какой записью веб-сервера связать событие? |
operation | order_export | Какой сценарий выполнялся? |
external_id | ORD-9182 | Какой объект повторить в тестовой среде? |
stage | partner_called | Успел ли код вызвать партнёра? |
kind | fatal_error | Какая ветка диагностики сработала? |
file, line | путь и номер строки | Где открыть ту версию кода? |
Этап меняйте до побочного эффекта, а не после него. Перед запросом поставьте request_prepared, перед отправкой — partner_called, после сохранения ответа — response_saved. Если процесс оборвался между двумя метками, журнал всё равно покажет последнюю завершённую границу.
Ниже минимальный пример для одного HTTP-запроса. Он рассчитан на PHP 7+: использует Throwable и синтаксис анонимных функций с захватом переменных. Он показывает механизм, а не готовый production-логгер. В реальном приложении запись должна использовать общий структурированный лог, маскирование полей и защиту от повторной записи. Учебные значения идентификаторов вымышлены.
<?php function writeDiagnostic(array $record) { error_log(json_encode($record, JSON_UNESCAPED_UNICODE)); } function installDiagnostics($requestId, $operation, $externalId) { $context = array('request_id' => $requestId, 'operation' => $operation, 'external_id' => $externalId, 'stage' => 'started'); $setStage = function ($stage) use (&$context) { $context['stage'] = $stage; }; set_error_handler(function ($severity, $message, $file, $line) use (&$context) { if (!(error_reporting() & $severity)) { return false; } writeDiagnostic($context + array('kind' => 'php_error', 'error_type' => $severity, 'message' => $message, 'file' => $file, 'line' => $line)); return false; }); set_exception_handler(function (Throwable $error) use (&$context) { writeDiagnostic($context + array('kind' => 'uncaught_throwable', 'class' => get_class($error), 'message' => $error->getMessage(), 'file' => $error->getFile(), 'line' => $error->getLine())); }); register_shutdown_function(function () use (&$context) { $last = error_get_last(); $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR); if ($last === null || !in_array($last['type'], $fatalTypes, true)) { return; } writeDiagnostic($context + array('kind' => 'fatal_error', 'error_type' => $last['type'], 'message' => $last['message'], 'file' => $last['file'], 'line' => $last['line'])); }); return $setStage; } $setStage = installDiagnostics('sync-4f2a', 'order_export', 'ORD-9182'); $setStage('request_prepared'); // Здесь находится вызов клиента партнёра. $setStage('partner_called');\nВ примере обработчик ошибки возвращает false. Это передаёт событие стандартному обработчику PHP и не меняет поведение приложения незаметно. Если проект хочет превращать предупреждения в исключения, это нужно сделать отдельным решением и проверить для каждого типа ошибки.
Shutdown-функция не должна отправлять сетевой запрос. При падении сети такой вызов может зависнуть и потерять исходную запись. Используйте локальный канал логирования, который уже принят в приложении. Если запись сама завершилась ошибкой, диагностический слой добавит шум вместо факта.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В журнале только 500 | Контекст не создаётся до интеграции | Найти место регистрации обработчиков и первую метку этапа | Создать контекст до подготовки запроса |
| Предупреждение есть, штатная запись исчезла | Обработчик не вернул false | Проверить возврат из callback | Вернуть false или явно принять новую политику |
| Исключение не связано с операцией | Обработчик установлен после внешнего вызова | Сравнить порядок регистрации и вызова клиента | Установить его в точке входа |
| Фатальная ошибка не записалась | Ошибка произошла до регистрации или процесс убит сигналом | Проверить начало выполнения и код завершения процесса | Добавить раннюю регистрацию и смотреть журналы окружения |
| Одна ошибка записалась дважды | Её обработали и exception-handler, и shutdown-handler | Сравнить kind, время и ID операции | Добавить признак уже записанного события |
| В журнале оказался токен | Записали входной запрос целиком | Проверить схему полей и тестовые записи | Оставить allowlist полей и маскировать секреты |
request_id, имя операции и внешний ID. Запишите только разрешённые поля.trigger_error(). Проверьте поля записи и сохранение штатного поведения.RuntimeException или Error. Убедитесь, что запись содержит класс и последнюю метку.E_USER_ERROR для такой проверки не подходит: этот тип приходит в пользовательский обработчик ошибок, а не в запасной shutdown-путь.Схема не исправляет причину сбоя. Она только сохраняет факт, который иначе исчезнет. Она не перехватывает синтаксическую ошибку в файле, который не был выполнен. Она не гарантирует запись при SIGKILL, аварии хоста или потере диска. Для этих случаев нужны проверки сборки, журналы PHP-FPM или веб-сервера и мониторинг инфраструктуры.
Она также не даёт права регистрировать секреты. Сообщение исключения может содержать URL с query-параметрами, SQL-фрагмент или ответ внешнего сервиса. Перед записью задайте allowlist полей и правило маскирования. Если сомневаетесь, сохраните тип, этап и внутренний код ошибки, а не исходный текст.
\nНе ставьте shutdown-обработчик единственным способом узнать о 500. Обычные исключения должны обрабатываться на своём уровне, а HTTP-статус и трассировка должны оставаться в системах наблюдаемости. Этот слой нужен для связи ошибки с конкретной бизнес-операцией.
\nДиагностика готова, если в изолированных тестах каждая из трёх веток оставляет одну запись с request_id, операцией, внешним ID, последним этапом и местом ошибки. Предупреждение сохраняет ожидаемое штатное поведение. Непойманный Throwable не создаёт вторую ошибку при исправном логгере. Фатальный тест запускается отдельным процессом и не меняет данные внешней системы. В записи нет токенов, паролей и полного тела запроса.
После этого проверьте отрицательный путь: ошибка до регистрации, принудительное завершение процесса, недоступный логгер и повторная запись. Для каждого случая должно быть понятно, какой внешний журнал или контрольный сигнал принимает эстафету. Если такого ответа нет, схема ещё не описывает реальные границы системы.
\nThrowable