Подключение Gotcha к MODX: транспортный пакет, события и код в базе
Gotcha принимает данные по протоколу приёма Sentry, поэтому подключение любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс. MODX выделяется среди CMS двумя вещами: код элементов хранится в базе, а дополнения распространяются транспортными пакетами, которые собираются скриптом на живой установке. Обе особенности заметно влияют на устройство плагина.
Разберём его целиком — вместе с граблями, на которые мы наступили при проверке на живом MODX 3.2.2.
Что будем собирать
| Сигнал | Откуда шлётся | Что нужно |
|---|---|---|
| Ошибки PHP (исключения и фаталы) | сайт | цепочка обработчиков поверх штатных |
| Время ответа по шаблонам | сайт | события OnMODXInit / OnWebPageComplete |
| Ошибки JS и Web Vitals | браузер | событие OnWebPagePrerender |
| Аптайм и 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 в нём публичен по
замыслу. В плагине он станет системной настройкой gotcha.dsn: пустое значение
— плагин полностью выключен.
Особенность первая: код элементов живёт в базе
Плагин MODX — это строка в таблице modx_site_plugins, а не файл. Значит, его
не видно в git, его нельзя нормально отревьюить и в нём неудобно работать
редактору кода.
Обходится это просто: в самом плагине держим только диспетчер событий, а всю логику выносим в обычный класс на диске.
require_once MODX_CORE_PATH . 'components/gotcha/model/Monitor.php';
use Gotcha\Monitoring\Monitor;
switch ($modx->event->name) {
case 'OnMODXInit':
Monitor::init($modx);
break;
case 'OnWebPagePrerender':
Monitor::injectBrowser($modx);
break;
case 'OnPageNotFound':
Monitor::notFound($modx);
break;
case 'OnWebPageComplete':
Monitor::finish();
break;
}
Грабля с открывающим тегом. MODX хранит код элементов без
<?phpи подставляет тег сам при выполнении. Если положить в пакет файл плагина целиком, закешированный элемент получит два тега подряд, и сайт упадёт сParse error: syntax error, unexpected token "<"— причём упадёт и админка, и даже скрипт установки, который бутстрапит MODX. Мы этот круг проходили: лечится тем, что сборщик пакета срезает тег.function stripPhpTags(string $code): string { $code = preg_replace('/^\s*<\?php\s*/', '', $code); return trim(preg_replace('/\?>\s*$/', '', $code)); }
Ошибки: встроиться в цепочку
MODX ставит собственный set_error_handler (класс modErrorHandler) при
инициализации. Перебить его значило бы лишить сайт штатного журнала ошибок,
поэтому забираем предыдущий обработчик и передаём ему управление после себя.
Обработчика исключений MODX не ставит вовсе — его вешаем свой.
private static function chainHandlers(): void
{
$previousError = set_error_handler(null);
set_error_handler(static 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));
self::flush();
}
return $previousError !== null ? $previousError($code, $message, $file, $line) : false;
});
$previousException = set_exception_handler(null);
set_exception_handler(static function (\Throwable $e) use ($previousException): void {
\Sentry\captureException($e);
self::flush();
if ($previousException !== null) {
$previousException($e);
}
});
}
Проверено на живом сайте: исключение из сниппета и обращение к несуществующему классу доходят со стектрейсом, в котором виден и сам сниппет.
Транзакции: шаблон вместо ресурса
Роутов у MODX нет: страницу определяет ресурс, а ресурсов на контентном сайте бывают тысячи. Назвать транзакцию по ресурсу — значит получить тысячи строк в отчёте, то есть ту самую проблему кардинальности.
Правильная единица — шаблон: их на сайте единицы, и именно шаблон определяет, как страница собирается и во что это обходится. Плюс контекст, чтобы витрина не смешивалась с админкой:
private static function transactionName($modx): string
{
$context = (string) ($modx->context ? $modx->context->get('key') : 'web');
if ($context === 'mgr') {
return 'mgr';
}
if ($modx->resource === null) {
return $context;
}
$templateId = (int) $modx->resource->get('template');
if ($templateId === 0) {
return $context . ':(blank)';
}
$template = $modx->getObject(\MODX\Revolution\modTemplate::class, $templateId);
$name = $template !== null ? (string) $template->get('templatename') : (string) $templateId;
return $context . ':' . $name;
}
В отчёте это выглядит так:
web:BaseTemplate страницы сайта
mgr админка
404 ненайденные адреса
Тонкость с порядком событий: на OnMODXInit ресурс ещё не выбран, поэтому
имя уточняется позже, на OnWebPagePrerender. Но OnWebPagePrerender
срабатывает и после OnPageNotFound — MODX форвардит 404 на страницу
ошибки и рендерит её обычным шаблоном. Без блокировки имя 404 тут же
затиралось бы обратно на web:BaseTemplate:
public static function notFound($modx): void
{
if (self::$transaction === null) {
return;
}
// Транзакцию закрываем прямо здесь, а не в shutdown: на 404 MODX
// завершает запрос через exit(), и к моменту нашей shutdown-функции
// отправлять уже некому — транзакция просто теряется.
self::$transaction->setName('404');
self::$transaction->setHttpStatus(404);
self::$nameLocked = true;
self::finish();
}
Ещё одна деталь, стоившая отдельного захода отладки: свой flush() при
закрытии транзакции. Sentry SDK регистрирует shutdown-обработчик при
init(), то есть раньше нашего, и к моменту, когда наша shutdown-функция
закрывает транзакцию, отправка SDK уже отработала. На обычной странице
выручает OnWebPageComplete (он срабатывает внутри запроса), а на 404 — нет.
Браузерный SDK
У MODX нет менеджера ассетов, зато OnWebPagePrerender отдаёт готовый вывод
страницы по ссылке — в него и дописываем скрипт перед </head>:
$output = &$modx->resource->_output;
$position = stripos($output, '</head>');
if ($position === false) {
return;
}
$src = rtrim((string) $modx->getOption('site_url'), '/')
. '/assets/components/gotcha/js/sentry.min.js';
$script = '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"></script>'
. '<script>Sentry.init({ /* ... */ });</script>';
$output = substr($output, 0, $position) . $script . substr($output, $position);
Адрес строим из системной настройки site_url — относительная ссылка
сломалась бы на любом ЧПУ-адресе глубже первого уровня.
Особенность вторая: транспортный пакет
Дополнения MODX распространяются транспортными пакетами
(*.transport.zip), и собирает их скрипт, выполняющийся на живой
установке — ему нужны классы xPDOTransport из ядра. Это непривычно после
Joomla и WordPress, где сборка это просто zip.
Скелет сборщика:
// Бутстрап MODX: без него нет ни MODX_CORE_PATH, ни классов xPDOTransport.
$modxRoot = rtrim(getenv('MODX_ROOT') ?: '/var/www/html', '/') . '/';
require_once $modxRoot . 'config.core.php';
require_once MODX_CORE_PATH . 'vendor/autoload.php';
$modx = new modX();
$modx->initialize('mgr');
$builder = new \MODX\Revolution\Transport\modPackageBuilder($modx);
$builder->createPackage('gotcha', '1.0.0', 'pl');
$builder->registerNamespace('gotcha', false, true, '{core_path}components/gotcha/');
Дальше собирается объектное дерево: категория → плагин → его подписки на
события. Файлы (библиотека с vendor/ и браузерный бандл) добавляются
резолверами:
$vehicle->resolve('file', [
'source' => rtrim($sources['core'], '/'),
'target' => "return MODX_CORE_PATH . 'components/';",
]);
$vehicle->resolve('file', [
'source' => rtrim($sources['assets'], '/'),
'target' => "return MODX_ASSETS_PATH . 'components/';",
]);
Системные настройки кладутся отдельными «транспортными средствами», причём с
UPDATE_OBJECT => false:
$builder->putVehicle($builder->createVehicle($setting, [
xPDOTransport::UNIQUE_KEY => 'key',
xPDOTransport::PRESERVE_KEYS => true,
// Настройки не перетираем при обновлении: DSN уже введён вручную.
xPDOTransport::UPDATE_OBJECT => false,
]));
Без этого обновление пакета затёрло бы введённый администратором DSN пустой строкой — классический способ «сломать мониторинг обновлением».
Мелочь, стоившая времени.
addMany()в xPDO принимает аргумент по ссылке, поэтому$category->addMany([$plugin])падает сcould not be passed by reference. Нужна переменная:$plugins = [$plugin]; $category->addMany($plugins);
Обязательный шаг: изоляция vendor
Плагин несёт свой vendor/, и MODX несёт свой (guzzle, psr/*, symfony/*), и
любое соседнее дополнение — тоже. Побеждает автолоадер, сработавший первым;
на несовпадающих мажорах сайт падает целиком. Лечится префиксом через
php-scoper:
return [
'prefix' => 'Gotcha\\Monitoring\\Vendor',
'finders' => [
Finder::create()->files()->in('core/components/gotcha/vendor'),
Finder::create()->files()->name('Monitor.php')->in('core/components/gotcha/model'),
],
// Наш namespace и классы ядра MODX не трогаем.
'exclude-namespaces' => ['Gotcha\\Monitoring', 'MODX', 'xPDO'],
'expose-global-constants' => true,
'expose-global-classes' => true,
'expose-global-functions' => true,
];
Важно, чтобы скоупинг не портил исходники: сборка складывает результат в
отдельный каталог dist/, из которого и собирается пакет, а core/ и
assets/ остаются нетронутыми и лежат в git в исходном виде.
Установка
Пакеты → Установщик → Загрузить пакет, выбрать
gotcha-1.0.0-pl.transport.zip, установить. Затем Система → Системные
настройки, фильтр по пространству имён gotcha: вписать DSN, при желании
поправить окружение и долю трейсов.
Проверка: обратитесь к странице со сниппетом, который бросает исключение, — событие появится в разделе «Проблемы» через несколько секунд.
Аптайм и алерты
Аптайм не требует правок в коде — Gotcha сам ходит на публичный URL снаружи: Аптайм → Новый монитор → HTTP, URL сайта, интервал и пороги. Инциденты, SSL-алерты и публичная status-страница — из коробки (Аптайм).
В разделе «Оповещения» правила (новый issue, регрессия, всплеск) уже включены; остаётся добавить канал доставки — Telegram, webhook или email (Алерты).
Что смотреть на сайте под MODX в первую очередь
| Метрика | Зачем |
|---|---|
| Ошибки PHP по релизам | «после правки сниппета посыпались 500-е» |
| Ошибки JS в браузере | форма или галерея сломаны у части посетителей |
| p95 по шаблонам | видно, какой шаблон тяжёлый: каталог, статья, поиск |
| Web Vitals (LCP, INP, CLS) | реальная скорость у людей, а не в Lighthouse |
| Доля 404 | всплеск обычно означает битые ссылки или сканеры |
| Аптайм и срок SSL | сайт лежит / сертификат истекает через три дня |
Итог
- Логику — в файл, в плагине только диспетчер: код элементов MODX живёт в базе, и держать там сотню строк неудобно и невидимо для git.
- Код элемента хранится без
<?php— сборщик пакета обязан срезать тег, иначе кеш элемента ломает весь сайт. - Обработчики ошибок — цепочкой: MODX уже занял
set_error_handler. - Имя транзакции — контекст и шаблон, а не ресурс.
- 404 закрывать синхронно в
OnPageNotFoundи делать свойflush(): shutdown-обработчик SDK регистрируется раньше нашего. - Настройки в пакете — с
UPDATE_OBJECT => false, иначе обновление затрёт введённый DSN. vendor/скоупить, а результат складывать в отдельный каталог, чтобы исходники в репозитории оставались чистыми.
Дальше — документация, установка и раздел подключения SDK.