Connecting Gotcha to OpenCart: errors, routes and a cardinality trap

Gotcha ingests data over the Sentry ingest protocol, so connecting any PHP application means an official Sentry SDK pointed at your instance. OpenCart is interesting because it has neither a service container like Drupal nor a stock log extension point like Bitrix. What it does have is an event system and routes, and a decent integration can be built on those.

Let’s walk through the extension in full, including one trap that would have quietly wrecked the monitoring on a real store. The prebuilt archive is opencart-gotcha-1.0.0.ocmod.zip, tested on OpenCart 4.1.0.3.

What we’ll collect

SignalSent fromWhat you need
PHP errors (exceptions and fatals)storefronta handler chain on top of the stock ones
Response time per routestorefrontthe catalog/controller/*/before event
JS errors and Web Vitalsbrowserthe catalog/view/common/header/after 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 extension it becomes a setting: an empty value disables the extension entirely.

Extension layout

OpenCart 4 expects a directory with install.json at the root and subfolders per area:

gotcha/
├── install.json                                 extension manifest
├── admin/controller/module/gotcha.php           settings page
├── admin/model/module/gotcha.php                event subscriptions
├── admin/language/{en-gb,ru-ru}/module/gotcha.php
├── admin/view/template/module/gotcha.twig
├── catalog/controller/module/monitor.php        all the monitoring logic
├── catalog/view/javascript/gotcha/sentry.min.js browser SDK
└── system/library/vendor/                       composer require sentry/sentry

The namespace is tied to the file location: a storefront controller is Opencart\Catalog\Controller\Extension\Gotcha\Module, an admin one Opencart\Admin\Controller\Extension\Gotcha\Module. Get the namespace wrong and the engine simply won’t find the class.

Errors: join the chain, don’t replace it

OpenCart installs its own set_error_handler and set_exception_handler in system/framework.php — that is, before extensions are loaded at all. Replacing them with yours would break the stock log (storage/logs/error.log) and the store’s error page.

The right move is to join the chain: grab the previous handler, install yours, report the event and hand control on.

private function chainErrorHandlers(): void {
    $previousException = set_exception_handler(null);
    set_exception_handler(function (\Throwable $e) use ($previousException): void {
        \Sentry\captureException($e);
        $this->flush();

        if ($previousException !== null) {
            $previousException($e);
        }
    });

    $previousError = set_error_handler(null);
    set_error_handler(function (int $code, string $message, string $file = '', int $line = 0) use ($previousError) {
        // Notices and deprecations are the usual noise from the engine and old
        // extensions; 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));
            $this->flush();
        }

        return $previousError !== null ? $previousError($code, $message, $file, $line) : false;
    });
}

set_exception_handler(null) is a PHP idiom: the function returns the previous handler while setting null. Right after that we install ours, now knowing who to pass the baton to.

flush() is mandatory on a fatal: the SDK buffers events and sends them at the end of the request, and on a fatal error there is no “end of the request”.

private function flush(): void {
    $client = \Sentry\SentrySdk::getCurrentHub()->getClient();

    if ($client !== null) {
        $client->flush(2);
    }
}

Transactions: the route is almost an endpoint already

Everything in OpenCart revolves around index.php?route=product/product. The route is the endpoint: product/product instead of thousands of product URLs, checkout/checkout instead of every cart.

The catalog/controller/*/before event fires before every storefront controller, so that’s where we start:

public function start(string &$route): void {
    if (self::$started) {
        return;
    }

    self::$started = true;
    // ... init SDK ...
    $this->chainErrorHandlers();
    $this->startTransaction();
}

The static flag matters: a single request runs several controllers (startup ones, column modules), and the event fires for each.

The trap: the route comes from the visitor

Here is where a mistake would quietly kill the whole Performance section. The obvious thing to write is:

$route = $_GET['route'] ?? 'common/home';   // don't do this

route is a query string parameter, i.e. input fully controlled by whoever visits the site. Any scanner (and a store gets plenty) will try hundreds of non-existent values, and each becomes its own “endpoint” in the report. Within a day the route list drowns in junk — exactly the cardinality problem transaction names exist to avoid.

We caught this on a live test stand: a request for ?route=no/such/route happily created a transaction under that name. The cure is to accept a route only when a real controller sits behind it:

private function resolveRoute(): string {
    $route = (string) ($this->request->get['route'] ?? 'common/home');

    // The controller method is separated by a dot: product/product.review
    $path = explode('.', $route)[0];

    if (!preg_match('~^[a-z0-9_/]+$~i', $path)) {
        return '404';
    }

    if (is_file(DIR_APPLICATION . 'controller/' . $path . '.php')) {
        return $route;
    }

    // Extension routes: extension/<code>/<type>/<name>
    $parts = explode('/', $path);

    if (($parts[0] ?? '') === 'extension' && isset($parts[1])) {
        $file = DIR_EXTENSION . $parts[1] . '/catalog/controller/'
            . implode('/', array_slice($parts, 2)) . '.php';

        if (is_file($file)) {
            return $route;
        }
    }

    return '404';
}

The file check costs one is_file() per request and buys a closed set of names: as many rows in the report as there are controllers in the store. An attempt at ?route=../../etc/passwd also collapses into 404.

After the fix the report reads:

common/home         the front page
product/product     product pages
product/category    categories
account/login       login
404                 everything non-existent, scanners included

The transaction is closed in register_shutdown_function — on a fatal the end of the request never arrives, while PHP’s shutdown handler always runs.

The browser SDK: an event on the header output

OpenCart has no asset manager, but view/*/after events hand you the rendered HTML — so we append the script before </head>:

public function inject(string &$route, array &$args, string &$output): void {
    // ... settings checks ...

    // Extension assets live under extension/<code>/, not at the site root.
    // Build an absolute URL from the store settings: a relative one would
    // break on any SEO URL deeper than one level.
    $src = rtrim((string) $this->config->get('config_url'), '/')
        . '/extension/gotcha/catalog/view/javascript/gotcha/sentry.min.js';

    $script = '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"></script>'
        . '<script>Sentry.init({ /* ... */ });</script>';

    $position = stripos($output, '</head>');

    if ($position !== false) {
        $output = substr($output, 0, $position) . $script . substr($output, $position);
    }
}

Two places where we already got it wrong while testing on a live store:

The asset path. Extension files live in extension/gotcha/catalog/view/javascript/…, not at the site root. The first version pointed at catalog/view/javascript/… and got a 404 — the SDK tag was on the page but nothing worked.

The relative URL. Even with the right path, a relative src breaks on SEO URLs deeper than one level: the browser resolves it against the current URL. Hence the absolute address from config_url.

Event subscriptions

Events live in the oc_event table and are created when the extension is installed:

private const EVENTS = [
    [
        'code'        => 'gotcha_start',
        'description' => 'Gotcha: SDK init and transaction start',
        // The wildcard means any storefront controller: the event fires on
        // every request rather than for one specific route.
        'trigger'     => 'catalog/controller/*/before',
        'action'      => 'extension/gotcha/module/monitor.start',
        'status'      => 1,
        'sort_order'  => 0,
    ],
    [
        'code'        => 'gotcha_browser',
        'description' => 'Gotcha: browser SDK injection',
        'trigger'     => 'catalog/view/common/header/after',
        'action'      => 'extension/gotcha/module/monitor.inject',
        'status'      => 1,
        'sort_order'  => 0,
    ],
];

public function install(): void {
    $this->load->model('setting/event');

    foreach (self::EVENTS as $event) {
        // Idempotency: reinstalling must not create duplicates.
        $this->model_setting_event->deleteEventByCode($event['code']);
        $this->model_setting_event->addEvent($event);
    }
}

deleteEventByCode before addEvent is not paranoia: without it a second install creates a second subscription and the handler runs twice.

A required step: scoping the vendor directory

The extension ships its own vendor/, OpenCart ships its own (Twig, guzzle), and so does every neighbouring extension. 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\\Vendor',
    'finders' => [
        Finder::create()->files()->in('system/library/vendor'),
        Finder::create()->files()->name('*.php')->in('catalog/controller'),
    ],
    // OpenCart core classes must not be renamed: the engine finds controllers
    // by the Opencart\Catalog\Controller\... namespace.
    'exclude-namespaces' => ['Opencart'],
    'expose-global-constants' => true,
    'expose-global-classes'   => true,
    'expose-global-functions' => true,
];

The expose-global-* flags are mandatory here: OpenCart leans heavily on global constants (DIR_APPLICATION, DIR_EXTENSION, VERSION) and renaming them would break everything.

A rake of its own with constants. In the storefront the controller path comes from DIR_APPLICATION; DIR_CATALOG is defined only in the admin area. The first version of the extension used DIR_CATALOG and died with Undefined constant ... \DIR_CATALOG. Amusingly, that very error was the first thing to reach Gotcha — the handler chain was working before anything else did.

Installing

Extensions → Installer, upload the archive, then Extensions → Extensions → Modules, find “Gotcha” and press “+” (install) — that creates the event subscriptions. Then the edit button: paste the DSN and enable it.

To verify, hit a route 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 store 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 in a store

MetricWhy
PHP errors per release“500s started after that payment module update”
JS errors in the browserthe cart or filter broken for a subset of shoppers
p95 per routeshows what is slow: catalogue, search, checkout
Web Vitals (LCP, INP, CLS)real speed for real shoppers, not in Lighthouse
checkout/* timingsthe most expensive path: slowness here costs money
Uptime and SSL expirythe store is down / the certificate expires in three days

Summary

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