Connecting Gotcha to MODX: transport packages, events and code in the database
Gotcha ingests data over the Sentry ingest protocol, so connecting any PHP application means an official Sentry SDK pointed at your instance. MODX stands out among CMSes in two ways: element code lives in the database, and add-ons are distributed as transport packages built by a script running on a live installation. Both facts shape the plugin noticeably.
Let’s go through it in full — including the rakes we stepped on while testing against a live MODX 3.2.2.
What we’ll collect
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (exceptions and fatals) | the site | a handler chain on top of the stock ones |
| Response time per template | the site | the OnMODXInit / OnWebPageComplete events |
| JS errors and Web Vitals | browser | the OnWebPagePrerender event |
| 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 plugin it becomes the gotcha.dsn system setting: an empty value
disables the plugin entirely.
Trait one: element code lives in the database
A MODX plugin is a row in modx_site_plugins, not a file. Which means it isn’t
in git, can’t be reviewed properly and is awkward to edit.
The way around it is simple: keep only an event dispatcher in the plugin itself and move all the logic into an ordinary class on disk.
require_once MODX_CORE_PATH . 'components/gotcha/model/Monitor.php';
use Gotcha\Monitoring\Monitor;
switch ($modx->event->name) {
case 'OnMODXInit':
Monitor::init($modx);
break;
case 'OnWebPagePrerender':
Monitor::injectBrowser($modx);
break;
case 'OnPageNotFound':
Monitor::notFound($modx);
break;
case 'OnWebPageComplete':
Monitor::finish();
break;
}
The opening-tag rake. MODX stores element code without
<?phpand adds the tag itself at execution time. Put the whole plugin file into the package and the cached element ends up with two tags in a row, taking the site down withParse error: syntax error, unexpected token "<"— and it takes down the manager and even the install script, which bootstraps MODX. We went around that loop; the cure is for the package builder to strip the tag.function stripPhpTags(string $code): string { $code = preg_replace('/^\s*<\?php\s*/', '', $code); return trim(preg_replace('/\?>\s*$/', '', $code)); }
Errors: join the chain
MODX installs its own set_error_handler (the modErrorHandler class) during
initialisation. Replacing it would deprive the site of its stock error log, so
we take the previous handler and pass control to it after ourselves. MODX
installs no exception handler at all, so that one is ours.
private static function chainHandlers(): void
{
$previousError = set_error_handler(null);
set_error_handler(static function (int $code, string $message, string $file = '', int $line = 0) use ($previousError) {
// Notices and deprecations are the usual snippet noise; they add
// nothing to Issues.
if (in_array($code, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE, E_COMPILE_ERROR], true)) {
\Sentry\captureException(new \ErrorException($message, 0, $code, $file, $line));
self::flush();
}
return $previousError !== null ? $previousError($code, $message, $file, $line) : false;
});
$previousException = set_exception_handler(null);
set_exception_handler(static function (\Throwable $e) use ($previousException): void {
\Sentry\captureException($e);
self::flush();
if ($previousException !== null) {
$previousException($e);
}
});
}
Verified on a live site: an exception thrown from a snippet and a reference to a missing class both arrive with a stack trace that names the snippet.
Transactions: the template, not the resource
MODX has no routes: a page is defined by a resource, and a content site can have thousands of them. Naming transactions after resources means thousands of rows in the report — the very cardinality problem we keep avoiding.
The right unit is the template: a site has a handful, and the template is what decides how a page is assembled and what it costs. Plus the context, so the front end doesn’t blend into the manager:
private static function transactionName($modx): string
{
$context = (string) ($modx->context ? $modx->context->get('key') : 'web');
if ($context === 'mgr') {
return 'mgr';
}
if ($modx->resource === null) {
return $context;
}
$templateId = (int) $modx->resource->get('template');
if ($templateId === 0) {
return $context . ':(blank)';
}
$template = $modx->getObject(\MODX\Revolution\modTemplate::class, $templateId);
$name = $template !== null ? (string) $template->get('templatename') : (string) $templateId;
return $context . ':' . $name;
}
In the report that reads:
web:BaseTemplate site pages
mgr the manager
404 addresses that don't exist
A subtlety about event order: at OnMODXInit no resource has been selected yet,
so the name is refined later, on OnWebPagePrerender. But OnWebPagePrerender
also fires after OnPageNotFound — MODX forwards a 404 to the error page and
renders it with an ordinary template. Without a lock the 404 name would be
immediately overwritten back to web:BaseTemplate:
public static function notFound($modx): void
{
if (self::$transaction === null) {
return;
}
// Close the transaction right here rather than in shutdown: on a 404 MODX
// ends the request with exit(), and by the time our shutdown function runs
// there is nobody left to send it — the transaction is simply lost.
self::$transaction->setName('404');
self::$transaction->setHttpStatus(404);
self::$nameLocked = true;
self::finish();
}
One more detail that cost a separate debugging round: our own flush() when
closing the transaction. The Sentry SDK registers its shutdown handler at
init(), i.e. before ours, so by the time our shutdown function closes the
transaction the SDK’s send has already run. On a regular page
OnWebPageComplete saves the day (it fires inside the request); on a 404 it
does not.
The browser SDK
MODX has no asset manager, but OnWebPagePrerender hands you the rendered page
output by reference — so we append the script before </head>:
$output = &$modx->resource->_output;
$position = stripos($output, '</head>');
if ($position === false) {
return;
}
$src = rtrim((string) $modx->getOption('site_url'), '/')
. '/assets/components/gotcha/js/sentry.min.js';
$script = '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"></script>'
. '<script>Sentry.init({ /* ... */ });</script>';
$output = substr($output, 0, $position) . $script . substr($output, $position);
The address is built from the site_url system setting — a relative link would
break on any SEO URL deeper than one level.
Trait two: the transport package
MODX add-ons are distributed as transport packages (*.transport.zip), and they
are built by a script running on a live installation — it needs the
xPDOTransport classes from the core. That’s unusual after Joomla and
WordPress, where building means zipping a folder.
The skeleton of the builder:
// Bootstrap MODX: without it there is no MODX_CORE_PATH and no xPDOTransport.
$modxRoot = rtrim(getenv('MODX_ROOT') ?: '/var/www/html', '/') . '/';
require_once $modxRoot . 'config.core.php';
require_once MODX_CORE_PATH . 'vendor/autoload.php';
$modx = new modX();
$modx->initialize('mgr');
$builder = new \MODX\Revolution\Transport\modPackageBuilder($modx);
$builder->createPackage('gotcha', '1.0.0', 'pl');
$builder->registerNamespace('gotcha', false, true, '{core_path}components/gotcha/');
Then you assemble an object tree: category → plugin → its event subscriptions.
Files (the library with vendor/ and the browser bundle) go in as resolvers:
$vehicle->resolve('file', [
'source' => rtrim($sources['core'], '/'),
'target' => "return MODX_CORE_PATH . 'components/';",
]);
$vehicle->resolve('file', [
'source' => rtrim($sources['assets'], '/'),
'target' => "return MODX_ASSETS_PATH . 'components/';",
]);
System settings become separate vehicles, and crucially with
UPDATE_OBJECT => false:
$builder->putVehicle($builder->createVehicle($setting, [
xPDOTransport::UNIQUE_KEY => 'key',
xPDOTransport::PRESERVE_KEYS => true,
// Don't overwrite settings on update: the DSN was entered by hand.
xPDOTransport::UPDATE_OBJECT => false,
]));
Without that, updating the package would overwrite the administrator’s DSN with an empty string — a classic way to “break monitoring by updating it”.
A small thing that cost time. xPDO’s
addMany()takes its argument by reference, so$category->addMany([$plugin])fails withcould not be passed by reference. It needs a variable:$plugins = [$plugin]; $category->addMany($plugins);
A required step: scoping the vendor directory
The plugin ships its own vendor/, MODX ships its own (guzzle, psr/*,
symfony/*), and so does every neighbouring add-on. Whichever autoloader
registered first wins, and on mismatched majors the site goes down entirely. The
fix is a prefix via php-scoper:
return [
'prefix' => 'Gotcha\\Monitoring\\Vendor',
'finders' => [
Finder::create()->files()->in('core/components/gotcha/vendor'),
Finder::create()->files()->name('Monitor.php')->in('core/components/gotcha/model'),
],
// Leave our namespace and the MODX core classes alone.
'exclude-namespaces' => ['Gotcha\\Monitoring', 'MODX', 'xPDO'],
'expose-global-constants' => true,
'expose-global-classes' => true,
'expose-global-functions' => true,
];
Scoping must not damage the sources: the build writes its result into a separate
dist/ directory, and the package is assembled from there, while core/ and
assets/ stay untouched and live in git in their original form.
Installing
Packages → Installer → Upload package, pick
gotcha-1.0.0-pl.transport.zip, install. Then System → System Settings,
filter by the gotcha namespace: paste the DSN and, if you like, adjust the
environment and the traces sample rate.
To verify, request a page with a snippet that throws — 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 MODX site
| Metric | Why |
|---|---|
| PHP errors per release | “500s started after that snippet edit” |
| JS errors in the browser | a form or gallery broken for a subset of visitors |
| p95 per template | shows which template is heavy: catalogue, article, search |
| Web Vitals (LCP, INP, CLS) | real speed for real people, not in Lighthouse |
| Share of 404s | a spike usually means broken links or scanners |
| Uptime and SSL expiry | the site is down / the certificate expires in three days |
Summary
- Logic in a file, only a dispatcher in the plugin: MODX element code lives in the database, and keeping a hundred lines there is awkward and invisible to git.
- Element code is stored without
<?php— the package builder must strip the tag, or the cached element takes the whole site down. - Chain the error handlers: MODX already owns
set_error_handler. - Transaction names are context plus template, not the resource.
- Close 404s synchronously in
OnPageNotFoundand call your ownflush(): the SDK’s shutdown handler is registered before yours. - Package settings need
UPDATE_OBJECT => false, or an update overwrites the DSN that was entered by hand. - Scope
vendor/and write the result to a separate directory so the repository sources stay clean.
Next: the documentation, installation and the SDK setup section.