Подключение Gotcha к 1С-Битрикс: ошибки, производительность и Web Vitals

Gotcha принимает данные по протоколу приёма Sentry, поэтому подключение любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс. С Битриксом есть нюанс: у него собственный обработчик ошибок, собственная система модулей и своё представление о том, что такое «страница». Если подключать SDK в лоб, половина ошибок не долетит, а отчёт по производительности окажется бесполезным.

Разберём модуль, который делает всё правильно: ошибки PHP через штатную точку расширения ядра, транзакции по физическим скриптам, браузерный SDK через Asset. Готовый архив — gotcha.monitoring-1.0.0.zip, проверен на «Управление сайтом: Старт» 26.150.

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

СигналОткуда шлётсяЧто нужно
Ошибки PHP (исключения и фаталы)ядро БитриксаExceptionHandlerLog в .settings.php
Время ответа по скриптаммодультранзакции на событиях OnPageStart/OnAfterEpilog
Ошибки JS и Web Vitalsбраузер@sentry/browser через Asset
Аптайм и 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 в нём публичен по замыслу. В модуле он станет настройкой: пустое значение — модуль полностью выключен.

Главное: Битрикс не отдаст вам ошибки просто так

Первое, что делают при подключении Sentry к любому PHP-проекту, — вызывают \Sentry\init(), а SDK ставит свои set_error_handler и set_exception_handler. В Битриксе это не сработает: ядро ставит свой обработчик в Bitrix\Main\Application::initializeExceptionHandler(), и он перехватывает всё раньше. Ошибка уйдёт в журнал Битрикса, а до вашего обработчика не доедет.

Бороться с ядром не нужно — у него есть штатная точка расширения. Битрикс отдаёт каждую перехваченную ошибку «логу», класс которого задан в .settings.php:

'exception_handling' => [
    'value' => [
        'log' => [
            'class_name'    => '\\Gotcha\\Monitoring\\ExceptionLog',
            'required_file' => 'modules/gotcha.monitoring/lib/ExceptionLog.php',
            'settings'      => [],
        ],
    ],
],

required_file резолвится через Loader::getLocal(), то есть путь ищется сначала в /local/, потом в /bitrix/ — модулю из /local/modules/ этого достаточно. Класс должен наследовать ExceptionHandlerLog:

namespace Gotcha\Monitoring;

use Bitrix\Main\Diag\ExceptionHandlerLog;

final class ExceptionLog extends ExceptionHandlerLog
{
    public function initialize(array $options): void
    {
    }

    public function write($exception, $logType): void
    {
        if (!$exception instanceof \Throwable || !Sdk::isEnabled()) {
            return;
        }

        // IGNORED_ERROR — то, что ядро само решило не считать проблемой
        // (подавленные @-оператором и т.п.). Шлём только настоящие сбои.
        if ($logType === self::IGNORED_ERROR || $logType === self::LOW_PRIORITY_ERROR) {
            return;
        }

        \Sentry\withScope(function (\Sentry\State\Scope $scope) use ($exception, $logType): void {
            $scope->setTag('bitrix.log_type', self::logTypeToString($logType));
            \Sentry\captureException($exception);
        });

        // Фатальную ошибку процесс не переживёт: без flush событие останется
        // в буфере и не уйдёт по сети.
        if ($logType === self::FATAL) {
            $client = \Sentry\SentrySdk::getCurrentHub()->getClient();

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

Два места, которые стоит объяснить.

Фильтр по $logType. Битрикс различает виды ошибок: UNCAUGHT_EXCEPTION, CAUGHT_EXCEPTION, FATAL, но также IGNORED_ERROR (подавленные) и LOW_PRIORITY_ERROR. Последние два — не сбои, и если их не отсечь, в issues польётся шум, из-за которого настоящие ошибки потеряются.

flush() на фатале. Sentry SDK копит события в буфере и отправляет их в конце запроса. При фатальной ошибке «конца запроса» в обычном смысле не будет, поэтому именно фатал — тот случай, когда буфер надо вытолкнуть руками. Без этой строчки самые важные ошибки как раз и не долетают.

Прописывать конфиг руками пользователю не нужно: это делает установщик модуля, о нём ниже.

Структура модуля

Модуль для Битрикса — это папка вида вендор.модуль в /local/modules/:

local/modules/gotcha.monitoring/
├── include.php             # автозагрузка классов, инициализация SDK
├── options.php             # страница настроек в админке
├── install/index.php       # класс установщика (CModule)
├── install/version.php
├── lib/Sdk.php             # инициализация Sentry SDK
├── lib/ExceptionLog.php    # приёмник ошибок ядра
├── lib/Monitor.php         # транзакции и браузерный SDK
├── lang/{ru,en}/           # языковые файлы
├── media/js/sentry.min.js  # браузерный SDK
└── vendor/                 # composer require sentry/sentry

/local/ вместо /bitrix/ — принципиально: содержимое /bitrix/modules/ перетирается обновлениями продукта, а /local/ Битрикс не трогает.

Точка входа

include.php подключается ядром при Loader::includeModule('gotcha.monitoring'), в том числе автоматически перед вызовом наших обработчиков событий:

use Bitrix\Main\Config\Option;
use Bitrix\Main\Loader;

defined('B_PROLOG_INCLUDED') && B_PROLOG_INCLUDED === true || die();

Loader::registerAutoLoadClasses('gotcha.monitoring', [
    'Gotcha\\Monitoring\\Monitor'      => 'lib/Monitor.php',
    'Gotcha\\Monitoring\\ExceptionLog' => 'lib/ExceptionLog.php',
    'Gotcha\\Monitoring\\Sdk'          => 'lib/Sdk.php',
]);

Gotcha\Monitoring\Sdk::init();

if (Option::get('gotcha.monitoring', 'browser', 'Y') === 'Y') {
    Gotcha\Monitoring\Monitor::injectBrowserSdk();
}

Строка defined('B_PROLOG_INCLUDED') || die() обязательна в каждом файле модуля: она не даёт выполнить файл при прямом обращении по URL.

Инициализация SDK

\Sentry\init([
    'dsn'                => $dsn,
    'environment'        => (string) Option::get('gotcha.monitoring', 'environment', 'production'),
    'release'            => 'bitrix@' . (defined('SM_VERSION') ? SM_VERSION : 'unknown'),
    'traces_sample_rate' => (float) Option::get('gotcha.monitoring', 'traces_sample_rate', '0.2'),
    // Обработчики ставит Битрикс: свои повесит ExceptionLog, а этим
    // мы бы только перебили штатную обработку ошибок ядра.
    'error_types'        => 0,
]);

error_types => 0 — важная деталь. Ошибки к нам приходят через ExceptionLog, а обработчики самого SDK тут только мешали бы: в лучшем случае дублировали события, в худшем — ломали штатную обработку ошибок Битрикса. release берём из SM_VERSION, чтобы в Gotcha было видно, на какой версии продукта случилась регрессия.

Имя транзакции: физический скрипт, а не URL

В Битриксе нет роутов в привычном смысле. Есть ЧПУ, которое urlrewrite разворачивает в один и тот же файл: /catalog/tovar-123/ и /catalog/tovar-456/ исполняет /catalog/index.php. Он и есть эндпойнт.

Возьми мы URL — и у магазина с двадцатью тысячами товаров появится двадцать тысяч «эндпойнтов», по одному на карточку, а раздел «Производительность» перестанет что-либо значить (подробнее — Кардинальность).

private static function transactionName(): string
{
    if (self::isConsole()) {
        return 'cron';
    }

    $script = (string) ($_SERVER['SCRIPT_NAME'] ?? '');

    if ($script === '') {
        return 'unknown';
    }

    // Общая точка входа AJAX-компонентов: без действия все запросы
    // слиплись бы в один эндпойнт.
    if (str_ends_with($script, '/bitrix/services/main/ajax.php')) {
        $action = (string) ($_REQUEST['action'] ?? '');

        return $action !== '' ? 'ajax:' . $action : 'ajax';
    }

    return $script;
}

Получается ровно то, что нужно видеть: /index.php, /catalog/index.php, /bitrix/admin/user_edit.php, ajax:..., cron. Десятки строк вместо десятков тысяч.

Транзакция: события ядра плюс страховка

public static function onPageStart(): void
{
    if (!Sdk::isEnabled() || self::$transaction !== null) {
        return;
    }

    $context = TransactionContext::make()
        ->setName(self::transactionName())
        ->setOp(self::isConsole() ? 'console' : 'http.server');

    self::$transaction = \Sentry\startTransaction($context);
    SentrySdk::getCurrentHub()->setSpan(self::$transaction);

    // Не только OnAfterEpilog: при фатальной ошибке до событий ядра дело
    // не доходит, а shutdown-функция PHP вызывается всегда.
    register_shutdown_function([self::class, 'finish']);
}

OnPageStart — самое раннее событие ядра, до пролога. Закрываем транзакцию на OnAfterEpilog, но дублируем register_shutdown_function: при фатальной ошибке эпилог не выполняется, а shutdown-обработчик PHP отработает всегда.

Браузерный SDK

У Битрикса штатный менеджер ассетов, ему и отдаём скрипт:

$asset = Asset::getInstance();
$asset->addJs('/bitrix/js/gotcha.monitoring/sentry.min.js');
$asset->addString(
    '<script>Sentry.init({'
    . 'dsn: ' . json_encode($dsn) . ','
    . 'integrations: [Sentry.browserTracingIntegration()],'
    . 'tracesSampleRate: ' . $rate
    . '});</script>'
);

Всё это попадёт в страницу при вызове $APPLICATION->ShowHead() — стандартной строке любого шаблона Битрикса. browserTracingIntegration сам снимает Web Vitals (LCP, CLS, INP, FCP, TTFB) и ловит JS-ошибки.

Бандл собирается один раз любым бандлером:

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

Установщик

Класс установщика в Битриксе называется по имени модуля с точкой, заменённой на подчёркивание, и наследует CModule. Он регистрирует модуль, подписывается на события и — главное — сам прописывает наш класс в .settings.php:

public function DoInstall(): bool
{
    ModuleManager::registerModule($this->MODULE_ID);

    RegisterModuleDependences('main', 'OnPageStart', $this->MODULE_ID,
        '\Gotcha\Monitoring\Monitor', 'onPageStart');
    RegisterModuleDependences('main', 'OnAfterEpilog', $this->MODULE_ID,
        '\Gotcha\Monitoring\Monitor', 'onAfterEpilog');

    $this->copyAssets();
    $this->registerExceptionLog();

    return true;
}

private function registerExceptionLog(): void
{
    $configuration = Configuration::getInstance();
    $value = $configuration->get('exception_handling') ?? [];

    $value['log'] = [
        'class_name'    => '\\Gotcha\\Monitoring\\ExceptionLog',
        'required_file' => 'modules/gotcha.monitoring/lib/ExceptionLog.php',
        'settings'      => [],
    ];

    $configuration->setValue('exception_handling', $value);
    $configuration->saveConfiguration();
}

DoUninstall() делает обратное: убирает log из конфига, удаляет js-файл и снимает подписки. Модуль, который не умеет удаляться начисто, — плохой модуль.

Грабля из практики. CModule объявляет методы InstallFiles() и UnInstallFiles() публичными. Если назвать свой приватный метод installFiles(), PHP выдаст фатальную ошибку прямо при установке: имена методов регистронезависимы, и приватный метод не может переопределить публичный. Называйте свои методы иначе — например copyAssets().

Настройки

options.php в корне модуля Битрикс сам показывает в разделе Настройки → Настройки продукта → Настройки модулей. Страница делается штатным CAdminTabControl, значения хранятся в Option::set()/Option::get(). Обязательное: проверка $USER->IsAdmin() и check_bitrix_sessid() перед сохранением — без неё форма открыта для CSRF.

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

Модуль несёт свой vendor/, и соседние модули — тоже. Там встречаются те же пакеты (psr/log, guzzlehttp/*, symfony/*) других мажорных версий. Побеждает автолоадер, сработавший первым, а дальше — 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: на новых версиях всё работало, а на старой сайт отдавал белый экран на каждой странице.

Лечится переименованием namespace зависимостей при сборке — php-scoper:

// scoper.inc.php
return [
    'prefix' => 'Gotcha\\Monitoring\\Vendor',
    'finders' => [
        Finder::create()->files()->in('vendor'),
        Finder::create()->files()->name('*.php')->in('lib'),
    ],
    // Namespace модуля и классы ядра Битрикса не трогаем.
    'exclude-namespaces' => ['Gotcha\\Monitoring', 'Bitrix'],
    // API Битрикса — глобальные функции и классы (CModule, CAdminTabControl).
    'expose-global-functions' => true,
    'expose-global-classes'   => true,
];

Для Битрикса важны две последние строки: половина его API — глобальные функции (RegisterModuleDependences, CopyDirFiles) и классы без namespace (CModule, CAdminTabControl). Переименуй их — и модуль не установится.

Установка

unzip gotcha.monitoring-1.0.0.zip -d /path/to/site/local/modules/

Затем Marketplace → Установленные решения, найти «Gotcha: мониторинг ошибок и производительности», нажать «Установить». После этого — Настройки → Настройки продукта → Настройки модулей → Gotcha, вставить DSN.

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

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

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

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

Что смотреть на сайте под Битриксом в первую очередь

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

Итог

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