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
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (components, plugins, template) | Joomla backend | the plugin, onError event |
| Response time per “endpoint” | Joomla backend | the plugin, transactions |
| JS errors and Web Vitals | browser | @sentry/browser, injected by the same plugin |
| Business metrics (orders, sign-ups) | cron → OTLP | POST to /v1/metrics |
| 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); 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:
- 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 theonErrorevent, and a plugin can pick the originalThrowablefrom it. - The plain PHP SDK doesn’t create transactions on its own. Unlike the
Symfony and Laravel bundles,
sentry/sentrywithout a framework integration doesn’t instrument HTTP requests — you open and close the transaction manually. In Joomla theonAfterRouteandonAfterRespondevents 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:
- The library is visible in the extensions list. An administrator can see
what that new folder under
libraries/is and where it came from; removing the package removes both extensions. - Dependencies update independently of the logic. A Sentry SDK patch lands — the library is updated, the plugin is untouched, and vice versa.
- The library is reusable. A second Gotcha extension (a status module, say)
would use the same
libraries/gotchainstead of shipping a second copy of the SDK.
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.phpwith its ownpostflight(), 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:
| Package | Joomla 4.4 | Joomla 5 | Joomla 6 |
|---|---|---|---|
psr/log | 1.1.4 | 3.0.2 | 3.0.2 |
psr/http-message | 1.1 | 1.1 | 2.0 |
psr/container | 1.1.1 | 1.1.2 | 2.0.2 |
symfony/options-resolver | v5.4 | v6.4 | v7.4 |
symfony/deprecation-contracts | v2.5 | v3.6 | v3.6 |
guzzlehttp/psr7 | absent | 2.12.3 | 2.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:
| Metric | Why | Where it comes from |
|---|---|---|
| PHP errors per release | “500s started after that extension update” | the plugin, onError |
| JS errors in the browser | the slider/form is broken for some visitors only | @sentry/browser |
| p95 response time per component | which component is slow: content, shop, search | transactions |
| Web Vitals: LCP, INP, CLS | real speed for visitors, not in Lighthouse | browserTracingIntegration |
| Uptime and SSL expiry | the site is down / certificate expires in 3 days | Gotcha HTTP monitor |
| Business metrics: orders, sign-ups | “monitoring is green but there are no orders” — the sneakiest failure | OTLP, 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
- There is no official Joomla SDK — and none is needed: one ~100-line
system plugin wires up
sentry/sentryand covers errors, tracing and Web Vitals. It works the same on Joomla 4.2+, 5 and 6. onErrorinstead ofset_exception_handler: Joomla intercepts exceptions itself; take them from the event.- Transactions are manual,
onAfterRoute→onAfterRespond, named byoption.view, not by URL. - A package, not a bare plugin: a
pkg_of a dependency library plus the system plugin — the Joomla way, and easier to update. vendor/must be scoped with php-scoper: the versions ofpsr/log,psr/http-messageandsymfony/*in Joomla 4, 5 and 6 are mutually incompatible, and without a prefix the clash takes down the whole site, not just the monitoring.- Uptime and SSL — a monitor on the public URL, no code.
- Business metrics — cron + an OTLP POST, threshold alerts in the UI.
Next: the documentation, installation and the SDK setup section.