Подключение Gotcha к WordPress: ошибки, производительность и Web Vitals

Gotcha принимает данные по протоколу приёма Sentry, поэтому подключение любого PHP-приложения — это официальный Sentry SDK, нацеленный на ваш инстанс. Для WordPress есть сторонние плагины-обёртки над Sentry, но в них тянется чужой vendor, чужие настройки и чужие решения о том, что считать «эндпойнтом». Свой плагин на ~250 строк оказывается и понятнее, и точнее — а заодно показывает, как устроено подключение изнутри.

Разберём его целиком: ошибки PHP, транзакции по типам страниц, браузерный SDK с Web Vitals. Готовый архив — gotcha-monitoring-1.0.0.zip, проверен на WordPress 7.0.

Что будем собирать

СигналОткуда шлётсяЧто нужно
Ошибки PHP (исключения и фаталы)бэкенд WordPresssentry/sentry, обработчики ставит сам SDK
Время ответа по типам страницбэкенд WordPressтранзакции, имя по типу страницы
Ошибки JS и Web Vitalsбраузер@sentry/browser, вставляет тот же плагин
Аптайм и SSLсам Gotcha наружуHTTP-монитор, кода не требует
АлертыGotchaканал (Telegram/webhook/email) в UI

Где взять DSN

После создания проекта Gotcha перекидывает на страницу «Подключение» (/projects/<id>/setup). DSN выглядит так:

https://<public_key>@gotcha.example.com/<project_id>

Один и тот же DSN используют и PHP, и браузер — public_key в нём публичен по замыслу. В плагине он станет настройкой: пустое значение — плагин полностью выключен.

Чем WordPress отличается от Symfony и Joomla

Три вещи, которые определяют конструкцию плагина:

  1. Исключения никто не перехватывает. В отличие от Joomla, WordPress не ловит исключения сам — обработчики, которые ставит \Sentry\init(), работают напрямую. Отдельный код для отлова ошибок не нужен вообще.
  2. Нет ни роутов, ни контроллеров. В Symfony имя транзакции берут из роута, в Joomla — из option и view. В WordPress ничего этого нет: есть WP_Query и набор условных тегов (is_singular(), is_archive()…). Имя транзакции придётся собирать по ним — и это самое интересное место.
  3. Плагины живут в одном процессе. На типичном сайте их десятки, и каждый тянет свой vendor/. Об этом — отдельный раздел ниже, там же реальная авария.

Структура плагина

gotcha-monitoring/
├── gotcha-monitoring.php   # заголовок плагина, настройки, точка входа
├── readme.txt              # стандартный readme WordPress
├── src/Monitor.php         # SDK, транзакции, браузерный бандл
├── src/Settings.php        # страница «Настройки → Gotcha»
├── media/js/sentry.min.js  # браузерный SDK
└── vendor/                 # composer require sentry/sentry

Точка входа

Заголовок плагина — обычный докблок, WordPress читает его при сканировании папки:

<?php

/**
 * Plugin Name:       Gotcha Monitoring
 * Plugin URI:        https://getgotcha.ru/blog/wordpress-monitoring/
 * Description:       Ошибки PHP и JavaScript, время ответа по типам страниц и 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; — обязательная строка в каждом PHP-файле плагина: она не даёт выполнить файл при прямом обращении по URL.

Инициализация SDK

public function register(): void
{
    if ($this->options['dsn'] === '') {
        return; // пустой DSN — плагин полный 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']);
    }

    // Не add_action('shutdown'): PHP-хук отрабатывает и при фатальных
    // ошибках, когда до WP-хука дело уже не доходит.
    register_shutdown_function([$this, 'finishTransaction']);
}

Файл плагина подключается очень рано — до темы, до большинства хуков, — поэтому \Sentry\init() здесь ловит и ошибки других плагинов, загруженных следом. Проверено на двух видах отказа: необработанное исключение и обращение к несуществующему классу (фатал). Оба долетают со стектрейсом, хотя WordPress показывает свой «критическая ошибка на сайте».

register_shutdown_function вместо хука shutdown — принципиально: при фатальной ошибке WordPress до своих хуков не доходит, а PHP свой shutdown-обработчик вызывает всегда, и транзакция успевает закрыться.

Имя транзакции: главное решение

В WordPress нет роутов, поэтому напрашивается назвать транзакцию по URL — и это худшее, что можно сделать. У блога с десятью тысячами постов появится десять тысяч «эндпойнтов», по одному на статью, и раздел «Производительность» превратится в свалку (подробнее — Кардинальность).

Правильная единица — тип страницы. Их пара десятков, и по ним видно ровно то, что нужно: «карточки товаров медленнее категорий», «поиск тормозит». Условные теги WordPress дают это почти бесплатно:

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';
}

Условные теги работают только после разбора запроса, поэтому транзакция стартует с временным именем, а на хуке wp переименовывается. Временное имя — это контекст исполнения, и он важен сам по себе:

private function baseName(): string
{
    // WP-CLI грузит всё ядро, но это не HTTP-запрос: без отдельного имени
    // каждая консольная команда падала бы в общую кучу «frontend».
    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 и wp-signup.php не проходят через WP_Query, поэтому
    // хук `wp` для них не сработает — имя присваиваем сразу.
    $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';
}

Про wp-cli — находка из тестового прогона: команды консоли грузят всё ядро WordPress, обработчик срабатывает, и без отдельной ветки каждый вызов wp plugin list оседал в отчёте как обычный фронтенд-запрос. Мелочь, которую видно только на живом сайте.

Для REST берём первые сегменты маршрута, отбрасывая идентификаторы:

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 превращается в rest:/wp/v2/posts — один эндпойнт вместо тысячи. В отчёте это выглядит так:

front-page          8 событий
single.post         2
archive.category    1
search              2
rest:/wp/v2/posts   1
404                 1
wp-login            1
wp-cron             1

Браузерный SDK

Стандартный для WordPress способ — очередь скриптов:

public function enqueueBrowserSdk(): void
{
    $handle = 'gotcha-sentry';

    wp_enqueue_script(
        $handle,
        PLUGIN_URL . 'media/js/sentry.min.js',
        [],
        VERSION,
        false // в <head>: иначе SDK пропустит ошибки, случившиеся до подвала
    );

    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'])
    ));
}

Последний аргумент false (грузить в <head>, а не в подвал) — осознанный размен: скрипт в шапке чуть отодвигает отрисовку, зато ловит ошибки, которые случились до конца страницы. Для мониторинга это правильная сторона размена.

browserTracingIntegration сам снимает Web Vitals (LCP, CLS, INP, FCP, TTFB). Бандл собирается один раз любым бандлером:

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 живёт на другом домене — это нормальный случай: приёмник отвечает CORS-заголовками, браузер шлёт напрямую. Помешать может только собственный Content-Security-Policy: добавьте адрес инстанса в connect-src.

Настройки

Страница делается штатным Settings API: add_options_page для пункта меню, register_setting с sanitize_callback — для сохранения. Одна деталь, которую часто забывают, — ссылка «Настройки» прямо в списке плагинов:

add_filter(
    'plugin_action_links_' . plugin_basename(PLUGIN_FILE),
    [$this, 'actionLinks']
);

Без неё пользователь после активации остаётся один на один с вопросом «и что дальше». Там же, на странице настроек, стоит показать подсказку, пока DSN пуст, и ссылки на документацию.

Обязательный шаг: изоляция vendor

Вот грабля, на которую наступают почти все, кто первый раз пакует composer-зависимости в плагин CMS. Наш плагин несёт свой vendor/, но и десятки соседних плагинов несут свои — и там встречаются те же пакеты: psr/log, guzzlehttp/*, symfony/*. Классы называются одинаково, а мажорные версии разные. Кто первым зарегистрировал автолоадер, тот и определил, чей класс загрузится, а дальше — fatal error на ровном месте:

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 = [])

Ровно это мы поймали, когда делали такой же плагин для Joomla: на новых версиях всё работало, а на старой сайт отдавал белый экран на каждой странице. В WordPress риск даже выше — там в одном процессе живут не два vendor/, а двадцать.

Лечится это не подбором версий, а переименованием namespace зависимостей при сборке — 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 — глобальные, их трогать нельзя.
    'expose-global-functions' => true,
];

После сборки \Sentry\init(...) в коде плагина превращается в \Gotcha\Monitoring\Vendor\Sentry\init(...), а vendor/psr/log живёт в Gotcha\Monitoring\Vendor\Psr\Log. Конфликт исчезает навсегда — с ядром и с любым соседним плагином.

Правило простое: любое расширение CMS с собственным vendor/ нужно скоупить.

Установка

Сборка zip — папка со слагом плагина внутри архива, иначе WordPress его не примет:

zip -r gotcha-monitoring.zip gotcha-monitoring/

Либо берём готовый архив. Дальше — Плагины → Добавить новый → Загрузить плагин, активировать, открыть Настройки → Gotcha и вставить DSN. Проверка: бросьте тестовое исключение или обратитесь к несуществующему классу — событие появится в разделе «Проблемы» через несколько секунд.

Аптайм и алерты

Аптайм не требует правок в коде — Gotcha сам ходит на публичный URL снаружи: Аптайм → Новый монитор → HTTP, URL сайта, интервал и пороги. Инциденты, SSL-алерты и публичная status-страница — из коробки (Аптайм).

В разделе «Оповещения» правила (новый issue, регрессия, всплеск) уже включены; остаётся добавить канал доставки — Telegram, webhook или email (Алерты).

Что смотреть на сайте WordPress в первую очередь

МетрикаЗачем
Ошибки PHP по релизам«после обновления плагина посыпались 500-е» — самая частая авария WordPress
Ошибки JS в браузереформа или слайдер сломаны только у части посетителей
p95 по типам страницвидно, что медленно: карточка товара, поиск или архив
Web Vitals (LCP, INP, CLS)реальная скорость у людей, а не в Lighthouse
Время admin-ajax и wp-cronклассический источник тормозов, невидимый в обычной аналитике
Аптайм и срок SSLсайт лежит / сертификат истекает через три дня

Итог

Дальше — документация, установка и раздел подключения SDK.