{ "index": 357, "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, а не случайная особенность конкретного проекта. Если не передать свой список 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