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

SignalSent fromWhat you need
PHP errors (exceptions and fatals)the sitea handler chain on top of the stock ones
Response time per templatethe sitethe OnMODXInit / OnWebPageComplete events
JS errors and Web Vitalsbrowserthe OnWebPagePrerender event
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 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 <?php and 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 with Parse 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 with could 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

MetricWhy
PHP errors per release“500s started after that snippet edit”
JS errors in the browsera form or gallery broken for a subset of visitors
p95 per templateshows which template is heavy: catalogue, article, search
Web Vitals (LCP, INP, CLS)real speed for real people, not in Lighthouse
Share of 404sa spike usually means broken links or scanners
Uptime and SSL expirythe site is down / the certificate expires in three days

Summary

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