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
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (exceptions and fatals) | storefront | a handler chain on top of the stock ones |
| Response time per route | storefront | the catalog/controller/*/before event |
| JS errors and Web Vitals | browser | the catalog/view/common/header/after 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 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_CATALOGis defined only in the admin area. The first version of the extension usedDIR_CATALOGand died withUndefined 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
| Metric | Why |
|---|---|
| PHP errors per release | “500s started after that payment module update” |
| JS errors in the browser | the cart or filter broken for a subset of shoppers |
| p95 per route | shows what is slow: catalogue, search, checkout |
| Web Vitals (LCP, INP, CLS) | real speed for real shoppers, not in Lighthouse |
checkout/* timings | the most expensive path: slowness here costs money |
| Uptime and SSL expiry | the store is down / the certificate expires in three days |
Summary
- Chain the error handlers, don’t replace them: OpenCart installs its own before any extension loads, and breaking them is not an option.
flush()on a fatal, or the most important events stay in the buffer.- Validate the route against a controller file:
routecomes from the visitor, and without the check any scanner mints endless “endpoints”. - Extension assets live under
extension/<code>/and need absolute URLs, or the SDK won’t load on SEO URLs. deleteEventByCodebeforeaddEvent, or a reinstall doubles the subscriptions.- Scope
vendor/withexpose-global-*— OpenCart runs on global constants.
Next: the documentation, installation and the SDK setup section.