8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 357,
|
||
"slug": "editorial-2018-02-practice-php-diagnostics",
|
||
"title": "PHP-интеграция вернула 500: как сохранить причину сбоя",
|
||
"excerpt": "Разбираем, почему одного set_error_handler недостаточно для диагностики PHP-интеграции, как связать ошибку с операцией и где проходит граница shutdown-обработчика.",
|
||
"contentHtml": "<p>Интеграционный endpoint вернул 500. В журнале остались только время и путь к скрипту. Партнёр повторил запрос позже, но уже с другим идентификатором. Вторая попытка прошла, поэтому по журналу нельзя понять, где остановился первый запрос.</p>\n<p>Цена такой ошибки — не сам HTTP-статус. Команда теряет исходные факты и повторяет расследование вслепую. Она проверяет сеть, базу и чужой API в случайном порядке. Иногда разработчик добавляет ещё один <code>try/catch</code>, но фатальная ошибка PHP может не дойти до этого блока.</p>\n<p>Тезис статьи простой: диагностике нужен короткий контекст операции и отдельная ветка для каждого класса сбоя. <code>set_error_handler()</code> фиксирует обрабатываемые ошибки. <code>set_exception_handler()</code> фиксирует непойманный <code>Throwable</code>. Shutdown-функция проверяет последнюю ошибку после завершения скрипта. Ни одна из веток не заменяет остальные.</p>\n<figure><img src=\"/assets/editorial/2018/php-fatal-context-flow.svg\" alt=\"Поток диагностики PHP: контекст операции проходит через обработчик ошибки, обработчик исключения и shutdown-функцию\" /><figcaption>Контекст создаётся до внешнего вызова. Поэтому запись сохраняет операцию и этап даже тогда, когда код не успел сформировать ответ.</figcaption></figure>\n<h2>Механизм: три точки перехвата в PHP 7+</h2>\n<p>Пользовательский обработчик ошибок не получает <code>E_ERROR</code>, <code>E_PARSE</code>, <code>E_CORE_ERROR</code>, <code>E_CORE_WARNING</code>, <code>E_COMPILE_ERROR</code> и <code>E_COMPILE_WARNING</code>. Он также не видит ошибку, которая произошла до его регистрации. Это граница API, а не случайная особенность конкретного проекта. Если не передать свой список <code>error_levels</code>, переданная функция вызывается для каждого уровня ошибки независимо от настройки <code>error_reporting()</code>; в примере обработчик проверяет текущий уровень перед записью и возвращает <code>false</code>, если уровень отключён.</p>\n<p>Обработчик исключений вызывается, если исключение не поймал <code>try/catch</code>. В PHP 7 и новее его аргументом может быть любой <code>Throwable</code>: и <code>Exception</code>, и <code>Error</code>. После вызова обработчика выполнение запроса заканчивается, поэтому сам обработчик не должен бросать новое исключение.</p>\n<p>Shutdown-функция выполняется после завершения скрипта или после <code>exit()</code>. В ней можно вызвать <code>error_get_last()</code> и проверить тип последней ошибки. Это страховочный слой для части фатальных ошибок. Он не ловит синтаксическую ошибку в файле, который не дал приложению запуститься, и не спасает процесс, убитый сигналом.</p>\n<p>Логируйте не весь запрос, а минимальный контекст: идентификатор операции, имя интеграции, внешний ID, текущий этап, тип сбоя, файл и строку. Тело запроса, токены, cookies и ответы партнёра могут содержать секреты и персональные данные. Их нельзя добавлять в журнал только ради удобства расследования.</p>\n<h2>Контекст, который отвечает на вопросы</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Пример</th><th scope=\"col\">Вопрос</th></tr></thead><tbody><tr><td><code>request_id</code></td><td><code>sync-4f2a</code></td><td>С какой записью веб-сервера связать событие?</td></tr><tr><td><code>operation</code></td><td><code>order_export</code></td><td>Какой сценарий выполнялся?</td></tr><tr><td><code>external_id</code></td><td><code>ORD-9182</code></td><td>Какой объект повторить в тестовой среде?</td></tr><tr><td><code>stage</code></td><td><code>partner_called</code></td><td>Успел ли код вызвать партнёра?</td></tr><tr><td><code>kind</code></td><td><code>fatal_error</code></td><td>Какая ветка диагностики сработала?</td></tr><tr><td><code>file</code>, <code>line</code></td><td>путь и номер строки</td><td>Где открыть ту версию кода?</td></tr></tbody></table></div>\n<p>Этап меняйте до побочного эффекта, а не после него. Перед запросом поставьте <code>request_prepared</code>, перед отправкой — <code>partner_called</code>, после сохранения ответа — <code>response_saved</code>. Если процесс оборвался между двумя метками, журнал всё равно покажет последнюю завершённую границу.</p>\n<h2>Учебный пример обвязки для PHP 7+</h2>\n<p>Ниже минимальный пример для одного HTTP-запроса. Он рассчитан на PHP 7+: использует <code>Throwable</code> и синтаксис анонимных функций с захватом переменных. Он показывает механизм, а не готовый production-логгер. В реальном приложении запись должна использовать общий структурированный лог, маскирование полей и защиту от повторной записи. Учебные значения идентификаторов вымышлены.</p>\n<pre><code><?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');</code></pre>\n<p>В примере обработчик ошибки возвращает <code>false</code>. Это передаёт событие стандартному обработчику PHP и не меняет поведение приложения незаметно. Если проект хочет превращать предупреждения в исключения, это нужно сделать отдельным решением и проверить для каждого типа ошибки.</p>\n<p>Shutdown-функция не должна отправлять сетевой запрос. При падении сети такой вызов может зависнуть и потерять исходную запись. Используйте локальный канал логирования, который уже принят в приложении. Если запись сама завершилась ошибкой, диагностический слой добавит шум вместо факта.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>В журнале только 500</td><td>Контекст не создаётся до интеграции</td><td>Найти место регистрации обработчиков и первую метку этапа</td><td>Создать контекст до подготовки запроса</td></tr><tr><td>Предупреждение есть, штатная запись исчезла</td><td>Обработчик не вернул <code>false</code></td><td>Проверить возврат из callback</td><td>Вернуть <code>false</code> или явно принять новую политику</td></tr><tr><td>Исключение не связано с операцией</td><td>Обработчик установлен после внешнего вызова</td><td>Сравнить порядок регистрации и вызова клиента</td><td>Установить его в точке входа</td></tr><tr><td>Фатальная ошибка не записалась</td><td>Ошибка произошла до регистрации или процесс убит сигналом</td><td>Проверить начало выполнения и код завершения процесса</td><td>Добавить раннюю регистрацию и смотреть журналы окружения</td></tr><tr><td>Одна ошибка записалась дважды</td><td>Её обработали и exception-handler, и shutdown-handler</td><td>Сравнить <code>kind</code>, время и ID операции</td><td>Добавить признак уже записанного события</td></tr><tr><td>В журнале оказался токен</td><td>Записали входной запрос целиком</td><td>Проверить схему полей и тестовые записи</td><td>Оставить allowlist полей и маскировать секреты</td></tr></tbody></table></div>\n<h2>Порядок проверки</h2>\n<ol><li>Найдите точку входа интеграции и зарегистрируйте обработчики до создания клиента и первого побочного эффекта.</li><li>Создайте <code>request_id</code>, имя операции и внешний ID. Запишите только разрешённые поля.</li><li>Добавьте этапы перед подготовкой запроса, перед внешним вызовом и после сохранения результата.</li><li>Запустите изолированный учебный сценарий с <code>trigger_error()</code>. Проверьте поля записи и сохранение штатного поведения.</li><li>Запустите отдельный тест с непойманным <code>RuntimeException</code> или <code>Error</code>. Убедитесь, что запись содержит класс и последнюю метку.</li><li>В тестовой среде отдельным процессом проверьте фатальную ветку после регистрации shutdown-функции. Не используйте для этого реальный заказ или боевой endpoint. <code>E_USER_ERROR</code> для такой проверки не подходит: этот тип приходит в пользовательский обработчик ошибок, а не в запасной shutdown-путь.</li><li>Проверьте дубли, маскирование и связь с журналом веб-сервера. Удалите учебные идентификаторы до публикации кода.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Схема не исправляет причину сбоя. Она только сохраняет факт, который иначе исчезнет. Она не перехватывает синтаксическую ошибку в файле, который не был выполнен. Она не гарантирует запись при <code>SIGKILL</code>, аварии хоста или потере диска. Для этих случаев нужны проверки сборки, журналы PHP-FPM или веб-сервера и мониторинг инфраструктуры.</p>\n<p>Она также не даёт права регистрировать секреты. Сообщение исключения может содержать URL с query-параметрами, SQL-фрагмент или ответ внешнего сервиса. Перед записью задайте allowlist полей и правило маскирования. Если сомневаетесь, сохраните тип, этап и внутренний код ошибки, а не исходный текст.</p>\n<p>Не ставьте shutdown-обработчик единственным способом узнать о 500. Обычные исключения должны обрабатываться на своём уровне, а HTTP-статус и трассировка должны оставаться в системах наблюдаемости. Этот слой нужен для связи ошибки с конкретной бизнес-операцией.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Диагностика готова, если в изолированных тестах каждая из трёх веток оставляет одну запись с <code>request_id</code>, операцией, внешним ID, последним этапом и местом ошибки. Предупреждение сохраняет ожидаемое штатное поведение. Непойманный <code>Throwable</code> не создаёт вторую ошибку при исправном логгере. Фатальный тест запускается отдельным процессом и не меняет данные внешней системы. В записи нет токенов, паролей и полного тела запроса.</p>\n<p>После этого проверьте отрицательный путь: ошибка до регистрации, принудительное завершение процесса, недоступный логгер и повторная запись. Для каждого случая должно быть понятно, какой внешний журнал или контрольный сигнал принимает эстафету. Если такого ответа нет, схема ещё не описывает реальные границы системы.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.php.net/manual/en/function.set-error-handler.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: set_error_handler()</a> — перехватываемые и неперехватываемые типы ошибок</li><li><a href=\"https://www.php.net/manual/en/function.set-exception-handler.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: set_exception_handler()</a> — обработка непойманного <code>Throwable</code></li><li><a href=\"https://www.php.net/manual/en/function.register-shutdown-function.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: register_shutdown_function()</a> и <a href=\"https://www.php.net/manual/en/function.error-get-last.php\" target=\"_blank\" rel=\"noopener\">error_get_last()</a> — данные последней ошибки</li><li><a href=\"https://www.php.net/manual/en/function.error-reporting.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: error_reporting()</a> — текущий уровень, который учитывает обработчик</li></ul>"
|
||
}
|