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
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (exceptions and fatals) | Bitrix core | ExceptionHandlerLog in .settings.php |
| Response time per script | the module | transactions on OnPageStart/OnAfterEpilog |
| JS errors and Web Vitals | browser | @sentry/browser via Asset |
| Uptime and SSL | Gotcha, outbound | HTTP monitor, no code |
| Alerts | Gotcha | a 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.
CModuledeclaresInstallFiles()andUnInstallFiles()as public. Name your own private methodinstallFiles()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
| Metric | Why |
|---|---|
| PHP errors per release | “500s started after that module update” |
| JS errors in the browser | the cart or filter broken for a subset of visitors |
| p95 per script | shows what is slow: catalogue, checkout or search |
| Web Vitals (LCP, INP, CLS) | real speed for real people, not in Lighthouse |
ajax:* and agent timings | a classic source of slowness in an online shop |
| Uptime and SSL expiry | the site is down / the certificate expires in three days |
Summary
- Errors go through
ExceptionHandlerLog, notset_exception_handler: the Bitrix core has its own handler and it always gets there first. flush()on a fatal, or the most important events stay in the buffer.- Transaction names are physical scripts, not URLs: with SEF URLs every product would otherwise become its own endpoint.
register_shutdown_functionalongsideOnAfterEpilog— on a fatal the epilog never runs.- The module lives in
/local/modules/so product updates don’t wipe it. vendor/must be scoped, but withexpose-global-functions— half the Bitrix API is global.
Next: the documentation, installation and the SDK setup section.