Подключение Gotcha к OpenCart: ошибки, маршруты и защита от кардинальности

Gotcha принимает данные по протоколу приёма Sentry, поэтому подключение любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс. OpenCart интересен тем, что у него нет ни контейнера сервисов, как в Drupal, ни штатной точки для логов, как в Битриксе. Зато есть система событий и маршруты, и на них строится вполне приличная интеграция.

Разберём расширение целиком, включая одну ловушку, которая на боевом магазине превратилась бы в аварию мониторинга. Готовый архив — opencart-gotcha-1.0.0.ocmod.zip, проверен на OpenCart 4.1.0.3.

Что будем собирать

СигналОткуда шлётсяЧто нужно
Ошибки PHP (исключения и фаталы)витринацепочка обработчиков поверх штатных
Время ответа по маршрутамвитринасобытие catalog/controller/*/before
Ошибки JS и Web Vitalsбраузерсобытие catalog/view/common/header/after
Аптайм и SSLсам Gotcha наружуHTTP-монитор, кода не требует
АлертыGotchaканал (Telegram/webhook/email) в UI

Где взять DSN

После создания проекта Gotcha перекидывает на страницу «Подключение» (/projects/<id>/setup). DSN выглядит так:

https://<public_key>@gotcha.example.com/<project_id>

Один и тот же DSN используют и PHP, и браузер — public_key в нём публичен по замыслу. В расширении он станет настройкой: пустое значение — расширение полностью выключено.

Структура расширения

OpenCart 4 ждёт папку с install.json в корне и подпапками по областям:

gotcha/
├── install.json                                 манифест расширения
├── admin/controller/module/gotcha.php           страница настроек
├── admin/model/module/gotcha.php                подписки на события
├── admin/language/{en-gb,ru-ru}/module/gotcha.php
├── admin/view/template/module/gotcha.twig
├── catalog/controller/module/monitor.php        вся логика мониторинга
├── catalog/view/javascript/gotcha/sentry.min.js браузерный SDK
└── system/library/vendor/                       composer require sentry/sentry

Namespace жёстко привязан к расположению файла: контроллер витрины — Opencart\Catalog\Controller\Extension\Gotcha\Module, админки — Opencart\Admin\Controller\Extension\Gotcha\Module. Ошибётесь в namespace — движок просто не найдёт класс.

Ошибки: встроиться в цепочку, а не перебить

OpenCart ставит собственные set_error_handler и set_exception_handler в system/framework.php — то есть до того, как расширения вообще загружены. Перебить их своими значило бы сломать штатный лог (storage/logs/error.log) и страницу ошибки магазина.

Правильный ход — встроиться в цепочку: забрать предыдущий обработчик, поставить свой, отправить событие и передать управление дальше.

private function chainErrorHandlers(): void {
    $previousException = set_exception_handler(null);
    set_exception_handler(function (\Throwable $e) use ($previousException): void {
        \Sentry\captureException($e);
        $this->flush();

        if ($previousException !== null) {
            $previousException($e);
        }
    });

    $previousError = set_error_handler(null);
    set_error_handler(function (int $code, string $message, string $file = '', int $line = 0) use ($previousError) {
        // Notice и deprecated — обычный шум движка и старых расширений,
        // в issues от них пользы нет.
        if (in_array($code, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE, E_COMPILE_ERROR], true)) {
            \Sentry\captureException(new \ErrorException($message, 0, $code, $file, $line));
            $this->flush();
        }

        return $previousError !== null ? $previousError($code, $message, $file, $line) : false;
    });
}

Приём set_exception_handler(null) — идиома PHP: функция возвращает предыдущий обработчик, а сама ставит null. Сразу после этого ставим свой, уже зная, кому передать эстафету.

flush() обязателен на фатале: SDK копит события в буфере и отправляет в конце запроса, а «конца запроса» при фатальной ошибке не будет.

private function flush(): void {
    $client = \Sentry\SentrySdk::getCurrentHub()->getClient();

    if ($client !== null) {
        $client->flush(2);
    }
}

Транзакции: маршрут — почти готовый эндпойнт

У OpenCart всё крутится вокруг index.php?route=product/product. Маршрут — это и есть эндпойнт: product/product вместо тысяч URL карточек товара, checkout/checkout вместо каждой корзины.

Событие catalog/controller/*/before срабатывает перед каждым контроллером витрины — на нём и стартуем:

public function start(string &$route): void {
    if (self::$started) {
        return;
    }

    self::$started = true;
    // ... init SDK ...
    $this->chainErrorHandlers();
    $this->startTransaction();
}

Статический флаг важен: за один запрос OpenCart выполняет несколько контроллеров (стартовые, модули колонок), и событие срабатывает на каждом.

Ловушка: маршрут приходит от посетителя

А вот здесь легко сделать ошибку, которая тихо убьёт весь раздел «Производительность». Напрашивается:

$route = $_GET['route'] ?? 'common/home';   // так делать нельзя

route — это строка запроса, то есть вход, полностью управляемый тем, кто пришёл на сайт. Любой сканер (а их на магазине хватает) переберёт сотни несуществующих значений, и каждое станет отдельным «эндпойнтом» в отчёте. Через сутки список маршрутов утонет в мусоре — ровно та проблема кардинальности, от которой мы бережём имена транзакций.

Мы поймали это на живом стенде: запрос ?route=no/such/route спокойно создал транзакцию с таким именем. Лечение — принимать маршрут, только если за ним есть реальный контроллер:

private function resolveRoute(): string {
    $route = (string) ($this->request->get['route'] ?? 'common/home');

    // Метод контроллера отделяется точкой: product/product.review
    $path = explode('.', $route)[0];

    if (!preg_match('~^[a-z0-9_/]+$~i', $path)) {
        return '404';
    }

    if (is_file(DIR_APPLICATION . 'controller/' . $path . '.php')) {
        return $route;
    }

    // Маршруты расширений: extension/<code>/<type>/<name>
    $parts = explode('/', $path);

    if (($parts[0] ?? '') === 'extension' && isset($parts[1])) {
        $file = DIR_EXTENSION . $parts[1] . '/catalog/controller/'
            . implode('/', array_slice($parts, 2)) . '.php';

        if (is_file($file)) {
            return $route;
        }
    }

    return '404';
}

Проверка по файлу стоит один is_file() на запрос, а взамен даёт замкнутый список имён: сколько контроллеров в магазине, столько и строк в отчёте. Попытка ?route=../../etc/passwd тоже схлопывается в 404.

После правки отчёт выглядит так:

common/home         главная
product/product     карточки товаров
product/category    категории
account/login       вход
404                 всё несуществующее, включая сканеры

Закрывается транзакция в register_shutdown_function — при фатале до конца запроса дело не дойдёт, а shutdown-обработчик PHP отработает всегда.

Браузерный SDK: событие на вывод шапки

Менеджера ассетов у OpenCart нет, зато события вида view/*/after отдают готовый HTML — в него и дописываем скрипт перед </head>:

public function inject(string &$route, array &$args, string &$output): void {
    // ... проверки настроек ...

    // Ассеты расширения лежат под extension/<code>/, а не в корне сайта.
    // URL строим абсолютным из настроек магазина: относительный сломался бы
    // на любом ЧПУ-адресе глубже первого уровня.
    $src = rtrim((string) $this->config->get('config_url'), '/')
        . '/extension/gotcha/catalog/view/javascript/gotcha/sentry.min.js';

    $script = '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"></script>'
        . '<script>Sentry.init({ /* ... */ });</script>';

    $position = stripos($output, '</head>');

    if ($position !== false) {
        $output = substr($output, 0, $position) . $script . substr($output, $position);
    }
}

Два места, где мы уже ошиблись, пока проверяли на живом магазине:

Путь к ассету. Файлы расширения лежат не в корне сайта, а в extension/gotcha/catalog/view/javascript/…. Первая версия ссылалась на catalog/view/javascript/… и получала 404, а SDK на странице был, но не работал.

Относительная ссылка. Даже с верным путём относительный src ломается на ЧПУ-адресах глубже первого уровня: браузер разрешит его относительно текущего URL. Поэтому абсолютный адрес из config_url.

Подписки на события

События живут в таблице oc_event и заводятся при установке расширения:

private const EVENTS = [
    [
        'code'        => 'gotcha_start',
        'description' => 'Gotcha: инициализация SDK и старт транзакции',
        // Звёздочка — любой контроллер витрины: событие сработает на
        // каждом запросе, а не только на конкретном маршруте.
        'trigger'     => 'catalog/controller/*/before',
        'action'      => 'extension/gotcha/module/monitor.start',
        'status'      => 1,
        'sort_order'  => 0,
    ],
    [
        'code'        => 'gotcha_browser',
        'description' => 'Gotcha: вставка браузерного SDK',
        'trigger'     => 'catalog/view/common/header/after',
        'action'      => 'extension/gotcha/module/monitor.inject',
        'status'      => 1,
        'sort_order'  => 0,
    ],
];

public function install(): void {
    $this->load->model('setting/event');

    foreach (self::EVENTS as $event) {
        // Идемпотентность: повторная установка не должна плодить дубли.
        $this->model_setting_event->deleteEventByCode($event['code']);
        $this->model_setting_event->addEvent($event);
    }
}

deleteEventByCode перед addEvent — не перестраховка: без него повторная установка расширения заведёт вторую подписку, и обработчик будет вызываться дважды.

Обязательный шаг: изоляция vendor

Расширение несёт свой vendor/, и OpenCart несёт свой (Twig, guzzle), и любое соседнее расширение — тоже. Побеждает автолоадер, сработавший первым, а на несовпадающих мажорах сайт падает целиком. Лечится префиксом через php-scoper:

return [
    'prefix' => 'Gotcha\\Vendor',
    'finders' => [
        Finder::create()->files()->in('system/library/vendor'),
        Finder::create()->files()->name('*.php')->in('catalog/controller'),
    ],
    // Классы ядра OpenCart переименовывать нельзя: по namespace
    // Opencart\Catalog\Controller\... движок находит контроллеры.
    'exclude-namespaces' => ['Opencart'],
    'expose-global-constants' => true,
    'expose-global-classes'   => true,
    'expose-global-functions' => true,
];

expose-global-* здесь обязательны: OpenCart активно пользуется глобальными константами (DIR_APPLICATION, DIR_EXTENSION, VERSION), и переименовывать их нельзя.

Отдельная грабля с константами. В витрине путь к контроллерам задаёт DIR_APPLICATION, а DIR_CATALOG определён только в админке. Первая версия расширения использовала DIR_CATALOG и падала с Undefined constant ... \DIR_CATALOG. Забавно, что именно эта ошибка первой и приехала в Gotcha — то есть цепочка обработчиков работала ещё до того, как заработало всё остальное.

Установка

Расширения → Установщик, загрузить архив, затем Расширения → Расширения → Модули, найти «Gotcha» и нажать «+» (установить) — это заведёт подписки на события. Дальше кнопка редактирования: вставить DSN и включить.

Проверка: обратитесь к маршруту, который бросает исключение, — событие появится в разделе «Проблемы» через несколько секунд.

Аптайм и алерты

Аптайм не требует правок в коде — Gotcha сам ходит на публичный URL снаружи: Аптайм → Новый монитор → HTTP, URL магазина, интервал и пороги. Инциденты, SSL-алерты и публичная status-страница — из коробки (Аптайм).

В разделе «Оповещения» правила (новый issue, регрессия, всплеск) уже включены; остаётся добавить канал доставки — Telegram, webhook или email (Алерты).

Что смотреть в магазине в первую очередь

МетрикаЗачем
Ошибки PHP по релизам«после обновления модуля оплаты посыпались 500-е»
Ошибки JS в браузерекорзина или фильтр сломаны у части покупателей
p95 по маршрутамвидно, что медленно: каталог, поиск, оформление заказа
Web Vitals (LCP, INP, CLS)реальная скорость у покупателей, а не в Lighthouse
Время checkout/*самый дорогой путь: тормоза здесь стоят денег
Аптайм и срок SSLмагазин лежит / сертификат истекает через три дня

Итог

Дальше — документация, установка и раздел подключения SDK.