DarkRiDDeR10 мин

PHP. Как записать причину 500-й ошибки в интеграции

PHPОтладкаИнтеграции

Интеграционный 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() даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.

Схема: контекст операции создаётся перед интеграцией; предупреждение идёт в set_error_handler, исключение — в set_exception_handler, фатальная ошибка проверяется при shutdown
Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.

Сначала определить, что именно нужно найти потом

Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком $_POST, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.

Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: request_prepared, partner_called, response_saved. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.

Поле журналаПримерЗачем оно нужно
request_idsync-20180207-4f2aСвязать запись PHP с логом веб-сервера и сообщением партнёра
operationorder_exportНе смешать импорт каталога, webhook и ручной запуск
external_idORD-9182Повторить один сценарий без поиска по всему набору данных
stagepartner_calledПонять, успел ли код дойти до внешнего вызова
error_typeE_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-функции, а сам тест не должен менять состояние сторонней системы.

  1. Добавить обвязку в точку входа до вызова клиента интеграции и задать request_id, операцию и внешний ID.
  2. Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.
  3. Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.
  4. В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.
  5. Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.

Где эта схема заканчивается

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

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

Порядок, который остаётся в проекте

Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.

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