From 88771946ec280f9de6dc1ac3dc0324bc7a18074a Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:15:27 +0300 Subject: [PATCH] Editorial: polish PHP diagnostics article 357 --- editorial/agent-rewrites/357.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 может не дойти до этого блока.

\n

Тезис статьи простой: диагностике нужен короткий контекст операции и отдельная ветка для каждого класса сбоя. set_error_handler() фиксирует обрабатываемые ошибки. set_exception_handler() фиксирует непойманный Throwable. Shutdown-функция проверяет последнюю ошибку после завершения скрипта. Ни одна из веток не заменяет остальные.

\n
\"Поток
Контекст создаётся до внешнего вызова. Поэтому запись сохраняет операцию и этап даже тогда, когда код не успел сформировать ответ.
\n

Механизм: три разных точки перехвата

\n

Пользовательский обработчик ошибок не получает E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR и E_COMPILE_WARNING. Он также не видит ошибку, которая произошла до его регистрации. Это граница API, а не случайная особенность конкретного проекта.

\n

Обработчик исключений вызывается, если исключение не поймал try/catch. В PHP 7 и новее его аргументом может быть любой Throwable: и Exception, и Error. После вызова обработчика выполнение запроса заканчивается, поэтому сам обработчик не должен бросать новое исключение.

\n

Shutdown-функция выполняется после завершения скрипта или после exit(). В ней можно вызвать error_get_last() и проверить тип последней ошибки. Это страховочный слой для части фатальных ошибок. Он не ловит синтаксическую ошибку в файле, который не дал приложению запуститься, и не спасает процесс, убитый сигналом.

\n

Логируйте не весь запрос, а минимальный контекст: идентификатор операции, имя интеграции, внешний ID, текущий этап, тип сбоя, файл и строку. Тело запроса, токены, cookies и ответы партнёра могут содержать секреты и персональные данные. Их нельзя добавлять в журнал только ради удобства расследования.

\n

Контекст, который отвечает на вопросы

\n
ПолеПримерВопрос
request_idsync-4f2aС какой записью веб-сервера связать событие?
operationorder_exportКакой сценарий выполнялся?
external_idORD-9182Какой объект повторить в тестовой среде?
stagepartner_calledУспел ли код вызвать партнёра?
kindfatal_errorКакая ветка диагностики сработала?
file, lineпуть и номер строкиГде открыть ту версию кода?
\n

Этап меняйте до побочного эффекта, а не после него. Перед запросом поставьте request_prepared, перед отправкой — partner_called, после сохранения ответа — response_saved. Если процесс оборвался между двумя метками, журнал всё равно покажет последнюю завершённую границу.

\n

Учебный пример обвязки

\n

Ниже минимальный пример для одного 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 и не меняет поведение приложения незаметно. Если проект хочет превращать предупреждения в исключения, это нужно сделать отдельным решением и проверить для каждого типа ошибки.

\n

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

\n

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

\n
СимптомПричинаПроверкаДействие
В журнале только 500Контекст не создаётся до интеграцииНайти место регистрации обработчиков и первую метку этапаСоздать контекст до подготовки запроса
Предупреждение есть, штатная запись исчезлаОбработчик не вернул falseПроверить возврат из callbackВернуть false или явно принять новую политику
Исключение не связано с операциейОбработчик установлен после внешнего вызоваСравнить порядок регистрации и вызова клиентаУстановить его в точке входа
Фатальная ошибка не записаласьОшибка произошла до регистрации или процесс убит сигналомПроверить начало выполнения и код завершения процессаДобавить раннюю регистрацию и смотреть журналы окружения
Одна ошибка записалась дваждыЕё обработали и exception-handler, и shutdown-handlerСравнить kind, время и ID операцииДобавить признак уже записанного события
В журнале оказался токенЗаписали входной запрос целикомПроверить схему полей и тестовые записиОставить allowlist полей и маскировать секреты
\n

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

\n
  1. Найдите точку входа интеграции и зарегистрируйте обработчики до создания клиента и первого побочного эффекта.
  2. Создайте request_id, имя операции и внешний ID. Запишите только разрешённые поля.
  3. Добавьте этапы перед подготовкой запроса, перед внешним вызовом и после сохранения результата.
  4. Запустите изолированный учебный сценарий с trigger_error(). Проверьте поля записи и сохранение штатного поведения.
  5. Запустите отдельный тест с непойманным RuntimeException или Error. Убедитесь, что запись содержит класс и последнюю метку.
  6. В тестовой среде проверьте фатальную ветку после регистрации shutdown-функции. Не используйте для этого реальный заказ или боевой endpoint.
  7. Проверьте дубли, маскирование и связь с журналом веб-сервера. Удалите учебные идентификаторы до публикации кода.
\n

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

\n

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

\n

Она также не даёт права регистрировать секреты. Сообщение исключения может содержать URL с query-параметрами, SQL-фрагмент или ответ внешнего сервиса. Перед записью задайте allowlist полей и правило маскирования. Если сомневаетесь, сохраните тип, этап и внутренний код ошибки, а не исходный текст.

\n

Не ставьте shutdown-обработчик единственным способом узнать о 500. Обычные исключения должны обрабатываться на своём уровне, а HTTP-статус и трассировка должны оставаться в системах наблюдаемости. Этот слой нужен для связи ошибки с конкретной бизнес-операцией.

\n

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

\n

Диагностика готова, если в изолированных тестах каждая из трёх веток оставляет одну запись с request_id, операцией, внешним ID, последним этапом и местом ошибки. Предупреждение сохраняет ожидаемое штатное поведение. Непойманный Throwable не создаёт вторую ошибку. Фатальный тест не меняет данные внешней системы. В записи нет токенов, паролей и полного тела запроса.

\n

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

\n

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

\n" + "contentHtml": "

Интеграционный endpoint вернул 500. В журнале остались только время и путь к скрипту. Партнёр повторил запрос позже, но уже с другим идентификатором. Вторая попытка прошла, поэтому по журналу нельзя понять, где остановился первый запрос.

\n

Цена такой ошибки — не сам HTTP-статус. Команда теряет исходные факты и повторяет расследование вслепую. Она проверяет сеть, базу и чужой API в случайном порядке. Иногда разработчик добавляет ещё один try/catch, но фатальная ошибка PHP может не дойти до этого блока.

\n

Тезис статьи простой: диагностике нужен короткий контекст операции и отдельная ветка для каждого класса сбоя. set_error_handler() фиксирует обрабатываемые ошибки. set_exception_handler() фиксирует непойманный Throwable. Shutdown-функция проверяет последнюю ошибку после завершения скрипта. Ни одна из веток не заменяет остальные.

\n
\"Поток
Контекст создаётся до внешнего вызова. Поэтому запись сохраняет операцию и этап даже тогда, когда код не успел сформировать ответ.
\n

Механизм: три точки перехвата в PHP 7+

\n

Пользовательский обработчик ошибок не получает E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR и E_COMPILE_WARNING. Он также не видит ошибку, которая произошла до его регистрации. Это граница API, а не случайная особенность конкретного проекта. Если не передать свой список error_levels, переданная функция вызывается для каждого уровня ошибки независимо от настройки error_reporting(); в примере обработчик проверяет текущий уровень перед записью и возвращает false, если уровень отключён.

\n

Обработчик исключений вызывается, если исключение не поймал try/catch. В PHP 7 и новее его аргументом может быть любой Throwable: и Exception, и Error. После вызова обработчика выполнение запроса заканчивается, поэтому сам обработчик не должен бросать новое исключение.

\n

Shutdown-функция выполняется после завершения скрипта или после exit(). В ней можно вызвать error_get_last() и проверить тип последней ошибки. Это страховочный слой для части фатальных ошибок. Он не ловит синтаксическую ошибку в файле, который не дал приложению запуститься, и не спасает процесс, убитый сигналом.

\n

Логируйте не весь запрос, а минимальный контекст: идентификатор операции, имя интеграции, внешний ID, текущий этап, тип сбоя, файл и строку. Тело запроса, токены, cookies и ответы партнёра могут содержать секреты и персональные данные. Их нельзя добавлять в журнал только ради удобства расследования.

\n

Контекст, который отвечает на вопросы

\n
ПолеПримерВопрос
request_idsync-4f2aС какой записью веб-сервера связать событие?
operationorder_exportКакой сценарий выполнялся?
external_idORD-9182Какой объект повторить в тестовой среде?
stagepartner_calledУспел ли код вызвать партнёра?
kindfatal_errorКакая ветка диагностики сработала?
file, lineпуть и номер строкиГде открыть ту версию кода?
\n

Этап меняйте до побочного эффекта, а не после него. Перед запросом поставьте request_prepared, перед отправкой — partner_called, после сохранения ответа — response_saved. Если процесс оборвался между двумя метками, журнал всё равно покажет последнюю завершённую границу.

\n

Учебный пример обвязки для PHP 7+

\n

Ниже минимальный пример для одного HTTP-запроса. Он рассчитан на PHP 7+: использует Throwable и синтаксис анонимных функций с захватом переменных. Он показывает механизм, а не готовый 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 и не меняет поведение приложения незаметно. Если проект хочет превращать предупреждения в исключения, это нужно сделать отдельным решением и проверить для каждого типа ошибки.

\n

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

\n

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

\n
СимптомПричинаПроверкаДействие
В журнале только 500Контекст не создаётся до интеграцииНайти место регистрации обработчиков и первую метку этапаСоздать контекст до подготовки запроса
Предупреждение есть, штатная запись исчезлаОбработчик не вернул falseПроверить возврат из callbackВернуть false или явно принять новую политику
Исключение не связано с операциейОбработчик установлен после внешнего вызоваСравнить порядок регистрации и вызова клиентаУстановить его в точке входа
Фатальная ошибка не записаласьОшибка произошла до регистрации или процесс убит сигналомПроверить начало выполнения и код завершения процессаДобавить раннюю регистрацию и смотреть журналы окружения
Одна ошибка записалась дваждыЕё обработали и exception-handler, и shutdown-handlerСравнить kind, время и ID операцииДобавить признак уже записанного события
В журнале оказался токенЗаписали входной запрос целикомПроверить схему полей и тестовые записиОставить allowlist полей и маскировать секреты
\n

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

\n
  1. Найдите точку входа интеграции и зарегистрируйте обработчики до создания клиента и первого побочного эффекта.
  2. Создайте request_id, имя операции и внешний ID. Запишите только разрешённые поля.
  3. Добавьте этапы перед подготовкой запроса, перед внешним вызовом и после сохранения результата.
  4. Запустите изолированный учебный сценарий с trigger_error(). Проверьте поля записи и сохранение штатного поведения.
  5. Запустите отдельный тест с непойманным RuntimeException или Error. Убедитесь, что запись содержит класс и последнюю метку.
  6. В тестовой среде отдельным процессом проверьте фатальную ветку после регистрации shutdown-функции. Не используйте для этого реальный заказ или боевой endpoint. E_USER_ERROR для такой проверки не подходит: этот тип приходит в пользовательский обработчик ошибок, а не в запасной shutdown-путь.
  7. Проверьте дубли, маскирование и связь с журналом веб-сервера. Удалите учебные идентификаторы до публикации кода.
\n

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

\n

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

\n

Она также не даёт права регистрировать секреты. Сообщение исключения может содержать URL с query-параметрами, SQL-фрагмент или ответ внешнего сервиса. Перед записью задайте allowlist полей и правило маскирования. Если сомневаетесь, сохраните тип, этап и внутренний код ошибки, а не исходный текст.

\n

Не ставьте shutdown-обработчик единственным способом узнать о 500. Обычные исключения должны обрабатываться на своём уровне, а HTTP-статус и трассировка должны оставаться в системах наблюдаемости. Этот слой нужен для связи ошибки с конкретной бизнес-операцией.

\n

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

\n

Диагностика готова, если в изолированных тестах каждая из трёх веток оставляет одну запись с request_id, операцией, внешним ID, последним этапом и местом ошибки. Предупреждение сохраняет ожидаемое штатное поведение. Непойманный Throwable не создаёт вторую ошибку при исправном логгере. Фатальный тест запускается отдельным процессом и не меняет данные внешней системы. В записи нет токенов, паролей и полного тела запроса.

\n

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

\n

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

\n" }