Connecting Gotcha to 1C-Bitrix: errors, performance and Web Vitals

Gotcha ingests data over the Sentry ingest protocol, so connecting any PHP application means an official Sentry SDK pointed at your instance. Bitrix adds a wrinkle: it has its own error handler, its own module system and its own idea of what a “page” is. Wire the SDK up naively and half your errors never arrive, while the performance report turns out useless.

Let’s build a module that gets it right: PHP errors through the core’s own extension point, transactions named after physical scripts, the browser SDK via Asset. The prebuilt archive is gotcha.monitoring-1.0.0.zip, tested on “Site Management: Start” 26.150.

What we’ll collect

SignalSent fromWhat you need
PHP errors (exceptions and fatals)Bitrix coreExceptionHandlerLog in .settings.php
Response time per scriptthe moduletransactions on OnPageStart/OnAfterEpilog
JS errors and Web Vitalsbrowser@sentry/browser via Asset
Uptime and SSLGotcha, outboundHTTP monitor, no code
AlertsGotchaa channel (Telegram/webhook/email) in the UI

Where to get the DSN

After you create a project, Gotcha redirects you to the “Setup” page (/projects/<id>/setup). The DSN looks like this:

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

PHP and the browser use the same DSN — the public_key in it is public by design. In the module it becomes a setting: an empty value disables the module entirely.

The main thing: Bitrix won’t hand you its errors

The first thing anyone does when wiring Sentry into a PHP project is call \Sentry\init(), and the SDK installs its own set_error_handler and set_exception_handler. In Bitrix that does not work: the core installs its own handler in Bitrix\Main\Application::initializeExceptionHandler(), and it intercepts everything first. The error lands in the Bitrix log and never reaches your handler.

There is no need to fight the core — it has a proper extension point. Bitrix hands every intercepted error to a “log” whose class is named in .settings.php:

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

required_file is resolved through Loader::getLocal(), so the path is looked up under /local/ first and /bitrix/ second — exactly what a module in /local/modules/ needs. The class must extend 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 is what the core itself decided is not a problem
        // (suppressed with @ and the like). Send real failures only.
        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);
        });

        // The process won't survive a fatal: without a flush the event stays
        // in the buffer and never goes over the wire.
        if ($logType === self::FATAL) {
            $client = \Sentry\SentrySdk::getCurrentHub()->getClient();

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

Two spots deserve an explanation.

Filtering on $logType. Bitrix distinguishes kinds of errors: UNCAUGHT_EXCEPTION, CAUGHT_EXCEPTION, FATAL, but also IGNORED_ERROR (suppressed) and LOW_PRIORITY_ERROR. The last two are not failures, and letting them through floods Issues with noise that buries the real errors.

flush() on a fatal. The Sentry SDK buffers events and sends them at the end of the request. On a fatal error there is no “end of the request” in the usual sense, so a fatal is exactly the case where the buffer must be pushed by hand. Without that line the most important errors are the ones that never arrive.

Users don’t have to edit the config by hand: the module’s installer does it, see below.

Module structure

A Bitrix module is a vendor.module directory under /local/modules/:

local/modules/gotcha.monitoring/
├── include.php             # class autoloading, SDK initialisation
├── options.php             # settings page in the admin panel
├── install/index.php       # installer class (CModule)
├── install/version.php
├── lib/Sdk.php             # Sentry SDK initialisation
├── lib/ExceptionLog.php    # sink for core errors
├── lib/Monitor.php         # transactions and the browser SDK
├── lang/{ru,en}/           # language files
├── media/js/sentry.min.js  # browser SDK
└── vendor/                 # composer require sentry/sentry

/local/ rather than /bitrix/ matters: the contents of /bitrix/modules/ are overwritten by product updates, while /local/ is left alone.

Entry point

include.php is loaded by the core on Loader::includeModule('gotcha.monitoring'), including automatically before our event handlers run:

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();
}

The defined('B_PROLOG_INCLUDED') || die() line belongs in every module file: it stops the file from executing when requested directly by URL.

Initialising the 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'),
    // Bitrix owns the handlers: ExceptionLog delivers our errors, and the
    // SDK's own handlers would only override the core's error handling.
    'error_types'        => 0,
]);

error_types => 0 is the important detail. Errors reach us through ExceptionLog, and the SDK’s own handlers would only get in the way — at best duplicating events, at worst breaking the core’s error handling. release comes from SM_VERSION so Gotcha can show which product version a regression started in.

Naming transactions: the physical script, not the URL

Bitrix has no routes in the usual sense. It has SEF URLs that urlrewrite resolves to one and the same file: /catalog/product-123/ and /catalog/product-456/ are both executed by /catalog/index.php. That file is the endpoint.

Name transactions by URL and a shop with twenty thousand products gets twenty thousand “endpoints”, one per product card, and the Performance section stops meaning anything (see Cardinality).

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

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

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

    // The shared entry point for AJAX components: without the action every
    // request would collapse into a single endpoint.
    if (str_ends_with($script, '/bitrix/services/main/ajax.php')) {
        $action = (string) ($_REQUEST['action'] ?? '');

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

    return $script;
}

What you get is exactly what you want to see: /index.php, /catalog/index.php, /bitrix/admin/user_edit.php, ajax:..., cron. Dozens of rows instead of tens of thousands.

Transactions: core events plus a safety net

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);

    // Not just OnAfterEpilog: on a fatal error the core's events are never
    // reached, while PHP always calls its shutdown functions.
    register_shutdown_function([self::class, 'finish']);
}

OnPageStart is the earliest core event, before the prolog. We close the transaction on OnAfterEpilog but double up with register_shutdown_function: on a fatal error the epilog never runs, while PHP’s shutdown handler always does.

The browser SDK

Bitrix has a proper asset manager, so we hand the script to it:

$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>'
);

All of it lands in the page when $APPLICATION->ShowHead() runs — a standard line in every Bitrix template. browserTracingIntegration collects Web Vitals (LCP, CLS, INP, FCP, TTFB) on its own and catches JS errors.

The bundle is built once with any bundler:

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

The installer

A Bitrix installer class is named after the module with the dot replaced by an underscore and extends CModule. It registers the module, subscribes to events and — most importantly — writes our class into .settings.php itself:

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() does the reverse: drops log from the config, removes the js file and unsubscribes from the events. A module that cannot uninstall cleanly is a bad module.

A rake from practice. CModule declares InstallFiles() and UnInstallFiles() as public. Name your own private method installFiles() and PHP throws a fatal error during installation: method names are case-insensitive, and a private method cannot override a public one. Name yours something else — copyAssets(), for instance.

Settings

Bitrix picks up options.php from the module root and shows it under Settings → System settings → Module settings. The page uses the stock CAdminTabControl, values live in Option::set()/Option::get(). Non-optional: a $USER->IsAdmin() check and check_bitrix_sessid() before saving — without it the form is open to CSRF.

A required step: scoping the vendor directory

The module ships its own vendor/, and so do neighbouring modules. They contain the same packages (psr/log, guzzlehttp/*, symfony/*) at different major versions. Whichever autoloader registered first wins, and then you get a fatal error out of nowhere:

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 = [])

That is exactly what we hit while building the same kind of plugin for Joomla: newer versions were fine, while the older one served a white screen on every page.

The fix is renaming the dependencies’ namespaces at build time, with php-scoper:

// scoper.inc.php
return [
    'prefix' => 'Gotcha\\Monitoring\\Vendor',
    'finders' => [
        Finder::create()->files()->in('vendor'),
        Finder::create()->files()->name('*.php')->in('lib'),
    ],
    // Leave the module namespace and Bitrix core classes alone.
    'exclude-namespaces' => ['Gotcha\\Monitoring', 'Bitrix'],
    // The Bitrix API is global functions and classes (CModule, CAdminTabControl).
    'expose-global-functions' => true,
    'expose-global-classes'   => true,
];

Those last two lines matter for Bitrix specifically: half of its API is global functions (RegisterModuleDependences, CopyDirFiles) and namespace-less classes (CModule, CAdminTabControl). Rename those and the module won’t install.

Installing

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

Then Marketplace → Installed solutions, find “Gotcha: error and performance monitoring”, press “Install”. After that go to Settings → System settings → Module settings → Gotcha and paste the DSN.

To verify, throw an exception anywhere on the site — the event shows up in “Issues” within seconds.

Uptime and alerts

Uptime needs no code changes — Gotcha probes your public URL from the outside: Uptime → New monitor → HTTP, the site URL, interval and thresholds. Incidents, SSL alerts and a public status page come out of the box (Uptime).

In the “Alerts” section the rules (new issue, regression, spike) are enabled already; all that’s left is a delivery channel — Telegram, webhook or email (Alerts).

What to watch first on a Bitrix site

MetricWhy
PHP errors per release“500s started after that module update”
JS errors in the browserthe cart or filter broken for a subset of visitors
p95 per scriptshows what is slow: catalogue, checkout or search
Web Vitals (LCP, INP, CLS)real speed for real people, not in Lighthouse
ajax:* and agent timingsa classic source of slowness in an online shop
Uptime and SSL expirythe site is down / the certificate expires in three days

Summary

Next: the documentation, installation and the SDK setup section.