Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один try/catch вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса? Цена ошибки — повторный разбор интеграции без исходных фактов.
Почему одного set_error_handler недостаточно
Первое, что обычно хочется сделать, — повесить set_error_handler и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает E_ERROR, E_PARSE, E_CORE_ERROR и E_COMPILE_ERROR. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.
Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или Error в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после exit(), а error_get_last() даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.
Сначала определить, что именно нужно найти потом
Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком $_POST, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.
Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: request_prepared, partner_called, response_saved. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.
| Поле журнала | Пример | Зачем оно нужно |
|---|---|---|
request_id | sync-20180207-4f2a | Связать запись PHP с логом веб-сервера и сообщением партнёра |
operation | order_export | Не смешать импорт каталога, webhook и ручной запуск |
external_id | ORD-9182 | Повторить один сценарий без поиска по всему набору данных |
stage | partner_called | Понять, успел ли код дойти до внешнего вызова |
error_type | E_ERROR или Throwable | Отделить ошибку PHP от ответа HTTP |
file, line | путь и строка | Открыть точку падения в той версии кода, которая работала в момент сбоя |
Минимальная обвязка для PHP 7
Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается false. Это удобно на первом внедрении: существующие настройки error_reporting и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.
<?php
function writeIntegrationLog(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function installIntegrationDiagnostics($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;
}
writeIntegrationLog($context + array(
'kind' => 'php_error',
'error_type' => $severity,
'message' => $message,
'file' => $file,
'line' => $line,
));
return false;
});
set_exception_handler(function (Throwable $error) use (&$context) {
writeIntegrationLog($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;
}
writeIntegrationLog($context + array(
'kind' => 'fatal_error',
'error_type' => $last['type'],
'message' => $last['message'],
'file' => $last['file'],
'line' => $last['line'],
));
});
return $setStage;
}
$setStage = installIntegrationDiagnostics(
'sync-20180207-4f2a',
'order_export',
'ORD-9182'
);
$setStage('request_prepared');
// Здесь вызывается клиент партнёра.
$setStage('partner_called');
В настоящем коде генерация request_id и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.
Как проверить схему до аварии
Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с trigger_error("diagnostic test", E_USER_WARNING). Для исключения — выбросить RuntimeException после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через set_error_handler, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.
- Добавить обвязку в точку входа до вызова клиента интеграции и задать
request_id, операцию и внешний ID. - Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.
- Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.
- В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.
- Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.
Где эта схема заканчивается
Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.
Ещё одна граница — дубли. Ошибка внутри set_error_handler и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.
Порядок, который остаётся в проекте
Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.
Проверяемые источники
- PHP manual: set_error_handler — какие ошибки передаются пользовательскому обработчику и какие типы он не перехватывает
- PHP manual: set_exception_handler — обработчик непойманного Throwable, который получает Error и Exception
- PHP manual: register_shutdown_function — когда PHP вызывает зарегистрированную функцию завершения
- PHP manual: error_get_last — формат последней ошибки: type, message, file и line