Connecting Gotcha to Joomla: errors, performance and site metrics

Gotcha ingests data over the Sentry ingest protocol and over OTLP, so connecting any PHP application means an official Sentry SDK pointed at your instance. Symfony and Laravel have ready-made bundles, but there is no official “Sentry for Joomla” package — which makes it a great excuse to show how the integration works “by hand”: one small system plugin covers errors, tracing and Web Vitals in one go — and works the same on Joomla 4.2+, 5 and 6. Full code below, and if you’d rather not build it yourself, there is a ready-to-install package with the same code (tested on Joomla 4.4, 5.4 and 6).

What we’ll collect

SignalSent fromWhat you need
PHP errors (components, plugins, template)Joomla backendthe plugin, onError event
Response time per “endpoint”Joomla backendthe plugin, transactions
JS errors and Web Vitalsbrowser@sentry/browser, injected by the same plugin
Business metrics (orders, sign-ups)cron → OTLPPOST to /v1/metrics
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); you can get back via the “SDK setup” button. 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 our plugin the DSN becomes a parameter: an empty value fully disables the plugin, and moving to another instance is a one-field edit in the admin panel.

Why a system plugin

Two Joomla specifics that make “just call \Sentry\init() in index.php” not work the way you’d expect:

  1. Joomla catches exceptions itself. An unhandled exception thrown by a component never reaches set_exception_handler — the core intercepts it and renders an error page. But right before that it dispatches the onError event, and a plugin can pick the original Throwable from it.
  2. The plain PHP SDK doesn’t create transactions on its own. Unlike the Symfony and Laravel bundles, sentry/sentry without a framework integration doesn’t instrument HTTP requests — you open and close the transaction manually. In Joomla the onAfterRoute and onAfterRespond events are a perfect fit for that.

A system plugin subscribes to both events plus two more — and you get a full integration without touching the core or the template.

Architecture: a package of a plugin and a library

Dropping vendor/ straight into the plugin folder is tempting, and it does work. The Joomla-idiomatic shape, though, is a package (pkg_) holding two extensions: the system plugin with the logic and a library (lib_) with the composer dependencies.

Why bother:

pkg_gotcha.zip
├── pkg_gotcha.xml                  # package manifest
├── script.php                      # version checks + post-install message
├── language/{en-GB,ru-RU}/         # package strings
└── packages/
    ├── lib_gotcha.zip              # library: vendor + manifest
    └── plg_system_gotcha.zip       # plugin: logic, languages, browser bundle

The package manifest lists the nested extensions — Joomla installs them itself, in order:

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

The library manifest is short: its name decides the folder it lands in (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>

The plugin loads the autoloader from the library rather than from its own folder:

// Dependencies live in the package library (libraries/gotcha) rather than in
// the plugin folder: that way they update separately and can be reused.
require_once JPATH_LIBRARIES . '/gotcha/vendor/autoload.php';

The rest of this article is about the plugin itself.

Plugin files

plg_system_gotcha.zip
├── gotcha.xml               # manifest
├── script.php               # installer script: checks + auto-enable
├── services/provider.php    # registration (Joomla 4/5/6)
├── src/Extension/Gotcha.php # all the logic
├── language/{en-GB,ru-RU}/  # UI and message strings
└── media/js/sentry.min.js   # browser SDK (built below)

The plugin manifest, 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>

The installer script, script.php: checks and a post-install message

Joomla can run an installer script, and it earns its place twice. First, it keeps the extension out of environments where it wouldn’t work (preflight checks the Joomla and PHP versions). Second, it tells the human what to do next. A blank “installed successfully” box is a wasted opportunity: postflight can render HTML with links to the docs, the server install guide and the article.

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
{
    // Enable the plugin right away: with no DSN it is a complete no-op,
    // so all the user has left to do is paste the 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/en/" 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;
}

Only the package shows the message. The plugin has its own script.php with its own postflight(), and greeting the user from both the plugin and the package means seeing the same text twice during a single install. Keep the version checks and auto-enable in the plugin; keep the greeting at package level.

The texts live in language files rather than in the code: language/en-GB/plg_system_gotcha.sys.ini and ru-RU/. The .sys.ini suffix matters — Joomla reads that file in the extension manager and during installation, while the plain .ini is read when the user opens the plugin’s settings. You want both:

PLG_SYSTEM_GOTCHA="System - Gotcha"
PLG_SYSTEM_GOTCHA_AFTER_INSTALL="Thank you for installing the Gotcha plugin!"
PLG_SYSTEM_GOTCHA_POSTINSTALL_BODY="<p>The plugin sends to your Gotcha instance: <strong>PHP errors</strong>…</p>"
PLG_SYSTEM_GOTCHA_LINK_DOCS="Documentation"
PLG_SYSTEM_GOTCHA_LINK_DOCS_URL="https://getgotcha.ru/en/docs/sdk/"
PLG_SYSTEM_GOTCHA_ERROR_COMPATIBLE_JOOMLA="The Gotcha plugin is compatible with Joomla %s and above."

The manifest references them by key rather than by text: <name>PLG_SYSTEM_GOTCHA</name>, <description>PLG_SYSTEM_GOTCHA_XML_DESCRIPTION</description>, and label /description on every settings field. An admin panel in Russian then shows Russian labels, an English one shows English.

Registration, services/provider.php

The standard Joomla 4/5 service provider:

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

The logic, 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; // empty DSN — the plugin is fully disabled
        }

        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 didn't run — DSN is empty
        }

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

        // Name by component and view, not by URL: there are millions of
        // SEF URLs and only dozens of "endpoints". Otherwise the
        // Performance section becomes a dump of unique transactions.
        $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;
        }

        // On Joomla 4/5 this is an ErrorEvent with getError(); the generic
        // 'subject' access works there and on Joomla 6 alike.
        $error = method_exists($event, 'getError')
            ? $event->getError()
            : $event->getArgument('subject');

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

        // 401/403/404/405 are ordinary web noise (scanners, broken links),
        // not application errors.
        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();
    }
}

Note the transaction name: com_content.article, com_virtuemart.category — these are Joomla’s real “endpoints”. Name transactions by URL and every SEF address becomes its own row, making the Performance section useless — more in Cardinality.

The browser bundle

Classic Joomla has no frontend build step, so build a self-contained @sentry/browser bundle once, with any bundler — for example 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 collects Web Vitals (LCP, CLS, INP, FCP, TTFB) on its own and catches JS errors on the page. Gotcha living on a different domain than the site is the normal case: the ingest endpoint answers with CORS headers, so the browser sends directly, no proxy. The only thing that can get in the way is your own Content-Security-Policy — add the instance address to connect-src.

A required step: scoping the vendor directory

Here is the rake almost everyone steps on the first time they package composer dependencies into a CMS extension. The package ships its own vendor/, but so does Joomla — and they contain the same packages. The class names are identical and the versions are not. Whichever autoloader registered first decides which class gets loaded, 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 our first test run produced: Joomla 5 and 6 were fine, while Joomla 4.4 served a white screen on every page. Note what breaks — the whole site, not just the monitoring.

The obvious idea is “exclude those packages from composer and use the ones Joomla already ships”. That doesn’t work — look at what actually sits in libraries/vendor across versions:

PackageJoomla 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/psr7absent2.12.32.12.3

The majors diverge, and guzzlehttp/psr7 isn’t in Joomla 4 at all. Leaning on the core libraries would mean a separate build per Joomla version — and a new break with every major release.

The fix is renaming the dependencies’ namespaces at build time, with php-scoper. It walks vendor/ and our own code and prefixes every third-party class, after which our copies stop existing as far as everyone else is concerned:

// scoper.inc.php
return [
    'prefix' => 'Gotcha\\Vendor',
    'finders' => [
        Finder::create()->files()->in('vendor'),
        Finder::create()->files()->name('*.php')->in('plugin/src'),
    ],
    // Leave the plugin's own namespace alone — Joomla finds it by that name.
    // php-scoper still rewrites the Sentry references inside our files.
    'exclude-namespaces' => ['Joomla'],
];

After the build, \Sentry\init(...) in the plugin code becomes \Gotcha\Vendor\Sentry\init(...), and vendor/psr/log lives in Gotcha\Vendor\Psr\Log. The conflict is gone for good — both with the core and with any other extension that brought its own Sentry or Guzzle. One archive works on 4.2+, 5 and 6.

One build detail matters: vendor/ and the plugin code must be scoped in a single pass. Splitting them into two archives (library and plugin) can only happen afterwards — otherwise the Sentry references in the plugin won’t match the prefix in the library.

The rule is simple: any CMS extension with its own vendor/ needs scoping. That holds for Joomla, for WordPress and for Bitrix alike — anywhere extensions share a single PHP process.

Installing

Build a zip and install it the regular way:

./build.sh          # composer install -> php-scoper -> two zips -> the package

Or grab the prebuilt package — the exact code from this article, with a scoped vendor/ and the browser bundle included (tested on Joomla 4.4, 5.4 and 6 from the official docker images).

System → Install → Extensions, upload pkg_gotcha-1.2.1.zip — Joomla installs both the library and the plugin, and the plugin enables itself. Then System → Plugins → System - Gotcha, paste the DSN. To verify, temporarily throw an exception in any template or hit a component that fails with a 500 — the event shows up in the “Issues” section within seconds.

Which metrics to collect from the site

The most valuable part of monitoring a CMS site is not exotic — it’s a handful of boring numbers that answer 90% of “what’s up with the site?” questions:

MetricWhyWhere it comes from
PHP errors per release“500s started after that extension update”the plugin, onError
JS errors in the browserthe slider/form is broken for some visitors only@sentry/browser
p95 response time per componentwhich component is slow: content, shop, searchtransactions
Web Vitals: LCP, INP, CLSreal speed for visitors, not in LighthousebrowserTracingIntegration
Uptime and SSL expirythe site is down / certificate expires in 3 daysGotcha HTTP monitor
Business metrics: orders, sign-ups“monitoring is green but there are no orders” — the sneakiest failureOTLP, cron

The first four rows are already covered by the plugin above. Two remain.

Uptime: not a single line of code

Gotcha probes your public URL from the outside. In the project: Uptime → New monitor → HTTP, the site URL, interval and thresholds. Incidents, SSL alerts and a public status page come out of the box. See Uptime.

Business metrics over OTLP

Gotcha accepts numeric metrics over OTLP/HTTP — POST to /v1/metrics with an Authorization: Bearer <public_key> header (the key is the part of the DSN between https:// and @). For a Joomla site the simplest source of numbers is the database itself, read by cron. For example, the user count every 5 minutes:

#!/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)"'"}]}}]}]}]}'

The same way you can send VirtueMart/HikaShop orders, form submissions, the mail queue size — anything a SELECT COUNT(*) can produce. Threshold alerts attach to metrics (“zero orders per hour during business hours” is as strong a signal as a spike of 500s). More in Metrics and Metric alerts.

Alerts

In the “Alerts” section the rules (new issue, regression, spike) are enabled already; what’s left is adding a delivery channel — Telegram, webhook or email. Channels attach to uptime monitors and to metric threshold rules as well. See Alerts.

Summary

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