Files

8 lines
18 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&lt;?php function writeDiagnostic(array $record) { error_log(json_encode($record, JSON_UNESCAPED_UNICODE)); } function installDiagnostics($requestId, $operation, $externalId) { $context = array('request_id' =&gt; $requestId, 'operation' =&gt; $operation, 'external_id' =&gt; $externalId, 'stage' =&gt; 'started'); $setStage = function ($stage) use (&amp;$context) { $context['stage'] = $stage; }; set_error_handler(function ($severity, $message, $file, $line) use (&amp;$context) { if (!(error_reporting() &amp; $severity)) { return false; } writeDiagnostic($context + array('kind' =&gt; 'php_error', 'error_type' =&gt; $severity, 'message' =&gt; $message, 'file' =&gt; $file, 'line' =&gt; $line)); return false; }); set_exception_handler(function (Throwable $error) use (&amp;$context) { writeDiagnostic($context + array('kind' =&gt; 'uncaught_throwable', 'class' =&gt; get_class($error), 'message' =&gt; $error-&gt;getMessage(), 'file' =&gt; $error-&gt;getFile(), 'line' =&gt; $error-&gt;getLine())); }); register_shutdown_function(function () use (&amp;$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' =&gt; 'fatal_error', 'error_type' =&gt; $last['type'], 'message' =&gt; $last['message'], 'file' =&gt; $last['file'], 'line' =&gt; $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>"
}