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

SignalSent fromWhat you need
PHP errors (exceptions and fatals)WordPress backendsentry/sentry; the SDK installs the handlers
Response time per page typeWordPress backendtransactions named by page type
JS errors and Web Vitalsbrowser@sentry/browser, injected by the same plugin
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 a setting: an empty value disables the plugin entirely.

How WordPress differs from Symfony and Joomla

Three things shape the plugin:

  1. 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.
  2. There are no routes and no controllers. In Symfony the transaction name comes from the route, in Joomla from option and view. WordPress has neither: it has WP_Query and 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.
  3. 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

MetricWhy
PHP errors per release“500s started after that plugin update” — the most common WordPress outage
JS errors in the browsera form or slider broken for a subset of visitors
p95 per page typeshows 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 timea classic source of slowness, invisible in ordinary analytics
Uptime and SSL expirythe site is down / the certificate expires in three days

Summary

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