Подключение Gotcha к Joomla: ошибки, производительность и метрики сайта

Gotcha принимает данные по протоколу приёма Sentry и по OTLP, поэтому подключение любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс. Для Symfony и Laravel есть готовые бандлы, а вот официального пакета «Sentry для Joomla» не существует — и это отличный повод показать, как устроено подключение «руками»: один небольшой системный плагин закрывает ошибки, трейсинг и Web Vitals разом — и работает одинаково на Joomla 4.2+, 5 и 6. Полный код — ниже, а если собирать руками не хочется — есть готовый к установке пакет с тем же кодом (проверен на Joomla 4.4, 5.4 и 6).

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

СигналОткуда шлётсяЧто нужно
Ошибки PHP (компоненты, плагины, шаблон)бэкенд Joomlaплагин, событие onError
Время ответа по «эндпойнтам»бэкенд Joomlaплагин, транзакции
Ошибки JS и Web Vitalsбраузер@sentry/browser, инъекция тем же плагином
Бизнес-метрики (заказы, регистрации)cron → OTLPPOST на /v1/metrics
Аптайм и SSLсам Gotcha наружуHTTP-монитор, кода не требует
АлертыGotchaканал (Telegram/webhook/email) в UI

Где взять DSN

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

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

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

Почему именно системный плагин

Две особенности Joomla, из-за которых «просто \Sentry\init() в index.php» не сработает как надо:

  1. Joomla сама ловит исключения. Необработанное исключение компонента не доходит до set_exception_handler — ядро перехватывает его и рисует страницу ошибки. Зато перед этим оно рассылает событие onError, и плагин может забрать из него исходный Throwable.
  2. Чистый PHP SDK не создаёт транзакций сам. В отличие от бандлов Symfony и Laravel, sentry/sentry без фреймворк-интеграции не инструментирует HTTP-запросы — транзакцию нужно открыть и закрыть вручную. В Joomla для этого идеально подходят события onAfterRoute и onAfterRespond.

Системный плагин слушает оба события плюс ещё два — и получается полноценная интеграция без единой правки ядра или шаблона.

Архитектура: пакет из плагина и библиотеки

Соблазнительно положить vendor/ прямо в папку плагина — работать будет. Но по-джумловому правильнее собрать пакет (pkg_), внутри которого два расширения: системный плагин с логикой и библиотека (lib_) с composer-зависимостями.

Зачем так:

pkg_gotcha.zip
├── pkg_gotcha.xml                  # манифест пакета
├── script.php                      # проверки версий + сообщение после установки
├── language/{ru-RU,en-GB}/         # строки пакета
└── packages/
    ├── lib_gotcha.zip              # библиотека: vendor + манифест
    └── plg_system_gotcha.zip       # плагин: логика, языки, браузерный бандл

Манифест пакета перечисляет вложенные расширения — Joomla ставит их сама, по порядку:

<extension type="package" method="upgrade">
    <name>PKG_GOTCHA</name>
    <packagename>gotcha</packagename>
    <version>1.2.1</version>
    <scriptfile>script.php</scriptfile>
    <files folder="packages">
        <file type="library" id="gotcha">lib_gotcha.zip</file>
        <file type="plugin" id="gotcha" group="system">plg_system_gotcha.zip</file>
    </files>
</extension>

Манифест библиотеки — короткий: имя определяет папку, в которую она встанет (libraries/gotcha):

<extension type="library" method="upgrade">
    <name>LIB_GOTCHA</name>
    <libraryname>gotcha</libraryname>
    <version>1.2.1</version>
    <files>
        <folder>vendor</folder>
    </files>
</extension>

Плагин подключает автолоадер из библиотеки, а не из своей папки:

// Зависимости живут в библиотеке пакета (libraries/gotcha), а не в
// папке плагина: так их можно обновлять отдельно и переиспользовать.
require_once JPATH_LIBRARIES . '/gotcha/vendor/autoload.php';

Дальше по тексту — устройство самого плагина.

Файлы плагина

plg_system_gotcha.zip
├── gotcha.xml               # манифест
├── script.php               # installer script: проверки + автовключение
├── services/provider.php    # регистрация (Joomla 4/5/6)
├── src/Extension/Gotcha.php # вся логика
├── language/{ru-RU,en-GB}/  # строки интерфейса и сообщений
└── media/js/sentry.min.js   # браузерный SDK (соберём ниже)

Манифест плагина gotcha.xml

<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
    <name>PLG_SYSTEM_GOTCHA</name>
    <author>Gotcha</author>
    <authorUrl>https://getgotcha.ru</authorUrl>
    <creationDate>2026-08</creationDate>
    <license>MIT</license>
    <version>1.2.1</version>
    <description>PLG_SYSTEM_GOTCHA_XML_DESCRIPTION</description>
    <namespace path="src">Joomla\Plugin\System\Gotcha</namespace>
    <scriptfile>script.php</scriptfile>
    <files>
        <folder plugin="gotcha">services</folder>
        <folder>src</folder>
        <folder>language</folder>
    </files>
    <languages folder="language">
        <language tag="en-GB">en-GB/plg_system_gotcha.ini</language>
        <language tag="en-GB">en-GB/plg_system_gotcha.sys.ini</language>
        <language tag="ru-RU">ru-RU/plg_system_gotcha.ini</language>
        <language tag="ru-RU">ru-RU/plg_system_gotcha.sys.ini</language>
    </languages>
    <media destination="plg_system_gotcha" folder="media">
        <folder>js</folder>
    </media>
    <config>
        <fields name="params">
            <fieldset name="basic">
                <field name="dsn" type="text" size="60"
                       label="PLG_SYSTEM_GOTCHA_FIELD_DSN_LABEL"
                       description="PLG_SYSTEM_GOTCHA_FIELD_DSN_DESC"/>
                <field name="environment" type="text" default="production"
                       label="PLG_SYSTEM_GOTCHA_FIELD_ENVIRONMENT_LABEL"
                       description="PLG_SYSTEM_GOTCHA_FIELD_ENVIRONMENT_DESC"/>
                <field name="traces_sample_rate" type="text" default="0.2"
                       label="PLG_SYSTEM_GOTCHA_FIELD_TSR_LABEL"
                       description="PLG_SYSTEM_GOTCHA_FIELD_TSR_DESC"/>
                <field name="browser" type="radio" default="1"
                       label="PLG_SYSTEM_GOTCHA_FIELD_BROWSER_LABEL"
                       description="PLG_SYSTEM_GOTCHA_FIELD_BROWSER_DESC"
                       layout="joomla.form.field.radio.switcher">
                    <option value="0">JNO</option>
                    <option value="1">JYES</option>
                </field>
            </fieldset>
        </fields>
    </config>
</extension>

Installer script script.php: проверки и сообщение после установки

Joomla умеет запускать скрипт установщика — им закрывают две задачи. Первая: не дать поставить расширение туда, где оно не заработает (preflight с проверкой версий Joomla и PHP). Вторая: показать человеку, что делать дальше. Пустое окно «установлено успешно» — упущенный шанс: в postflight можно вывести HTML со ссылками на документацию, установку сервера и статью.

public function preflight(string $type, InstallerAdapter $adapter): bool
{
    if ($type === 'uninstall') {
        return true;
    }

    if (!(new Version())->isCompatible($this->minimumJoomla)) {
        $this->app->enqueueMessage(
            Text::sprintf('PLG_SYSTEM_GOTCHA_ERROR_COMPATIBLE_JOOMLA', $this->minimumJoomla),
            'error'
        );

        return false;
    }

    return true;
}

public function install(InstallerAdapter $adapter): bool
{
    // Включаем плагин сразу после установки: без DSN он полный no-op,
    // а пользователю остаётся только вписать DSN.
    $plugin          = new \stdClass();
    $plugin->type    = 'plugin';
    $plugin->element = $adapter->getElement();
    $plugin->folder  = (string) $adapter->getParent()->manifest->attributes()['group'];
    $plugin->enabled = 1;

    $this->db->updateObject('#__extensions', $plugin, ['type', 'element', 'folder']);

    return true;
}

public function postflight(string $type, InstallerAdapter $adapter): bool
{
    if ($type === 'uninstall') {
        return true;
    }

    $html = '<div class="row m-0">'
        . '<div class="col-12 col-md-8 p-0 pe-3">'
        . '<h2>' . Text::_('PLG_SYSTEM_GOTCHA_AFTER_' . strtoupper($type)) . '</h2>'
        . Text::_('PLG_SYSTEM_GOTCHA_POSTINSTALL_BODY')
        . '</div>'
        . '<div class="col-12 col-md-4 p-0">'
        . '<a class="btn btn-primary w-100" href="https://getgotcha.ru" target="_blank">getgotcha.ru</a>'
        . '<a class="btn btn-outline-primary w-100" href="' . Text::_('PLG_SYSTEM_GOTCHA_LINK_DOCS_URL') . '" target="_blank">'
        . Text::_('PLG_SYSTEM_GOTCHA_LINK_DOCS') . '</a>'
        . '</div></div>';

    $this->app->enqueueMessage($html, 'info');

    return true;
}

Сообщение показывает только пакет. У плагина свой script.php со своим postflight(), и если вывести приветствие и там, и в пакете — при установке пользователь увидит один и тот же текст дважды. В плагине оставляем проверки версий и автовключение, приветствие — на уровне пакета.

Тексты — не в коде, а в языковых файлах language/ru-RU/plg_system_gotcha.sys.ini и en-GB/. Суффикс .sys.ini важен: этот файл Joomla читает в менеджере расширений и во время установки, а обычный .ini — когда пользователь открывает настройки плагина. Оба нужны:

PLG_SYSTEM_GOTCHA="System - Gotcha"
PLG_SYSTEM_GOTCHA_AFTER_INSTALL="Спасибо за установку плагина Gotcha!"
PLG_SYSTEM_GOTCHA_POSTINSTALL_BODY="<p>Плагин отправляет в ваш инстанс Gotcha: <strong>ошибки PHP</strong>…</p>"
PLG_SYSTEM_GOTCHA_LINK_DOCS="Документация"
PLG_SYSTEM_GOTCHA_LINK_DOCS_URL="https://getgotcha.ru/docs/sdk/"
PLG_SYSTEM_GOTCHA_ERROR_COMPATIBLE_JOOMLA="Плагин Gotcha совместим с Joomla %s и выше."

В манифесте на них ссылаются ключами, а не текстом: <name>PLG_SYSTEM_GOTCHA</name>, <description>PLG_SYSTEM_GOTCHA_XML_DESCRIPTION</description> и label/description у каждого поля настроек. Тогда админка на русском покажет русские подписи, на английском — английские.

Регистрация services/provider.php

Стандартный для Joomla 4/5 сервис-провайдер:

<?php

defined('_JEXEC') or die;

use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\System\Gotcha\Extension\Gotcha;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            PluginInterface::class,
            function (Container $container) {
                $plugin = new Gotcha(
                    $container->get(DispatcherInterface::class),
                    (array) PluginHelper::getPlugin('system', 'gotcha')
                );
                $plugin->setApplication(Factory::getApplication());

                return $plugin;
            }
        );
    }
};

Логика src/Extension/Gotcha.php

<?php

namespace Joomla\Plugin\System\Gotcha\Extension;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Sentry\SentrySdk;
use Sentry\Tracing\Transaction;
use Sentry\Tracing\TransactionContext;

\defined('_JEXEC') or die;

final class Gotcha extends CMSPlugin implements SubscriberInterface
{
    private ?Transaction $transaction = null;

    public static function getSubscribedEvents(): array
    {
        return [
            'onAfterInitialise'   => 'init',
            'onAfterRoute'        => 'startTransaction',
            'onError'             => 'captureError',
            'onBeforeCompileHead' => 'injectBrowserSdk',
            'onAfterRespond'      => 'finishTransaction',
        ];
    }

    public function init(): void
    {
        $dsn = trim((string) $this->params->get('dsn', ''));

        if ($dsn === '') {
            return; // пустой DSN — плагин полностью выключен
        }

        require_once __DIR__ . '/../../vendor/autoload.php';

        \Sentry\init([
            'dsn'                => $dsn,
            'environment'        => (string) $this->params->get('environment', 'production'),
            'release'            => 'joomla@' . JVERSION,
            'traces_sample_rate' => (float) $this->params->get('traces_sample_rate', 0.2),
        ]);
    }

    public function startTransaction(): void
    {
        if (!class_exists(SentrySdk::class)) {
            return; // init не отработал — DSN пуст
        }

        $input = $this->getApplication()->getInput();

        // Имя — по компоненту и view, а не по URL: SEF-адресов миллионы,
        // а "эндпойнтов" — десятки. Иначе раздел «Производительность»
        // превратится в свалку уникальных транзакций.
        $name = $input->getCmd('option', 'core') . '.' . $input->getCmd('view', 'default');

        $this->transaction = \Sentry\startTransaction(
            TransactionContext::make()->setName($name)->setOp('http.server')
        );
        SentrySdk::getCurrentHub()->setSpan($this->transaction);
    }

    public function captureError(Event $event): void
    {
        if (!class_exists(SentrySdk::class)) {
            return;
        }

        // В Joomla 4/5 это ErrorEvent с getError(); generic-доступ через
        // 'subject' работает и там, и в Joomla 6.
        $error = method_exists($event, 'getError')
            ? $event->getError()
            : $event->getArgument('subject');

        if (!$error instanceof \Throwable) {
            return;
        }

        // 401/403/404/405 — обычный веб-шум (сканеры, битые ссылки),
        // а не ошибки приложения.
        if (\in_array((int) $error->getCode(), [401, 403, 404, 405], true)) {
            return;
        }

        \Sentry\captureException($error);
    }

    public function injectBrowserSdk(): void
    {
        $app = $this->getApplication();
        $dsn = trim((string) $this->params->get('dsn', ''));

        if ($dsn === '' || !(bool) $this->params->get('browser', 1) || !$app->isClient('site')) {
            return;
        }

        $wa = $app->getDocument()->getWebAssetManager();
        $wa->registerAndUseScript('plg_system_gotcha.sdk', 'plg_system_gotcha/sentry.min.js');
        $wa->addInlineScript(
            'Sentry.init({'
            . 'dsn: ' . json_encode($dsn) . ','
            . 'environment: ' . json_encode((string) $this->params->get('environment', 'production')) . ','
            . 'integrations: [Sentry.browserTracingIntegration()],'
            . 'tracesSampleRate: ' . (float) $this->params->get('traces_sample_rate', 0.2)
            . '});',
            [],
            [],
            ['plg_system_gotcha.sdk']
        );
    }

    public function finishTransaction(): void
    {
        if ($this->transaction === null) {
            return;
        }

        $this->transaction->setHttpStatus(http_response_code());
        $this->transaction->finish();
    }
}

Обратите внимание на имя транзакции: com_content.article, com_virtuemart.category — это и есть «эндпойнты» Joomla. Если назвать транзакции по URL, каждый SEF-адрес станет отдельной строкой и раздел «Производительность» потеряет смысл — подробнее в Кардинальности.

Браузерный бандл

У классической Joomla нет фронтенд-сборки, поэтому соберём самодостаточный бандл @sentry/browser один раз, любым бандлером — например esbuild:

npm i @sentry/browser esbuild
npx esbuild <(echo "export * from '@sentry/browser'") \
  --bundle --minify --format=iife --global-name=Sentry \
  --outfile=media/js/sentry.min.js

browserTracingIntegration сам снимает Web Vitals (LCP, CLS, INP, FCP, TTFB) и ловит JS-ошибки на странице. Если Gotcha живёт на другом домене, чем сайт, — это нормальный случай: приёмник отвечает CORS-заголовками, браузер шлёт напрямую, без прокси. Единственное, что может помешать, — ваш собственный Content-Security-Policy: добавьте адрес инстанса в connect-src.

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

Вот та грабля, на которую наступают почти все, кто первый раз пакует composer-зависимости в расширение CMS. Пакет несёт свой vendor/, но и Joomla несёт свой — и там встречаются те же пакеты. Классы называются одинаково, а версии разные. Кто первым зарегистрировал автолоадер, тот и определил, чей класс загрузится, а дальше — fatal error на ровном месте:

PHP Fatal error: Declaration of Psr\Log\NullLogger::log($level, Stringable|string $message,
array $context = []): void must be compatible with Psr\Log\LoggerInterface::log($level,
$message, array $context = [])

Ровно это мы и получили при первом тесте: на Joomla 5 и 6 всё работало, а Joomla 4.4 слегла в белый экран на каждой странице. И заметьте: сломался бы сайт целиком, а не мониторинг.

Напрашивается решение «исключить эти пакеты из composer и пользоваться теми, что уже есть в Joomla». Не выйдет — посмотрите, что реально лежит в libraries/vendor разных версий:

ПакетJoomla 4.4Joomla 5Joomla 6
psr/log1.1.43.0.23.0.2
psr/http-message1.11.12.0
psr/container1.1.11.1.22.0.2
symfony/options-resolverv5.4v6.4v7.4
symfony/deprecation-contractsv2.5v3.6v3.6
guzzlehttp/psr7нет2.12.32.12.3

Мажорные версии разъезжаются, а guzzlehttp/psr7 в Joomla 4 нет вовсе. Опора на библиотеки ядра означала бы отдельную сборку под каждую версию Joomla — и переставать работать при каждом крупном релизе.

Лечится это переименованием namespace зависимостей при сборке — php-scoper. Он проходит по vendor/ и нашему коду и приписывает всем чужим классам префикс, после чего наши копии перестают существовать для всех остальных:

// scoper.inc.php
return [
    'prefix' => 'Gotcha\\Vendor',
    'finders' => [
        Finder::create()->files()->in('vendor'),
        Finder::create()->files()->name('*.php')->in('plugin/src'),
    ],
    // Namespace самого плагина не трогаем — по нему Joomla его находит.
    // Ссылки на Sentry внутри наших файлов php-scoper перепишет сам.
    'exclude-namespaces' => ['Joomla'],
];

После сборки в коде плагина \Sentry\init(...) превращается в \Gotcha\Vendor\Sentry\init(...), а vendor/psr/log живёт в Gotcha\Vendor\Psr\Log. Конфликт исчезает навсегда — и с ядром, и с любым другим расширением, которое притащило свой Sentry или Guzzle. Один архив работает на 4.2+, 5 и 6.

Важная деталь сборки: скоупить нужно за один прогон и vendor/, и код плагина. Разложить их по двум архивам (библиотека и плагин) можно только потом — иначе ссылки на Sentry в плагине не совпадут с префиксом в библиотеке.

Правило простое: любое расширение CMS с собственным vendor/ нужно скоупить. Это верно и для Joomla, и для WordPress, и для Битрикса — везде, где расширения живут в одном PHP-процессе.

Установка

Собираем zip и ставим штатно:

./build.sh          # composer install -> php-scoper -> два zip -> пакет

Либо берём готовый пакет — это ровно тот код, что в статье, вместе с изолированным vendor/ и собранным браузерным бандлом (проверен на Joomla 4.4, 5.4 и 6 из официальных docker-образов).

Система → Установка → Расширения, загрузить pkg_gotcha-1.2.1.zip — Joomla поставит и библиотеку, и плагин, плагин включится сам. Дальше Система → Плагины → System - Gotcha, вписать DSN. Проверка — временно бросить исключение в любом шаблоне или обратиться к несуществующему компоненту с кодом 500; событие появится в разделе «Проблемы» через несколько секунд.

Какие метрики собирать с сайта

Самое ценное в мониторинге сайта на CMS — не экзотика, а несколько скучных показателей, которые закрывают 90% вопросов «а что с сайтом?»:

МетрикаЗачемОткуда берётся
Ошибки PHP по релизам«после обновления расширения посыпались 500-е»плагин, onError
Ошибки JS в браузереслайдер/форма сломаны только у части посетителей@sentry/browser
p95 времени ответа по компонентамкакой компонент тормозит: контент, магазин, поисктранзакции
Web Vitals: LCP, INP, CLSреальная скорость у посетителей, а не в LighthousebrowserTracingIntegration
Аптайм и срок SSL-сертификатасайт лежит / сертификат истекает через 3 дняHTTP-монитор Gotcha
Бизнес-метрики: заказы, регистрации«мониторинг зелёный, а заказов нет» — самый коварный отказOTLP, cron

Первые четыре строки уже закрыты плагином выше. Остались две.

Аптайм: без единой строки кода

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

Бизнес-метрики по OTLP

Числовые метрики Gotcha принимает по OTLP/HTTP — POST на /v1/metrics с заголовком Authorization: Bearer <public_key> (ключ — часть DSN между https:// и @). Для Joomla-сайта проще всего снять цифры прямо из базы кроном. Например, число пользователей раз в 5 минут:

#!/bin/bash
USERS=$(mysql -N -e "SELECT COUNT(*) FROM j_users" joomla_db)
curl -s -X POST https://gotcha.example.com/v1/metrics \
  -H "Authorization: Bearer <public_key>" \
  -H "Content-Type: application/json" \
  -d '{"resourceMetrics":[{"resource":{"attributes":[
        {"key":"service.name","value":{"stringValue":"joomla-site"}}]},
      "scopeMetrics":[{"metrics":[{"name":"users_total","gauge":{"dataPoints":[
        {"asDouble":'"$USERS"',"timeUnixNano":"'"$(date +%s%N)"'"}]}}]}]}]}'

Тем же способом отправляются заказы VirtueMart/HikaShop, отправки форм, размер очереди писем — любое число из SELECT COUNT(*). На метрики вешаются пороговые алерты («заказов за час = 0 в рабочее время» — сигнал не хуже пятисоток). Подробнее — Метрики и Алерты по метрикам.

Алерты

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

Итог

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