Connecting Gotcha to WordPress: errors, performance and Web Vitals
Gotcha ingests data over the Sentry ingest protocol, so connecting any PHP application means an official Sentry SDK pointed at your instance. There are third-party WordPress plugins that wrap Sentry, but they bring someone else’s vendor tree, someone else’s settings and someone else’s idea of what counts as an “endpoint”. A ~250-line plugin of your own turns out both clearer and more accurate — and it shows how the integration works from the inside.
Let’s walk through all of it: PHP errors, transactions per page type, and the browser SDK with Web Vitals. The prebuilt archive is gotcha-monitoring-1.0.0.zip, tested on WordPress 7.0.
What we’ll collect
| Signal | Sent from | What you need |
|---|---|---|
| PHP errors (exceptions and fatals) | WordPress backend | sentry/sentry; the SDK installs the handlers |
| Response time per page type | WordPress backend | transactions named by page type |
| JS errors and Web Vitals | browser | @sentry/browser, injected by the same plugin |
| 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 a setting: an empty value disables the plugin
entirely.
How WordPress differs from Symfony and Joomla
Three things shape the plugin:
- Nothing intercepts exceptions. Unlike Joomla, WordPress does not catch
exceptions itself, so the handlers installed by
\Sentry\init()work directly. No error-catching code of our own is needed at all. - There are no routes and no controllers. In Symfony the transaction name
comes from the route, in Joomla from
optionandview. WordPress has neither: it hasWP_Queryand a set of conditional tags (is_singular(),is_archive()…). The transaction name has to be assembled from those — the most interesting part of the job. - Plugins share one process. A typical site runs dozens of them, each with
its own
vendor/. That gets its own section below, with a real outage in it.
Plugin structure
gotcha-monitoring/
├── gotcha-monitoring.php # plugin header, options, entry point
├── readme.txt # the standard WordPress readme
├── src/Monitor.php # SDK, transactions, browser bundle
├── src/Settings.php # the "Settings → Gotcha" page
├── media/js/sentry.min.js # browser SDK
└── vendor/ # composer require sentry/sentry
Entry point
The plugin header is an ordinary docblock; WordPress reads it when scanning the directory:
<?php
/**
* Plugin Name: Gotcha Monitoring
* Plugin URI: https://getgotcha.ru/en/blog/wordpress-monitoring/
* Description: PHP and JavaScript errors, response time per page type and Web Vitals.
* Version: 1.0.0
* Requires at least: 5.9
* Requires PHP: 8.1
* License: MIT
* Text Domain: gotcha-monitoring
*/
declare(strict_types=1);
namespace Gotcha\Monitoring;
defined('ABSPATH') || exit;
const VERSION = '1.0.0';
const OPTION_NAME = 'gotcha_monitoring';
define(__NAMESPACE__ . '\PLUGIN_FILE', __FILE__);
define(__NAMESPACE__ . '\PLUGIN_URL', plugin_dir_url(__FILE__));
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/Settings.php';
require_once __DIR__ . '/src/Monitor.php';
function options(): array
{
$saved = get_option(OPTION_NAME, []);
return [
'dsn' => trim((string) ($saved['dsn'] ?? '')),
'environment' => (string) ($saved['environment'] ?? 'production'),
'traces_sample_rate' => (float) ($saved['traces_sample_rate'] ?? 0.2),
'browser' => (bool) ($saved['browser'] ?? true),
];
}
(new Settings())->register();
(new Monitor(options()))->register();
defined('ABSPATH') || exit; belongs in every PHP file of a plugin: it stops
the file from executing when requested directly by URL.
Initialising the SDK
public function register(): void
{
if ($this->options['dsn'] === '') {
return; // empty DSN — the plugin is a complete no-op
}
\Sentry\init([
'dsn' => $this->options['dsn'],
'environment' => $this->options['environment'],
'release' => 'wordpress@' . get_bloginfo('version'),
'traces_sample_rate' => $this->options['traces_sample_rate'],
]);
$this->startTransaction();
add_action('wp', [$this, 'nameFromQuery'], 1);
add_action('rest_api_init', [$this, 'nameFromRest'], 1);
if ($this->options['browser']) {
add_action('wp_enqueue_scripts', [$this, 'enqueueBrowserSdk']);
}
// Not add_action('shutdown'): the PHP hook also runs on fatal errors,
// where the WP hook is never reached.
register_shutdown_function([$this, 'finishTransaction']);
}
A plugin file is loaded very early — before the theme, before most hooks — so
\Sentry\init() here also catches errors from plugins loaded after it. Verified
against two kinds of failure: an uncaught exception and a reference to a
missing class (a fatal). Both arrive with a stack trace, even though WordPress
renders its own “critical error” page.
register_shutdown_function instead of the shutdown hook is the key
detail: on a fatal error WordPress never reaches its own hooks, while PHP always
calls its shutdown handlers, so the transaction still gets closed.
Naming transactions: the one real decision
WordPress has no routes, which tempts you to name transactions by URL — the worst thing you could do. A blog with ten thousand posts would produce ten thousand “endpoints”, one per article, and the Performance section becomes a dump (see Cardinality).
The right unit is the page type. There are a couple of dozen, and they show exactly what you need: “product pages are slower than categories”, “search is dragging”. WordPress conditional tags hand you this almost for free:
private function queryName(): string
{
if (is_404()) {
return '404';
}
if (is_feed()) {
return 'feed';
}
if (is_front_page()) {
return 'front-page';
}
if (is_singular()) {
return 'single.' . (string) get_post_type();
}
if (is_post_type_archive()) {
return 'archive.' . (string) get_post_type();
}
if (is_search()) {
return 'search';
}
if (is_author()) {
return 'archive.author';
}
if (is_date()) {
return 'archive.date';
}
if (is_tax() || is_category() || is_tag()) {
$taxonomy = get_queried_object();
return 'archive.' . (isset($taxonomy->taxonomy) ? (string) $taxonomy->taxonomy : 'term');
}
if (is_home()) {
return 'home';
}
return 'frontend';
}
Conditional tags only work after the query has been parsed, so the transaction
starts under a provisional name and is renamed on the wp hook. That
provisional name is the execution context, and it matters in its own right:
private function baseName(): string
{
// WP-CLI loads the whole core, but it is not an HTTP request: without a
// name of its own every console command would land in the "frontend" pile.
if (defined('WP_CLI') && WP_CLI) {
return 'wp-cli';
}
if (wp_doing_cron()) {
return 'wp-cron';
}
if (wp_doing_ajax()) {
return 'admin-ajax';
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return 'rest';
}
// wp-login.php and wp-signup.php never go through WP_Query, so the `wp`
// hook won't fire for them — name them right away.
$script = basename((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
if ($script === 'wp-login.php' || $script === 'wp-signup.php') {
return rtrim($script, '.php');
}
if (is_admin()) {
return 'admin';
}
return 'frontend';
}
The wp-cli branch came out of the test run: console commands load the entire
WordPress core, the handler fires, and without a branch of its own every
wp plugin list showed up in the report as a plain front-end request. A small
thing you only see on a live site.
For REST we take the leading route segments and drop the identifiers:
public function nameFromRest(): void
{
$route = (string) ($GLOBALS['wp']->query_vars['rest_route'] ?? '');
if ($route === '') {
return;
}
$segments = array_slice(array_filter(explode('/', $route)), 0, 3);
$this->transaction->setName('rest:/' . implode('/', $segments));
}
/wp/v2/posts/123 becomes rest:/wp/v2/posts — one endpoint instead of a
thousand. In the report it looks like this:
front-page 8 events
single.post 2
archive.category 1
search 2
rest:/wp/v2/posts 1
404 1
wp-login 1
wp-cron 1
The browser SDK
The WordPress-native way is the script queue:
public function enqueueBrowserSdk(): void
{
$handle = 'gotcha-sentry';
wp_enqueue_script(
$handle,
PLUGIN_URL . 'media/js/sentry.min.js',
[],
VERSION,
false // in <head>: otherwise the SDK misses errors thrown before the footer
);
wp_add_inline_script($handle, sprintf(
'Sentry.init({dsn:%s,environment:%s,integrations:[Sentry.browserTracingIntegration()],tracesSampleRate:%s});',
wp_json_encode($this->options['dsn']),
wp_json_encode($this->options['environment']),
wp_json_encode($this->options['traces_sample_rate'])
));
}
That final false (load in <head> rather than the footer) is a deliberate
trade: a script in the head delays rendering slightly, but it catches errors
thrown before the end of the page. For monitoring that is the right side of the
trade.
browserTracingIntegration collects Web Vitals (LCP, CLS, INP, FCP, TTFB)
on its own. The bundle is built once with any bundler:
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
Gotcha living on another domain is the normal case: the ingest endpoint answers
with CORS headers and the browser posts directly. The only thing that can get in
the way is your own Content-Security-Policy — add the instance address to
connect-src.
Settings
The page uses the stock Settings API: add_options_page for the menu entry,
register_setting with a sanitize_callback for saving. One detail people
often skip is the “Settings” link right in the plugins list:
add_filter(
'plugin_action_links_' . plugin_basename(PLUGIN_FILE),
[$this, 'actionLinks']
);
Without it, a user who just activated the plugin is left wondering what to do next. The settings page is also the place for a hint while the DSN is still empty, plus links to the docs.
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 plugin. Our plugin ships its own vendor/, and so do
the dozens of plugins next to it — containing the same packages: psr/log,
guzzlehttp/*, symfony/*. The class names are identical and the major
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 we hit while building the same kind of plugin for Joomla:
newer versions were fine, while the older one served a white screen on every
page. In WordPress the risk is higher still — a single process holds not two
vendor/ trees but twenty.
The fix is not version juggling but renaming the dependencies’ namespaces at build time, with php-scoper:
// scoper.inc.php
return [
'prefix' => 'Gotcha\\Monitoring\\Vendor',
'finders' => [
Finder::create()->files()->in('vendor'),
Finder::create()->files()->name('*.php')->in('src'),
],
'exclude-namespaces' => ['Gotcha\\Monitoring'],
// WordPress functions are global — leave them alone.
'expose-global-functions' => true,
];
After the build, \Sentry\init(...) in the plugin code becomes
\Gotcha\Monitoring\Vendor\Sentry\init(...), and vendor/psr/log lives in
Gotcha\Monitoring\Vendor\Psr\Log. The conflict is gone for good — both with
the core and with any neighbouring plugin.
The rule is simple: any CMS extension with its own vendor/ needs scoping.
Installing
Build the zip with a directory named after the plugin slug inside it, or WordPress will refuse it:
zip -r gotcha-monitoring.zip gotcha-monitoring/
Or take the prebuilt archive. Then Plugins → Add New → Upload Plugin, activate, open Settings → Gotcha and paste the DSN. To verify, throw a test exception or reference a missing class — 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 WordPress site
| Metric | Why |
|---|---|
| PHP errors per release | “500s started after that plugin update” — the most common WordPress outage |
| JS errors in the browser | a form or slider broken for a subset of visitors |
| p95 per page type | shows what is slow: product page, search or archive |
| Web Vitals (LCP, INP, CLS) | real speed for real people, not in Lighthouse |
admin-ajax and wp-cron time | a classic source of slowness, invisible in ordinary analytics |
| Uptime and SSL expiry | the site is down / the certificate expires in three days |
Summary
- A ~250-line plugin wires up
sentry/sentryand covers errors, tracing and Web Vitals. - The SDK catches errors itself — WordPress does not intercept exceptions, unlike Joomla.
register_shutdown_functioninstead of theshutdownhook, or the transaction never closes on a fatal.- Transaction names are page types, not URLs:
single.post,archive.category,rest:/wp/v2/posts. vendor/must be scoped with php-scoper, or a version clash with a neighbouring plugin takes down the whole site.
Next: the documentation, installation and the SDK setup section.