Подключение 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сайт лежит / сертификат истекает через три дня

Итог

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