Подключение 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 | магазин лежит / сертификат истекает через три дня |
Итог
- Обработчики ошибок — цепочкой, а не заменой: OpenCart ставит свои раньше всех расширений, и ломать их нельзя.
flush()на фатале — иначе самые важные события остаются в буфере.- Маршрут проверять по файлу контроллера:
routeприходит от посетителя, и без проверки любой сканер насыпет бесконечно новых «эндпойнтов». - Ассеты расширения — по пути
extension/<code>/…и абсолютным URL, иначе на ЧПУ-адресах SDK не загрузится. deleteEventByCodeпередaddEvent, иначе повторная установка задвоит подписки.vendor/скоупить сexpose-global-*— OpenCart живёт на глобальных константах.
Дальше — документация, установка и раздел подключения SDK.